v0.3.0 release
This commit is contained in:
parent
822dc2f5fd
commit
a5548bb776
|
@ -13,6 +13,7 @@ rice-box.go
|
||||||
/backend/*.cov
|
/backend/*.cov
|
||||||
/backend/test_config.yaml
|
/backend/test_config.yaml
|
||||||
/backend/srv
|
/backend/srv
|
||||||
|
/backend/http/dist
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules
|
node_modules
|
||||||
|
|
31
CHANGELOG.md
31
CHANGELOG.md
|
@ -2,6 +2,33 @@
|
||||||
|
|
||||||
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).
|
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.0
|
||||||
|
|
||||||
|
This Release focuses on the API and making it more accessible for developers to access functions without the UI.
|
||||||
|
|
||||||
|
**New Features**:
|
||||||
|
- You can now long-live api tokens to interact with API from the user settings page.
|
||||||
|
- These tokens have the same permissions as your user.
|
||||||
|
- Helpful swagger page for API usage.
|
||||||
|
- Some API's were refactored for friendlier API usage, moving some attributes to parameters and first looking for a api token, then using the stored cookie if none is found. This allows for all api requests from swagger page to work without a token.
|
||||||
|
- Add file size to search preview! Should have been in last release... sorry!
|
||||||
|
|
||||||
|
**Notes**:
|
||||||
|
- Replaced backend http framework with go standard library.
|
||||||
|
- Right-click Context menu can target the item that was right-clicked. To fully address https://github.com/gtsteffaniak/filebrowser/issues/214
|
||||||
|
- adjusted settings menu for mobile, always shows all available cards rather than grayed out cards that need to be clicked.
|
||||||
|
- longer and more cryptographically secure share links based on UUID rather than base64.
|
||||||
|
|
||||||
|
**Bugfixes**:
|
||||||
|
- Fixed ui bug with shares with password.
|
||||||
|
- Fixes baseurl related bugs https://github.com/gtsteffaniak/filebrowser/pull/228 Thanks @SimLV
|
||||||
|
- Fixed empty directory load issue.
|
||||||
|
- Fixed image preview cutoff on mobile.
|
||||||
|
- Fixed issue introduced in v0.2.10 where new files and folders were not showing up on ui
|
||||||
|
- Fixed preview issue where preview would not load after viewing video files.
|
||||||
|
- Fixed sorting issue where files were not sorted by name by default.
|
||||||
|
- Fixed copy file prompt issue
|
||||||
|
|
||||||
## v0.2.10
|
## v0.2.10
|
||||||
|
|
||||||
**New Features**:
|
**New Features**:
|
||||||
|
@ -17,7 +44,7 @@ All notable changes to this project will be documented in this file. For commit
|
||||||
**Notes**:
|
**Notes**:
|
||||||
- Memory usage from index is reduced by ~40%
|
- Memory usage from index is reduced by ~40%
|
||||||
- Indexing time has increased 2x due to the extra processing time required to calculate directory sizes.
|
- Indexing time has increased 2x due to the extra processing time required to calculate directory sizes.
|
||||||
- File size calcuations use 1024 base vs previous 1000 base (matching windows explorer)
|
- File size calculations use 1024 base vs previous 1000 base (matching windows explorer)
|
||||||
|
|
||||||
## v0.2.9
|
## v0.2.9
|
||||||
|
|
||||||
|
@ -40,7 +67,7 @@ All notable changes to this project will be documented in this file. For commit
|
||||||
|
|
||||||
## v0.2.8
|
## v0.2.8
|
||||||
|
|
||||||
- **Feature**: New gallary view scaling options (closes [#141](https://github.com/gtsteffaniak/filebrowser/issues/141))
|
- **Feature**: New gallery view scaling options (closes [#141](https://github.com/gtsteffaniak/filebrowser/issues/141))
|
||||||
- **Change**: Refactored backend files functions
|
- **Change**: Refactored backend files functions
|
||||||
- **Change**: Improved UI response to filesystem changes
|
- **Change**: Improved UI response to filesystem changes
|
||||||
- **Change**: Added frontend tests for deployment integrity
|
- **Change**: Added frontend tests for deployment integrity
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
FROM golang:1.22-alpine AS base
|
FROM golang:1.23-alpine AS base
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
ARG REVISION
|
ARG REVISION
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY ./backend ./
|
COPY ./backend ./
|
||||||
|
#RUN swag init --output swagger/docs
|
||||||
|
RUN ln -s swagger /usr/local/go/src/
|
||||||
RUN go build -ldflags="-w -s \
|
RUN go build -ldflags="-w -s \
|
||||||
-X 'github.com/gtsteffaniak/filebrowser/version.Version=${VERSION}' \
|
-X 'github.com/gtsteffaniak/filebrowser/version.Version=${VERSION}' \
|
||||||
-X 'github.com/gtsteffaniak/filebrowser/version.CommitSHA=${REVISION}'" \
|
-X 'github.com/gtsteffaniak/filebrowser/version.CommitSHA=${REVISION}'" \
|
||||||
|
@ -19,5 +21,7 @@ FROM alpine:latest
|
||||||
ENV FILEBROWSER_NO_EMBEDED="true"
|
ENV FILEBROWSER_NO_EMBEDED="true"
|
||||||
RUN apk --no-cache add ca-certificates mailcap
|
RUN apk --no-cache add ca-certificates mailcap
|
||||||
COPY --from=base /app/filebrowser* ./
|
COPY --from=base /app/filebrowser* ./
|
||||||
COPY --from=nbuild /app/dist/ ./frontend/dist/
|
# exposing default port for auto discovery.
|
||||||
|
EXPOSE 80
|
||||||
|
COPY --from=nbuild /app/dist/ ./http/dist/
|
||||||
ENTRYPOINT [ "./filebrowser" ]
|
ENTRYPOINT [ "./filebrowser" ]
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
FROM golang:1.22-alpine AS base
|
FROM golang:1.23-alpine AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY ./backend ./
|
COPY ./backend ./
|
||||||
RUN go build -ldflags="-w -s" -o filebrowser .
|
RUN go build -ldflags="-w -s" -o filebrowser .
|
||||||
|
|
61
README.md
61
README.md
|
@ -10,41 +10,43 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Starting with v0.2.0, *ALL* configuration is done via `filebrowser.yaml`
|
> Starting with `v0.3.0` API routes have been slightly altered for friendly usage outside of the UI.
|
||||||
> Configuration file.
|
> If on windows, please use docker. The windows binary is unstable and may not work.
|
||||||
> Starting with v0.2.4 *ALL* share links need to be re-created (due to
|
|
||||||
> security fix).
|
|
||||||
|
|
||||||
FileBrowser Quantum is a fork of the filebrowser opensource project with the
|
FileBrowser Quantum is a fork of the file browser opensource project with the following changes:
|
||||||
following changes:
|
|
||||||
|
|
||||||
1. [x] Efficiently indexed files
|
1. [x] Efficiently indexed files
|
||||||
- Real-time search results as you type
|
- Real-time search results as you type
|
||||||
- Search Works with more type filters
|
- Search Works with more type filters
|
||||||
- Enhanced interactive results page.
|
- Enhanced interactive results page.
|
||||||
2. [x] Revamped and simplified GUI navbar and sidebar menu.
|
- file/folder sizes are shown in the response
|
||||||
|
1. [x] Revamped and simplified GUI navbar and sidebar menu.
|
||||||
- Additional compact view mode as well as refreshed view mode
|
- Additional compact view mode as well as refreshed view mode
|
||||||
styles.
|
styles.
|
||||||
3. [x] Revamped and simplified configuration via `filebrowser.yml` config file.
|
1. [x] Revamped and simplified configuration via `filebrowser.yml` config file.
|
||||||
4. [x] Faster listing browsing
|
1. [x] Better listing browsing
|
||||||
- Switching view modes is instant
|
- Switching view modes is instant
|
||||||
|
- Folder sizes are shown as well
|
||||||
- Changing Sort order is instant
|
- Changing Sort order is instant
|
||||||
- The entire directory is loaded in 1/3 the time
|
- The entire directory is loaded in 1/3 the time
|
||||||
|
1. Developer API support
|
||||||
|
- Can create long-live API Tokens.
|
||||||
|
- Helpful Swagger page available at `/swagger` endpoint.
|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
FileBrowser Quantum provides a file managing interface within a specified directory
|
FileBrowser Quantum provides a file-managing interface within a specified directory
|
||||||
and can be used to upload, delete, preview, rename, and edit your files.
|
and can be used to upload, delete, preview, rename, and edit your files.
|
||||||
It allows the creation of multiple users and each user can have its
|
It allows the creation of multiple users and each user can have its
|
||||||
directory.
|
directory.
|
||||||
|
|
||||||
This repository is a fork of the original [filebrowser](https://github.com/filebrowser/filebrowser)
|
This repository is a fork of the original [filebrowser](https://github.com/filebrowser/filebrowser)
|
||||||
with a collection of changes that make this program work better in terms of
|
with a collection of changes that make this program work better in terms of
|
||||||
aesthetics and performance. Improved search, simplified ui
|
aesthetics and performance. Improved search, simplified UI
|
||||||
(without removing features) and more secure and up-to-date
|
(without removing features) and more secure and up-to-date
|
||||||
build are just a few examples.
|
build are just a few examples.
|
||||||
|
|
||||||
FileBrowser Quantum differs significantly to the original.
|
FileBrowser Quantum differs significantly from the original.
|
||||||
There are hundreds of thousands of lines changed and they are generally
|
There are hundreds of thousands of lines changed and they are generally
|
||||||
no longer compatible with each other. This has been intentional -- the
|
no longer compatible with each other. This has been intentional -- the
|
||||||
focus of this fork is on a few key principles:
|
focus of this fork is on a few key principles:
|
||||||
|
@ -68,10 +70,9 @@ action panel. If the action is available based on context, it will show up as
|
||||||
a popup menu.
|
a popup menu.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img width="800" src="https://github.com/gtsteffaniak/filebrowser/assets/42989099/899152cf-3e69-4179-aa82-752af2df3fc6" title="Main Screenshot">
|
<img width="800" src="https://github.com/user-attachments/assets/2be7a6c5-0f95-4d9f-bc05-484ee71246d8" title="Search GIF">
|
||||||
<img width="800" src="https://github.com/user-attachments/assets/18c02d03-5c60-4e15-9c32-3cfe058a0c49" title="Main Screenshot">
|
<img width="800" src="https://github.com/user-attachments/assets/f55a6f1f-b930-4399-98b5-94da6e90527a" title="Navigation GIF">
|
||||||
<img width="800" src="https://github.com/user-attachments/assets/75226dc4-9802-46f0-9e3c-e4403d3275da" title="Main Screenshot">
|
<img width="800" src="https://github.com/user-attachments/assets/75226dc4-9802-46f0-9e3c-e4403d3275da" title="Main Screenshot">
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
@ -89,7 +90,6 @@ docker run -it -v /path/to/folder:/srv -p 80:80 gtstef/filebrowser
|
||||||
- with local storage
|
- with local storage
|
||||||
|
|
||||||
```
|
```
|
||||||
version: '3.7'
|
|
||||||
services:
|
services:
|
||||||
filebrowser:
|
filebrowser:
|
||||||
volumes:
|
volumes:
|
||||||
|
@ -105,7 +105,6 @@ services:
|
||||||
- with network share
|
- with network share
|
||||||
|
|
||||||
```
|
```
|
||||||
version: '3.7'
|
|
||||||
services:
|
services:
|
||||||
filebrowser:
|
filebrowser:
|
||||||
volumes:
|
volumes:
|
||||||
|
@ -121,7 +120,7 @@ volumes:
|
||||||
driver_opts:
|
driver_opts:
|
||||||
type: cifs
|
type: cifs
|
||||||
o: "username=admin,password=password,rw" # enter valid info here
|
o: "username=admin,password=password,rw" # enter valid info here
|
||||||
device: "//192.168.1.100/share/" # enter valid hinfo here
|
device: "//192.168.1.100/share/" # enter valid info here
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -135,10 +134,24 @@ 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 command line:
|
There are very few commands available. There are 3 actions done via command line:
|
||||||
|
|
||||||
1. Running the program, as shown on install step. Only argument used is the config file, if you choose to override default "filebrowser.yaml"
|
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"
|
||||||
2. Checking the version info via `./filebrowser version`
|
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"]`
|
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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
You use the token as a 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">
|
||||||
|
Failed Request
|
||||||
|
<img width="500" alt="image" src="https://github.com/user-attachments/assets/4da0deae-f93d-4d94-83b1-68806afb343a">
|
||||||
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All configuration is now done via a single configuration file:
|
All configuration is now done via a single configuration file:
|
||||||
|
@ -149,11 +162,12 @@ View the [Configuration Help Page](./docs/configuration.md) for available
|
||||||
configuration options and other help.
|
configuration options and other help.
|
||||||
|
|
||||||
|
|
||||||
## Migration from filebrowser/filebrowser
|
## Migration from the original filebrowser
|
||||||
|
|
||||||
If you currently use the original filebrowser but want to try using this.
|
If you currently use the original filebrowser but want to try using this.
|
||||||
I recommend you start fresh without reusing the database. If you want to
|
I would recommend that you start fresh without reusing the database. However,
|
||||||
migrate your existing database to FileBrowser Quantum, visit the [migration
|
If you want to migrate your existing database to FileBrowser Quantum,
|
||||||
|
visit the [migration
|
||||||
readme](./docs/migration.md)
|
readme](./docs/migration.md)
|
||||||
|
|
||||||
## Comparison Chart
|
## Comparison Chart
|
||||||
|
@ -185,7 +199,8 @@ Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
Single sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
Single sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||||
LDAP sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
LDAP sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||||
2FA sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
2FA sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||||
Long-live API key support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||||
Mobile App | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
|
Mobile App | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
|
||||||
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
tags support | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
|
tags support | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
|
||||||
|
|
|
@ -9,7 +9,7 @@ import (
|
||||||
// Auther is the authentication interface.
|
// Auther is the authentication interface.
|
||||||
type Auther interface {
|
type Auther interface {
|
||||||
// Auth is called to authenticate a request.
|
// Auth is called to authenticate a request.
|
||||||
Auth(r *http.Request, usr users.Store) (*users.User, error)
|
Auth(r *http.Request, userStore *users.Storage) (*users.User, error)
|
||||||
// LoginPage indicates if this auther needs a login page.
|
// LoginPage indicates if this auther needs a login page.
|
||||||
LoginPage() bool
|
LoginPage() bool
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@ type HookAuth struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth authenticates the user via a json in content body.
|
// Auth authenticates the user via a json in content body.
|
||||||
func (a *HookAuth) Auth(r *http.Request, usr users.Store) (*users.User, error) {
|
func (a *HookAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
|
||||||
var cred hookCred
|
var cred hookCred
|
||||||
|
|
||||||
if r.Body == nil {
|
if r.Body == nil {
|
||||||
|
@ -51,7 +51,6 @@ func (a *HookAuth) Auth(r *http.Request, usr users.Store) (*users.User, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case "auth":
|
case "auth":
|
||||||
u, err := a.SaveUser()
|
u, err := a.SaveUser()
|
||||||
|
@ -187,7 +186,7 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
|
||||||
func (a *HookAuth) GetUser(d *users.User) *users.User {
|
func (a *HookAuth) GetUser(d *users.User) *users.User {
|
||||||
// adds all permissions when user is admin
|
// adds all permissions when user is admin
|
||||||
isAdmin := d.Perm.Admin
|
isAdmin := d.Perm.Admin
|
||||||
perms := settings.Permissions{
|
perms := users.Permissions{
|
||||||
Admin: isAdmin,
|
Admin: isAdmin,
|
||||||
Execute: isAdmin || d.Perm.Execute,
|
Execute: isAdmin || d.Perm.Execute,
|
||||||
Create: isAdmin || d.Perm.Create,
|
Create: isAdmin || d.Perm.Create,
|
||||||
|
|
|
@ -23,7 +23,7 @@ type JSONAuth struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth authenticates the user via a json in content body.
|
// Auth authenticates the user via a json in content body.
|
||||||
func (a JSONAuth) Auth(r *http.Request, usr users.Store) (*users.User, error) {
|
func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
|
||||||
config := &settings.Config
|
config := &settings.Config
|
||||||
var cred jsonCred
|
var cred jsonCred
|
||||||
|
|
||||||
|
@ -47,8 +47,7 @@ func (a JSONAuth) Auth(r *http.Request, usr users.Store) (*users.User, error) {
|
||||||
return nil, os.ErrPermission
|
return nil, os.ErrPermission
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
u, err := userStore.Get(config.Server.Root, cred.Username)
|
||||||
u, err := usr.Get(config.Server.Root, cred.Username)
|
|
||||||
if err != nil || !users.CheckPwd(cred.Password, u.Password) {
|
if err != nil || !users.CheckPwd(cred.Password, u.Password) {
|
||||||
return nil, os.ErrPermission
|
return nil, os.ErrPermission
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ const MethodNoAuth = "noauth"
|
||||||
type NoAuth struct{}
|
type NoAuth struct{}
|
||||||
|
|
||||||
// Auth uses authenticates user 1.
|
// Auth uses authenticates user 1.
|
||||||
func (a NoAuth) Auth(r *http.Request, usr users.Store) (*users.User, error) {
|
func (a NoAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
|
||||||
return usr.Get(settings.Config.Server.Root, uint(1))
|
return usr.Get(settings.Config.Server.Root, uint(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,7 @@ type ProxyAuth struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth authenticates the user via an HTTP header.
|
// Auth authenticates the user via an HTTP header.
|
||||||
func (a ProxyAuth) Auth(r *http.Request, usr users.Store) (*users.User, error) {
|
func (a ProxyAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
|
||||||
username := r.Header.Get(a.Header)
|
username := r.Header.Get(a.Header)
|
||||||
user, err := usr.Get(settings.Config.Server.Root, username)
|
user, err := usr.Get(settings.Config.Server.Root, username)
|
||||||
if err == errors.ErrNotExist {
|
if err == errors.ErrNotExist {
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
# Ignore everything in this directory
|
|
||||||
*
|
|
||||||
# Except this file
|
|
||||||
!.gitignore
|
|
|
@ -1,20 +1,11 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"embed"
|
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/diskcache"
|
"github.com/gtsteffaniak/filebrowser/diskcache"
|
||||||
"github.com/gtsteffaniak/filebrowser/files"
|
"github.com/gtsteffaniak/filebrowser/files"
|
||||||
|
@ -22,29 +13,15 @@ import (
|
||||||
"github.com/gtsteffaniak/filebrowser/img"
|
"github.com/gtsteffaniak/filebrowser/img"
|
||||||
"github.com/gtsteffaniak/filebrowser/settings"
|
"github.com/gtsteffaniak/filebrowser/settings"
|
||||||
"github.com/gtsteffaniak/filebrowser/storage"
|
"github.com/gtsteffaniak/filebrowser/storage"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/swagger/docs"
|
||||||
|
"github.com/swaggo/swag"
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/users"
|
"github.com/gtsteffaniak/filebrowser/users"
|
||||||
"github.com/gtsteffaniak/filebrowser/utils"
|
|
||||||
"github.com/gtsteffaniak/filebrowser/version"
|
"github.com/gtsteffaniak/filebrowser/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed dist/*
|
|
||||||
var assets embed.FS
|
|
||||||
|
|
||||||
var (
|
|
||||||
nonEmbededFS = os.Getenv("FILEBROWSER_NO_EMBEDED") == "true"
|
|
||||||
)
|
|
||||||
|
|
||||||
type dirFS struct {
|
|
||||||
http.Dir
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d dirFS) Open(name string) (fs.File, error) {
|
|
||||||
return d.Dir.Open(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getStore(config string) (*storage.Storage, bool) {
|
func getStore(config string) (*storage.Storage, bool) {
|
||||||
// Use the config file (global flag)
|
// Use the config file (global flag)
|
||||||
log.Printf("Using Config file : %v", config)
|
|
||||||
settings.Initialize(config)
|
settings.Initialize(config)
|
||||||
store, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
|
store, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -146,12 +123,16 @@ func StartFilebrowser() {
|
||||||
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
|
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
|
||||||
}
|
}
|
||||||
log.Printf("Initializing FileBrowser Quantum (%v)\n", version.Version)
|
log.Printf("Initializing FileBrowser Quantum (%v)\n", version.Version)
|
||||||
log.Println("Embeded frontend :", !nonEmbededFS)
|
log.Printf("Using Config file : %v", configPath)
|
||||||
|
log.Println("Embeded frontend :", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true")
|
||||||
log.Println(database)
|
log.Println(database)
|
||||||
log.Println("Sources :", settings.Config.Server.Root)
|
log.Println("Sources :", settings.Config.Server.Root)
|
||||||
log.Print("Indexing interval : ", indexingInterval)
|
log.Println("Indexing interval :", indexingInterval)
|
||||||
|
|
||||||
serverConfig := settings.Config.Server
|
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)
|
// initialize indexing and schedule indexing ever n minutes (default 5)
|
||||||
go files.InitializeIndex(serverConfig.IndexingInterval, serverConfig.Indexing)
|
go files.InitializeIndex(serverConfig.IndexingInterval, serverConfig.Indexing)
|
||||||
if err := rootCMD(store, &serverConfig); err != nil {
|
if err := rootCMD(store, &serverConfig); err != nil {
|
||||||
|
@ -159,13 +140,6 @@ func StartFilebrowser() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanupHandler(listener net.Listener, c chan os.Signal) { //nolint:interfacer
|
|
||||||
sig := <-c
|
|
||||||
log.Printf("Caught signal %s: shutting down.", sig)
|
|
||||||
listener.Close()
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func rootCMD(store *storage.Storage, serverConfig *settings.Server) error {
|
func rootCMD(store *storage.Storage, serverConfig *settings.Server) error {
|
||||||
if serverConfig.NumImageProcessors < 1 {
|
if serverConfig.NumImageProcessors < 1 {
|
||||||
log.Fatal("Image resize workers count could not be < 1")
|
log.Fatal("Image resize workers count could not be < 1")
|
||||||
|
@ -186,57 +160,7 @@ func rootCMD(store *storage.Storage, serverConfig *settings.Server) error {
|
||||||
// No-op cache if no cacheDir is specified
|
// No-op cache if no cacheDir is specified
|
||||||
fileCache = diskcache.NewNoOp()
|
fileCache = diskcache.NewNoOp()
|
||||||
}
|
}
|
||||||
|
fbhttp.StartHttp(imgSvc, store, fileCache)
|
||||||
|
|
||||||
fbhttp.SetupEnv(store, serverConfig, fileCache)
|
|
||||||
|
|
||||||
_, err := os.Stat(serverConfig.Root)
|
|
||||||
utils.CheckErr(fmt.Sprint("cmd os.Stat ", serverConfig.Root), err)
|
|
||||||
var listener net.Listener
|
|
||||||
address := serverConfig.Address + ":" + strconv.Itoa(serverConfig.Port)
|
|
||||||
switch {
|
|
||||||
case serverConfig.Socket != "":
|
|
||||||
listener, err = net.Listen("unix", serverConfig.Socket)
|
|
||||||
utils.CheckErr("net.Listen", err)
|
|
||||||
err = os.Chmod(serverConfig.Socket, os.FileMode(0666)) // socket-perm
|
|
||||||
utils.CheckErr("os.Chmod", err)
|
|
||||||
case serverConfig.TLSKey != "" && serverConfig.TLSCert != "":
|
|
||||||
cer, err := tls.LoadX509KeyPair(serverConfig.TLSCert, serverConfig.TLSKey) //nolint:govet
|
|
||||||
utils.CheckErr("tls.LoadX509KeyPair", err)
|
|
||||||
listener, err = tls.Listen("tcp", address, &tls.Config{
|
|
||||||
MinVersion: tls.VersionTLS12,
|
|
||||||
Certificates: []tls.Certificate{cer}},
|
|
||||||
)
|
|
||||||
utils.CheckErr("tls.Listen", err)
|
|
||||||
default:
|
|
||||||
listener, err = net.Listen("tcp", address)
|
|
||||||
utils.CheckErr("net.Listen", err)
|
|
||||||
}
|
|
||||||
sigc := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
|
|
||||||
go cleanupHandler(listener, sigc)
|
|
||||||
if !nonEmbededFS {
|
|
||||||
assetsFs, err := fs.Sub(assets, "dist")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Could not embed frontend. Does backend/cmd/dist exist? Must be built and exist first")
|
|
||||||
}
|
|
||||||
handler, err := fbhttp.NewHandler(imgSvc, assetsFs)
|
|
||||||
utils.CheckErr("fbhttp.NewHandler", err)
|
|
||||||
defer listener.Close()
|
|
||||||
log.Println("Listening on", listener.Addr().String())
|
|
||||||
//nolint: gosec
|
|
||||||
if err := http.Serve(listener, handler); err != nil {
|
|
||||||
log.Fatalf("Could not start server on port %d: %v", serverConfig.Port, err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
assetsFs := dirFS{Dir: http.Dir("frontend/dist")}
|
|
||||||
handler, err := fbhttp.NewHandler(imgSvc, assetsFs)
|
|
||||||
utils.CheckErr("fbhttp.NewHandler", err)
|
|
||||||
defer listener.Close()
|
|
||||||
log.Println("Listening on", listener.Addr().String())
|
|
||||||
//nolint: gosec
|
|
||||||
if err := http.Serve(listener, handler); err != nil {
|
|
||||||
log.Fatalf("Could not start server on port %d: %v", serverConfig.Port, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/rules"
|
|
||||||
"github.com/gtsteffaniak/filebrowser/settings"
|
"github.com/gtsteffaniak/filebrowser/settings"
|
||||||
"github.com/gtsteffaniak/filebrowser/storage"
|
"github.com/gtsteffaniak/filebrowser/storage"
|
||||||
"github.com/gtsteffaniak/filebrowser/users"
|
"github.com/gtsteffaniak/filebrowser/users"
|
||||||
|
@ -66,7 +65,7 @@ func getUserIdentifier(flags *pflag.FlagSet) interface{} {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func printRules(rulez []rules.Rule, id interface{}) {
|
func printRules(rulez []users.Rule, id interface{}) {
|
||||||
|
|
||||||
for id, rule := range rulez {
|
for id, rule := range rulez {
|
||||||
fmt.Printf("(%d) ", id)
|
fmt.Printf("(%d) ", id)
|
||||||
|
|
|
@ -5,7 +5,6 @@ import (
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/rules"
|
|
||||||
"github.com/gtsteffaniak/filebrowser/settings"
|
"github.com/gtsteffaniak/filebrowser/settings"
|
||||||
"github.com/gtsteffaniak/filebrowser/storage"
|
"github.com/gtsteffaniak/filebrowser/storage"
|
||||||
"github.com/gtsteffaniak/filebrowser/users"
|
"github.com/gtsteffaniak/filebrowser/users"
|
||||||
|
@ -32,13 +31,13 @@ var rulesAddCmd = &cobra.Command{
|
||||||
regexp.MustCompile(exp)
|
regexp.MustCompile(exp)
|
||||||
}
|
}
|
||||||
|
|
||||||
rule := rules.Rule{
|
rule := users.Rule{
|
||||||
Allow: allow,
|
Allow: allow,
|
||||||
Regex: regex,
|
Regex: regex,
|
||||||
}
|
}
|
||||||
|
|
||||||
if regex {
|
if regex {
|
||||||
rule.Regexp = &rules.Regexp{Raw: exp}
|
rule.Regexp = &users.Regexp{Raw: exp}
|
||||||
} else {
|
} else {
|
||||||
rule.Path = exp
|
rule.Path = exp
|
||||||
}
|
}
|
||||||
|
|
Binary file not shown.
|
@ -19,8 +19,9 @@ import (
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/errors"
|
"github.com/gtsteffaniak/filebrowser/errors"
|
||||||
"github.com/gtsteffaniak/filebrowser/rules"
|
"github.com/gtsteffaniak/filebrowser/fileutils"
|
||||||
"github.com/gtsteffaniak/filebrowser/settings"
|
"github.com/gtsteffaniak/filebrowser/settings"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -32,31 +33,30 @@ type ReducedItem struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
ModTime time.Time `json:"modified"`
|
ModTime time.Time `json:"modified"`
|
||||||
IsDir bool `json:"isDir,omitempty"`
|
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
Mode os.FileMode `json:"-"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileInfo describes a file.
|
// FileInfo describes a file.
|
||||||
// reduced item is non-recursive reduced "Items", used to pass flat items array
|
// reduced item is non-recursive reduced "Items", used to pass flat items array
|
||||||
type FileInfo struct {
|
type FileInfo struct {
|
||||||
Items []*FileInfo `json:"-"`
|
Files []ReducedItem `json:"-"`
|
||||||
ReducedItems []ReducedItem `json:"items,omitempty"`
|
Dirs map[string]*FileInfo `json:"-"`
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Items []ReducedItem `json:"items"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Extension string `json:"-"`
|
Extension string `json:"-"`
|
||||||
ModTime time.Time `json:"modified"`
|
ModTime time.Time `json:"modified"`
|
||||||
CacheTime time.Time `json:"-"`
|
CacheTime time.Time `json:"-"`
|
||||||
Mode os.FileMode `json:"-"`
|
Mode os.FileMode `json:"-"`
|
||||||
IsDir bool `json:"isDir,omitempty"`
|
|
||||||
IsSymlink bool `json:"isSymlink,omitempty"`
|
IsSymlink bool `json:"isSymlink,omitempty"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Subtitles []string `json:"subtitles,omitempty"`
|
Subtitles []string `json:"subtitles,omitempty"`
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
Checksums map[string]string `json:"checksums,omitempty"`
|
Checksums map[string]string `json:"checksums,omitempty"`
|
||||||
Token string `json:"token,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
NumDirs int `json:"numDirs"`
|
|
||||||
NumFiles int `json:"numFiles"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileOptions are the options when getting a file info.
|
// FileOptions are the options when getting a file info.
|
||||||
|
@ -67,58 +67,18 @@ type FileOptions struct {
|
||||||
Expand bool
|
Expand bool
|
||||||
ReadHeader bool
|
ReadHeader bool
|
||||||
Token string
|
Token string
|
||||||
Checker rules.Checker
|
Checker users.Checker
|
||||||
Content bool
|
Content bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy file info method, only called on non-indexed directories.
|
func (f FileOptions) Components() (string, string) {
|
||||||
// Once indexing completes for the first time, NewFileInfo is never called.
|
return filepath.Dir(f.Path), filepath.Base(f.Path)
|
||||||
func NewFileInfo(opts FileOptions) (*FileInfo, error) {
|
|
||||||
|
|
||||||
index := GetIndex(rootPath)
|
|
||||||
if !opts.Checker.Check(opts.Path) {
|
|
||||||
return nil, os.ErrPermission
|
|
||||||
}
|
|
||||||
file, err := stat(opts)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if opts.Expand {
|
|
||||||
if file.IsDir {
|
|
||||||
if err = file.readListing(opts.Path, opts.Checker, opts.ReadHeader); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
cleanedItems := []ReducedItem{}
|
|
||||||
for _, item := range file.Items {
|
|
||||||
// This is particularly useful for root of index, while indexing hasn't finished.
|
|
||||||
// adds the directory sizes for directories that have been indexed already.
|
|
||||||
if item.IsDir {
|
|
||||||
adjustedPath := index.makeIndexPath(opts.Path+"/"+item.Name, true)
|
|
||||||
info, _ := index.GetMetadataInfo(adjustedPath)
|
|
||||||
item.Size = info.Size
|
|
||||||
}
|
|
||||||
cleanedItems = append(cleanedItems, ReducedItem{
|
|
||||||
Name: item.Name,
|
|
||||||
Size: item.Size,
|
|
||||||
IsDir: item.IsDir,
|
|
||||||
ModTime: item.ModTime,
|
|
||||||
Type: item.Type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
file.Items = nil
|
|
||||||
file.ReducedItems = cleanedItems
|
|
||||||
return file, nil
|
|
||||||
}
|
|
||||||
err = file.detectType(opts.Path, opts.Modify, opts.Content, true)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return file, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func FileInfoFaster(opts FileOptions) (*FileInfo, error) {
|
func FileInfoFaster(opts FileOptions) (*FileInfo, error) {
|
||||||
|
index := GetIndex(rootPath)
|
||||||
|
opts.Path = index.makeIndexPath(opts.Path)
|
||||||
|
|
||||||
// Lock access for the specific path
|
// Lock access for the specific path
|
||||||
pathMutex := getMutex(opts.Path)
|
pathMutex := getMutex(opts.Path)
|
||||||
pathMutex.Lock()
|
pathMutex.Lock()
|
||||||
|
@ -126,68 +86,94 @@ func FileInfoFaster(opts FileOptions) (*FileInfo, error) {
|
||||||
if !opts.Checker.Check(opts.Path) {
|
if !opts.Checker.Check(opts.Path) {
|
||||||
return nil, os.ErrPermission
|
return nil, os.ErrPermission
|
||||||
}
|
}
|
||||||
index := GetIndex(rootPath)
|
_, isDir, err := GetRealPath(opts.Path)
|
||||||
adjustedPath := index.makeIndexPath(opts.Path, opts.IsDir)
|
if err != nil {
|
||||||
if opts.IsDir {
|
return nil, err
|
||||||
info, exists := index.GetMetadataInfo(adjustedPath)
|
}
|
||||||
if exists && !opts.Content {
|
opts.IsDir = isDir
|
||||||
|
// 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
|
// Let's not refresh if less than a second has passed
|
||||||
if time.Since(info.CacheTime) > time.Second {
|
if time.Since(info.CacheTime) > time.Second {
|
||||||
go RefreshFileInfo(opts) //nolint:errcheck
|
RefreshFileInfo(opts) //nolint:errcheck
|
||||||
}
|
}
|
||||||
// refresh cache after
|
|
||||||
return &info, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// don't bother caching content
|
|
||||||
if opts.Content {
|
if opts.Content {
|
||||||
file, err := NewFileInfo(opts)
|
content := ""
|
||||||
return file, err
|
content, err = getContent(opts.Path)
|
||||||
}
|
|
||||||
err := RefreshFileInfo(opts)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
file, err := NewFileInfo(opts)
|
return info, err
|
||||||
return file, err
|
|
||||||
}
|
}
|
||||||
info, exists := index.GetMetadataInfo(adjustedPath + "/" + filepath.Base(opts.Path))
|
info.Content = content
|
||||||
if !exists || info.Name == "" {
|
|
||||||
return NewFileInfo(opts)
|
|
||||||
}
|
}
|
||||||
return &info, nil
|
return info, nil
|
||||||
|
}
|
||||||
|
err = RefreshFileInfo(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info, exists = index.GetReducedMetadata(opts.Path, opts.IsDir)
|
||||||
|
if !exists {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if opts.Content {
|
||||||
|
content, err := getContent(opts.Path)
|
||||||
|
if err != nil {
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
info.Content = content
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func RefreshFileInfo(opts FileOptions) error {
|
func RefreshFileInfo(opts FileOptions) error {
|
||||||
if !opts.Checker.Check(opts.Path) {
|
refreshOptions := FileOptions{
|
||||||
return fmt.Errorf("permission denied: %s", opts.Path)
|
Path: opts.Path,
|
||||||
|
IsDir: opts.IsDir,
|
||||||
|
Token: opts.Token,
|
||||||
}
|
}
|
||||||
index := GetIndex(rootPath)
|
index := GetIndex(rootPath)
|
||||||
adjustedPath := index.makeIndexPath(opts.Path, opts.IsDir)
|
|
||||||
file, err := stat(opts)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("File/folder does not exist to refresh data: %s", opts.Path)
|
return fmt.Errorf("file/folder does not exist to refresh data: %s", refreshOptions.Path)
|
||||||
}
|
}
|
||||||
_ = file.detectType(opts.Path, true, opts.Content, opts.ReadHeader)
|
|
||||||
if file.IsDir {
|
//utils.PrintStructFields(*file)
|
||||||
err := file.readListing(opts.Path, opts.Checker, opts.ReadHeader)
|
result := index.UpdateMetadata(file)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Dir info could not be read: %s", opts.Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result := index.UpdateFileMetadata(adjustedPath, *file)
|
|
||||||
if !result {
|
if !result {
|
||||||
return fmt.Errorf("File/folder does not exist in metadata: %s", adjustedPath)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func stat(opts FileOptions) (*FileInfo, error) {
|
func stat(opts FileOptions) (*FileInfo, error) {
|
||||||
info, err := os.Lstat(opts.Path)
|
realPath, _, err := GetRealPath(rootPath, opts.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info, err := os.Lstat(realPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
file := &FileInfo{
|
file := &FileInfo{
|
||||||
Path: opts.Path,
|
Path: opts.Path,
|
||||||
Name: info.Name(),
|
Name: filepath.Base(opts.Path),
|
||||||
ModTime: info.ModTime(),
|
ModTime: info.ModTime(),
|
||||||
Mode: info.Mode(),
|
Mode: info.Mode(),
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
|
@ -195,32 +181,98 @@ func stat(opts FileOptions) (*FileInfo, error) {
|
||||||
Token: opts.Token,
|
Token: opts.Token,
|
||||||
}
|
}
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
file.IsDir = true
|
// Open and read directory contents
|
||||||
|
dir, err := os.Open(realPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
if info.Mode()&os.ModeSymlink != 0 {
|
defer dir.Close()
|
||||||
file.IsSymlink = true
|
|
||||||
targetInfo, err := os.Stat(opts.Path)
|
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 {
|
if err == nil {
|
||||||
file.Size = targetInfo.Size()
|
itemInfo.Name = info.Name()
|
||||||
file.IsDir = targetInfo.IsDir()
|
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
|
return file, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checksum checksums a given File for a given User, using a specific
|
// Checksum checksums a given File for a given User, using a specific
|
||||||
// algorithm. The checksums data is saved on File object.
|
// algorithm. The checksums data is saved on File object.
|
||||||
func (i *FileInfo) Checksum(algo string) error {
|
func (i *FileInfo) Checksum(algo string) error {
|
||||||
if i.IsDir {
|
|
||||||
return errors.ErrIsDirectory
|
|
||||||
}
|
|
||||||
|
|
||||||
if i.Checksums == nil {
|
if i.Checksums == nil {
|
||||||
i.Checksums = map[string]string{}
|
i.Checksums = map[string]string{}
|
||||||
}
|
}
|
||||||
|
fullpath := filepath.Join(i.Path, i.Name)
|
||||||
reader, err := os.Open(i.Path)
|
reader, err := os.Open(fullpath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -265,10 +317,7 @@ func GetRealPath(relativePath ...string) (string, bool, error) {
|
||||||
// Convert relative path to absolute path
|
// Convert relative path to absolute path
|
||||||
absolutePath, err := filepath.Abs(joinedPath)
|
absolutePath, err := filepath.Abs(joinedPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, err
|
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", combined, err)
|
||||||
}
|
|
||||||
if !Exists(absolutePath) {
|
|
||||||
return absolutePath, false, nil // return without error
|
|
||||||
}
|
}
|
||||||
// Resolve symlinks and get the real path
|
// Resolve symlinks and get the real path
|
||||||
return resolveSymlinks(absolutePath)
|
return resolveSymlinks(absolutePath)
|
||||||
|
@ -279,8 +328,48 @@ func DeleteFiles(absPath string, opts FileOptions) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
opts.Path = filepath.Dir(absPath)
|
|
||||||
err = RefreshFileInfo(opts)
|
err = RefreshFileInfo(opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MoveResource(realsrc, realdst string, isSrcDir bool) error {
|
||||||
|
err := fileutils.MoveFile(realsrc, realdst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// refresh info for source and dest
|
||||||
|
err = RefreshFileInfo(FileOptions{
|
||||||
|
Path: realsrc,
|
||||||
|
IsDir: isSrcDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return errors.ErrEmptyKey
|
||||||
|
}
|
||||||
|
refreshConfig := FileOptions{Path: realdst, IsDir: true}
|
||||||
|
if !isSrcDir {
|
||||||
|
refreshConfig.Path = filepath.Dir(realdst)
|
||||||
|
}
|
||||||
|
err = RefreshFileInfo(refreshConfig)
|
||||||
|
if err != nil {
|
||||||
|
return errors.ErrEmptyKey
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyResource(realsrc, realdst string, isSrcDir bool) error {
|
||||||
|
err := fileutils.CopyFile(realsrc, realdst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshConfig := FileOptions{Path: realdst, IsDir: true}
|
||||||
|
if !isSrcDir {
|
||||||
|
refreshConfig.Path = filepath.Dir(realdst)
|
||||||
|
}
|
||||||
|
err = RefreshFileInfo(refreshConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.ErrEmptyKey
|
return errors.ErrEmptyKey
|
||||||
}
|
}
|
||||||
|
@ -288,12 +377,12 @@ func DeleteFiles(absPath string, opts FileOptions) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func WriteDirectory(opts FileOptions) error {
|
func WriteDirectory(opts FileOptions) error {
|
||||||
|
realPath, _, _ := GetRealPath(rootPath, opts.Path)
|
||||||
// Ensure the parent directories exist
|
// Ensure the parent directories exist
|
||||||
err := os.MkdirAll(opts.Path, 0775)
|
err := os.MkdirAll(realPath, 0775)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
opts.Path = filepath.Dir(opts.Path)
|
|
||||||
err = RefreshFileInfo(opts)
|
err = RefreshFileInfo(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.ErrEmptyKey
|
return errors.ErrEmptyKey
|
||||||
|
@ -339,7 +428,7 @@ func resolveSymlinks(path string) (string, bool, error) {
|
||||||
// Get the file info
|
// Get the file info
|
||||||
info, err := os.Lstat(path)
|
info, err := os.Lstat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, err
|
return path, false, fmt.Errorf("could not stat path: %v, %s", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a symlink
|
// Check if it's a symlink
|
||||||
|
@ -347,7 +436,7 @@ func resolveSymlinks(path string) (string, bool, error) {
|
||||||
// Read the symlink target
|
// Read the symlink target
|
||||||
target, err := os.Readlink(path)
|
target, err := os.Readlink(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, err
|
return path, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the target relative to the symlink's directory
|
// Resolve the target relative to the symlink's directory
|
||||||
|
@ -360,78 +449,83 @@ func resolveSymlinks(path string) (string, bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// addContent reads and sets content based on the file type.
|
// addContent reads and sets content based on the file type.
|
||||||
func (i *FileInfo) addContent(path string) error {
|
func getContent(path string) (string, error) {
|
||||||
if !i.IsDir {
|
realPath, _, err := GetRealPath(rootPath, path)
|
||||||
content, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(realPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
stringContent := string(content)
|
stringContent := string(content)
|
||||||
if !utf8.ValidString(stringContent) {
|
if !utf8.ValidString(stringContent) {
|
||||||
return nil
|
return "", fmt.Errorf("file is not utf8 encoded")
|
||||||
}
|
}
|
||||||
if stringContent == "" {
|
if stringContent == "" {
|
||||||
i.Content = "empty-file-x6OlSil"
|
return "empty-file-x6OlSil", nil
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
i.Content = stringContent
|
return stringContent, nil
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectType detects the file type.
|
// detectType detects the file type.
|
||||||
func (i *FileInfo) detectType(path string, modify, saveContent, readHeader bool) error {
|
func (i *ReducedItem) detectType(path string, modify, saveContent, readHeader bool) error {
|
||||||
if i.IsDir {
|
name := i.Name
|
||||||
return nil
|
var contentErr error
|
||||||
}
|
var contentString string
|
||||||
if IsNamedPipe(i.Mode) {
|
|
||||||
i.Type = "blob"
|
|
||||||
if saveContent {
|
if saveContent {
|
||||||
return i.addContent(path)
|
contentString, contentErr = getContent(path)
|
||||||
|
if contentErr == nil {
|
||||||
|
i.Content = contentString
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if IsNamedPipe(i.Mode) {
|
||||||
|
i.Type = "blob"
|
||||||
|
return contentErr
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(name)
|
||||||
var buffer []byte
|
var buffer []byte
|
||||||
if readHeader {
|
if readHeader {
|
||||||
buffer = i.readFirstBytes()
|
buffer = i.readFirstBytes(path)
|
||||||
mimetype := mime.TypeByExtension(i.Extension)
|
mimetype := mime.TypeByExtension(ext)
|
||||||
if mimetype == "" {
|
if mimetype == "" {
|
||||||
http.DetectContentType(buffer)
|
http.DetectContentType(buffer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := filepath.Ext(i.Name)
|
|
||||||
for _, fileType := range AllFiletypeOptions {
|
for _, fileType := range AllFiletypeOptions {
|
||||||
if IsMatchingType(ext, fileType) {
|
if IsMatchingType(ext, fileType) {
|
||||||
i.Type = fileType
|
i.Type = fileType
|
||||||
}
|
}
|
||||||
|
|
||||||
switch i.Type {
|
switch i.Type {
|
||||||
case "text":
|
case "text":
|
||||||
if !modify {
|
if !modify {
|
||||||
i.Type = "textImmutable"
|
i.Type = "textImmutable"
|
||||||
}
|
}
|
||||||
if saveContent {
|
if saveContent {
|
||||||
return i.addContent(path)
|
return contentErr
|
||||||
}
|
}
|
||||||
case "video":
|
case "video":
|
||||||
parentDir := strings.TrimRight(path, i.Name)
|
// TODO add back somewhere else, not during metadata fetch
|
||||||
i.detectSubtitles(parentDir)
|
//parentDir := strings.TrimRight(path, name)
|
||||||
|
//i.detectSubtitles(parentDir)
|
||||||
case "doc":
|
case "doc":
|
||||||
if ext == ".pdf" {
|
if ext == ".pdf" {
|
||||||
i.Type = "pdf"
|
i.Type = "pdf"
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if saveContent {
|
if saveContent {
|
||||||
return i.addContent(path)
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if i.Type == "" {
|
if i.Type == "" {
|
||||||
i.Type = "blob"
|
i.Type = "blob"
|
||||||
if saveContent {
|
if saveContent {
|
||||||
return i.addContent(path)
|
return contentErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -439,8 +533,8 @@ func (i *FileInfo) detectType(path string, modify, saveContent, readHeader bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
// readFirstBytes reads the first bytes of the file.
|
// readFirstBytes reads the first bytes of the file.
|
||||||
func (i *FileInfo) readFirstBytes() []byte {
|
func (i *ReducedItem) readFirstBytes(path string) []byte {
|
||||||
file, err := os.Open(i.Path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
i.Type = "blob"
|
i.Type = "blob"
|
||||||
return nil
|
return nil
|
||||||
|
@ -458,113 +552,42 @@ func (i *FileInfo) readFirstBytes() []byte {
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectSubtitles detects subtitles for video files.
|
// detectSubtitles detects subtitles for video files.
|
||||||
func (i *FileInfo) detectSubtitles(parentDir string) {
|
//func (i *FileInfo) detectSubtitles(path string) {
|
||||||
if i.Type != "video" {
|
// if i.Type != "video" {
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
i.Subtitles = []string{}
|
// parentDir := filepath.Dir(path)
|
||||||
ext := filepath.Ext(i.Name)
|
// fileName := filepath.Base(path)
|
||||||
dir, err := os.Open(parentDir)
|
// i.Subtitles = []string{}
|
||||||
if err != nil {
|
// ext := filepath.Ext(fileName)
|
||||||
// Directory must have been deleted, remove it from the index
|
// dir, err := os.Open(parentDir)
|
||||||
return
|
// if err != nil {
|
||||||
}
|
// // Directory must have been deleted, remove it from the index
|
||||||
defer dir.Close() // Ensure directory handle is closed
|
// return
|
||||||
|
// }
|
||||||
files, err := dir.Readdir(-1)
|
// defer dir.Close() // Ensure directory handle is closed
|
||||||
if err != nil {
|
//
|
||||||
return
|
// files, err := dir.Readdir(-1)
|
||||||
}
|
// if err != nil {
|
||||||
|
// return
|
||||||
base := strings.TrimSuffix(i.Name, ext)
|
// }
|
||||||
subtitleExts := []string{".vtt", ".txt", ".srt", ".lrc"}
|
//
|
||||||
|
// base := strings.TrimSuffix(fileName, ext)
|
||||||
for _, f := range files {
|
// subtitleExts := []string{".vtt", ".txt", ".srt", ".lrc"}
|
||||||
if f.IsDir() || !strings.HasPrefix(f.Name(), base) {
|
//
|
||||||
continue
|
// for _, f := range files {
|
||||||
}
|
// if f.IsDir() || !strings.HasPrefix(f.Name(), base) {
|
||||||
|
// continue
|
||||||
for _, subtitleExt := range subtitleExts {
|
// }
|
||||||
if strings.HasSuffix(f.Name(), subtitleExt) {
|
//
|
||||||
i.Subtitles = append(i.Subtitles, filepath.Join(parentDir, f.Name()))
|
// for _, subtitleExt := range subtitleExts {
|
||||||
break
|
// if strings.HasSuffix(f.Name(), subtitleExt) {
|
||||||
}
|
// i.Subtitles = append(i.Subtitles, filepath.Join(parentDir, f.Name()))
|
||||||
}
|
// break
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
// }
|
||||||
// readListing reads the contents of a directory and fills the listing.
|
//}
|
||||||
func (i *FileInfo) readListing(path string, checker rules.Checker, readHeader bool) error {
|
|
||||||
dir, err := os.Open(i.Path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer dir.Close()
|
|
||||||
|
|
||||||
files, err := dir.Readdir(-1)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
listing := &FileInfo{
|
|
||||||
Items: []*FileInfo{},
|
|
||||||
NumDirs: 0,
|
|
||||||
NumFiles: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, f := range files {
|
|
||||||
name := f.Name()
|
|
||||||
fPath := filepath.Join(i.Path, name)
|
|
||||||
|
|
||||||
if !checker.Check(fPath) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
isSymlink, isInvalidLink := false, false
|
|
||||||
if IsSymlink(f.Mode()) {
|
|
||||||
isSymlink = true
|
|
||||||
info, err := os.Stat(fPath)
|
|
||||||
if err == nil {
|
|
||||||
f = info
|
|
||||||
} else {
|
|
||||||
isInvalidLink = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
file := &FileInfo{
|
|
||||||
Name: name,
|
|
||||||
Size: f.Size(),
|
|
||||||
ModTime: f.ModTime(),
|
|
||||||
Mode: f.Mode(),
|
|
||||||
}
|
|
||||||
if f.IsDir() {
|
|
||||||
file.IsDir = true
|
|
||||||
}
|
|
||||||
if isSymlink {
|
|
||||||
file.IsSymlink = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if file.IsDir {
|
|
||||||
listing.NumDirs++
|
|
||||||
} else {
|
|
||||||
listing.NumFiles++
|
|
||||||
|
|
||||||
if isInvalidLink {
|
|
||||||
file.Type = "invalid_link"
|
|
||||||
} else {
|
|
||||||
err := file.detectType(path, true, false, readHeader)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
listing.Items = append(listing.Items, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
i.Items = listing.Items
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsNamedPipe(mode os.FileMode) bool {
|
func IsNamedPipe(mode os.FileMode) bool {
|
||||||
return mode&os.ModeNamedPipe != 0
|
return mode&os.ModeNamedPipe != 0
|
||||||
|
|
|
@ -63,14 +63,11 @@ func Test_GetRealPath(t *testing.T) {
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
realPath, isDir, err := GetRealPath(tt.paths...)
|
realPath, isDir, _ := GetRealPath(tt.paths...)
|
||||||
adjustedRealPath := strings.TrimPrefix(realPath, trimPrefix)
|
adjustedRealPath := strings.TrimPrefix(realPath, trimPrefix)
|
||||||
if tt.want.path != adjustedRealPath || tt.want.isDir != isDir {
|
if tt.want.path != adjustedRealPath || tt.want.isDir != isDir {
|
||||||
t.Errorf("expected %v:%v but got: %v:%v", tt.want.path, tt.want.isDir, adjustedRealPath, isDir)
|
t.Errorf("expected %v:%v but got: %v:%v", tt.want.path, tt.want.isDir, adjustedRealPath, isDir)
|
||||||
}
|
}
|
||||||
if err != nil {
|
|
||||||
t.Error("got error", err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@ import (
|
||||||
|
|
||||||
type Index struct {
|
type Index struct {
|
||||||
Root string
|
Root string
|
||||||
Directories map[string]FileInfo
|
Directories map[string]*FileInfo
|
||||||
NumDirs int
|
NumDirs int
|
||||||
NumFiles int
|
NumFiles int
|
||||||
inProgress bool
|
inProgress bool
|
||||||
|
@ -43,7 +43,7 @@ func indexingScheduler(intervalMinutes uint32) {
|
||||||
// Set the indexing flag to indicate that indexing is in progress
|
// Set the indexing flag to indicate that indexing is in progress
|
||||||
si.resetCount()
|
si.resetCount()
|
||||||
// Perform the indexing operation
|
// Perform the indexing operation
|
||||||
err := si.indexFiles(si.Root)
|
err := si.indexFiles("/")
|
||||||
// Reset the indexing flag to indicate that indexing has finished
|
// Reset the indexing flag to indicate that indexing has finished
|
||||||
si.inProgress = false
|
si.inProgress = false
|
||||||
// Update the LastIndexed time
|
// Update the LastIndexed time
|
||||||
|
@ -64,15 +64,13 @@ func indexingScheduler(intervalMinutes uint32) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define a function to recursively index files and directories
|
// Define a function to recursively index files and directories
|
||||||
func (si *Index) indexFiles(path string) error {
|
func (si *Index) indexFiles(adjustedPath string) error {
|
||||||
// Ensure path is cleaned and normalized
|
realPath := strings.TrimRight(si.Root, "/") + adjustedPath
|
||||||
adjustedPath := si.makeIndexPath(path, true)
|
|
||||||
|
|
||||||
// Open the directory
|
// Open the directory
|
||||||
dir, err := os.Open(path)
|
dir, err := os.Open(realPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If the directory can't be opened (e.g., deleted), remove it from the index
|
si.RemoveDirectory(adjustedPath) // Remove if it can't be opened
|
||||||
si.RemoveDirectory(adjustedPath)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer dir.Close()
|
defer dir.Close()
|
||||||
|
@ -82,7 +80,7 @@ func (si *Index) indexFiles(path string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the directory is already up-to-date
|
// Skip directories that haven't been modified since the last index
|
||||||
if dirInfo.ModTime().Before(si.LastIndexed) {
|
if dirInfo.ModTime().Before(si.LastIndexed) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -93,90 +91,73 @@ func (si *Index) indexFiles(path string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recursively process files and directories
|
|
||||||
fileInfos := []*FileInfo{}
|
|
||||||
var totalSize int64
|
var totalSize int64
|
||||||
var numDirs, numFiles int
|
var numDirs, numFiles int
|
||||||
|
fileInfos := []ReducedItem{}
|
||||||
for _, file := range files {
|
dirInfos := map[string]*FileInfo{}
|
||||||
parentInfo := &FileInfo{
|
combinedPath := adjustedPath + "/"
|
||||||
Name: file.Name(),
|
if adjustedPath == "/" {
|
||||||
Size: file.Size(),
|
combinedPath = "/"
|
||||||
ModTime: file.ModTime(),
|
|
||||||
IsDir: file.IsDir(),
|
|
||||||
}
|
}
|
||||||
childInfo, err := si.InsertInfo(path, parentInfo)
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
// Log error, but continue processing other files
|
log.Printf("Failed to index directory %s: %v", itemInfo.Path, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Fetch the metadata for the subdirectory after indexing
|
||||||
// Accumulate directory size and items
|
subDirInfo, exists := si.GetMetadataInfo(itemInfo.Path, true)
|
||||||
totalSize += childInfo.Size
|
if exists {
|
||||||
if childInfo.IsDir {
|
itemInfo.Size = subDirInfo.Size
|
||||||
|
totalSize += subDirInfo.Size // Add subdirectory size to the total
|
||||||
|
}
|
||||||
|
dirInfos[itemInfo.Name] = itemInfo
|
||||||
numDirs++
|
numDirs++
|
||||||
} else {
|
} 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++
|
numFiles++
|
||||||
}
|
}
|
||||||
_ = childInfo.detectType(path, true, false, false)
|
|
||||||
fileInfos = append(fileInfos, childInfo)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create FileInfo for the current directory
|
// Create FileInfo for the current directory
|
||||||
dirFileInfo := &FileInfo{
|
dirFileInfo := &FileInfo{
|
||||||
Items: fileInfos,
|
Path: adjustedPath,
|
||||||
Name: filepath.Base(path),
|
Files: fileInfos,
|
||||||
|
Dirs: dirInfos,
|
||||||
Size: totalSize,
|
Size: totalSize,
|
||||||
ModTime: dirInfo.ModTime(),
|
ModTime: dirInfo.ModTime(),
|
||||||
CacheTime: time.Now(),
|
|
||||||
IsDir: true,
|
|
||||||
NumDirs: numDirs,
|
|
||||||
NumFiles: numFiles,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add directory to index
|
// Update the current directory metadata in the index
|
||||||
si.mu.Lock()
|
si.UpdateMetadata(dirFileInfo)
|
||||||
si.Directories[adjustedPath] = *dirFileInfo
|
|
||||||
si.NumDirs += numDirs
|
si.NumDirs += numDirs
|
||||||
si.NumFiles += numFiles
|
si.NumFiles += numFiles
|
||||||
si.mu.Unlock()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertInfo function to handle adding a file or directory into the index
|
func (si *Index) makeIndexPath(subPath string) string {
|
||||||
func (si *Index) InsertInfo(parentPath string, file *FileInfo) (*FileInfo, error) {
|
if strings.HasPrefix(subPath, "./") {
|
||||||
filePath := filepath.Join(parentPath, file.Name)
|
subPath = strings.TrimPrefix(subPath, ".")
|
||||||
|
|
||||||
// Check if it's a directory and recursively index it
|
|
||||||
if file.IsDir {
|
|
||||||
// Recursively index directory
|
|
||||||
err := si.indexFiles(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(subPath, ".") || si.Root == subPath {
|
||||||
// Return directory info from the index
|
|
||||||
adjustedPath := si.makeIndexPath(filePath, true)
|
|
||||||
si.mu.RLock()
|
|
||||||
dirInfo := si.Directories[adjustedPath]
|
|
||||||
si.mu.RUnlock()
|
|
||||||
return &dirInfo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create FileInfo for regular files
|
|
||||||
fileInfo := &FileInfo{
|
|
||||||
Path: filePath,
|
|
||||||
Name: file.Name,
|
|
||||||
Size: file.Size,
|
|
||||||
ModTime: file.ModTime,
|
|
||||||
IsDir: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
return fileInfo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (si *Index) makeIndexPath(subPath string, isDir bool) string {
|
|
||||||
if si.Root == subPath {
|
|
||||||
return "/"
|
return "/"
|
||||||
}
|
}
|
||||||
// clean path
|
// clean path
|
||||||
|
@ -185,14 +166,39 @@ func (si *Index) makeIndexPath(subPath string, isDir bool) string {
|
||||||
adjustedPath := strings.TrimPrefix(subPath, si.Root)
|
adjustedPath := strings.TrimPrefix(subPath, si.Root)
|
||||||
// remove trailing slash
|
// remove trailing slash
|
||||||
adjustedPath = strings.TrimSuffix(adjustedPath, "/")
|
adjustedPath = strings.TrimSuffix(adjustedPath, "/")
|
||||||
// add leading slash for root of index
|
|
||||||
if adjustedPath == "" {
|
|
||||||
adjustedPath = "/"
|
|
||||||
} else if !isDir {
|
|
||||||
adjustedPath = filepath.Dir(adjustedPath)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(adjustedPath, "/") {
|
if !strings.HasPrefix(adjustedPath, "/") {
|
||||||
adjustedPath = "/" + adjustedPath
|
adjustedPath = "/" + adjustedPath
|
||||||
}
|
}
|
||||||
return 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)
|
||||||
|
}
|
||||||
|
|
|
@ -2,8 +2,8 @@ package files
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
@ -23,27 +23,26 @@ func BenchmarkFillIndex(b *testing.B) {
|
||||||
|
|
||||||
func (si *Index) createMockData(numDirs, numFilesPerDir int) {
|
func (si *Index) createMockData(numDirs, numFilesPerDir int) {
|
||||||
for i := 0; i < numDirs; i++ {
|
for i := 0; i < numDirs; i++ {
|
||||||
dirName := generateRandomPath(rand.Intn(3) + 1)
|
dirPath := generateRandomPath(rand.Intn(3) + 1)
|
||||||
files := []*FileInfo{} // Slice of FileInfo
|
files := []ReducedItem{} // Slice of FileInfo
|
||||||
|
|
||||||
// Simulating files and directories with FileInfo
|
// Simulating files and directories with FileInfo
|
||||||
for j := 0; j < numFilesPerDir; j++ {
|
for j := 0; j < numFilesPerDir; j++ {
|
||||||
newFile := &FileInfo{
|
newFile := ReducedItem{
|
||||||
Name: "file-" + getRandomTerm() + getRandomExtension(),
|
Name: "file-" + getRandomTerm() + getRandomExtension(),
|
||||||
IsDir: false,
|
|
||||||
Size: rand.Int63n(1000), // Random size
|
Size: rand.Int63n(1000), // Random size
|
||||||
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
|
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
|
||||||
|
Type: "blob",
|
||||||
}
|
}
|
||||||
files = append(files, newFile)
|
files = append(files, newFile)
|
||||||
}
|
}
|
||||||
|
dirInfo := &FileInfo{
|
||||||
|
Name: filepath.Base(dirPath),
|
||||||
|
Path: dirPath,
|
||||||
|
Files: files,
|
||||||
|
}
|
||||||
|
|
||||||
// Simulate inserting files into index
|
si.UpdateMetadata(dirInfo)
|
||||||
for _, file := range files {
|
|
||||||
_, err := si.InsertInfo(dirName, file)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("Error inserting file:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
package files
|
package files
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math/rand"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
"github.com/gtsteffaniak/filebrowser/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -14,123 +14,116 @@ var (
|
||||||
maxSearchResults = 100
|
maxSearchResults = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
func (si *Index) Search(search string, scope string, sourceSession string) ([]string, map[string]map[string]bool) {
|
type searchResult struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (si *Index) Search(search string, scope string, sourceSession string) []searchResult {
|
||||||
// Remove slashes
|
// Remove slashes
|
||||||
scope = strings.TrimLeft(scope, "/")
|
scope = si.makeIndexPath(scope)
|
||||||
scope = strings.TrimRight(scope, "/")
|
runningHash := utils.GenerateRandomHash(4)
|
||||||
runningHash := generateRandomHash(4)
|
|
||||||
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
|
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
|
||||||
searchOptions := ParseSearch(search)
|
searchOptions := ParseSearch(search)
|
||||||
fileListTypes := make(map[string]map[string]bool)
|
results := make(map[string]searchResult, 0)
|
||||||
matching := []string{}
|
|
||||||
count := 0
|
count := 0
|
||||||
|
directories := si.getDirsInScope(scope)
|
||||||
for _, searchTerm := range searchOptions.Terms {
|
for _, searchTerm := range searchOptions.Terms {
|
||||||
if searchTerm == "" {
|
if searchTerm == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if count > maxSearchResults {
|
||||||
|
break
|
||||||
|
}
|
||||||
si.mu.Lock()
|
si.mu.Lock()
|
||||||
for dirName, dir := range si.Directories {
|
for _, dirName := range directories {
|
||||||
isDir := true
|
|
||||||
files := []string{}
|
|
||||||
for _, item := range dir.Items {
|
|
||||||
if !item.IsDir {
|
|
||||||
files = append(files, item.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
value, found := sessionInProgress.Load(sourceSession)
|
|
||||||
if !found || value != runningHash {
|
|
||||||
si.mu.Unlock()
|
si.mu.Unlock()
|
||||||
return []string{}, map[string]map[string]bool{}
|
dir, found := si.GetReducedMetadata(dirName, true)
|
||||||
|
si.mu.Lock()
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if count > maxSearchResults {
|
if count > maxSearchResults {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
pathName := scopedPathNameFilter(dirName, scope, isDir)
|
reducedDir := ReducedItem{
|
||||||
if pathName == "" {
|
Name: filepath.Base(dirName),
|
||||||
continue // path not matched
|
Type: "directory",
|
||||||
}
|
Size: dir.Size,
|
||||||
fileTypes := map[string]bool{}
|
|
||||||
si.mu.Unlock()
|
|
||||||
matches, fileType := si.containsSearchTerm(dirName, searchTerm, *searchOptions, isDir, fileTypes)
|
|
||||||
si.mu.Lock()
|
|
||||||
if matches {
|
|
||||||
fileListTypes[pathName] = fileType
|
|
||||||
matching = append(matching, pathName)
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
isDir = false
|
|
||||||
for _, file := range files {
|
|
||||||
if file == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value, found := sessionInProgress.Load(sourceSession)
|
|
||||||
if !found || value != runningHash {
|
|
||||||
return []string{}, map[string]map[string]bool{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|
||||||
|
fullPath := dirName + "/" + item.Name
|
||||||
|
if item.Type == "directory" {
|
||||||
|
fullPath += "/"
|
||||||
|
}
|
||||||
|
value, found := sessionInProgress.Load(sourceSession)
|
||||||
|
if !found || value != runningHash {
|
||||||
|
si.mu.Unlock()
|
||||||
|
return []searchResult{}
|
||||||
|
}
|
||||||
if count > maxSearchResults {
|
if count > maxSearchResults {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
fullName := strings.TrimLeft(pathName+file, "/")
|
matches := item.containsSearchTerm(searchTerm, searchOptions)
|
||||||
fileTypes := map[string]bool{}
|
if matches {
|
||||||
si.mu.Unlock()
|
scopedPath := strings.TrimPrefix(strings.TrimPrefix(fullPath, scope), "/")
|
||||||
matches, fileType := si.containsSearchTerm(fullName, searchTerm, *searchOptions, isDir, fileTypes)
|
results[scopedPath] = searchResult{Path: scopedPath, Type: item.Type, Size: item.Size}
|
||||||
si.mu.Lock()
|
|
||||||
if !matches {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fileListTypes[fullName] = fileType
|
|
||||||
matching = append(matching, fullName)
|
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
si.mu.Unlock()
|
si.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort keys based on the number of elements in the path after splitting by "/"
|
||||||
|
sortedKeys := make([]searchResult, 0, len(results))
|
||||||
|
for _, v := range results {
|
||||||
|
sortedKeys = append(sortedKeys, v)
|
||||||
|
}
|
||||||
// Sort the strings based on the number of elements after splitting by "/"
|
// Sort the strings based on the number of elements after splitting by "/"
|
||||||
sort.Slice(matching, func(i, j int) bool {
|
sort.Slice(sortedKeys, func(i, j int) bool {
|
||||||
parts1 := strings.Split(matching[i], "/")
|
parts1 := strings.Split(sortedKeys[i].Path, "/")
|
||||||
parts2 := strings.Split(matching[j], "/")
|
parts2 := strings.Split(sortedKeys[j].Path, "/")
|
||||||
return len(parts1) < len(parts2)
|
return len(parts1) < len(parts2)
|
||||||
})
|
})
|
||||||
return matching, fileListTypes
|
return sortedKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
func scopedPathNameFilter(pathName string, scope string, isDir bool) string {
|
// returns true if the file name contains the search term
|
||||||
pathName = strings.TrimLeft(pathName, "/")
|
// returns file type if the file name contains the search term
|
||||||
pathName = strings.TrimRight(pathName, "/")
|
// returns size of file/dir if the file name contains the search term
|
||||||
if strings.HasPrefix(pathName, scope) || scope == "" {
|
func (fi ReducedItem) containsSearchTerm(searchTerm string, options *SearchOptions) bool {
|
||||||
pathName = strings.TrimPrefix(pathName, scope)
|
|
||||||
pathName = strings.TrimLeft(pathName, "/")
|
|
||||||
if isDir {
|
|
||||||
pathName = pathName + "/"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
pathName = "" // return not matched
|
|
||||||
}
|
|
||||||
return pathName
|
|
||||||
}
|
|
||||||
|
|
||||||
func (si *Index) containsSearchTerm(pathName string, searchTerm string, options SearchOptions, isDir bool, fileTypes map[string]bool) (bool, map[string]bool) {
|
fileTypes := map[string]bool{}
|
||||||
largerThan := int64(options.LargerThan) * 1024 * 1024
|
largerThan := int64(options.LargerThan) * 1024 * 1024
|
||||||
smallerThan := int64(options.SmallerThan) * 1024 * 1024
|
smallerThan := int64(options.SmallerThan) * 1024 * 1024
|
||||||
conditions := options.Conditions
|
conditions := options.Conditions
|
||||||
fileName := filepath.Base(pathName)
|
lowerFileName := strings.ToLower(fi.Name)
|
||||||
adjustedPath := si.makeIndexPath(pathName, isDir)
|
|
||||||
|
|
||||||
// Convert to lowercase if not exact match
|
// Convert to lowercase if not exact match
|
||||||
if !conditions["exact"] {
|
if !conditions["exact"] {
|
||||||
fileName = strings.ToLower(fileName)
|
|
||||||
searchTerm = strings.ToLower(searchTerm)
|
searchTerm = strings.ToLower(searchTerm)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file name contains the search term
|
// Check if the file name contains the search term
|
||||||
if !strings.Contains(fileName, searchTerm) {
|
if !strings.Contains(lowerFileName, searchTerm) {
|
||||||
return false, map[string]bool{}
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize file size and fileTypes map
|
// Initialize file size and fileTypes map
|
||||||
var fileSize int64
|
var fileSize int64
|
||||||
extension := filepath.Ext(fileName)
|
extension := filepath.Ext(lowerFileName)
|
||||||
|
|
||||||
// Collect file types
|
// Collect file types
|
||||||
for _, k := range AllFiletypeOptions {
|
for _, k := range AllFiletypeOptions {
|
||||||
|
@ -138,31 +131,9 @@ func (si *Index) containsSearchTerm(pathName string, searchTerm string, options
|
||||||
fileTypes[k] = true
|
fileTypes[k] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
isDir := fi.Type == "directory"
|
||||||
fileTypes["dir"] = isDir
|
fileTypes["dir"] = isDir
|
||||||
// Get file info if needed for size-related conditions
|
fileSize = fi.Size
|
||||||
if largerThan > 0 || smallerThan > 0 {
|
|
||||||
fileInfo, exists := si.GetMetadataInfo(adjustedPath)
|
|
||||||
if !exists {
|
|
||||||
return false, fileTypes
|
|
||||||
} else if !isDir {
|
|
||||||
// Look for specific file in ReducedItems
|
|
||||||
for _, item := range fileInfo.ReducedItems {
|
|
||||||
lower := strings.ToLower(item.Name)
|
|
||||||
if strings.Contains(lower, searchTerm) {
|
|
||||||
if item.Size == 0 {
|
|
||||||
return false, fileTypes
|
|
||||||
}
|
|
||||||
fileSize = item.Size
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fileSize = fileInfo.Size
|
|
||||||
}
|
|
||||||
if fileSize == 0 {
|
|
||||||
return false, fileTypes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evaluate all conditions
|
// Evaluate all conditions
|
||||||
for t, v := range conditions {
|
for t, v := range conditions {
|
||||||
|
@ -173,33 +144,35 @@ func (si *Index) containsSearchTerm(pathName string, searchTerm string, options
|
||||||
case "larger":
|
case "larger":
|
||||||
if largerThan > 0 {
|
if largerThan > 0 {
|
||||||
if fileSize <= largerThan {
|
if fileSize <= largerThan {
|
||||||
return false, fileTypes
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "smaller":
|
case "smaller":
|
||||||
if smallerThan > 0 {
|
if smallerThan > 0 {
|
||||||
if fileSize >= smallerThan {
|
if fileSize >= smallerThan {
|
||||||
return false, fileTypes
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// Handle other file type conditions
|
// Handle other file type conditions
|
||||||
notMatchType := v != fileTypes[t]
|
notMatchType := v != fileTypes[t]
|
||||||
if notMatchType {
|
if notMatchType {
|
||||||
return false, fileTypes
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, fileTypes
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateRandomHash(length int) string {
|
func (si *Index) getDirsInScope(scope string) []string {
|
||||||
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
newList := []string{}
|
||||||
rand.New(rand.NewSource(time.Now().UnixNano()))
|
si.mu.Lock()
|
||||||
result := make([]byte, length)
|
defer si.mu.Unlock()
|
||||||
for i := range result {
|
for k := range si.Directories {
|
||||||
result[i] = charset[rand.Intn(len(charset))]
|
if strings.HasPrefix(k, scope) || scope == "" {
|
||||||
|
newList = append(newList, k)
|
||||||
}
|
}
|
||||||
return string(result)
|
}
|
||||||
|
return newList
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,22 +88,26 @@ func TestSearchWhileIndexing(t *testing.T) {
|
||||||
|
|
||||||
func TestSearchIndexes(t *testing.T) {
|
func TestSearchIndexes(t *testing.T) {
|
||||||
index := Index{
|
index := Index{
|
||||||
Directories: map[string]FileInfo{
|
Directories: map[string]*FileInfo{
|
||||||
"test": {Items: []*FileInfo{{Name: "audio1.wav"}}},
|
"/test": {Files: []ReducedItem{{Name: "audio1.wav", Type: "audio"}}},
|
||||||
"test/path": {Items: []*FileInfo{{Name: "file.txt"}}},
|
"/test/path": {Files: []ReducedItem{{Name: "file.txt", Type: "text"}}},
|
||||||
"new/test": {Items: []*FileInfo{
|
"/new/test": {Files: []ReducedItem{
|
||||||
{Name: "audio.wav"},
|
{Name: "audio.wav", Type: "audio"},
|
||||||
{Name: "video.mp4"},
|
{Name: "video.mp4", Type: "video"},
|
||||||
{Name: "video.MP4"},
|
{Name: "video.MP4", Type: "video"},
|
||||||
}},
|
|
||||||
"new/test/path": {Items: []*FileInfo{{Name: "archive.zip"}}},
|
|
||||||
"/firstDir": {Items: []*FileInfo{
|
|
||||||
{Name: "archive.zip", Size: 100},
|
|
||||||
{Name: "thisIsDir", IsDir: true, Size: 2 * 1024 * 1024},
|
|
||||||
}},
|
}},
|
||||||
|
"/new/test/path": {Files: []ReducedItem{{Name: "archive.zip", Type: "archive"}}},
|
||||||
|
"/firstDir": {
|
||||||
|
Files: []ReducedItem{
|
||||||
|
{Name: "archive.zip", Size: 100, Type: "archive"},
|
||||||
|
},
|
||||||
|
Dirs: map[string]*FileInfo{
|
||||||
|
"thisIsDir": {Name: "thisIsDir", Size: 2 * 1024 * 1024},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/firstDir/thisIsDir": {
|
"/firstDir/thisIsDir": {
|
||||||
Items: []*FileInfo{
|
Files: []ReducedItem{
|
||||||
{Name: "hi.txt"},
|
{Name: "hi.txt", Type: "text"},
|
||||||
},
|
},
|
||||||
Size: 2 * 1024 * 1024,
|
Size: 2 * 1024 * 1024,
|
||||||
},
|
},
|
||||||
|
@ -113,112 +117,106 @@ func TestSearchIndexes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
search string
|
search string
|
||||||
scope string
|
scope string
|
||||||
expectedResult []string
|
expectedResult []searchResult
|
||||||
expectedTypes map[string]map[string]bool
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
search: "audio",
|
search: "audio",
|
||||||
scope: "/new/",
|
scope: "/new/",
|
||||||
expectedResult: []string{"test/audio.wav"},
|
expectedResult: []searchResult{
|
||||||
expectedTypes: map[string]map[string]bool{
|
{
|
||||||
"test/audio.wav": {"audio": true, "dir": false},
|
Path: "test/audio.wav",
|
||||||
|
Type: "audio",
|
||||||
|
Size: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
search: "test",
|
search: "test",
|
||||||
scope: "/",
|
scope: "/",
|
||||||
expectedResult: []string{"test/", "new/test/"},
|
expectedResult: []searchResult{
|
||||||
expectedTypes: map[string]map[string]bool{
|
{
|
||||||
"test/": {"dir": true},
|
Path: "test/",
|
||||||
"new/test/": {"dir": true},
|
Type: "directory",
|
||||||
|
Size: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "new/test/",
|
||||||
|
Type: "directory",
|
||||||
|
Size: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
search: "archive",
|
search: "archive",
|
||||||
scope: "/",
|
scope: "/",
|
||||||
expectedResult: []string{"firstDir/archive.zip", "new/test/path/archive.zip"},
|
expectedResult: []searchResult{
|
||||||
expectedTypes: map[string]map[string]bool{
|
{
|
||||||
"new/test/path/archive.zip": {"archive": true, "dir": false},
|
Path: "firstDir/archive.zip",
|
||||||
"firstDir/archive.zip": {"archive": true, "dir": false},
|
Type: "archive",
|
||||||
|
Size: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "new/test/path/archive.zip",
|
||||||
|
Type: "archive",
|
||||||
|
Size: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
search: "arch",
|
search: "arch",
|
||||||
scope: "/firstDir",
|
scope: "/firstDir",
|
||||||
expectedResult: []string{"archive.zip"},
|
expectedResult: []searchResult{
|
||||||
expectedTypes: map[string]map[string]bool{
|
{
|
||||||
"archive.zip": {"archive": true, "dir": false},
|
Path: "archive.zip",
|
||||||
|
Type: "archive",
|
||||||
|
Size: 100,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
search: "isdir",
|
search: "isdir",
|
||||||
scope: "/",
|
scope: "/",
|
||||||
expectedResult: []string{"firstDir/thisIsDir/"},
|
expectedResult: []searchResult{
|
||||||
expectedTypes: map[string]map[string]bool{
|
{
|
||||||
"firstDir/thisIsDir/": {"dir": true},
|
Path: "firstDir/thisIsDir/",
|
||||||
|
Type: "directory",
|
||||||
|
Size: 2097152,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
search: "dir type:largerThan=1",
|
search: "IsDir type:largerThan=1",
|
||||||
scope: "/",
|
scope: "/",
|
||||||
expectedResult: []string{"firstDir/thisIsDir/"},
|
expectedResult: []searchResult{
|
||||||
expectedTypes: map[string]map[string]bool{
|
{
|
||||||
"firstDir/thisIsDir/": {"dir": true},
|
Path: "firstDir/thisIsDir/",
|
||||||
|
Type: "directory",
|
||||||
|
Size: 2097152,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
search: "video",
|
search: "video",
|
||||||
scope: "/",
|
scope: "/",
|
||||||
expectedResult: []string{
|
expectedResult: []searchResult{
|
||||||
"new/test/video.mp4",
|
{
|
||||||
"new/test/video.MP4",
|
Path: "new/test/video.MP4",
|
||||||
|
Type: "video",
|
||||||
|
Size: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "new/test/video.mp4",
|
||||||
|
Type: "video",
|
||||||
|
Size: 0,
|
||||||
},
|
},
|
||||||
expectedTypes: map[string]map[string]bool{
|
|
||||||
"new/test/video.MP4": {"video": true, "dir": false},
|
|
||||||
"new/test/video.mp4": {"video": true, "dir": false},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.search, func(t *testing.T) {
|
t.Run(tt.search, func(t *testing.T) {
|
||||||
actualResult, actualTypes := index.Search(tt.search, tt.scope, "")
|
result := index.Search(tt.search, tt.scope, "")
|
||||||
assert.Equal(t, tt.expectedResult, actualResult)
|
assert.Equal(t, tt.expectedResult, result)
|
||||||
assert.Equal(t, tt.expectedTypes, actualTypes)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_scopedPathNameFilter(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
args struct {
|
|
||||||
pathName string
|
|
||||||
scope string
|
|
||||||
isDir bool // Assuming isDir should be included in args
|
|
||||||
}
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "scope test",
|
|
||||||
args: struct {
|
|
||||||
pathName string
|
|
||||||
scope string
|
|
||||||
isDir bool
|
|
||||||
}{
|
|
||||||
pathName: "/",
|
|
||||||
scope: "/",
|
|
||||||
isDir: false,
|
|
||||||
},
|
|
||||||
want: "", // Update this with the expected result
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := scopedPathNameFilter(tt.args.pathName, tt.args.scope, tt.args.isDir); got != tt.want {
|
|
||||||
t.Errorf("scopedPathNameFilter() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,72 +2,89 @@ package files
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/settings"
|
"github.com/gtsteffaniak/filebrowser/settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
|
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
|
||||||
func (si *Index) UpdateFileMetadata(adjustedPath string, info FileInfo) bool {
|
func (si *Index) UpdateMetadata(info *FileInfo) bool {
|
||||||
si.mu.Lock()
|
si.mu.Lock()
|
||||||
defer si.mu.Unlock()
|
defer si.mu.Unlock()
|
||||||
dir, exists := si.Directories[adjustedPath]
|
|
||||||
if !exists {
|
|
||||||
si.Directories[adjustedPath] = FileInfo{}
|
|
||||||
}
|
|
||||||
return si.SetFileMetadata(adjustedPath, dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetFileMetadata sets the FileInfo for the specified directory in the index.
|
|
||||||
// internal use only
|
|
||||||
func (si *Index) SetFileMetadata(adjustedPath string, info FileInfo) bool {
|
|
||||||
_, exists := si.Directories[adjustedPath]
|
|
||||||
if !exists {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
info.CacheTime = time.Now()
|
info.CacheTime = time.Now()
|
||||||
si.Directories[adjustedPath] = info
|
si.Directories[info.Path] = info
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMetadataInfo retrieves the FileInfo from the specified directory in the index.
|
// GetMetadataInfo retrieves the FileInfo from the specified directory in the index.
|
||||||
func (si *Index) GetMetadataInfo(adjustedPath string) (FileInfo, bool) {
|
func (si *Index) GetReducedMetadata(target string, isDir bool) (*FileInfo, bool) {
|
||||||
si.mu.RLock()
|
si.mu.RLock()
|
||||||
dir, exists := si.Directories[adjustedPath]
|
defer si.mu.RUnlock()
|
||||||
si.mu.RUnlock()
|
checkDir := si.makeIndexPath(target)
|
||||||
if !exists {
|
if !isDir {
|
||||||
return dir, exists
|
checkDir = si.makeIndexPath(filepath.Dir(target))
|
||||||
}
|
}
|
||||||
// remove recursive items, we only want this directories direct files
|
dir, exists := si.Directories[checkDir]
|
||||||
cleanedItems := []ReducedItem{}
|
if !exists {
|
||||||
for _, item := range dir.Items {
|
return nil, false
|
||||||
cleanedItems = append(cleanedItems, ReducedItem{
|
}
|
||||||
|
if !isDir {
|
||||||
|
if checkDir == "/" {
|
||||||
|
checkDir = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
baseName := filepath.Base(target)
|
||||||
|
for _, item := range dir.Files {
|
||||||
|
if item.Name == baseName {
|
||||||
|
return &FileInfo{
|
||||||
Name: item.Name,
|
Name: item.Name,
|
||||||
Size: item.Size,
|
Size: item.Size,
|
||||||
IsDir: item.IsDir,
|
|
||||||
ModTime: item.ModTime,
|
ModTime: item.ModTime,
|
||||||
Type: item.Type,
|
Type: item.Type,
|
||||||
|
Path: checkDir + "/" + item.Name,
|
||||||
|
}, 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",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
dir.Items = nil
|
cleanedItems = append(cleanedItems, dir.Files...)
|
||||||
dir.ReducedItems = cleanedItems
|
sort.Slice(cleanedItems, func(i, j int) bool {
|
||||||
realPath, _, _ := GetRealPath(adjustedPath)
|
return cleanedItems[i].Name < cleanedItems[j].Name
|
||||||
dir.Path = realPath
|
})
|
||||||
return dir, exists
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDirectoryInfo sets the directory information in the index.
|
// GetMetadataInfo retrieves the FileInfo from the specified directory in the index.
|
||||||
func (si *Index) SetDirectoryInfo(adjustedPath string, dir FileInfo) {
|
func (si *Index) GetMetadataInfo(target string, isDir bool) (*FileInfo, bool) {
|
||||||
si.mu.Lock()
|
|
||||||
si.Directories[adjustedPath] = dir
|
|
||||||
si.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetDirectoryInfo sets the directory information in the index.
|
|
||||||
func (si *Index) GetDirectoryInfo(adjustedPath string) (FileInfo, bool) {
|
|
||||||
si.mu.RLock()
|
si.mu.RLock()
|
||||||
dir, exists := si.Directories[adjustedPath]
|
defer si.mu.RUnlock()
|
||||||
si.mu.RUnlock()
|
checkDir := si.makeIndexPath(target)
|
||||||
|
if !isDir {
|
||||||
|
checkDir = si.makeIndexPath(filepath.Dir(target))
|
||||||
|
}
|
||||||
|
dir, exists := si.Directories[checkDir]
|
||||||
return dir, exists
|
return dir, exists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -108,11 +125,12 @@ func GetIndex(root string) *Index {
|
||||||
}
|
}
|
||||||
newIndex := &Index{
|
newIndex := &Index{
|
||||||
Root: rootPath,
|
Root: rootPath,
|
||||||
Directories: map[string]FileInfo{},
|
Directories: map[string]*FileInfo{},
|
||||||
NumDirs: 0,
|
NumDirs: 0,
|
||||||
NumFiles: 0,
|
NumFiles: 0,
|
||||||
inProgress: false,
|
inProgress: false,
|
||||||
}
|
}
|
||||||
|
newIndex.Directories["/"] = &FileInfo{}
|
||||||
indexesMutex.Lock()
|
indexesMutex.Lock()
|
||||||
indexes = append(indexes, newIndex)
|
indexes = append(indexes, newIndex)
|
||||||
indexesMutex.Unlock()
|
indexesMutex.Unlock()
|
||||||
|
|
|
@ -32,9 +32,9 @@ func TestGetFileMetadataSize(t *testing.T) {
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
fileInfo, _ := testIndex.GetMetadataInfo(tt.adjustedPath)
|
fileInfo, _ := testIndex.GetReducedMetadata(tt.adjustedPath, true)
|
||||||
// Iterate over fileInfo.Items to look for expectedName
|
// Iterate over fileInfo.Items to look for expectedName
|
||||||
for _, item := range fileInfo.ReducedItems {
|
for _, item := range fileInfo.Items {
|
||||||
// Assert the existence and the name
|
// Assert the existence and the name
|
||||||
if item.Name == tt.expectedName {
|
if item.Name == tt.expectedName {
|
||||||
assert.Equal(t, tt.expectedSize, item.Size)
|
assert.Equal(t, tt.expectedSize, item.Size)
|
||||||
|
@ -53,28 +53,29 @@ func TestGetFileMetadata(t *testing.T) {
|
||||||
adjustedPath string
|
adjustedPath string
|
||||||
expectedName string
|
expectedName string
|
||||||
expectedExists bool
|
expectedExists bool
|
||||||
|
isDir bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "testpath exists",
|
name: "testpath exists",
|
||||||
adjustedPath: "/testpath",
|
adjustedPath: "/testpath/testfile.txt",
|
||||||
expectedName: "testfile.txt",
|
expectedName: "testfile.txt",
|
||||||
expectedExists: true,
|
expectedExists: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "testpath not exists",
|
name: "testpath not exists",
|
||||||
adjustedPath: "/testpath",
|
adjustedPath: "/testpath/nonexistent.txt",
|
||||||
expectedName: "nonexistent.txt",
|
expectedName: "nonexistent.txt",
|
||||||
expectedExists: false,
|
expectedExists: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "File exists in /anotherpath",
|
name: "File exists in /anotherpath",
|
||||||
adjustedPath: "/anotherpath",
|
adjustedPath: "/anotherpath/afile.txt",
|
||||||
expectedName: "afile.txt",
|
expectedName: "afile.txt",
|
||||||
expectedExists: true,
|
expectedExists: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "File does not exist in /anotherpath",
|
name: "File does not exist in /anotherpath",
|
||||||
adjustedPath: "/anotherpath",
|
adjustedPath: "/anotherpath/nonexistentfile.txt",
|
||||||
expectedName: "nonexistentfile.txt",
|
expectedName: "nonexistentfile.txt",
|
||||||
expectedExists: false,
|
expectedExists: false,
|
||||||
},
|
},
|
||||||
|
@ -83,20 +84,33 @@ func TestGetFileMetadata(t *testing.T) {
|
||||||
adjustedPath: "/nonexistentpath",
|
adjustedPath: "/nonexistentpath",
|
||||||
expectedName: "",
|
expectedName: "",
|
||||||
expectedExists: false,
|
expectedExists: false,
|
||||||
|
isDir: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
fileInfo, _ := testIndex.GetMetadataInfo(tt.adjustedPath)
|
fileInfo, _ := testIndex.GetReducedMetadata(tt.adjustedPath, tt.isDir)
|
||||||
|
if fileInfo == nil {
|
||||||
found := false
|
found := false
|
||||||
|
assert.Equal(t, tt.expectedExists, found)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
if tt.isDir {
|
||||||
// Iterate over fileInfo.Items to look for expectedName
|
// Iterate over fileInfo.Items to look for expectedName
|
||||||
for _, item := range fileInfo.ReducedItems {
|
for _, item := range fileInfo.Items {
|
||||||
// Assert the existence and the name
|
// Assert the existence and the name
|
||||||
if item.Name == tt.expectedName {
|
if item.Name == tt.expectedName {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if fileInfo.Name == tt.expectedName {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
assert.Equal(t, tt.expectedExists, found)
|
assert.Equal(t, tt.expectedExists, found)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -104,42 +118,42 @@ func TestGetFileMetadata(t *testing.T) {
|
||||||
|
|
||||||
// Test for UpdateFileMetadata
|
// Test for UpdateFileMetadata
|
||||||
func TestUpdateFileMetadata(t *testing.T) {
|
func TestUpdateFileMetadata(t *testing.T) {
|
||||||
index := &Index{
|
info := &FileInfo{
|
||||||
Directories: map[string]FileInfo{
|
|
||||||
"/testpath": {
|
|
||||||
Path: "/testpath",
|
Path: "/testpath",
|
||||||
Name: "testpath",
|
Name: "testpath",
|
||||||
IsDir: true,
|
Type: "directory",
|
||||||
ReducedItems: []ReducedItem{
|
Files: []ReducedItem{
|
||||||
{Name: "testfile.txt"},
|
{Name: "testfile.txt"},
|
||||||
{Name: "anotherfile.txt"},
|
{Name: "anotherfile.txt"},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
|
|
||||||
|
index := &Index{
|
||||||
|
Directories: map[string]*FileInfo{
|
||||||
|
"/testpath": info,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
info := FileInfo{Name: "testfile.txt"}
|
success := index.UpdateMetadata(info)
|
||||||
|
|
||||||
success := index.UpdateFileMetadata("/testpath", info)
|
|
||||||
if !success {
|
if !success {
|
||||||
t.Fatalf("expected UpdateFileMetadata to succeed")
|
t.Fatalf("expected UpdateFileMetadata to succeed")
|
||||||
}
|
}
|
||||||
|
|
||||||
dir, exists := index.Directories["/testpath"]
|
fileInfo, exists := index.GetReducedMetadata("/testpath/testfile.txt", false)
|
||||||
if !exists || dir.ReducedItems[0].Name != "testfile.txt" {
|
if !exists || fileInfo.Name != "testfile.txt" {
|
||||||
t.Fatalf("expected testfile.txt to be updated in the directory metadata")
|
t.Fatalf("expected testfile.txt to be updated in the directory metadata:%v %v", exists, info.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test for GetDirMetadata
|
// Test for GetDirMetadata
|
||||||
func TestGetDirMetadata(t *testing.T) {
|
func TestGetDirMetadata(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
_, exists := testIndex.GetMetadataInfo("/testpath")
|
_, exists := testIndex.GetReducedMetadata("/testpath", true)
|
||||||
if !exists {
|
if !exists {
|
||||||
t.Fatalf("expected GetDirMetadata to return initialized metadata map")
|
t.Fatalf("expected GetDirMetadata to return initialized metadata map")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, exists = testIndex.GetMetadataInfo("/nonexistent")
|
_, exists = testIndex.GetReducedMetadata("/nonexistent", true)
|
||||||
if exists {
|
if exists {
|
||||||
t.Fatalf("expected GetDirMetadata to return false for nonexistent directory")
|
t.Fatalf("expected GetDirMetadata to return false for nonexistent directory")
|
||||||
}
|
}
|
||||||
|
@ -148,51 +162,37 @@ func TestGetDirMetadata(t *testing.T) {
|
||||||
// Test for SetDirectoryInfo
|
// Test for SetDirectoryInfo
|
||||||
func TestSetDirectoryInfo(t *testing.T) {
|
func TestSetDirectoryInfo(t *testing.T) {
|
||||||
index := &Index{
|
index := &Index{
|
||||||
Directories: map[string]FileInfo{
|
Directories: map[string]*FileInfo{
|
||||||
"/testpath": {
|
"/testpath": {
|
||||||
Path: "/testpath",
|
Path: "/testpath",
|
||||||
Name: "testpath",
|
Name: "testpath",
|
||||||
IsDir: true,
|
Type: "directory",
|
||||||
Items: []*FileInfo{
|
Items: []ReducedItem{
|
||||||
{Name: "testfile.txt"},
|
{Name: "testfile.txt"},
|
||||||
{Name: "anotherfile.txt"},
|
{Name: "anotherfile.txt"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
dir := FileInfo{
|
dir := &FileInfo{
|
||||||
Path: "/newPath",
|
Path: "/newPath",
|
||||||
Name: "newPath",
|
Name: "newPath",
|
||||||
IsDir: true,
|
Type: "directory",
|
||||||
Items: []*FileInfo{
|
Items: []ReducedItem{
|
||||||
{Name: "testfile.txt"},
|
{Name: "testfile.txt"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
index.SetDirectoryInfo("/newPath", dir)
|
index.UpdateMetadata(dir)
|
||||||
storedDir, exists := index.Directories["/newPath"]
|
storedDir, exists := index.Directories["/newPath"]
|
||||||
if !exists || storedDir.Items[0].Name != "testfile.txt" {
|
if !exists || storedDir.Items[0].Name != "testfile.txt" {
|
||||||
t.Fatalf("expected SetDirectoryInfo to store directory info correctly")
|
t.Fatalf("expected SetDirectoryInfo to store directory info correctly")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test for GetDirectoryInfo
|
|
||||||
func TestGetDirectoryInfo(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
dir, exists := testIndex.GetDirectoryInfo("/testpath")
|
|
||||||
if !exists || dir.Items[0].Name != "testfile.txt" {
|
|
||||||
t.Fatalf("expected GetDirectoryInfo to return correct directory info")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, exists = testIndex.GetDirectoryInfo("/nonexistent")
|
|
||||||
if exists {
|
|
||||||
t.Fatalf("expected GetDirectoryInfo to return false for nonexistent directory")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test for RemoveDirectory
|
// Test for RemoveDirectory
|
||||||
func TestRemoveDirectory(t *testing.T) {
|
func TestRemoveDirectory(t *testing.T) {
|
||||||
index := &Index{
|
index := &Index{
|
||||||
Directories: map[string]FileInfo{
|
Directories: map[string]*FileInfo{
|
||||||
"/testpath": {},
|
"/testpath": {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -234,14 +234,12 @@ func init() {
|
||||||
NumFiles: 10,
|
NumFiles: 10,
|
||||||
NumDirs: 5,
|
NumDirs: 5,
|
||||||
inProgress: false,
|
inProgress: false,
|
||||||
Directories: map[string]FileInfo{
|
Directories: map[string]*FileInfo{
|
||||||
"/testpath": {
|
"/testpath": {
|
||||||
Path: "/testpath",
|
Path: "/testpath",
|
||||||
Name: "testpath",
|
Name: "testpath",
|
||||||
IsDir: true,
|
Type: "directory",
|
||||||
NumDirs: 1,
|
Files: []ReducedItem{
|
||||||
NumFiles: 2,
|
|
||||||
Items: []*FileInfo{
|
|
||||||
{Name: "testfile.txt", Size: 100},
|
{Name: "testfile.txt", Size: 100},
|
||||||
{Name: "anotherfile.txt", Size: 100},
|
{Name: "anotherfile.txt", Size: 100},
|
||||||
},
|
},
|
||||||
|
@ -249,13 +247,13 @@ func init() {
|
||||||
"/anotherpath": {
|
"/anotherpath": {
|
||||||
Path: "/anotherpath",
|
Path: "/anotherpath",
|
||||||
Name: "anotherpath",
|
Name: "anotherpath",
|
||||||
IsDir: true,
|
Type: "directory",
|
||||||
NumDirs: 1,
|
Files: []ReducedItem{
|
||||||
NumFiles: 1,
|
|
||||||
Items: []*FileInfo{
|
|
||||||
{Name: "directory", IsDir: true, Size: 100},
|
|
||||||
{Name: "afile.txt", Size: 100},
|
{Name: "afile.txt", Size: 100},
|
||||||
},
|
},
|
||||||
|
Dirs: map[string]*FileInfo{
|
||||||
|
"directory": {Name: "directory", Type: "directory", Size: 100},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Copy copies a file or folder from one place to another.
|
// Copy copies a file or folder from one place to another.
|
||||||
func Copy(src, dst string) error {
|
func CopyHelper(src, dst string) error {
|
||||||
src = filepath.Clean(src)
|
src = filepath.Clean(src)
|
||||||
if src == "" {
|
if src == "" {
|
||||||
return os.ErrNotExist
|
return os.ErrNotExist
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
../../frontend/dist
|
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"name": "frontend",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
|
@ -7,10 +7,9 @@ require (
|
||||||
github.com/disintegration/imaging v1.6.2
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/dsoprea/go-exif/v3 v3.0.1
|
github.com/dsoprea/go-exif/v3 v3.0.1
|
||||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568
|
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568
|
||||||
github.com/goccy/go-yaml v1.12.0
|
github.com/goccy/go-yaml v1.14.3
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
github.com/golang-jwt/jwt/v4 v4.5.1
|
||||||
github.com/google/go-cmp v0.6.0
|
github.com/google/go-cmp v0.6.0
|
||||||
github.com/gorilla/mux v1.8.1
|
|
||||||
github.com/marusama/semaphore/v2 v2.5.0
|
github.com/marusama/semaphore/v2 v2.5.0
|
||||||
github.com/mholt/archiver/v3 v3.5.1
|
github.com/mholt/archiver/v3 v3.5.1
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
|
@ -18,39 +17,45 @@ require (
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
github.com/spf13/pflag v1.0.5
|
github.com/spf13/pflag v1.0.5
|
||||||
github.com/stretchr/testify v1.9.0
|
github.com/stretchr/testify v1.9.0
|
||||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
|
github.com/swaggo/http-swagger v1.3.4
|
||||||
golang.org/x/crypto v0.26.0
|
github.com/swaggo/swag v1.16.4
|
||||||
golang.org/x/image v0.19.0
|
golang.org/x/crypto v0.29.0
|
||||||
golang.org/x/text v0.17.0
|
golang.org/x/image v0.22.0
|
||||||
|
golang.org/x/text v0.20.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
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/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // 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-logging v0.0.0-20200710184922-b02d349568dd // indirect
|
||||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
|
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
|
||||||
github.com/fatih/color v1.17.0 // indirect
|
|
||||||
github.com/go-errors/errors v1.5.1 // indirect
|
github.com/go-errors/errors v1.5.1 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||||
|
github.com/go-openapi/spec v0.21.0 // indirect
|
||||||
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect
|
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.17.9 // 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/klauspost/pgzip v1.2.6 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/nwaples/rardecode v1.1.3 // indirect
|
github.com/nwaples/rardecode v1.1.3 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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/ulikunitz/xz v0.5.12 // indirect
|
||||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.etcd.io/bbolt v1.3.11 // indirect
|
go.etcd.io/bbolt v1.3.11 // indirect
|
||||||
golang.org/x/net v0.28.0 // indirect
|
golang.org/x/net v0.31.0 // indirect
|
||||||
golang.org/x/sys v0.24.0 // indirect
|
golang.org/x/sys v0.27.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect
|
golang.org/x/tools v0.27.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
120
backend/go.sum
120
backend/go.sum
|
@ -1,10 +1,12 @@
|
||||||
github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
|
github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
|
||||||
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||||
|
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 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
|
||||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
|
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.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
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 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
|
||||||
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
|
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=
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
@ -32,8 +34,6 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LV
|
||||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
|
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
|
||||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
|
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
|
||||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
|
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
|
||||||
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
|
|
||||||
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
|
|
||||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
|
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
|
||||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
|
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
|
||||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||||
|
@ -45,16 +45,18 @@ github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
|
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||||
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||||
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
|
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||||
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
|
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
|
||||||
github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
|
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
|
||||||
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
|
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
||||||
github.com/goccy/go-yaml v1.12.0 h1:/1WHjnMsI1dlIBQutrvSMGZRQufVO3asrHfTwfACoPM=
|
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||||
github.com/goccy/go-yaml v1.12.0/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU=
|
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
github.com/goccy/go-yaml v1.14.3 h1:8tVD+aqqPLWisSEhM+6wWoiURWXCx6BwaTKS6ZeITgM=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/goccy/go-yaml v1.14.3/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=
|
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||||
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||||
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||||
|
@ -71,34 +73,31 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
|
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.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||||
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
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/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.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
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=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
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 h1:o/1QJD9DBYOWRnDhPwDVAXQn6mQYD0gZaS1Tpx6DJGM=
|
||||||
github.com/marusama/semaphore/v2 v2.5.0/go.mod h1:z9nMiNUekt/LTpTUQdpp+4sJeYqUGpwMHfW0Z8V8fnQ=
|
github.com/marusama/semaphore/v2 v2.5.0/go.mod h1:z9nMiNUekt/LTpTUQdpp+4sJeYqUGpwMHfW0Z8V8fnQ=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
|
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/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.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||||
|
@ -111,6 +110,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||||
|
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||||
|
@ -123,8 +124,12 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc=
|
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=
|
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||||
|
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
|
||||||
|
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.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.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||||
|
@ -133,60 +138,83 @@ github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaU
|
||||||
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
|
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 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
|
||||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
|
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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||||
go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
|
go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
|
||||||
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
|
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
|
||||||
|
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ=
|
golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g=
|
||||||
golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys=
|
golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||||
|
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
|
||||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
|
||||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
|
||||||
|
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||||
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||||
|
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
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=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 h1:LLhsEBxRTBLuKlQxFBYUOU8xyFgXv6cOTp2HASDlsDk=
|
|
||||||
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
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 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
|
|
@ -0,0 +1,121 @@
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gtsteffaniak/filebrowser/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
name := r.URL.Query().Get("name")
|
||||||
|
durationStr := r.URL.Query().Get("days")
|
||||||
|
permissionsStr := r.URL.Query().Get("permissions")
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
return http.StatusInternalServerError, fmt.Errorf("api name must be valid")
|
||||||
|
}
|
||||||
|
if durationStr == "" {
|
||||||
|
return http.StatusInternalServerError, fmt.Errorf("api duration must be valid")
|
||||||
|
}
|
||||||
|
if permissionsStr == "" {
|
||||||
|
return http.StatusInternalServerError, fmt.Errorf("api permissions must be valid")
|
||||||
|
}
|
||||||
|
// Parse permissions from the query parameter
|
||||||
|
permissions := users.Permissions{
|
||||||
|
Api: strings.Contains(permissionsStr, "api") && d.user.Perm.Api,
|
||||||
|
Admin: strings.Contains(permissionsStr, "admin") && d.user.Perm.Admin,
|
||||||
|
Execute: strings.Contains(permissionsStr, "execute") && d.user.Perm.Execute,
|
||||||
|
Create: strings.Contains(permissionsStr, "create") && d.user.Perm.Create,
|
||||||
|
Rename: strings.Contains(permissionsStr, "rename") && d.user.Perm.Rename,
|
||||||
|
Modify: strings.Contains(permissionsStr, "modify") && d.user.Perm.Modify,
|
||||||
|
Delete: strings.Contains(permissionsStr, "delete") && d.user.Perm.Delete,
|
||||||
|
Share: strings.Contains(permissionsStr, "share") && d.user.Perm.Share,
|
||||||
|
Download: strings.Contains(permissionsStr, "download") && d.user.Perm.Download,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the duration string to an int64
|
||||||
|
durationInt, err := strconv.ParseInt(durationStr, 10, 64) // Base 10 and bit size of 64
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusBadRequest, fmt.Errorf("invalid duration value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here we assume the duration is in seconds; convert to time.Duration
|
||||||
|
duration := time.Duration(durationInt) * time.Hour * 24
|
||||||
|
|
||||||
|
// get request body like:
|
||||||
|
token, err := makeSignedTokenAPI(d.user, name, duration, permissions)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "key already exists with same name") {
|
||||||
|
return http.StatusConflict, err
|
||||||
|
}
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
response := HttpResponse{
|
||||||
|
Message: "here is your token!",
|
||||||
|
Token: token.Key,
|
||||||
|
}
|
||||||
|
return renderJSON(w, r, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
name := r.URL.Query().Get("name")
|
||||||
|
|
||||||
|
keyInfo, ok := d.user.ApiKeys[name]
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, fmt.Errorf("api key not found")
|
||||||
|
}
|
||||||
|
// Perform the user update
|
||||||
|
err := store.Users.DeleteApiKey(d.user.ID, name)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusNotFound, err
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeAPIKey(keyInfo.Key) // add to blacklist
|
||||||
|
response := HttpResponse{
|
||||||
|
Message: "successfully deleted api key from user",
|
||||||
|
}
|
||||||
|
return renderJSON(w, r, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthTokenMin struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Expires int64 `json:"expires"`
|
||||||
|
Permissions users.Permissions `json:"Permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func listApiKeysHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
key := r.URL.Query().Get("key")
|
||||||
|
|
||||||
|
if key != "" {
|
||||||
|
keyInfo, ok := d.user.ApiKeys[key]
|
||||||
|
if !ok {
|
||||||
|
return http.StatusNotFound, fmt.Errorf("api key not found")
|
||||||
|
}
|
||||||
|
modifiedKey := AuthTokenMin{
|
||||||
|
Key: keyInfo.Key,
|
||||||
|
Name: key,
|
||||||
|
Created: keyInfo.Created,
|
||||||
|
Expires: keyInfo.Expires,
|
||||||
|
Permissions: keyInfo.Permissions,
|
||||||
|
}
|
||||||
|
return renderJSON(w, r, modifiedKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
modifiedList := map[string]AuthTokenMin{}
|
||||||
|
for key, value := range d.user.ApiKeys {
|
||||||
|
modifiedList[key] = AuthTokenMin{
|
||||||
|
Key: value.Key,
|
||||||
|
Created: value.Created,
|
||||||
|
Expires: value.Expires,
|
||||||
|
Permissions: value.Permissions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderJSON(w, r, modifiedList)
|
||||||
|
}
|
|
@ -2,10 +2,12 @@ package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
@ -14,91 +16,77 @@ import (
|
||||||
"github.com/gtsteffaniak/filebrowser/errors"
|
"github.com/gtsteffaniak/filebrowser/errors"
|
||||||
"github.com/gtsteffaniak/filebrowser/settings"
|
"github.com/gtsteffaniak/filebrowser/settings"
|
||||||
"github.com/gtsteffaniak/filebrowser/users"
|
"github.com/gtsteffaniak/filebrowser/users"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type authToken struct {
|
var (
|
||||||
User users.User `json:"user"`
|
revokedApiKeyList map[string]bool
|
||||||
jwt.RegisteredClaims
|
revokeMu sync.Mutex
|
||||||
}
|
)
|
||||||
|
|
||||||
type extractor []string
|
|
||||||
|
|
||||||
func (e extractor) ExtractToken(r *http.Request) (string, error) {
|
|
||||||
token, _ := request.HeaderExtractor{"X-Auth"}.ExtractToken(r)
|
|
||||||
|
|
||||||
|
// first checks for cookie
|
||||||
|
// then checks for header Authorization as Bearer token
|
||||||
|
// then checks for query parameter
|
||||||
|
func extractToken(r *http.Request) (string, error) {
|
||||||
|
hasToken := false
|
||||||
|
tokenObj, err := r.Cookie("auth")
|
||||||
|
if err == nil {
|
||||||
|
hasToken = true
|
||||||
|
token := tokenObj.Value
|
||||||
// Checks if the token isn't empty and if it contains two dots.
|
// Checks if the token isn't empty and if it contains two dots.
|
||||||
// The former prevents incompatibility with URLs that previously
|
// The former prevents incompatibility with URLs that previously
|
||||||
// used basic auth.
|
// used basic auth.
|
||||||
if token != "" && strings.Count(token, ".") == 2 {
|
if token != "" && strings.Count(token, ".") == 2 {
|
||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Authorization header
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader != "" {
|
||||||
|
hasToken = true
|
||||||
|
// Split the header to get "Bearer {token}"
|
||||||
|
parts := strings.Split(authHeader, " ")
|
||||||
|
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||||
|
token := parts[1]
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
auth := r.URL.Query().Get("auth")
|
auth := r.URL.Query().Get("auth")
|
||||||
if auth != "" && strings.Count(auth, ".") == 2 {
|
if auth != "" {
|
||||||
|
hasToken = true
|
||||||
|
if strings.Count(auth, ".") == 2 {
|
||||||
return auth, nil
|
return auth, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodGet {
|
|
||||||
cookie, _ := r.Cookie("auth")
|
|
||||||
if cookie != nil && strings.Count(cookie.Value, ".") == 2 {
|
|
||||||
return cookie.Value, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hasToken {
|
||||||
|
return "", fmt.Errorf("invalid token provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", request.ErrNoTokenInRequest
|
return "", request.ErrNoTokenInRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
func withUser(fn handleFunc) handleFunc {
|
func loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
// Get the authentication method from the settings
|
||||||
keyFunc := func(token *jwt.Token) (interface{}, error) {
|
auther, err := store.Auth.Get(config.Auth.Method)
|
||||||
return d.settings.Auth.Key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var tk authToken
|
|
||||||
token, err := request.ParseFromRequest(r, &extractor{}, keyFunc, request.WithClaims(&tk))
|
|
||||||
|
|
||||||
if err != nil || !token.Valid {
|
|
||||||
return http.StatusUnauthorized, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
expired := !tk.VerifyExpiresAt(time.Now().Add(time.Hour), true)
|
|
||||||
updated := tk.IssuedAt != nil && tk.IssuedAt.Unix() < d.store.Users.LastUpdate(tk.User.ID)
|
|
||||||
|
|
||||||
if expired || updated {
|
|
||||||
w.Header().Add("X-Renew-Token", "true")
|
|
||||||
}
|
|
||||||
|
|
||||||
d.user, err = d.store.Users.Get(d.server.Root, tk.User.ID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return fn(w, r, d)
|
// Authenticate the user based on the request
|
||||||
}
|
user, err := auther.Auth(r, store.Users)
|
||||||
}
|
|
||||||
|
|
||||||
func withAdmin(fn handleFunc) handleFunc {
|
|
||||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
|
||||||
if !d.user.Perm.Admin {
|
|
||||||
return http.StatusForbidden, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return fn(w, r, d)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var loginHandler = func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
|
||||||
auther, err := d.store.Auth.Get(d.settings.Auth.Method)
|
|
||||||
if err != nil {
|
|
||||||
return http.StatusInternalServerError, err
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := auther.Auth(r, d.store.Users)
|
|
||||||
if err == os.ErrPermission {
|
if err == os.ErrPermission {
|
||||||
return http.StatusForbidden, nil
|
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||||
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
} else {
|
return
|
||||||
return printToken(w, r, d, user)
|
}
|
||||||
|
status, err := printToken(w, r, user) // Pass the data object
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, http.StatusText(status), status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -107,73 +95,115 @@ type signupBody struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var signupHandler = func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
func signupHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if !settings.Config.Auth.Signup {
|
if !settings.Config.Auth.Signup {
|
||||||
return http.StatusMethodNotAllowed, nil
|
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Body == nil {
|
if r.Body == nil {
|
||||||
return http.StatusBadRequest, nil
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
info := &signupBody{}
|
info := &signupBody{}
|
||||||
err := json.NewDecoder(r.Body).Decode(info)
|
err := json.NewDecoder(r.Body).Decode(info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusBadRequest, err
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.Password == "" || info.Username == "" {
|
if info.Password == "" || info.Username == "" {
|
||||||
return http.StatusBadRequest, nil
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := users.ApplyDefaults(users.User{})
|
user := settings.ApplyUserDefaults(users.User{})
|
||||||
user.Username = info.Username
|
user.Username = info.Username
|
||||||
user.Password = info.Password
|
user.Password = info.Password
|
||||||
|
|
||||||
userHome, err := d.settings.MakeUserDir(user.Username, user.Scope, d.server.Root)
|
userHome, err := config.MakeUserDir(user.Username, user.Scope, config.Server.Root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("create user: failed to mkdir user home dir: [%s]", userHome)
|
log.Printf("create user: failed to mkdir user home dir: [%s]", userHome)
|
||||||
return http.StatusInternalServerError, err
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
user.Scope = userHome
|
user.Scope = userHome
|
||||||
log.Printf("new user: %s, home dir: [%s].", user.Username, userHome)
|
log.Printf("new user: %s, home dir: [%s].", user.Username, userHome)
|
||||||
err = d.store.Users.Save(&user)
|
err = store.Users.Save(&user)
|
||||||
if err == errors.ErrExist {
|
if err == errors.ErrExist {
|
||||||
return http.StatusConflict, err
|
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
|
||||||
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return http.StatusOK, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var renewHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
return printToken(w, r, d, d.user)
|
// check if x-auth header is present and token is
|
||||||
})
|
return printToken(w, r, d.user)
|
||||||
|
}
|
||||||
|
|
||||||
func printToken(w http.ResponseWriter, _ *http.Request, d *data, user *users.User) (int, error) {
|
func printToken(w http.ResponseWriter, _ *http.Request, user *users.User) (int, error) {
|
||||||
duration, err := time.ParseDuration(settings.Config.Auth.TokenExpirationTime)
|
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.GenerateRandomHash(4), time.Hour*2, user.Perm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
duration = time.Hour * 2
|
if strings.Contains(err.Error(), "key already exists with same name") {
|
||||||
|
return http.StatusConflict, err
|
||||||
}
|
}
|
||||||
claims := &authToken{
|
|
||||||
User: *user,
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)),
|
|
||||||
Issuer: "File Browser",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
signed, err := token.SignedString(d.settings.Auth.Key)
|
|
||||||
if err != nil {
|
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
if _, err := w.Write([]byte(signed)); err != nil {
|
if _, err := w.Write([]byte(signed.Key)); err != nil {
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isRevokedApiKey(key string) bool {
|
||||||
|
_, exists := revokedApiKeyList[key]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeAPIKey(key string) {
|
||||||
|
revokeMu.Lock()
|
||||||
|
delete(revokedApiKeyList, key)
|
||||||
|
revokeMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions) (users.AuthToken, error) {
|
||||||
|
_, ok := user.ApiKeys[name]
|
||||||
|
if ok {
|
||||||
|
return users.AuthToken{}, fmt.Errorf("key already exists with same name %v ", name)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
expires := now.Add(duration)
|
||||||
|
claim := users.AuthToken{
|
||||||
|
Permissions: perms,
|
||||||
|
Created: now.Unix(),
|
||||||
|
Expires: expires.Unix(),
|
||||||
|
Name: name,
|
||||||
|
BelongsTo: user.ID,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expires),
|
||||||
|
Issuer: "FileBrowser Quantum",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim)
|
||||||
|
tokenString, err := token.SignedString(config.Auth.Key)
|
||||||
|
if err != nil {
|
||||||
|
return claim, err
|
||||||
|
}
|
||||||
|
claim.Key = tokenString
|
||||||
|
if strings.HasPrefix(name, "WEB_TOKEN") {
|
||||||
|
// don't add to api tokens, its a short lived web token
|
||||||
|
return claim, err
|
||||||
|
}
|
||||||
|
// Perform the user update
|
||||||
|
err = store.Users.AddApiKey(user.ID, name, claim)
|
||||||
|
if err != nil {
|
||||||
|
return claim, err
|
||||||
|
}
|
||||||
|
return claim, err
|
||||||
|
}
|
||||||
|
|
|
@ -1,75 +0,0 @@
|
||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/tomasen/realip"
|
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/runner"
|
|
||||||
"github.com/gtsteffaniak/filebrowser/settings"
|
|
||||||
"github.com/gtsteffaniak/filebrowser/storage"
|
|
||||||
"github.com/gtsteffaniak/filebrowser/users"
|
|
||||||
)
|
|
||||||
|
|
||||||
type handleFunc func(w http.ResponseWriter, r *http.Request, d *data) (int, error)
|
|
||||||
|
|
||||||
type data struct {
|
|
||||||
*runner.Runner
|
|
||||||
settings *settings.Settings
|
|
||||||
server *settings.Server
|
|
||||||
store *storage.Storage
|
|
||||||
user *users.User
|
|
||||||
raw interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check implements rules.Checker.
|
|
||||||
func (d *data) Check(path string) bool {
|
|
||||||
|
|
||||||
allow := true
|
|
||||||
for _, rule := range d.settings.Rules {
|
|
||||||
if rule.Matches(path) {
|
|
||||||
allow = rule.Allow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, rule := range d.user.Rules {
|
|
||||||
if rule.Matches(path) {
|
|
||||||
allow = rule.Allow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return allow
|
|
||||||
}
|
|
||||||
|
|
||||||
func handle(fn handleFunc, prefix string, store *storage.Storage, server *settings.Server) http.Handler {
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
||||||
|
|
||||||
settings, err := store.Settings.Get()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("ERROR: couldn't get settings: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
status, err := fn(w, r, &data{
|
|
||||||
Runner: &runner.Runner{Enabled: server.EnableExec, Settings: settings},
|
|
||||||
store: store,
|
|
||||||
settings: settings,
|
|
||||||
server: server,
|
|
||||||
})
|
|
||||||
|
|
||||||
if status >= 400 || err != nil {
|
|
||||||
clientIP := realip.FromRequest(r)
|
|
||||||
log.Printf("%s: %v %s %v", r.URL.Path, status, clientIP, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if status != 0 {
|
|
||||||
txt := http.StatusText(status)
|
|
||||||
http.Error(w, strconv.Itoa(status)+" "+txt, status)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return stripPrefix(prefix, handler)
|
|
||||||
}
|
|
|
@ -0,0 +1 @@
|
||||||
|
*
|
|
@ -1,85 +0,0 @@
|
||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/settings"
|
|
||||||
"github.com/gtsteffaniak/filebrowser/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
type modifyRequest struct {
|
|
||||||
What string `json:"what"` // Answer to: what data type?
|
|
||||||
Which []string `json:"which"` // Answer to: which fields?
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
store *storage.Storage
|
|
||||||
server *settings.Server
|
|
||||||
fileCache FileCache
|
|
||||||
)
|
|
||||||
|
|
||||||
func SetupEnv(storage *storage.Storage, s *settings.Server, cache FileCache) {
|
|
||||||
store = storage
|
|
||||||
server = s
|
|
||||||
fileCache = cache
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHandler(
|
|
||||||
imgSvc ImgService,
|
|
||||||
assetsFs fs.FS,
|
|
||||||
) (http.Handler, error) {
|
|
||||||
server.Clean()
|
|
||||||
|
|
||||||
r := mux.NewRouter()
|
|
||||||
r.Use(func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Security-Policy", `default-src 'self'; style-src 'unsafe-inline';`)
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
index, static := getStaticHandlers(store, server, assetsFs)
|
|
||||||
// NOTE: This fixes the issue where it would redirect if people did not put a
|
|
||||||
// trailing slash in the end. I hate this decision since this allows some awful
|
|
||||||
// URLs https://www.gorillatoolkit.org/pkg/mux#Router.SkipClean
|
|
||||||
r = r.SkipClean(true)
|
|
||||||
monkey := func(fn handleFunc, prefix string) http.Handler {
|
|
||||||
return handle(fn, prefix, store, server)
|
|
||||||
}
|
|
||||||
r.HandleFunc("/health", healthHandler)
|
|
||||||
r.PathPrefix("/static").Handler(static)
|
|
||||||
r.NotFoundHandler = index
|
|
||||||
api := r.PathPrefix("/api").Subrouter()
|
|
||||||
api.Handle("/login", monkey(loginHandler, ""))
|
|
||||||
api.Handle("/signup", monkey(signupHandler, ""))
|
|
||||||
api.Handle("/renew", monkey(renewHandler, ""))
|
|
||||||
users := api.PathPrefix("/users").Subrouter()
|
|
||||||
users.Handle("", monkey(usersGetHandler, "")).Methods("GET")
|
|
||||||
users.Handle("", monkey(userPostHandler, "")).Methods("POST")
|
|
||||||
users.Handle("/{id:[0-9]+}", monkey(userPutHandler, "")).Methods("PUT")
|
|
||||||
users.Handle("/{id:[0-9]+}", monkey(userGetHandler, "")).Methods("GET")
|
|
||||||
users.Handle("/{id:[0-9]+}", monkey(userDeleteHandler, "")).Methods("DELETE")
|
|
||||||
api.PathPrefix("/resources").Handler(monkey(resourceGetHandler, "/api/resources")).Methods("GET")
|
|
||||||
api.PathPrefix("/resources").Handler(monkey(resourceDeleteHandler(fileCache), "/api/resources")).Methods("DELETE")
|
|
||||||
api.PathPrefix("/resources").Handler(monkey(resourcePostHandler(fileCache), "/api/resources")).Methods("POST")
|
|
||||||
api.PathPrefix("/resources").Handler(monkey(resourcePutHandler, "/api/resources")).Methods("PUT")
|
|
||||||
api.PathPrefix("/resources").Handler(monkey(resourcePatchHandler(fileCache), "/api/resources")).Methods("PATCH")
|
|
||||||
api.PathPrefix("/usage").Handler(monkey(diskUsage, "/api/usage")).Methods("GET")
|
|
||||||
api.Path("/shares").Handler(monkey(shareListHandler, "/api/shares")).Methods("GET")
|
|
||||||
api.PathPrefix("/share").Handler(monkey(shareGetsHandler, "/api/share")).Methods("GET")
|
|
||||||
api.PathPrefix("/share").Handler(monkey(sharePostHandler, "/api/share")).Methods("POST")
|
|
||||||
api.PathPrefix("/share").Handler(monkey(shareDeleteHandler, "/api/share")).Methods("DELETE")
|
|
||||||
api.Handle("/settings", monkey(settingsGetHandler, "")).Methods("GET")
|
|
||||||
api.Handle("/settings", monkey(settingsPutHandler, "")).Methods("PUT")
|
|
||||||
api.PathPrefix("/raw").Handler(monkey(rawHandler, "/api/raw")).Methods("GET")
|
|
||||||
api.PathPrefix("/preview/{size}/{path:.*}").
|
|
||||||
Handler(monkey(previewHandler(imgSvc, fileCache, server.EnableThumbnails, server.ResizePreview), "/api/preview")).Methods("GET")
|
|
||||||
api.PathPrefix("/search").Handler(monkey(searchHandler, "/api/search")).Methods("GET")
|
|
||||||
public := api.PathPrefix("/public").Subrouter()
|
|
||||||
public.Handle("/publicUser", monkey(publicUserGetHandler, "")).Methods("GET")
|
|
||||||
public.PathPrefix("/dl").Handler(monkey(publicDlHandler, "/api/public/dl/")).Methods("GET")
|
|
||||||
public.PathPrefix("/share").Handler(monkey(publicShareHandler, "/api/public/share/")).Methods("GET")
|
|
||||||
return stripPrefix(server.BaseURL, r), nil
|
|
||||||
}
|
|
|
@ -0,0 +1,291 @@
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/files"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/runner"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
type requestContext struct {
|
||||||
|
user *users.User
|
||||||
|
*runner.Runner
|
||||||
|
raw interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type HttpResponse struct {
|
||||||
|
Status int `json:"status,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updated handleFunc to match the new signature
|
||||||
|
type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error)
|
||||||
|
|
||||||
|
// Middleware to handle file requests by hash and pass it to the handler
|
||||||
|
func withHashFileHelper(fn handleFunc) handleFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||||
|
path := r.URL.Query().Get("path")
|
||||||
|
hash := r.URL.Query().Get("hash")
|
||||||
|
data.user = &users.PublicUser
|
||||||
|
|
||||||
|
// Get the file link by hash
|
||||||
|
link, err := store.Share.GetByHash(hash)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusNotFound, err
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Retrieve the user (using the public user by default)
|
||||||
|
user := &users.PublicUser
|
||||||
|
|
||||||
|
// Get file information with options
|
||||||
|
file, err := files.FileInfoFaster(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,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return errToStatus(err), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the file info in the `data` object
|
||||||
|
data.raw = file
|
||||||
|
|
||||||
|
// Call the next handler with the data
|
||||||
|
return fn(w, r, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware to ensure the user is an admin
|
||||||
|
func withAdminHelper(fn handleFunc) handleFunc {
|
||||||
|
return withUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||||
|
// Ensure the user has admin permissions
|
||||||
|
if !data.user.Perm.Admin {
|
||||||
|
return http.StatusForbidden, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceed to the actual handler if the user is admin
|
||||||
|
return fn(w, r, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware to retrieve and authenticate user
|
||||||
|
func withUserHelper(fn handleFunc) handleFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||||
|
keyFunc := func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return config.Auth.Key, nil
|
||||||
|
}
|
||||||
|
tokenString, err := extractToken(r)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusUnauthorized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var tk users.AuthToken
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusUnauthorized, fmt.Errorf("error processing token, %v", err)
|
||||||
|
}
|
||||||
|
if !token.Valid {
|
||||||
|
return http.StatusUnauthorized, fmt.Errorf("invalid token")
|
||||||
|
}
|
||||||
|
if isRevokedApiKey(tk.Key) || tk.Expires < time.Now().Unix() {
|
||||||
|
return http.StatusUnauthorized, fmt.Errorf("token expired or revoked")
|
||||||
|
}
|
||||||
|
// Check if the token is about to expire and send a header to renew it
|
||||||
|
if tk.Expires < time.Now().Add(time.Hour).Unix() {
|
||||||
|
w.Header().Add("X-Renew-Token", "true")
|
||||||
|
}
|
||||||
|
// Retrieve the user from the store and store it in the context
|
||||||
|
data.user, err = store.Users.Get(config.Server.Root, tk.BelongsTo)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
// Call the handler function, passing in the context
|
||||||
|
return fn(w, r, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware to ensure the user is either the requested user or an admin
|
||||||
|
func withSelfOrAdminHelper(fn handleFunc) handleFunc {
|
||||||
|
return withUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||||
|
// Check if the current user is the same as the requested user or if they are an admin
|
||||||
|
if !data.user.Perm.Admin {
|
||||||
|
return http.StatusForbidden, nil
|
||||||
|
}
|
||||||
|
// Call the actual handler function with the updated context
|
||||||
|
return fn(w, r, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapHandler(fn handleFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := &requestContext{
|
||||||
|
Runner: &runner.Runner{Enabled: config.Server.EnableExec, Settings: config},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the actual handler function and get status code and error
|
||||||
|
status, err := fn(w, r, data)
|
||||||
|
|
||||||
|
// Handle the error case if there is one
|
||||||
|
if err != nil {
|
||||||
|
// Create an error response in JSON format
|
||||||
|
response := &HttpResponse{
|
||||||
|
Status: status, // Use the status code from the middleware
|
||||||
|
Message: err.Error(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the content type to JSON and status code
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
|
||||||
|
// Marshal the error response to JSON
|
||||||
|
errorBytes, marshalErr := json.Marshal(response)
|
||||||
|
if marshalErr != nil {
|
||||||
|
log.Printf("Error marshalling error response: %v", marshalErr)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the JSON error response
|
||||||
|
if _, writeErr := w.Write(errorBytes); writeErr != nil {
|
||||||
|
log.Printf("Error writing error response: %v", writeErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No error, proceed to write status if non-zero
|
||||||
|
if status != 0 {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withPermShareHelper(fn handleFunc) handleFunc {
|
||||||
|
return withUserHelper(func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
if !d.user.Perm.Share {
|
||||||
|
return http.StatusForbidden, nil
|
||||||
|
}
|
||||||
|
return fn(w, r, d)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func withPermShare(fn handleFunc) http.HandlerFunc {
|
||||||
|
return wrapHandler(withPermShareHelper(fn))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example of wrapping specific middleware functions for use with http.HandleFunc
|
||||||
|
func withHashFile(fn handleFunc) http.HandlerFunc {
|
||||||
|
return wrapHandler(withHashFileHelper(fn))
|
||||||
|
}
|
||||||
|
|
||||||
|
func withAdmin(fn handleFunc) http.HandlerFunc {
|
||||||
|
return wrapHandler(withAdminHelper(fn))
|
||||||
|
}
|
||||||
|
|
||||||
|
func withUser(fn handleFunc) http.HandlerFunc {
|
||||||
|
return wrapHandler(withUserHelper(fn))
|
||||||
|
}
|
||||||
|
|
||||||
|
func withSelfOrAdmin(fn handleFunc) http.HandlerFunc {
|
||||||
|
return wrapHandler(withSelfOrAdminHelper(fn))
|
||||||
|
}
|
||||||
|
|
||||||
|
func muxWithMiddleware(mux *http.ServeMux) *http.ServeMux {
|
||||||
|
wrappedMux := http.NewServeMux()
|
||||||
|
wrappedMux.Handle("/", LoggingMiddleware(mux))
|
||||||
|
return wrappedMux
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponseWriterWrapper wraps the standard http.ResponseWriter to capture the status code
|
||||||
|
type ResponseWriterWrapper struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
StatusCode int
|
||||||
|
wroteHeader bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteHeader captures the status code and ensures it's only written once
|
||||||
|
func (w *ResponseWriterWrapper) WriteHeader(statusCode int) {
|
||||||
|
if !w.wroteHeader { // Prevent WriteHeader from being called multiple times
|
||||||
|
if statusCode == 0 {
|
||||||
|
statusCode = http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
w.StatusCode = statusCode
|
||||||
|
w.ResponseWriter.WriteHeader(statusCode)
|
||||||
|
w.wroteHeader = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write is the method to write the response body and ensure WriteHeader is called
|
||||||
|
func (w *ResponseWriterWrapper) Write(b []byte) (int, error) {
|
||||||
|
if !w.wroteHeader { // Default to 200 if WriteHeader wasn't called explicitly
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
wrappedWriter := &ResponseWriterWrapper{ResponseWriter: w, StatusCode: http.StatusOK}
|
||||||
|
|
||||||
|
// Call the next handler
|
||||||
|
next.ServeHTTP(wrappedWriter, r)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
fullURL := r.URL.Path
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
fullURL += "?" + r.URL.RawQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the request and its status code
|
||||||
|
log.Printf("%s%-7s | %3d | %-15s | %-12s | \"%s\"%s",
|
||||||
|
color,
|
||||||
|
r.Method,
|
||||||
|
wrappedWriter.StatusCode, // Now capturing the correct status
|
||||||
|
r.RemoteAddr,
|
||||||
|
time.Since(start).String(),
|
||||||
|
fullURL,
|
||||||
|
"\033[0m", // Reset color
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderJSON(w http.ResponseWriter, _ *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
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, nil
|
||||||
|
}
|
|
@ -0,0 +1,252 @@
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/asdine/storm/v3"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/diskcache"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/img"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/settings"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/share"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/storage"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/storage/bolt"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/users"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestEnv(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "db")
|
||||||
|
db, err := storm.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
authStore, userStore, shareStore, settingsStore, err := bolt.NewStorage(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store = &storage.Storage{
|
||||||
|
Auth: authStore,
|
||||||
|
Users: userStore,
|
||||||
|
Share: shareStore,
|
||||||
|
Settings: settingsStore,
|
||||||
|
}
|
||||||
|
fileCache = diskcache.NewNoOp() // mocked
|
||||||
|
imgSvc = img.New(1) // mocked
|
||||||
|
config = &settings.Config // mocked
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithAdminHelper(t *testing.T) {
|
||||||
|
setupTestEnv(t)
|
||||||
|
// Mock a user who has admin permissions
|
||||||
|
adminUser := &users.User{
|
||||||
|
ID: 1,
|
||||||
|
Username: "admin",
|
||||||
|
Perm: users.Permissions{Admin: true}, // Ensure the user is an admin
|
||||||
|
}
|
||||||
|
nonAdminUser := &users.User{
|
||||||
|
ID: 2,
|
||||||
|
Username: "non-admin",
|
||||||
|
Perm: users.Permissions{Admin: false}, // Non-admin user
|
||||||
|
}
|
||||||
|
// Save the users to the mock database
|
||||||
|
if err := store.Users.Save(adminUser); err != nil {
|
||||||
|
t.Fatal("failed to save admin user:", err)
|
||||||
|
}
|
||||||
|
if err := store.Users.Save(nonAdminUser); err != nil {
|
||||||
|
t.Fatal("failed to save non-admin user:", err)
|
||||||
|
}
|
||||||
|
// Test cases for different scenarios
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
expectedStatusCode int
|
||||||
|
user *users.User
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Admin access allowed",
|
||||||
|
expectedStatusCode: http.StatusOK, // Admin should be able to access
|
||||||
|
user: adminUser,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Non-admin access forbidden",
|
||||||
|
expectedStatusCode: http.StatusForbidden, // Non-admin should be forbidden
|
||||||
|
user: nonAdminUser,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Mock the context with the current user
|
||||||
|
data := &requestContext{
|
||||||
|
user: tc.user,
|
||||||
|
}
|
||||||
|
token, err := makeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.GenerateRandomHash(4), time.Hour*2, tc.user.Perm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Error making token for request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap the usersGetHandler with the middleware
|
||||||
|
handler := withAdminHelper(mockHandler)
|
||||||
|
|
||||||
|
// Create a response recorder to capture the handler's output
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
// Create the request and apply the token as a cookie
|
||||||
|
req, err := http.NewRequest(http.MethodGet, "/users", http.NoBody)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Error creating request: %v", err)
|
||||||
|
}
|
||||||
|
req.AddCookie(&http.Cookie{
|
||||||
|
Name: "auth",
|
||||||
|
Value: token.Key,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Call the handler with the test request and mock context
|
||||||
|
status, err := handler(recorder, req, data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the status code
|
||||||
|
if status != tc.expectedStatusCode {
|
||||||
|
t.Errorf("\"%v\" expected status code %d, got %d", tc.name, tc.expectedStatusCode, status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||||
|
setupTestEnv(t)
|
||||||
|
|
||||||
|
const passwordBcrypt = "$2y$10$TFAmdCbyd/mEZDe5fUeZJu.MaJQXRTwdqb/IQV.eTn6dWrF58gCSe" // bcrypt hashed password
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
share *share.Link
|
||||||
|
token string
|
||||||
|
password string
|
||||||
|
extraHeaders map[string]string
|
||||||
|
expectedStatusCode int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Public share, no auth required",
|
||||||
|
share: &share.Link{
|
||||||
|
Hash: "public_hash",
|
||||||
|
},
|
||||||
|
expectedStatusCode: 0, // zero means 200 on helpers
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Private share, no auth provided",
|
||||||
|
share: &share.Link{
|
||||||
|
Hash: "private_hash",
|
||||||
|
UserID: 1,
|
||||||
|
PasswordHash: passwordBcrypt,
|
||||||
|
Token: "123",
|
||||||
|
},
|
||||||
|
expectedStatusCode: http.StatusUnauthorized,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Private share, valid token",
|
||||||
|
share: &share.Link{
|
||||||
|
Hash: "token_hash",
|
||||||
|
UserID: 1,
|
||||||
|
PasswordHash: passwordBcrypt,
|
||||||
|
Token: "123",
|
||||||
|
},
|
||||||
|
token: "123",
|
||||||
|
expectedStatusCode: 0, // zero means 200 on helpers
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Private share, invalid password",
|
||||||
|
share: &share.Link{
|
||||||
|
Hash: "pw_hash",
|
||||||
|
UserID: 1,
|
||||||
|
PasswordHash: passwordBcrypt,
|
||||||
|
Token: "123",
|
||||||
|
},
|
||||||
|
extraHeaders: map[string]string{
|
||||||
|
"X-SHARE-PASSWORD": "wrong-password",
|
||||||
|
},
|
||||||
|
expectedStatusCode: http.StatusUnauthorized,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Save the share in the mock store
|
||||||
|
if err := store.Share.Save(tc.share); err != nil {
|
||||||
|
t.Fatal("failed to save share:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a response recorder to capture handler output
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Wrap the handler with authentication middleware
|
||||||
|
handler := withHashFileHelper(publicShareHandler)
|
||||||
|
if err := store.Settings.Save(&settings.Settings{
|
||||||
|
Auth: settings.Auth{
|
||||||
|
Key: []byte("key"),
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("failed to save settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the request with query parameters and optional headers
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the response matches the expected status code
|
||||||
|
if status != tc.expectedStatusCode {
|
||||||
|
t.Errorf("expected status code %d, got %d", tc.expectedStatusCode, status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create a new HTTP request with optional parameters
|
||||||
|
func newTestRequest(t *testing.T, hash, token, password string, headers map[string]string) *http.Request {
|
||||||
|
req := newHTTPRequest(t, hash, func(r *http.Request) {
|
||||||
|
// Set query parameters based on provided values
|
||||||
|
q := r.URL.Query()
|
||||||
|
q.Set("path", "/")
|
||||||
|
q.Set("hash", hash)
|
||||||
|
if token != "" {
|
||||||
|
q.Set("token", token)
|
||||||
|
}
|
||||||
|
if password != "" {
|
||||||
|
q.Set("password", password)
|
||||||
|
}
|
||||||
|
r.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
// Set any extra headers if provided
|
||||||
|
for key, value := range headers {
|
||||||
|
r.Header.Set(key, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func mockHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
return http.StatusOK, nil // mock response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify newHTTPRequest to accept the hash and use it in the URL path.
|
||||||
|
func newHTTPRequest(t *testing.T, hash string, requestModifiers ...func(*http.Request)) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
url := "/public/share/" + hash + "/" // Dynamically include the hash in the URL path
|
||||||
|
r, err := http.NewRequest(http.MethodGet, url, http.NoBody)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create request: %v", err)
|
||||||
|
}
|
||||||
|
for _, modify := range requestModifiers {
|
||||||
|
modify(r)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
|
@ -1,4 +1,3 @@
|
||||||
//go:generate go-enum --sql --marshal --names --file $GOFILE
|
|
||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
@ -8,21 +7,12 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"github.com/gorilla/mux"
|
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/files"
|
"github.com/gtsteffaniak/filebrowser/files"
|
||||||
"github.com/gtsteffaniak/filebrowser/img"
|
"github.com/gtsteffaniak/filebrowser/img"
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
|
||||||
ENUM(
|
|
||||||
thumb
|
|
||||||
big
|
|
||||||
)
|
|
||||||
*/
|
|
||||||
type PreviewSize int
|
|
||||||
|
|
||||||
type ImgService interface {
|
type ImgService interface {
|
||||||
FormatFromExtension(ext string) (img.Format, error)
|
FormatFromExtension(ext string) (img.Format, error)
|
||||||
Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...img.Option) error
|
Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...img.Option) error
|
||||||
|
@ -34,82 +24,92 @@ type FileCache interface {
|
||||||
Delete(ctx context.Context, key string) error
|
Delete(ctx context.Context, key string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func previewHandler(imgSvc ImgService, fileCache FileCache, enableThumbnails, resizePreview bool) handleFunc {
|
// previewHandler handles the preview request for images.
|
||||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
// @Summary Get image preview
|
||||||
|
// @Description Returns a preview image based on the requested path and size.
|
||||||
|
// @Tags Resources
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param path query string true "File path of the image to preview"
|
||||||
|
// @Param size query string false "Preview size ('small' or 'large'). Default is based on server config."
|
||||||
|
// @Success 200 {file} file "Preview image content"
|
||||||
|
// @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 not found"
|
||||||
|
// @Failure 415 {object} map[string]string "Unsupported file type for preview"
|
||||||
|
// @Failure 500 {object} map[string]string "Internal server error"
|
||||||
|
// @Router /api/preview [get]
|
||||||
|
func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
path := r.URL.Query().Get("path")
|
||||||
|
previewSize := r.URL.Query().Get("size")
|
||||||
|
if previewSize != "small" {
|
||||||
|
previewSize = "large"
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
return http.StatusBadRequest, fmt.Errorf("invalid request path")
|
||||||
|
}
|
||||||
|
file, 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,
|
||||||
|
})
|
||||||
|
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" {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewSize == "large" && !config.Server.ResizePreview) ||
|
||||||
|
(previewSize == "small" && !config.Server.EnableThumbnails) {
|
||||||
if !d.user.Perm.Download {
|
if !d.user.Perm.Download {
|
||||||
return http.StatusAccepted, nil
|
return http.StatusAccepted, nil
|
||||||
}
|
}
|
||||||
vars := mux.Vars(r)
|
|
||||||
|
|
||||||
previewSize, err := ParsePreviewSize(vars["size"])
|
|
||||||
if err != nil {
|
|
||||||
return http.StatusBadRequest, err
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := files.FileInfoFaster(files.FileOptions{
|
|
||||||
Path: "/" + vars["path"],
|
|
||||||
Modify: d.user.Perm.Modify,
|
|
||||||
Expand: true,
|
|
||||||
ReadHeader: d.server.TypeDetectionByHeader,
|
|
||||||
Checker: d,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return errToStatus(err), err
|
|
||||||
}
|
|
||||||
setContentDisposition(w, r, file)
|
|
||||||
|
|
||||||
switch file.Type {
|
|
||||||
case "image":
|
|
||||||
return handleImagePreview(w, r, imgSvc, fileCache, file, previewSize, enableThumbnails, resizePreview)
|
|
||||||
default:
|
|
||||||
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", file.Type)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleImagePreview(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
imgSvc ImgService,
|
|
||||||
fileCache FileCache,
|
|
||||||
file *files.FileInfo,
|
|
||||||
previewSize PreviewSize,
|
|
||||||
enableThumbnails, resizePreview bool,
|
|
||||||
) (int, error) {
|
|
||||||
if (previewSize == PreviewSizeBig && !resizePreview) ||
|
|
||||||
(previewSize == PreviewSizeThumb && !enableThumbnails) {
|
|
||||||
return rawFileHandler(w, r, file)
|
return rawFileHandler(w, r, file)
|
||||||
}
|
}
|
||||||
format, err := imgSvc.FormatFromExtension(file.Extension)
|
|
||||||
|
format, err := imgSvc.FormatFromExtension(filepath.Ext(file.Name))
|
||||||
// Unsupported extensions directly return the raw data
|
// Unsupported extensions directly return the raw data
|
||||||
if err == img.ErrUnsupportedFormat || format == img.FormatGif {
|
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, file)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheKey := previewCacheKey(file, previewSize)
|
cacheKey := previewCacheKey(file, previewSize)
|
||||||
resizedImage, ok, err := fileCache.Load(r.Context(), cacheKey)
|
resizedImage, ok, err := fileCache.Load(r.Context(), cacheKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
resizedImage, err = createPreview(imgSvc, fileCache, file, previewSize)
|
resizedImage, err = createPreview(imgSvc, fileCache, file, previewSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Cache-Control", "private")
|
w.Header().Set("Cache-Control", "private")
|
||||||
http.ServeContent(w, r, file.Name, file.ModTime, bytes.NewReader(resizedImage))
|
http.ServeContent(w, r, file.Path, file.ModTime, bytes.NewReader(resizedImage))
|
||||||
|
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createPreview(imgSvc ImgService, fileCache FileCache,
|
func createPreview(imgSvc ImgService, fileCache FileCache, file *files.FileInfo, previewSize string) ([]byte, error) {
|
||||||
file *files.FileInfo, previewSize PreviewSize) ([]byte, error) {
|
|
||||||
fd, err := os.Open(file.Path)
|
fd, err := os.Open(file.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -123,11 +123,11 @@ func createPreview(imgSvc ImgService, fileCache FileCache,
|
||||||
)
|
)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case previewSize == PreviewSizeBig:
|
case previewSize == "large":
|
||||||
width = 1080
|
width = 1080
|
||||||
height = 1080
|
height = 1080
|
||||||
options = append(options, img.WithMode(img.ResizeModeFit), img.WithQuality(img.QualityMedium))
|
options = append(options, img.WithMode(img.ResizeModeFit), img.WithQuality(img.QualityMedium))
|
||||||
case previewSize == PreviewSizeThumb:
|
case previewSize == "small":
|
||||||
width = 256
|
width = 256
|
||||||
height = 256
|
height = 256
|
||||||
options = append(options, img.WithMode(img.ResizeModeFill), img.WithQuality(img.QualityLow), img.WithFormat(img.FormatJpeg))
|
options = append(options, img.WithMode(img.ResizeModeFill), img.WithQuality(img.QualityLow), img.WithFormat(img.FormatJpeg))
|
||||||
|
@ -150,6 +150,7 @@ func createPreview(imgSvc ImgService, fileCache FileCache,
|
||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func previewCacheKey(f *files.FileInfo, previewSize PreviewSize) string {
|
// Generates a cache key for the preview image
|
||||||
|
func previewCacheKey(f *files.FileInfo, previewSize string) string {
|
||||||
return fmt.Sprintf("%x%x%x", f.RealPath(), f.ModTime.Unix(), previewSize)
|
return fmt.Sprintf("%x%x%x", f.RealPath(), f.ModTime.Unix(), previewSize)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,100 +0,0 @@
|
||||||
// Code generated by go-enum
|
|
||||||
// DO NOT EDIT!
|
|
||||||
|
|
||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql/driver"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// PreviewSizeThumb is a PreviewSize of type Thumb
|
|
||||||
PreviewSizeThumb PreviewSize = iota
|
|
||||||
// PreviewSizeBig is a PreviewSize of type Big
|
|
||||||
PreviewSizeBig
|
|
||||||
)
|
|
||||||
|
|
||||||
const _PreviewSizeName = "thumbbig"
|
|
||||||
|
|
||||||
var _PreviewSizeNames = []string{
|
|
||||||
_PreviewSizeName[0:5],
|
|
||||||
_PreviewSizeName[5:8],
|
|
||||||
}
|
|
||||||
|
|
||||||
// PreviewSizeNames returns a list of possible string values of PreviewSize.
|
|
||||||
func PreviewSizeNames() []string {
|
|
||||||
tmp := make([]string, len(_PreviewSizeNames))
|
|
||||||
copy(tmp, _PreviewSizeNames)
|
|
||||||
return tmp
|
|
||||||
}
|
|
||||||
|
|
||||||
var _PreviewSizeMap = map[PreviewSize]string{
|
|
||||||
0: _PreviewSizeName[0:5],
|
|
||||||
1: _PreviewSizeName[5:8],
|
|
||||||
}
|
|
||||||
|
|
||||||
// String implements the Stringer interface.
|
|
||||||
func (x PreviewSize) String() string {
|
|
||||||
if str, ok := _PreviewSizeMap[x]; ok {
|
|
||||||
return str
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("PreviewSize(%d)", x)
|
|
||||||
}
|
|
||||||
|
|
||||||
var _PreviewSizeValue = map[string]PreviewSize{
|
|
||||||
_PreviewSizeName[0:5]: 0,
|
|
||||||
_PreviewSizeName[5:8]: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParsePreviewSize attempts to convert a string to a PreviewSize
|
|
||||||
func ParsePreviewSize(name string) (PreviewSize, error) {
|
|
||||||
if x, ok := _PreviewSizeValue[name]; ok {
|
|
||||||
return x, nil
|
|
||||||
}
|
|
||||||
return PreviewSize(0), fmt.Errorf("%s is not a valid PreviewSize, try [%s]", name, strings.Join(_PreviewSizeNames, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalText implements the text marshaller method
|
|
||||||
func (x PreviewSize) MarshalText() ([]byte, error) {
|
|
||||||
return []byte(x.String()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalText implements the text unmarshaller method
|
|
||||||
func (x *PreviewSize) UnmarshalText(text []byte) error {
|
|
||||||
name := string(text)
|
|
||||||
tmp, err := ParsePreviewSize(name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
*x = tmp
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan implements the Scanner interface.
|
|
||||||
func (x *PreviewSize) Scan(value interface{}) error {
|
|
||||||
var name string
|
|
||||||
|
|
||||||
switch v := value.(type) {
|
|
||||||
case string:
|
|
||||||
name = v
|
|
||||||
case []byte:
|
|
||||||
name = string(v)
|
|
||||||
case nil:
|
|
||||||
*x = PreviewSize(0)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tmp, err := ParsePreviewSize(name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
*x = tmp
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Value implements the driver Valuer interface.
|
|
||||||
func (x PreviewSize) Value() (driver.Value, error) {
|
|
||||||
return x.String(), nil
|
|
||||||
}
|
|
|
@ -1,11 +1,11 @@
|
||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
@ -14,97 +14,58 @@ import (
|
||||||
"github.com/gtsteffaniak/filebrowser/settings"
|
"github.com/gtsteffaniak/filebrowser/settings"
|
||||||
"github.com/gtsteffaniak/filebrowser/share"
|
"github.com/gtsteffaniak/filebrowser/share"
|
||||||
"github.com/gtsteffaniak/filebrowser/users"
|
"github.com/gtsteffaniak/filebrowser/users"
|
||||||
|
|
||||||
|
_ "github.com/gtsteffaniak/filebrowser/swagger/docs"
|
||||||
)
|
)
|
||||||
|
|
||||||
var withHashFile = func(fn handleFunc) handleFunc {
|
func publicShareHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
|
||||||
id, path := ifPathWithName(r)
|
|
||||||
link, err := d.store.Share.GetByHash(id)
|
|
||||||
if err != nil {
|
|
||||||
return errToStatus(err), err
|
|
||||||
}
|
|
||||||
if link.Hash != "" {
|
|
||||||
var status int
|
|
||||||
status, err = authenticateShareRequest(r, link)
|
|
||||||
if err != nil || status != 0 {
|
|
||||||
return status, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d.user = &users.PublicUser
|
|
||||||
realPath, isDir, err := files.GetRealPath(d.user.Scope, link.Path, path)
|
|
||||||
if err != nil {
|
|
||||||
return http.StatusNotFound, err
|
|
||||||
}
|
|
||||||
file, err := files.FileInfoFaster(files.FileOptions{
|
|
||||||
Path: realPath,
|
|
||||||
IsDir: isDir,
|
|
||||||
Modify: d.user.Perm.Modify,
|
|
||||||
Expand: true,
|
|
||||||
ReadHeader: d.server.TypeDetectionByHeader,
|
|
||||||
Checker: d,
|
|
||||||
Token: link.Token,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return errToStatus(err), err
|
|
||||||
}
|
|
||||||
d.raw = file
|
|
||||||
return fn(w, r, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ifPathWithName(r *http.Request) (id, filePath string) {
|
|
||||||
pathElements := strings.Split(r.URL.Path, "/")
|
|
||||||
id = pathElements[0]
|
|
||||||
allButFirst := path.Join(pathElements[1:]...)
|
|
||||||
return id, allButFirst
|
|
||||||
}
|
|
||||||
|
|
||||||
var publicShareHandler = withHashFile(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
|
||||||
file, ok := d.raw.(*files.FileInfo)
|
file, ok := d.raw.(*files.FileInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return http.StatusInternalServerError, fmt.Errorf("failed to assert type *files.FileInfo")
|
return http.StatusInternalServerError, fmt.Errorf("failed to assert type *files.FileInfo")
|
||||||
}
|
}
|
||||||
|
|
||||||
file.Path = strings.TrimPrefix(file.Path, settings.Config.Server.Root)
|
file.Path = strings.TrimPrefix(file.Path, settings.Config.Server.Root)
|
||||||
if file.IsDir {
|
|
||||||
return renderJSON(w, r, file)
|
return renderJSON(w, r, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderJSON(w, r, file)
|
func publicUserGetHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
})
|
|
||||||
|
|
||||||
var publicUserGetHandler = func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
|
||||||
// Call the actual handler logic here (e.g., renderJSON, etc.)
|
// Call the actual handler logic here (e.g., renderJSON, etc.)
|
||||||
// You may need to replace `fn` with the actual handler logic.
|
// You may need to replace `fn` with the actual handler logic.
|
||||||
return renderJSON(w, r, users.PublicUser)
|
status, err := renderJSON(w, r, users.PublicUser)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, http.StatusText(status), status)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var publicDlHandler = withHashFile(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
func publicDlHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
file, ok := d.raw.(*files.FileInfo)
|
file, _ := d.raw.(*files.FileInfo)
|
||||||
if !ok {
|
if file == nil {
|
||||||
return http.StatusInternalServerError, fmt.Errorf("failed to assert type *files.FileInfo")
|
return http.StatusInternalServerError, fmt.Errorf("failed to assert type files.FileInfo")
|
||||||
}
|
}
|
||||||
|
if d.user == nil {
|
||||||
if !file.IsDir {
|
return http.StatusUnauthorized, fmt.Errorf("failed to get user")
|
||||||
return rawFileHandler(w, r, file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if file.Type == "directory" {
|
||||||
return rawDirHandler(w, r, d, file)
|
return rawDirHandler(w, r, d, file)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
return rawFileHandler(w, r, file)
|
||||||
|
}
|
||||||
|
|
||||||
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
|
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
|
||||||
if l.PasswordHash == "" {
|
if l.PasswordHash == "" {
|
||||||
return 0, nil
|
return 200, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.URL.Query().Get("token") == l.Token {
|
if r.URL.Query().Get("token") == l.Token {
|
||||||
return 0, nil
|
return 200, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
password := r.Header.Get("X-SHARE-PASSWORD")
|
password := r.Header.Get("X-SHARE-PASSWORD")
|
||||||
password, err := url.QueryUnescape(password)
|
password, err := url.QueryUnescape(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return http.StatusUnauthorized, err
|
||||||
}
|
}
|
||||||
if password == "" {
|
if password == "" {
|
||||||
return http.StatusUnauthorized, nil
|
return http.StatusUnauthorized, nil
|
||||||
|
@ -113,12 +74,25 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
|
||||||
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||||
return http.StatusUnauthorized, nil
|
return http.StatusUnauthorized, nil
|
||||||
}
|
}
|
||||||
return 0, err
|
return 401, err
|
||||||
}
|
}
|
||||||
return 0, nil
|
return 200, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func healthHandler(w http.ResponseWriter, _ *http.Request) {
|
// health godoc
|
||||||
w.WriteHeader(http.StatusOK)
|
// @Summary Health Check
|
||||||
_, _ = w.Write([]byte(`{"status":"OK"}`))
|
// @Schemes
|
||||||
|
// @Description Returns the health status of the API.
|
||||||
|
// @Tags Health
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} HttpResponse "successful health check response"
|
||||||
|
// @Router /health [get]
|
||||||
|
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
response := HttpResponse{Message: "ok"} // Create response with status "ok"
|
||||||
|
err := json.NewEncoder(w).Encode(response) // Encode the response into JSON
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,137 +0,0 @@
|
||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/asdine/storm/v3"
|
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/settings"
|
|
||||||
"github.com/gtsteffaniak/filebrowser/share"
|
|
||||||
"github.com/gtsteffaniak/filebrowser/storage"
|
|
||||||
"github.com/gtsteffaniak/filebrowser/storage/bolt"
|
|
||||||
"github.com/gtsteffaniak/filebrowser/users"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPublicShareHandlerAuthentication(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const passwordBcrypt = "$2y$10$TFAmdCbyd/mEZDe5fUeZJu.MaJQXRTwdqb/IQV.eTn6dWrF58gCSe" //nolint:gosec
|
|
||||||
testCases := map[string]struct {
|
|
||||||
share *share.Link
|
|
||||||
req *http.Request
|
|
||||||
expectedStatusCode int
|
|
||||||
}{
|
|
||||||
"Public share, no auth required": {
|
|
||||||
share: &share.Link{Hash: "h"},
|
|
||||||
req: newHTTPRequest(t),
|
|
||||||
expectedStatusCode: 200,
|
|
||||||
},
|
|
||||||
"Private share, no auth provided, 401": {
|
|
||||||
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
|
||||||
req: newHTTPRequest(t),
|
|
||||||
expectedStatusCode: 401,
|
|
||||||
},
|
|
||||||
"Private share, authentication via token": {
|
|
||||||
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
|
||||||
req: newHTTPRequest(t, func(r *http.Request) { r.URL.RawQuery = "token=123" }),
|
|
||||||
expectedStatusCode: 200,
|
|
||||||
},
|
|
||||||
"Private share, authentication via invalid token, 401": {
|
|
||||||
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
|
||||||
req: newHTTPRequest(t, func(r *http.Request) { r.URL.RawQuery = "token=1234" }),
|
|
||||||
expectedStatusCode: 401,
|
|
||||||
},
|
|
||||||
"Private share, authentication via password": {
|
|
||||||
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
|
||||||
req: newHTTPRequest(t, func(r *http.Request) { r.Header.Set("X-SHARE-PASSWORD", "password") }),
|
|
||||||
expectedStatusCode: 200,
|
|
||||||
},
|
|
||||||
"Private share, authentication via invalid password, 401": {
|
|
||||||
share: &share.Link{Hash: "h", UserID: 1, PasswordHash: passwordBcrypt, Token: "123"},
|
|
||||||
req: newHTTPRequest(t, func(r *http.Request) { r.Header.Set("X-SHARE-PASSWORD", "wrong-password") }),
|
|
||||||
expectedStatusCode: 401,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, tc := range testCases {
|
|
||||||
for handlerName, handler := range map[string]handleFunc{"public share handler": publicShareHandler, "public dl handler": publicDlHandler} {
|
|
||||||
name, tc, handlerName, handler := name, tc, handlerName, handler
|
|
||||||
t.Run(fmt.Sprintf("%s: %s", handlerName, name), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dbPath := filepath.Join(t.TempDir(), "db")
|
|
||||||
db, err := storm.Open(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to open db: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
if err := db.Close(); err != nil { //nolint:govet
|
|
||||||
t.Errorf("failed to close db: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
authStore, userStore, shareStore, settingsStore, err := bolt.NewStorage(db)
|
|
||||||
storage := &storage.Storage{
|
|
||||||
Auth: authStore,
|
|
||||||
Users: userStore,
|
|
||||||
Share: shareStore,
|
|
||||||
Settings: settingsStore,
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to get storage: %v", err)
|
|
||||||
}
|
|
||||||
if err := storage.Share.Save(tc.share); err != nil {
|
|
||||||
t.Fatalf("failed to save share: %v", err)
|
|
||||||
}
|
|
||||||
if err := storage.Settings.Save(&settings.Settings{
|
|
||||||
Auth: settings.Auth{
|
|
||||||
Key: []byte("key"),
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("failed to save settings: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
storage.Users = &customFSUser{
|
|
||||||
Store: storage.Users,
|
|
||||||
}
|
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
handler := handle(handler, "", storage, &settings.Server{})
|
|
||||||
handler.ServeHTTP(recorder, tc.req)
|
|
||||||
result := recorder.Result()
|
|
||||||
defer result.Body.Close()
|
|
||||||
if result.StatusCode != tc.expectedStatusCode {
|
|
||||||
t.Errorf("expected status code %d, got status code %d", tc.expectedStatusCode, result.StatusCode)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newHTTPRequest(t *testing.T, requestModifiers ...func(*http.Request)) *http.Request {
|
|
||||||
t.Helper()
|
|
||||||
r, err := http.NewRequest(http.MethodGet, "h", http.NoBody)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to construct request: %v", err)
|
|
||||||
}
|
|
||||||
for _, modify := range requestModifiers {
|
|
||||||
modify(r)
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
type customFSUser struct {
|
|
||||||
users.Store
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cu *customFSUser) Get(baseScope string, id interface{}) (*users.User, error) {
|
|
||||||
user, err := cu.Store.Get(baseScope, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return user, nil
|
|
||||||
}
|
|
|
@ -77,21 +77,34 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, file *files.F
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var rawHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
// rawHandler serves the raw content of a file, multiple files, or directory in various formats.
|
||||||
|
// @Summary Get raw content of a file, multiple files, or directory
|
||||||
|
// @Description Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.
|
||||||
|
// @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 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'."
|
||||||
|
// @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 {
|
if !d.user.Perm.Download {
|
||||||
return http.StatusAccepted, nil
|
return http.StatusAccepted, nil
|
||||||
}
|
}
|
||||||
realPath, isDir, err := files.GetRealPath(d.user.Scope, r.URL.Path)
|
path := r.URL.Query().Get("path")
|
||||||
if err != nil {
|
|
||||||
return http.StatusInternalServerError, err
|
|
||||||
}
|
|
||||||
file, err := files.FileInfoFaster(files.FileOptions{
|
file, err := files.FileInfoFaster(files.FileOptions{
|
||||||
Path: realPath,
|
Path: filepath.Join(d.user.Scope, path),
|
||||||
IsDir: isDir,
|
|
||||||
Modify: d.user.Perm.Modify,
|
Modify: d.user.Perm.Modify,
|
||||||
Expand: false,
|
Expand: false,
|
||||||
ReadHeader: d.server.TypeDetectionByHeader,
|
ReadHeader: config.Server.TypeDetectionByHeader,
|
||||||
Checker: d,
|
Checker: d.user,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
|
@ -100,16 +113,15 @@ var rawHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data)
|
||||||
setContentDisposition(w, r, file)
|
setContentDisposition(w, r, file)
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
if file.Type == "directory" {
|
||||||
if !file.IsDir {
|
return rawDirHandler(w, r, d, file)
|
||||||
return rawFileHandler(w, r, file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rawDirHandler(w, r, d, file)
|
return rawFileHandler(w, r, file)
|
||||||
})
|
}
|
||||||
|
|
||||||
func addFile(ar archiver.Writer, d *data, path, commonPath string) error {
|
func addFile(ar archiver.Writer, d *requestContext, path, commonPath string) error {
|
||||||
if !d.Check(path) {
|
if !d.user.Check(path) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
|
@ -160,12 +172,11 @@ func addFile(ar archiver.Writer, d *data, path, commonPath string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rawDirHandler(w http.ResponseWriter, r *http.Request, d *data, file *files.FileInfo) (int, error) {
|
func rawDirHandler(w http.ResponseWriter, r *http.Request, d *requestContext, file *files.FileInfo) (int, error) {
|
||||||
filenames, err := parseQueryFiles(r, file, d.user)
|
filenames, err := parseQueryFiles(r, file, d.user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
|
|
||||||
extension, ar, err := parseQueryAlgorithm(r)
|
extension, ar, err := parseQueryAlgorithm(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
|
@ -202,7 +213,8 @@ func rawDirHandler(w http.ResponseWriter, r *http.Request, d *data, file *files.
|
||||||
}
|
}
|
||||||
|
|
||||||
func rawFileHandler(w http.ResponseWriter, r *http.Request, file *files.FileInfo) (int, error) {
|
func rawFileHandler(w http.ResponseWriter, r *http.Request, file *files.FileInfo) (int, error) {
|
||||||
fd, err := os.Open(file.Path)
|
realPath, _, _ := files.GetRealPath(file.Path)
|
||||||
|
fd, err := os.Open(realPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,27 +14,39 @@ import (
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/errors"
|
"github.com/gtsteffaniak/filebrowser/errors"
|
||||||
"github.com/gtsteffaniak/filebrowser/files"
|
"github.com/gtsteffaniak/filebrowser/files"
|
||||||
"github.com/gtsteffaniak/filebrowser/fileutils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
// resourceGetHandler retrieves information about a resource.
|
||||||
realPath, isDir, err := files.GetRealPath(d.user.Scope, r.URL.Path)
|
// @Summary Get resource information
|
||||||
if err != nil {
|
// @Description Returns metadata and optionally file contents for a specified resource path.
|
||||||
return http.StatusNotFound, err
|
// @Tags Resources
|
||||||
}
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param path query string true "Path to the resource"
|
||||||
|
// @Param source query string false "Name for the desired source, default is used if not provided"
|
||||||
|
// @Param content query string false "Include file content if true"
|
||||||
|
// @Param checksum query string false "Optional checksum validation"
|
||||||
|
// @Success 200 {object} files.FileInfo "Resource metadata"
|
||||||
|
// @Failure 404 {object} map[string]string "Resource not found"
|
||||||
|
// @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{
|
file, err := files.FileInfoFaster(files.FileOptions{
|
||||||
Path: realPath,
|
Path: filepath.Join(d.user.Scope, path),
|
||||||
IsDir: isDir,
|
|
||||||
Modify: d.user.Perm.Modify,
|
Modify: d.user.Perm.Modify,
|
||||||
Expand: true,
|
Expand: true,
|
||||||
ReadHeader: d.server.TypeDetectionByHeader,
|
ReadHeader: config.Server.TypeDetectionByHeader,
|
||||||
Checker: d,
|
Checker: d.user,
|
||||||
Content: r.URL.Query().Get("content") == "true",
|
Content: r.URL.Query().Get("content") == "true",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
}
|
}
|
||||||
if !file.IsDir {
|
if file.Type == "directory" {
|
||||||
|
return renderJSON(w, r, file)
|
||||||
|
}
|
||||||
if checksum := r.URL.Query().Get("checksum"); checksum != "" {
|
if checksum := r.URL.Query().Get("checksum"); checksum != "" {
|
||||||
err := file.Checksum(checksum)
|
err := file.Checksum(checksum)
|
||||||
if err == errors.ErrInvalidOption {
|
if err == errors.ErrInvalidOption {
|
||||||
|
@ -43,26 +55,40 @@ var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return renderJSON(w, r, file)
|
return renderJSON(w, r, file)
|
||||||
})
|
|
||||||
|
|
||||||
func resourceDeleteHandler(fileCache FileCache) handleFunc {
|
}
|
||||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
|
||||||
if r.URL.Path == "/" || !d.user.Perm.Delete {
|
// resourceDeleteHandler deletes a resource at a specified path.
|
||||||
|
// @Summary Delete a resource
|
||||||
|
// @Description Deletes a resource located at the specified path.
|
||||||
|
// @Tags Resources
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param path query string true "Path to the resource"
|
||||||
|
// @Param source query string false "Name for the desired source, default is used if not provided"
|
||||||
|
// @Success 200 "Resource deleted successfully"
|
||||||
|
// @Failure 403 {object} map[string]string "Forbidden"
|
||||||
|
// @Failure 404 {object} map[string]string "Resource not found"
|
||||||
|
// @Failure 500 {object} map[string]string "Internal server error"
|
||||||
|
// @Router /api/resources [delete]
|
||||||
|
func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
// TODO source := r.URL.Query().Get("source")
|
||||||
|
path := r.URL.Query().Get("path")
|
||||||
|
if path == "/" || !d.user.Perm.Delete {
|
||||||
return http.StatusForbidden, nil
|
return http.StatusForbidden, nil
|
||||||
}
|
}
|
||||||
realPath, isDir, err := files.GetRealPath(d.user.Scope, r.URL.Path)
|
realPath, isDir, err := files.GetRealPath(d.user.Scope, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusNotFound, err
|
return http.StatusNotFound, err
|
||||||
}
|
}
|
||||||
fileOpts := files.FileOptions{
|
fileOpts := files.FileOptions{
|
||||||
Path: realPath,
|
Path: filepath.Join(d.user.Scope, path),
|
||||||
IsDir: isDir,
|
IsDir: isDir,
|
||||||
Modify: d.user.Perm.Modify,
|
Modify: d.user.Perm.Modify,
|
||||||
Expand: false,
|
Expand: false,
|
||||||
ReadHeader: d.server.TypeDetectionByHeader,
|
ReadHeader: config.Server.TypeDetectionByHeader,
|
||||||
Checker: d,
|
Checker: d.user,
|
||||||
}
|
}
|
||||||
file, err := files.FileInfoFaster(fileOpts)
|
file, err := files.FileInfoFaster(fileOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -80,29 +106,40 @@ func resourceDeleteHandler(fileCache FileCache) handleFunc {
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
}
|
}
|
||||||
return http.StatusOK, nil
|
return http.StatusOK, nil
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resourcePostHandler(fileCache FileCache) handleFunc {
|
// resourcePostHandler creates or uploads a new resource.
|
||||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
// @Summary Create or upload a resource
|
||||||
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
|
// @Description Creates a new resource or uploads a file at the specified path. Supports file uploads and directory creation.
|
||||||
|
// @Tags Resources
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param path query string true "Path to the resource"
|
||||||
|
// @Param source query string false "Name for the desired source, default is used if not provided"
|
||||||
|
// @Param override query bool false "Override existing file if true"
|
||||||
|
// @Success 200 "Resource created successfully"
|
||||||
|
// @Failure 403 {object} map[string]string "Forbidden"
|
||||||
|
// @Failure 404 {object} map[string]string "Resource not found"
|
||||||
|
// @Failure 409 {object} map[string]string "Conflict - Resource already exists"
|
||||||
|
// @Failure 500 {object} map[string]string "Internal server error"
|
||||||
|
// @Router /api/resources [post]
|
||||||
|
func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
// TODO source := r.URL.Query().Get("source")
|
||||||
|
path := r.URL.Query().Get("path")
|
||||||
|
if !d.user.Perm.Create || !d.user.Check(path) {
|
||||||
return http.StatusForbidden, nil
|
return http.StatusForbidden, nil
|
||||||
}
|
}
|
||||||
realPath, isDir, err := files.GetRealPath(d.user.Scope, r.URL.Path)
|
|
||||||
if err != nil {
|
|
||||||
return http.StatusNotFound, err
|
|
||||||
}
|
|
||||||
fileOpts := files.FileOptions{
|
fileOpts := files.FileOptions{
|
||||||
Path: realPath,
|
Path: filepath.Join(d.user.Scope, path),
|
||||||
IsDir: isDir,
|
|
||||||
Modify: d.user.Perm.Modify,
|
Modify: d.user.Perm.Modify,
|
||||||
Expand: false,
|
Expand: false,
|
||||||
ReadHeader: d.server.TypeDetectionByHeader,
|
ReadHeader: config.Server.TypeDetectionByHeader,
|
||||||
Checker: d,
|
Checker: d.user,
|
||||||
}
|
}
|
||||||
// Directories creation on POST.
|
// Directories creation on POST.
|
||||||
if strings.HasSuffix(r.URL.Path, "/") {
|
if strings.HasSuffix(path, "/") {
|
||||||
err = files.WriteDirectory(fileOpts) // Assign to the existing `err` variable
|
err := files.WriteDirectory(fileOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
}
|
}
|
||||||
|
@ -126,20 +163,35 @@ func resourcePostHandler(fileCache FileCache) handleFunc {
|
||||||
}
|
}
|
||||||
err = files.WriteFile(fileOpts, r.Body)
|
err = files.WriteFile(fileOpts, r.Body)
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var resourcePutHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
// resourcePutHandler updates an existing file resource.
|
||||||
if !d.user.Perm.Modify || !d.Check(r.URL.Path) {
|
// @Summary Update a file resource
|
||||||
|
// @Description Updates an existing file at the specified path.
|
||||||
|
// @Tags Resources
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param path query string true "Path to the resource"
|
||||||
|
// @Param source query string false "Name for the desired source, default is used if not provided"
|
||||||
|
// @Success 200 "Resource updated successfully"
|
||||||
|
// @Failure 403 {object} map[string]string "Forbidden"
|
||||||
|
// @Failure 404 {object} map[string]string "Resource not found"
|
||||||
|
// @Failure 405 {object} map[string]string "Method not allowed"
|
||||||
|
// @Failure 500 {object} map[string]string "Internal server error"
|
||||||
|
// @Router /api/resources [put]
|
||||||
|
func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
// TODO source := r.URL.Query().Get("source")
|
||||||
|
path := r.URL.Query().Get("path")
|
||||||
|
if !d.user.Perm.Modify || !d.user.Check(path) {
|
||||||
return http.StatusForbidden, nil
|
return http.StatusForbidden, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow PUT for files.
|
// Only allow PUT for files.
|
||||||
if strings.HasSuffix(r.URL.Path, "/") {
|
if strings.HasSuffix(path, "/") {
|
||||||
return http.StatusMethodNotAllowed, nil
|
return http.StatusMethodNotAllowed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
realPath, isDir, err := files.GetRealPath(d.user.Scope, r.URL.Path)
|
realPath, isDir, err := files.GetRealPath(d.user.Scope, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusNotFound, err
|
return http.StatusNotFound, err
|
||||||
}
|
}
|
||||||
|
@ -148,21 +200,37 @@ var resourcePutHandler = withUser(func(w http.ResponseWriter, r *http.Request, d
|
||||||
IsDir: isDir,
|
IsDir: isDir,
|
||||||
Modify: d.user.Perm.Modify,
|
Modify: d.user.Perm.Modify,
|
||||||
Expand: false,
|
Expand: false,
|
||||||
ReadHeader: d.server.TypeDetectionByHeader,
|
ReadHeader: config.Server.TypeDetectionByHeader,
|
||||||
Checker: d,
|
Checker: d.user,
|
||||||
}
|
}
|
||||||
err = files.WriteFile(fileOpts, r.Body)
|
err = files.WriteFile(fileOpts, r.Body)
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
})
|
}
|
||||||
|
|
||||||
// TODO fix and verify this function still works in tests
|
// resourcePatchHandler performs a patch operation (e.g., move, rename) on a resource.
|
||||||
func resourcePatchHandler(fileCache FileCache) handleFunc {
|
// @Summary Patch resource (move/rename)
|
||||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
// @Description Moves or renames a resource to a new destination.
|
||||||
src := r.URL.Path
|
// @Tags Resources
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param from query string true "Path from resource"
|
||||||
|
// @Param destination query string true "Destination path for the resource"
|
||||||
|
// @Param action query string true "Action to perform (copy, rename)"
|
||||||
|
// @Param overwrite query bool false "Overwrite if destination exists"
|
||||||
|
// @Param rename query bool false "Rename if destination exists"
|
||||||
|
// @Success 200 "Resource moved/renamed successfully"
|
||||||
|
// @Failure 403 {object} map[string]string "Forbidden"
|
||||||
|
// @Failure 404 {object} map[string]string "Resource not found"
|
||||||
|
// @Failure 409 {object} map[string]string "Conflict - Destination exists"
|
||||||
|
// @Failure 500 {object} map[string]string "Internal server error"
|
||||||
|
// @Router /api/resources [patch]
|
||||||
|
func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
// TODO source := r.URL.Query().Get("source")
|
||||||
|
src := r.URL.Query().Get("from")
|
||||||
dst := r.URL.Query().Get("destination")
|
dst := r.URL.Query().Get("destination")
|
||||||
action := r.URL.Query().Get("action")
|
action := r.URL.Query().Get("action")
|
||||||
dst, err := url.QueryUnescape(dst)
|
dst, err := url.QueryUnescape(dst)
|
||||||
if !d.Check(src) || !d.Check(dst) {
|
if !d.user.Check(src) || !d.user.Check(dst) {
|
||||||
return http.StatusForbidden, nil
|
return http.StatusForbidden, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -171,26 +239,31 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
|
||||||
if dst == "/" || src == "/" {
|
if dst == "/" || src == "/" {
|
||||||
return http.StatusForbidden, nil
|
return http.StatusForbidden, nil
|
||||||
}
|
}
|
||||||
override := r.URL.Query().Get("override") == "true"
|
|
||||||
|
// check target dir exists
|
||||||
|
parentDir, _, err := files.GetRealPath(d.user.Scope, filepath.Dir(dst))
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusNotFound, err
|
||||||
|
}
|
||||||
|
realDest := parentDir + "/" + filepath.Base(dst)
|
||||||
|
realSrc, isSrcDir, err := files.GetRealPath(d.user.Scope, src)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusNotFound, err
|
||||||
|
}
|
||||||
|
overwrite := r.URL.Query().Get("overwrite") == "true"
|
||||||
rename := r.URL.Query().Get("rename") == "true"
|
rename := r.URL.Query().Get("rename") == "true"
|
||||||
if !override && !rename {
|
|
||||||
if _, err = os.Stat(dst); err == nil {
|
|
||||||
return http.StatusConflict, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if rename {
|
if rename {
|
||||||
dst = addVersionSuffix(dst)
|
realDest = addVersionSuffix(realDest)
|
||||||
}
|
}
|
||||||
// Permission for overwriting the file
|
// Permission for overwriting the file
|
||||||
if override && !d.user.Perm.Modify {
|
if overwrite && !d.user.Perm.Modify {
|
||||||
return http.StatusForbidden, nil
|
return http.StatusForbidden, nil
|
||||||
}
|
}
|
||||||
err = d.RunHook(func() error {
|
err = d.RunHook(func() error {
|
||||||
return patchAction(r.Context(), action, src, dst, d, fileCache)
|
return patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir)
|
||||||
}, action, src, dst, d.user)
|
}, action, realSrc, realDest, d.user)
|
||||||
|
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func addVersionSuffix(source string) string {
|
func addVersionSuffix(source string) string {
|
||||||
|
@ -210,46 +283,31 @@ func addVersionSuffix(source string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func delThumbs(ctx context.Context, fileCache FileCache, file *files.FileInfo) error {
|
func delThumbs(ctx context.Context, fileCache FileCache, file *files.FileInfo) error {
|
||||||
for _, previewSizeName := range PreviewSizeNames() {
|
if err := fileCache.Delete(ctx, previewCacheKey(file, "small")); err != nil {
|
||||||
size, _ := ParsePreviewSize(previewSizeName)
|
|
||||||
if err := fileCache.Delete(ctx, previewCacheKey(file, size)); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func patchAction(ctx context.Context, action, src, dst string, d *data, fileCache FileCache) error {
|
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, fileCache FileCache, isSrcDir bool) error {
|
||||||
switch action {
|
switch action {
|
||||||
// TODO: use enum
|
// TODO: use enum
|
||||||
case "copy":
|
case "copy":
|
||||||
if !d.user.Perm.Create {
|
if !d.user.Perm.Create {
|
||||||
return errors.ErrPermissionDenied
|
return errors.ErrPermissionDenied
|
||||||
}
|
}
|
||||||
|
return files.CopyResource(src, dst, isSrcDir)
|
||||||
return fileutils.Copy(src, dst)
|
case "rename", "move":
|
||||||
case "rename":
|
|
||||||
if !d.user.Perm.Rename {
|
if !d.user.Perm.Rename {
|
||||||
return errors.ErrPermissionDenied
|
return errors.ErrPermissionDenied
|
||||||
}
|
}
|
||||||
src = path.Clean("/" + src)
|
|
||||||
dst = path.Clean("/" + dst)
|
|
||||||
realDest, _, err := files.GetRealPath(d.user.Scope, dst)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
realSrc, isDir, err := files.GetRealPath(d.user.Scope, src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
file, err := files.FileInfoFaster(files.FileOptions{
|
file, err := files.FileInfoFaster(files.FileOptions{
|
||||||
Path: realSrc,
|
Path: src,
|
||||||
IsDir: isDir,
|
IsDir: isSrcDir,
|
||||||
Modify: d.user.Perm.Modify,
|
Modify: d.user.Perm.Modify,
|
||||||
Expand: false,
|
Expand: false,
|
||||||
ReadHeader: false,
|
ReadHeader: false,
|
||||||
Checker: d,
|
Checker: d.user,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -260,8 +318,7 @@ func patchAction(ctx context.Context, action, src, dst string, d *data, fileCach
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return files.MoveResource(src, dst, isSrcDir)
|
||||||
return fileutils.MoveFile(realSrc, realDest)
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
|
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
|
||||||
}
|
}
|
||||||
|
@ -272,28 +329,32 @@ type DiskUsageResponse struct {
|
||||||
Used uint64 `json:"used"`
|
Used uint64 `json:"used"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var diskUsage = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
// diskUsage returns the disk usage information for a given directory.
|
||||||
realPath, isDir, err := files.GetRealPath(d.user.Scope, r.URL.Path)
|
// @Summary Get disk usage
|
||||||
if err != nil {
|
// @Description Returns the total and used disk space for a specified directory.
|
||||||
return http.StatusNotFound, err
|
// @Tags Resources
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param source query string false "Name for the desired source, default is used if not provided"
|
||||||
|
// @Success 200 {object} DiskUsageResponse "Disk usage details"
|
||||||
|
// @Failure 404 {object} map[string]string "Directory not found"
|
||||||
|
// @Failure 500 {object} map[string]string "Internal server error"
|
||||||
|
// @Router /api/usage [get]
|
||||||
|
func diskUsage(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
source := r.URL.Query().Get("source")
|
||||||
|
if source == "" {
|
||||||
|
source = "/"
|
||||||
}
|
}
|
||||||
file, err := files.FileInfoFaster(files.FileOptions{
|
file, err := files.FileInfoFaster(files.FileOptions{
|
||||||
Path: realPath,
|
Path: source,
|
||||||
IsDir: isDir,
|
Checker: d.user,
|
||||||
Modify: d.user.Perm.Modify,
|
|
||||||
Expand: false,
|
|
||||||
ReadHeader: false,
|
|
||||||
Checker: d,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
}
|
}
|
||||||
fPath := file.RealPath()
|
fPath := file.RealPath()
|
||||||
if !file.IsDir {
|
if file.Type != "directory" {
|
||||||
return renderJSON(w, r, &DiskUsageResponse{
|
return http.StatusBadRequest, fmt.Errorf("path is not a directory")
|
||||||
Total: 0,
|
|
||||||
Used: 0,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
usage, err := disk.UsageWithContext(r.Context(), fPath)
|
usage, err := disk.UsageWithContext(r.Context(), fPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -303,4 +364,12 @@ var diskUsage = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (
|
||||||
Total: usage.Total,
|
Total: usage.Total,
|
||||||
Used: usage.Used,
|
Used: usage.Used,
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
|
func inspectIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Query().Get("path")
|
||||||
|
isDir := r.URL.Query().Get("isDir") == "true"
|
||||||
|
index := files.GetIndex(config.Server.Root)
|
||||||
|
info, _ := index.GetReducedMetadata(path, isDir)
|
||||||
|
renderJSON(w, r, info) // nolint:errcheck
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,186 @@
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/gtsteffaniak/filebrowser/settings"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/storage"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/version"
|
||||||
|
|
||||||
|
httpSwagger "github.com/swaggo/http-swagger" // http-swagger middleware
|
||||||
|
)
|
||||||
|
|
||||||
|
// Embed the files in the frontend/dist directory
|
||||||
|
//
|
||||||
|
//go:embed embed/*
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
// Boolean flag to determine whether to use the embedded FS or not
|
||||||
|
var embeddedFS = os.Getenv("FILEBROWSER_NO_EMBEDED") != "true"
|
||||||
|
|
||||||
|
// Custom dirFS to handle both embedded and non-embedded file systems
|
||||||
|
type dirFS struct {
|
||||||
|
http.Dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implement the Open method for dirFS, which wraps http.Dir
|
||||||
|
func (d dirFS) Open(name string) (fs.File, error) {
|
||||||
|
return d.Dir.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
store *storage.Storage
|
||||||
|
config *settings.Settings
|
||||||
|
fileCache FileCache
|
||||||
|
imgSvc ImgService
|
||||||
|
assetFs fs.FS
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
|
||||||
|
|
||||||
|
store = storage
|
||||||
|
fileCache = cache
|
||||||
|
imgSvc = Service
|
||||||
|
config = &settings.Config
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if embeddedFS {
|
||||||
|
// Embedded mode: Serve files from the embedded assets
|
||||||
|
assetFs, err = fs.Sub(assets, "embed")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Could not embed frontend. Does dist exist?")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assetFs = dirFS{Dir: http.Dir("http/dist")}
|
||||||
|
}
|
||||||
|
|
||||||
|
templateRenderer = &TemplateRenderer{
|
||||||
|
templates: template.Must(template.ParseFS(assetFs, "public/index.html")),
|
||||||
|
}
|
||||||
|
|
||||||
|
router := http.NewServeMux()
|
||||||
|
// API group routing
|
||||||
|
api := http.NewServeMux()
|
||||||
|
|
||||||
|
// User routes
|
||||||
|
api.HandleFunc("GET /users", withUser(userGetHandler))
|
||||||
|
api.HandleFunc("POST /users", withSelfOrAdmin(usersPostHandler))
|
||||||
|
api.HandleFunc("PUT /users", withUser(userPutHandler))
|
||||||
|
api.HandleFunc("DELETE /users", withSelfOrAdmin(userDeleteHandler))
|
||||||
|
|
||||||
|
// Auth routes
|
||||||
|
api.HandleFunc("POST /auth/login", loginHandler)
|
||||||
|
api.HandleFunc("GET /auth/signup", signupHandler)
|
||||||
|
api.HandleFunc("POST /auth/renew", withUser(renewHandler))
|
||||||
|
api.HandleFunc("PUT /auth/token", withUser(createApiKeyHandler))
|
||||||
|
api.HandleFunc("GET /auth/token", withUser(createApiKeyHandler))
|
||||||
|
api.HandleFunc("DELETE /auth/token", withUser(deleteApiKeyHandler))
|
||||||
|
api.HandleFunc("GET /auth/tokens", withUser(listApiKeysHandler))
|
||||||
|
|
||||||
|
// Resources routes
|
||||||
|
api.HandleFunc("GET /resources", withUser(resourceGetHandler))
|
||||||
|
api.HandleFunc("DELETE /resources", withUser(resourceDeleteHandler))
|
||||||
|
api.HandleFunc("POST /resources", withUser(resourcePostHandler))
|
||||||
|
api.HandleFunc("PUT /resources", withUser(resourcePutHandler))
|
||||||
|
api.HandleFunc("PATCH /resources", withUser(resourcePatchHandler))
|
||||||
|
api.HandleFunc("GET /usage", withUser(diskUsage))
|
||||||
|
api.HandleFunc("GET /raw", withUser(rawHandler))
|
||||||
|
api.HandleFunc("GET /preview", withUser(previewHandler))
|
||||||
|
if version.Version == "testing" || version.Version == "untracked" {
|
||||||
|
api.HandleFunc("GET /inspectIndex", inspectIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share routes
|
||||||
|
api.HandleFunc("GET /shares", withPermShare(shareListHandler))
|
||||||
|
api.HandleFunc("GET /share", withPermShare(shareGetsHandler))
|
||||||
|
api.HandleFunc("POST /share", withPermShare(sharePostHandler))
|
||||||
|
api.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
api.HandleFunc("GET /public/publicUser", publicUserGetHandler)
|
||||||
|
api.HandleFunc("GET /public/dl", withHashFile(publicDlHandler))
|
||||||
|
api.HandleFunc("GET /public/share", withHashFile(publicShareHandler))
|
||||||
|
|
||||||
|
// Settings routes
|
||||||
|
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
|
||||||
|
api.HandleFunc("PUT /settings", withAdmin(settingsPutHandler))
|
||||||
|
|
||||||
|
api.HandleFunc("GET /search", withUser(searchHandler))
|
||||||
|
apiPath := config.Server.BaseURL + "api"
|
||||||
|
router.Handle(apiPath+"/", http.StripPrefix(apiPath, api))
|
||||||
|
|
||||||
|
// Static and index file handlers
|
||||||
|
router.HandleFunc(fmt.Sprintf("GET %vstatic/", config.Server.BaseURL), staticFilesHandler)
|
||||||
|
router.HandleFunc(config.Server.BaseURL, indexHandler)
|
||||||
|
|
||||||
|
// health
|
||||||
|
router.HandleFunc(fmt.Sprintf("GET %vhealth/", config.Server.BaseURL), healthHandler)
|
||||||
|
|
||||||
|
// Swagger
|
||||||
|
router.Handle(fmt.Sprintf("%vswagger/", config.Server.BaseURL),
|
||||||
|
httpSwagger.Handler(
|
||||||
|
httpSwagger.URL(config.Server.BaseURL+"swagger/doc.json"), //The url pointing to API definition
|
||||||
|
httpSwagger.DeepLinking(true),
|
||||||
|
httpSwagger.DocExpansion("none"),
|
||||||
|
httpSwagger.DomID("swagger-ui"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
var scheme string
|
||||||
|
port := ""
|
||||||
|
|
||||||
|
// Determine whether to use HTTPS (TLS) or HTTP
|
||||||
|
if config.Server.TLSCert != "" && config.Server.TLSKey != "" {
|
||||||
|
// Load the TLS certificate and key
|
||||||
|
cer, err := tls.LoadX509KeyPair(config.Server.TLSCert, config.Server.TLSKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("could not load certificate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a custom TLS listener
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
Certificates: []tls.Certificate{cer},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set HTTPS scheme and default port for TLS
|
||||||
|
scheme = "https"
|
||||||
|
|
||||||
|
// Listen on TCP and wrap with TLS
|
||||||
|
listener, err := tls.Listen("tcp", fmt.Sprintf(":%v", config.Server.Port), tlsConfig)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("could not start TLS server: %v", err)
|
||||||
|
}
|
||||||
|
if config.Server.Port != 443 {
|
||||||
|
port = fmt.Sprintf(":%d", config.Server.Port)
|
||||||
|
}
|
||||||
|
// Build the full URL with host and port
|
||||||
|
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
|
||||||
|
log.Printf("Running at : %s", fullURL)
|
||||||
|
err = http.Serve(listener, muxWithMiddleware(router))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("could not start server: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Set HTTP scheme and the default port for HTTP
|
||||||
|
scheme = "http"
|
||||||
|
if config.Server.Port != 443 {
|
||||||
|
port = fmt.Sprintf(":%d", config.Server.Port)
|
||||||
|
}
|
||||||
|
// Build the full URL with host and port
|
||||||
|
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
|
||||||
|
log.Printf("Running at : %s", fullURL)
|
||||||
|
err := http.ListenAndServe(fmt.Sprintf(":%v", config.Server.Port), muxWithMiddleware(router))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("could not start server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,29 +8,63 @@ import (
|
||||||
"github.com/gtsteffaniak/filebrowser/settings"
|
"github.com/gtsteffaniak/filebrowser/settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var searchHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
// searchHandler handles search requests for files based on the provided query.
|
||||||
response := []map[string]interface{}{}
|
//
|
||||||
|
// This endpoint processes a search query, retrieves relevant file paths, and
|
||||||
|
// returns a JSON response with the search results. The search is performed
|
||||||
|
// against the file index, which is built from the root directory specified in
|
||||||
|
// the server's configuration. The results are filtered based on the user's scope.
|
||||||
|
//
|
||||||
|
// The handler expects the following headers in the request:
|
||||||
|
// - SessionId: A unique identifier for the user's session.
|
||||||
|
// - UserScope: The scope of the user, which influences the search context.
|
||||||
|
//
|
||||||
|
// The request URL should include a query parameter named `query` that specifies
|
||||||
|
// the search terms to use. The response will include an array of searchResponse objects
|
||||||
|
// containing the path, type, and dir status.
|
||||||
|
//
|
||||||
|
// Example request:
|
||||||
|
//
|
||||||
|
// GET api/search?query=myfile
|
||||||
|
//
|
||||||
|
// Example response:
|
||||||
|
// [
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "path": "/path/to/myfile.txt",
|
||||||
|
// "type": "text"
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// "path": "/path/to/mydir/",
|
||||||
|
// "type": "directory"
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// ]
|
||||||
|
//
|
||||||
|
// @Summary Search Files
|
||||||
|
// @Description Searches for files matching the provided query. Returns file paths and metadata based on the user's session and scope.
|
||||||
|
// @Tags Search
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param query query string true "Search query"
|
||||||
|
// @Param scope query string false "path within user scope to search, for example '/first/second' to search within the second directory only"
|
||||||
|
// @Param SessionId header string false "User session ID, add unique value to prevent collisions"
|
||||||
|
// @Success 200 {array} files.searchResult "List of search results"
|
||||||
|
// @Failure 400 {object} map[string]string "Bad Request"
|
||||||
|
// @Router /api/search [get]
|
||||||
|
func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
query := r.URL.Query().Get("query")
|
query := r.URL.Query().Get("query")
|
||||||
|
searchScope := strings.TrimPrefix(r.URL.Query().Get("scope"), ".")
|
||||||
|
searchScope = strings.TrimPrefix(searchScope, "/")
|
||||||
// Retrieve the User-Agent and X-Auth headers from the request
|
// Retrieve the User-Agent and X-Auth headers from the request
|
||||||
sessionId := r.Header.Get("SessionId")
|
sessionId := r.Header.Get("SessionId")
|
||||||
userScope := r.Header.Get("UserScope")
|
|
||||||
index := files.GetIndex(settings.Config.Server.Root)
|
index := files.GetIndex(settings.Config.Server.Root)
|
||||||
combinedScope := strings.TrimPrefix(userScope+r.URL.Path, ".")
|
userScope := strings.TrimPrefix(d.user.Scope, ".")
|
||||||
results, fileTypes := index.Search(query, combinedScope, sessionId)
|
combinedScope := strings.TrimPrefix(userScope+"/"+searchScope, "/")
|
||||||
for _, path := range results {
|
|
||||||
responseObj := map[string]interface{}{
|
// Perform the search using the provided query and user scope
|
||||||
"path": path,
|
response := index.Search(query, combinedScope, sessionId)
|
||||||
"dir": true,
|
// Set the Content-Type header to application/json
|
||||||
}
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if _, ok := fileTypes[path]; ok {
|
|
||||||
responseObj["dir"] = false
|
|
||||||
for filterType, value := range fileTypes[path] {
|
|
||||||
if value {
|
|
||||||
responseObj[filterType] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
response = append(response, responseObj)
|
|
||||||
}
|
|
||||||
return renderJSON(w, r, response)
|
return renderJSON(w, r, response)
|
||||||
})
|
}
|
||||||
|
|
|
@ -4,8 +4,8 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/rules"
|
|
||||||
"github.com/gtsteffaniak/filebrowser/settings"
|
"github.com/gtsteffaniak/filebrowser/settings"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
type settingsData struct {
|
type settingsData struct {
|
||||||
|
@ -13,37 +13,56 @@ type settingsData struct {
|
||||||
CreateUserDir bool `json:"createUserDir"`
|
CreateUserDir bool `json:"createUserDir"`
|
||||||
UserHomeBasePath string `json:"userHomeBasePath"`
|
UserHomeBasePath string `json:"userHomeBasePath"`
|
||||||
Defaults settings.UserDefaults `json:"defaults"`
|
Defaults settings.UserDefaults `json:"defaults"`
|
||||||
Rules []rules.Rule `json:"rules"`
|
Rules []users.Rule `json:"rules"`
|
||||||
Frontend settings.Frontend `json:"frontend"`
|
Frontend settings.Frontend `json:"frontend"`
|
||||||
Commands map[string][]string `json:"commands"`
|
Commands map[string][]string `json:"commands"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var settingsGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
// settingsGetHandler retrieves the current system settings.
|
||||||
|
// @Summary Get system settings
|
||||||
|
// @Description Returns the current configuration settings for signup, user directories, rules, frontend, and commands.
|
||||||
|
// @Tags Settings
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} settingsData "System settings data"
|
||||||
|
// @Router /api/settings [get]
|
||||||
|
func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
data := &settingsData{
|
data := &settingsData{
|
||||||
Signup: d.settings.Auth.Signup,
|
Signup: config.Auth.Signup,
|
||||||
CreateUserDir: d.settings.Server.CreateUserDir,
|
CreateUserDir: config.Server.CreateUserDir,
|
||||||
UserHomeBasePath: d.settings.Server.UserHomeBasePath,
|
UserHomeBasePath: config.Server.UserHomeBasePath,
|
||||||
Defaults: d.settings.UserDefaults,
|
Defaults: config.UserDefaults,
|
||||||
Rules: d.settings.Rules,
|
Rules: config.Rules,
|
||||||
Frontend: d.settings.Frontend,
|
Frontend: config.Frontend,
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderJSON(w, r, data)
|
return renderJSON(w, r, data)
|
||||||
})
|
}
|
||||||
|
|
||||||
var settingsPutHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
// settingsPutHandler updates the system settings.
|
||||||
|
// @Summary Update system settings
|
||||||
|
// @Description Updates the system configuration settings for signup, user directories, rules, frontend, and commands.
|
||||||
|
// @Tags Settings
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body settingsData true "Settings data to update"
|
||||||
|
// @Success 200 "Settings updated successfully"
|
||||||
|
// @Failure 400 {object} map[string]string "Bad request - failed to decode body"
|
||||||
|
// @Failure 500 {object} map[string]string "Internal server error"
|
||||||
|
// @Router /api/settings [put]
|
||||||
|
func settingsPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
req := &settingsData{}
|
req := &settingsData{}
|
||||||
err := json.NewDecoder(r.Body).Decode(req)
|
err := json.NewDecoder(r.Body).Decode(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusBadRequest, err
|
return http.StatusBadRequest, err
|
||||||
}
|
}
|
||||||
|
|
||||||
d.settings.Server.CreateUserDir = req.CreateUserDir
|
config.Server.CreateUserDir = req.CreateUserDir
|
||||||
d.settings.Server.UserHomeBasePath = req.UserHomeBasePath
|
config.Server.UserHomeBasePath = req.UserHomeBasePath
|
||||||
d.settings.UserDefaults = req.Defaults
|
config.UserDefaults = req.Defaults
|
||||||
d.settings.Rules = req.Rules
|
config.Rules = req.Rules
|
||||||
d.settings.Frontend = req.Frontend
|
config.Frontend = req.Frontend
|
||||||
d.settings.Auth.Signup = req.Signup
|
config.Auth.Signup = req.Signup
|
||||||
err = d.store.Settings.Save(d.settings)
|
err = store.Settings.Save(config)
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
})
|
}
|
||||||
|
|
|
@ -8,7 +8,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
@ -17,24 +16,24 @@ import (
|
||||||
"github.com/gtsteffaniak/filebrowser/share"
|
"github.com/gtsteffaniak/filebrowser/share"
|
||||||
)
|
)
|
||||||
|
|
||||||
func withPermShare(fn handleFunc) handleFunc {
|
// shareListHandler returns a list of all share links.
|
||||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
// @Summary List share links
|
||||||
if !d.user.Perm.Share {
|
// @Description Returns a list of share links for the current user, or all links if the user is an admin.
|
||||||
return http.StatusForbidden, nil
|
// @Tags Shares
|
||||||
}
|
// @Accept json
|
||||||
return fn(w, r, d)
|
// @Produce json
|
||||||
})
|
// @Success 200 {array} share.Link "List of share links"
|
||||||
}
|
// @Failure 500 {object} map[string]string "Internal server error"
|
||||||
|
// @Router /api/shares [get]
|
||||||
var shareListHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
var (
|
var (
|
||||||
s []*share.Link
|
s []*share.Link
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
if d.user.Perm.Admin {
|
if d.user.Perm.Admin {
|
||||||
s, err = d.store.Share.All()
|
s, err = store.Share.All()
|
||||||
} else {
|
} else {
|
||||||
s, err = d.store.Share.FindByUserID(d.user.ID)
|
s, err = store.Share.FindByUserID(d.user.ID)
|
||||||
}
|
}
|
||||||
if err == errors.ErrNotExist {
|
if err == errors.ErrNotExist {
|
||||||
return renderJSON(w, r, []*share.Link{})
|
return renderJSON(w, r, []*share.Link{})
|
||||||
|
@ -51,39 +50,68 @@ var shareListHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
|
||||||
return s[i].Expire < s[j].Expire
|
return s[i].Expire < s[j].Expire
|
||||||
})
|
})
|
||||||
return renderJSON(w, r, s)
|
return renderJSON(w, r, s)
|
||||||
})
|
}
|
||||||
|
|
||||||
var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
// shareGetsHandler retrieves share links for a specific resource path.
|
||||||
s, err := d.store.Share.Gets(r.URL.Path, d.user.ID)
|
// @Summary Get share links by path
|
||||||
|
// @Description Retrieves all share links associated with a specific resource path for the current user.
|
||||||
|
// @Tags Shares
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param path query string true "Resource path for which to retrieve share links"
|
||||||
|
// @Success 200 {array} share.Link "List of share links for the specified path"
|
||||||
|
// @Failure 500 {object} map[string]string "Internal server error"
|
||||||
|
// @Router /api/share [get]
|
||||||
|
func shareGetsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
path := r.URL.Query().Get("path")
|
||||||
|
s, err := store.Share.Gets(path, d.user.ID)
|
||||||
if err == errors.ErrNotExist {
|
if err == errors.ErrNotExist {
|
||||||
return renderJSON(w, r, []*share.Link{})
|
return renderJSON(w, r, []*share.Link{})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderJSON(w, r, s)
|
return renderJSON(w, r, s)
|
||||||
})
|
}
|
||||||
|
|
||||||
var shareDeleteHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
// shareDeleteHandler deletes a specific share link by its hash.
|
||||||
hash := strings.TrimSuffix(r.URL.Path, "/")
|
// @Summary Delete a share link
|
||||||
hash = strings.TrimPrefix(hash, "/")
|
// @Description Deletes a share link specified by its hash.
|
||||||
|
// @Tags Shares
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param hash path string true "Hash of the share link to delete"
|
||||||
|
// @Success 200 "Share link deleted successfully"
|
||||||
|
// @Failure 400 {object} map[string]string "Bad request - missing or invalid hash"
|
||||||
|
// @Failure 500 {object} map[string]string "Internal server error"
|
||||||
|
// @Router /api/shares/{hash} [delete]
|
||||||
|
func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
hash := r.URL.Query().Get("hash")
|
||||||
|
|
||||||
if hash == "" {
|
if hash == "" {
|
||||||
return http.StatusBadRequest, nil
|
return http.StatusBadRequest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := d.store.Share.Delete(hash)
|
err := store.Share.Delete(hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
}
|
}
|
||||||
|
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
})
|
}
|
||||||
|
|
||||||
var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
|
||||||
|
|
||||||
|
// sharePostHandler creates a new share link.
|
||||||
|
// @Summary Create a share link
|
||||||
|
// @Description Creates a new share link with an optional expiration time and password protection.
|
||||||
|
// @Tags Shares
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body share.CreateBody true "Share link creation parameters"
|
||||||
|
// @Success 200 {object} share.Link "Created share link"
|
||||||
|
// @Failure 400 {object} map[string]string "Bad request - failed to decode body"
|
||||||
|
// @Failure 500 {object} map[string]string "Internal server error"
|
||||||
|
// @Router /api/shares [post]
|
||||||
|
func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
var s *share.Link
|
var s *share.Link
|
||||||
var body share.CreateBody
|
var body share.CreateBody
|
||||||
if r.Body != nil {
|
if r.Body != nil {
|
||||||
|
@ -93,14 +121,11 @@ var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
bytes := make([]byte, 6) //nolint:gomnd
|
secure_hash, err := generateShortUUID()
|
||||||
_, err := rand.Read(bytes)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
|
|
||||||
str := base64.URLEncoding.EncodeToString(bytes)
|
|
||||||
|
|
||||||
var expire int64 = 0
|
var expire int64 = 0
|
||||||
|
|
||||||
if body.Expires != "" {
|
if body.Expires != "" {
|
||||||
|
@ -139,24 +164,24 @@ var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
|
||||||
token = base64.URLEncoding.EncodeToString(tokenBuffer)
|
token = base64.URLEncoding.EncodeToString(tokenBuffer)
|
||||||
stringHash = string(hash)
|
stringHash = string(hash)
|
||||||
}
|
}
|
||||||
|
path := r.URL.Query().Get("path")
|
||||||
s = &share.Link{
|
s = &share.Link{
|
||||||
Path: strings.TrimSuffix(r.URL.Path, "/"),
|
Path: path,
|
||||||
Hash: str,
|
Hash: secure_hash,
|
||||||
Expire: expire,
|
Expire: expire,
|
||||||
UserID: d.user.ID,
|
UserID: d.user.ID,
|
||||||
PasswordHash: stringHash,
|
PasswordHash: stringHash,
|
||||||
Token: token,
|
Token: token,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := d.store.Share.Save(s); err != nil {
|
if err := store.Share.Save(s); err != nil {
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderJSON(w, r, s)
|
return renderJSON(w, r, s)
|
||||||
})
|
}
|
||||||
|
|
||||||
func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, err error) {
|
func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, err error) {
|
||||||
|
|
||||||
if body.Password == "" {
|
if body.Password == "" {
|
||||||
return nil, 0, nil
|
return nil, 0, nil
|
||||||
}
|
}
|
||||||
|
@ -168,3 +193,18 @@ func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, e
|
||||||
|
|
||||||
return hash, 0, nil
|
return hash, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generateShortUUID() (string, error) {
|
||||||
|
// Generate 16 random bytes (128 bits of entropy)
|
||||||
|
bytes := make([]byte, 16)
|
||||||
|
_, err := rand.Read(bytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode the bytes to a URL-safe base64 string
|
||||||
|
uuid := base64.RawURLEncoding.EncodeToString(bytes)
|
||||||
|
|
||||||
|
// Trim the length to 22 characters for a shorter ID
|
||||||
|
return uuid[:22], nil
|
||||||
|
}
|
||||||
|
|
|
@ -14,41 +14,58 @@ import (
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/auth"
|
"github.com/gtsteffaniak/filebrowser/auth"
|
||||||
"github.com/gtsteffaniak/filebrowser/settings"
|
"github.com/gtsteffaniak/filebrowser/settings"
|
||||||
"github.com/gtsteffaniak/filebrowser/storage"
|
|
||||||
"github.com/gtsteffaniak/filebrowser/version"
|
"github.com/gtsteffaniak/filebrowser/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func handleWithStaticData(w http.ResponseWriter, _ *http.Request, d *data, fSys fs.FS, file, contentType string) (int, error) {
|
var templateRenderer *TemplateRenderer
|
||||||
|
|
||||||
|
type TemplateRenderer struct {
|
||||||
|
templates *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render renders a template document with headers and data
|
||||||
|
func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data interface{}) error {
|
||||||
|
// Set headers
|
||||||
|
w.Header().Set("Cache-Control", "no-cache, private, max-age=0")
|
||||||
|
w.Header().Set("Pragma", "no-cache")
|
||||||
|
w.Header().Set("X-Accel-Expires", "0")
|
||||||
|
w.Header().Set("Transfer-Encoding", "identity")
|
||||||
|
// Execute the template with the provided data
|
||||||
|
return t.templates.ExecuteTemplate(w, name, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentType string) {
|
||||||
w.Header().Set("Content-Type", contentType)
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
|
||||||
auther, err := d.store.Auth.Get(d.settings.Auth.Method)
|
auther, err := store.Auth.Get(config.Auth.Method)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data := map[string]interface{}{
|
data := map[string]interface{}{
|
||||||
"Name": d.settings.Frontend.Name,
|
"Name": config.Frontend.Name,
|
||||||
"DisableExternal": d.settings.Frontend.DisableExternal,
|
"DisableExternal": config.Frontend.DisableExternal,
|
||||||
"DisableUsedPercentage": d.settings.Frontend.DisableUsedPercentage,
|
"DisableUsedPercentage": config.Frontend.DisableUsedPercentage,
|
||||||
"darkMode": settings.Config.UserDefaults.DarkMode,
|
"darkMode": settings.Config.UserDefaults.DarkMode,
|
||||||
"Color": d.settings.Frontend.Color,
|
"Color": config.Frontend.Color,
|
||||||
"BaseURL": d.server.BaseURL,
|
"BaseURL": config.Server.BaseURL,
|
||||||
"Version": version.Version,
|
"Version": version.Version,
|
||||||
"CommitSHA": version.CommitSHA,
|
"CommitSHA": version.CommitSHA,
|
||||||
"StaticURL": path.Join(d.server.BaseURL, "/static"),
|
"StaticURL": path.Join(config.Server.BaseURL, "static"),
|
||||||
"Signup": settings.Config.Auth.Signup,
|
"Signup": settings.Config.Auth.Signup,
|
||||||
"NoAuth": d.settings.Auth.Method == "noauth",
|
"NoAuth": config.Auth.Method == "noauth",
|
||||||
"AuthMethod": d.settings.Auth.Method,
|
"AuthMethod": config.Auth.Method,
|
||||||
"LoginPage": auther.LoginPage(),
|
"LoginPage": auther.LoginPage(),
|
||||||
"CSS": false,
|
"CSS": false,
|
||||||
"ReCaptcha": false,
|
"ReCaptcha": false,
|
||||||
"EnableThumbs": d.server.EnableThumbnails,
|
"EnableThumbs": config.Server.EnableThumbnails,
|
||||||
"ResizePreview": d.server.ResizePreview,
|
"ResizePreview": config.Server.ResizePreview,
|
||||||
"EnableExec": d.server.EnableExec,
|
"EnableExec": config.Server.EnableExec,
|
||||||
}
|
}
|
||||||
|
|
||||||
if d.settings.Frontend.Files != "" {
|
if config.Frontend.Files != "" {
|
||||||
fPath := filepath.Join(d.settings.Frontend.Files, "custom.css")
|
fPath := filepath.Join(config.Frontend.Files, "custom.css")
|
||||||
_, err := os.Stat(fPath) //nolint:govet
|
_, err := os.Stat(fPath) //nolint:govet
|
||||||
|
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
@ -60,15 +77,17 @@ func handleWithStaticData(w http.ResponseWriter, _ *http.Request, d *data, fSys
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if d.settings.Auth.Method == "password" {
|
if config.Auth.Method == "password" {
|
||||||
raw, err := d.store.Auth.Get(d.settings.Auth.Method) //nolint:govet
|
raw, err := store.Auth.Get(config.Auth.Method) //nolint:govet
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
auther, ok := raw.(*auth.JSONAuth)
|
auther, ok := raw.(*auth.JSONAuth)
|
||||||
if !ok {
|
if !ok {
|
||||||
return http.StatusInternalServerError, fmt.Errorf("failed to assert type *auth.JSONAuth")
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if auther.ReCaptcha != nil {
|
if auther.ReCaptcha != nil {
|
||||||
|
@ -80,77 +99,47 @@ func handleWithStaticData(w http.ResponseWriter, _ *http.Request, d *data, fSys
|
||||||
|
|
||||||
b, err := json.Marshal(data)
|
b, err := json.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data["Json"] = strings.ReplaceAll(string(b), `'`, `\'`)
|
data["globalVars"] = strings.ReplaceAll(string(b), `'`, `\'`)
|
||||||
|
|
||||||
fileContents, err := fs.ReadFile(fSys, file)
|
// Render the template with global variables
|
||||||
if err != nil {
|
if err := templateRenderer.Render(w, file, data); err != nil {
|
||||||
if err == os.ErrNotExist {
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return http.StatusNotFound, err
|
|
||||||
}
|
}
|
||||||
return http.StatusInternalServerError, err
|
|
||||||
}
|
|
||||||
index := template.Must(template.New("index").Delims("[{[", "]}]").Parse(string(fileContents)))
|
|
||||||
err = index.Execute(w, data)
|
|
||||||
if err != nil {
|
|
||||||
return http.StatusInternalServerError, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStaticHandlers(store *storage.Storage, server *settings.Server, assetsFs fs.FS) (index, static http.Handler) {
|
func staticFilesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
index = handle(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
return http.StatusNotFound, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("x-xss-protection", "1; mode=block")
|
|
||||||
return handleWithStaticData(w, r, d, assetsFs, "public/index.html", "text/html; charset=utf-8")
|
|
||||||
}, "", store, server)
|
|
||||||
|
|
||||||
static = handle(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
return http.StatusNotFound, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxAge = 86400 // 1 day
|
const maxAge = 86400 // 1 day
|
||||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%v", maxAge))
|
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%v", maxAge))
|
||||||
|
w.Header().Set("Content-Security-Policy", `default-src 'self'; style-src 'unsafe-inline';`)
|
||||||
if d.settings.Frontend.Files != "" {
|
// Remove "/static/" from the request path
|
||||||
if strings.HasPrefix(r.URL.Path, "img/") {
|
adjustedPath := strings.TrimPrefix(r.URL.Path, fmt.Sprintf("%vstatic/", config.Server.BaseURL))
|
||||||
fPath := filepath.Join(d.settings.Frontend.Files, r.URL.Path)
|
adjustedCompressed := adjustedPath + ".gz"
|
||||||
if _, err := os.Stat(fPath); err == nil {
|
if strings.HasSuffix(adjustedPath, ".js") {
|
||||||
http.ServeFile(w, r, fPath)
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
} else if r.URL.Path == "custom.css" && d.settings.Frontend.Files != "" {
|
|
||||||
http.ServeFile(w, r, filepath.Join(d.settings.Frontend.Files, "custom.css"))
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasSuffix(r.URL.Path, ".js") {
|
|
||||||
http.FileServer(http.FS(assetsFs)).ServeHTTP(w, r)
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fileContents, err := fs.ReadFile(assetsFs, r.URL.Path+".gz")
|
|
||||||
if err != nil {
|
|
||||||
return http.StatusNotFound, err
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Encoding", "gzip")
|
|
||||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8") // Set the correct MIME type for JavaScript files
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8") // Set the correct MIME type for JavaScript files
|
||||||
|
|
||||||
if _, err := w.Write(fileContents); err != nil {
|
|
||||||
return http.StatusInternalServerError, err
|
|
||||||
}
|
}
|
||||||
|
// Check if the gzipped version of the file exists
|
||||||
return 0, nil
|
fileContents, err := fs.ReadFile(assetFs, adjustedCompressed)
|
||||||
}, "/static/", store, server)
|
if err == nil {
|
||||||
|
w.Header().Set("Content-Encoding", "gzip") // Let the browser know the file is compressed
|
||||||
return index, static
|
status, err := w.Write(fileContents) // Write the gzipped file content to the response
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, http.StatusText(status), status)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Otherwise, serve the regular file
|
||||||
|
http.StripPrefix(fmt.Sprintf("%vstatic/", config.Server.BaseURL), http.FileServer(http.FS(assetFs))).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleWithStaticData(w, r, "index.html", "text/html")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,12 +2,12 @@ package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
"golang.org/x/text/cases"
|
"golang.org/x/text/cases"
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
|
|
||||||
|
@ -26,55 +26,38 @@ type Sorting struct {
|
||||||
By string `json:"by"`
|
By string `json:"by"`
|
||||||
Asc bool `json:"asc"`
|
Asc bool `json:"asc"`
|
||||||
}
|
}
|
||||||
type modifyUserRequest struct {
|
type UserRequest struct {
|
||||||
modifyRequest
|
What string `json:"what"`
|
||||||
|
Which []string `json:"which"`
|
||||||
Data *users.User `json:"data"`
|
Data *users.User `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUserID(r *http.Request) (uint, error) {
|
// userGetHandler retrieves a user by ID.
|
||||||
vars := mux.Vars(r)
|
// @Summary Retrieve a user by ID
|
||||||
i, err := strconv.ParseUint(vars["id"], 10, 0)
|
// @Description Returns a user's details based on their ID.
|
||||||
if err != nil {
|
// @Tags Users
|
||||||
return 0, err
|
// @Accept json
|
||||||
}
|
// @Produce json
|
||||||
return uint(i), err
|
// @Param id path int true "User ID" or "self"
|
||||||
}
|
// @Success 200 {object} users.User "User details"
|
||||||
|
// @Failure 403 {object} map[string]string "Forbidden"
|
||||||
|
// @Failure 404 {object} map[string]string "Not Found"
|
||||||
|
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||||
|
// @Router /api/users/{id} [get]
|
||||||
|
func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
givenUserIdString := r.URL.Query().Get("id")
|
||||||
|
|
||||||
func getUser(_ http.ResponseWriter, r *http.Request) (*modifyUserRequest, error) {
|
// since api self is used to validate a logged in user
|
||||||
if r.Body == nil {
|
w.Header().Add("X-Renew-Token", "false")
|
||||||
return nil, errors.ErrEmptyRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &modifyUserRequest{}
|
var givenUserId uint
|
||||||
err := json.NewDecoder(r.Body).Decode(req)
|
if givenUserIdString == "self" {
|
||||||
if err != nil {
|
givenUserId = d.user.ID
|
||||||
return nil, err
|
} else if givenUserIdString == "" {
|
||||||
}
|
if !d.user.Perm.Admin {
|
||||||
|
|
||||||
if req.What != "user" {
|
|
||||||
return nil, errors.ErrInvalidDataType
|
|
||||||
}
|
|
||||||
|
|
||||||
return req, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func withSelfOrAdmin(fn handleFunc) handleFunc {
|
|
||||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
|
||||||
id, err := getUserID(r)
|
|
||||||
if err != nil {
|
|
||||||
return http.StatusInternalServerError, err
|
|
||||||
}
|
|
||||||
if d.user.ID != id && !d.user.Perm.Admin {
|
|
||||||
return http.StatusForbidden, nil
|
return http.StatusForbidden, nil
|
||||||
}
|
}
|
||||||
|
users, err := store.Users.Gets(config.Server.Root)
|
||||||
d.raw = id
|
|
||||||
return fn(w, r, d)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var usersGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
|
||||||
users, err := d.store.Users.Gets(d.server.Root)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
|
@ -82,46 +65,106 @@ var usersGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
u.Password = ""
|
u.Password = ""
|
||||||
}
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
u.ApiKeys = nil
|
||||||
|
}
|
||||||
|
|
||||||
sort.Slice(users, func(i, j int) bool {
|
sort.Slice(users, func(i, j int) bool {
|
||||||
return users[i].ID < users[j].ID
|
return users[i].ID < users[j].ID
|
||||||
})
|
})
|
||||||
|
|
||||||
return renderJSON(w, r, users)
|
return renderJSON(w, r, users)
|
||||||
})
|
} else {
|
||||||
|
num, _ := strconv.ParseUint(givenUserIdString, 10, 32)
|
||||||
|
givenUserId = uint(num)
|
||||||
|
}
|
||||||
|
|
||||||
var userGetHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
if givenUserId != d.user.ID && !d.user.Perm.Admin {
|
||||||
u, err := d.store.Users.Get(d.server.Root, d.raw.(uint))
|
return http.StatusForbidden, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the user details
|
||||||
|
u, err := store.Users.Get(config.Server.Root, givenUserId)
|
||||||
if err == errors.ErrNotExist {
|
if err == errors.ErrNotExist {
|
||||||
return http.StatusNotFound, err
|
return http.StatusNotFound, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove the password from the response if the user is not an admin
|
||||||
u.Password = ""
|
u.Password = ""
|
||||||
|
u.ApiKeys = nil
|
||||||
if !d.user.Perm.Admin {
|
if !d.user.Perm.Admin {
|
||||||
u.Scope = ""
|
u.Scope = ""
|
||||||
}
|
}
|
||||||
return renderJSON(w, r, u)
|
|
||||||
})
|
|
||||||
|
|
||||||
var userDeleteHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
return renderJSON(w, r, u)
|
||||||
err := d.store.Users.Delete(d.raw.(uint))
|
}
|
||||||
|
|
||||||
|
// userDeleteHandler deletes a user by ID.
|
||||||
|
// @Summary Delete a user by ID
|
||||||
|
// @Description Deletes a user identified by their ID.
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "User ID"
|
||||||
|
// @Success 200 "User deleted successfully"
|
||||||
|
// @Failure 403 {object} map[string]string "Forbidden"
|
||||||
|
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||||
|
// @Router /api/users/{id} [delete]
|
||||||
|
func userDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
givenUserIdString := r.URL.Query().Get("id")
|
||||||
|
num, _ := strconv.ParseUint(givenUserIdString, 10, 32)
|
||||||
|
givenUserId := uint(num)
|
||||||
|
|
||||||
|
if givenUserId == d.user.ID || !d.user.Perm.Admin {
|
||||||
|
return http.StatusForbidden, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the user
|
||||||
|
err := store.Users.Delete(givenUserId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
}
|
}
|
||||||
|
|
||||||
return http.StatusOK, nil
|
return http.StatusOK, nil
|
||||||
})
|
}
|
||||||
|
|
||||||
var userPostHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
// usersPostHandler creates a new user.
|
||||||
req, err := getUser(w, r)
|
// @Summary Create a new user
|
||||||
|
// @Description Adds a new user to the system.
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param data body users.User true "User data to create a new user"
|
||||||
|
// @Success 201 {object} users.User "Created user"
|
||||||
|
// @Failure 400 {object} map[string]string "Bad Request"
|
||||||
|
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||||
|
// @Router /api/users [post]
|
||||||
|
func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
if !d.user.Perm.Admin {
|
||||||
|
return http.StatusForbidden, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the user's scope
|
||||||
|
_, _, err := files.GetRealPath(config.Server.Root, d.user.Scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusBadRequest, err
|
return http.StatusBadRequest, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read the JSON body
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
// Parse the JSON into the UserRequest struct
|
||||||
|
var req UserRequest
|
||||||
|
if err = json.Unmarshal(body, &req); err != nil {
|
||||||
|
return http.StatusBadRequest, err
|
||||||
|
}
|
||||||
|
|
||||||
if len(req.Which) != 0 {
|
if len(req.Which) != 0 {
|
||||||
return http.StatusBadRequest, nil
|
return http.StatusBadRequest, nil
|
||||||
}
|
}
|
||||||
|
@ -137,22 +180,50 @@ var userPostHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *
|
||||||
|
|
||||||
w.Header().Set("Location", "/settings/users/"+strconv.FormatUint(uint64(req.Data.ID), 10))
|
w.Header().Set("Location", "/settings/users/"+strconv.FormatUint(uint64(req.Data.ID), 10))
|
||||||
return http.StatusCreated, nil
|
return http.StatusCreated, nil
|
||||||
})
|
}
|
||||||
|
|
||||||
var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
// userPutHandler updates an existing user's details.
|
||||||
req, err := getUser(w, r)
|
// @Summary Update a user's details
|
||||||
|
// @Description Updates the details of a user identified by ID.
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "User ID"
|
||||||
|
// @Param data body users.User true "User data to update"
|
||||||
|
// @Success 200 {object} users.User "Updated user details"
|
||||||
|
// @Failure 400 {object} map[string]string "Bad Request"
|
||||||
|
// @Failure 403 {object} map[string]string "Forbidden"
|
||||||
|
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||||
|
// @Router /api/users/{id} [put]
|
||||||
|
func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||||
|
givenUserIdString := r.URL.Query().Get("id")
|
||||||
|
num, _ := strconv.ParseUint(givenUserIdString, 10, 32)
|
||||||
|
givenUserId := uint(num)
|
||||||
|
|
||||||
|
if givenUserId != d.user.ID && !d.user.Perm.Admin {
|
||||||
|
return http.StatusForbidden, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the user's scope
|
||||||
|
_, _, err := files.GetRealPath(config.Server.Root, d.user.Scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusBadRequest, err
|
return http.StatusBadRequest, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Data.ID != d.raw.(uint) {
|
// Read the JSON body
|
||||||
return http.StatusBadRequest, nil
|
body, err := io.ReadAll(r.Body)
|
||||||
}
|
|
||||||
_, _, err = files.GetRealPath(d.server.Root, req.Data.Scope)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusBadRequest, nil
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
// Parse the JSON into the UserRequest struct
|
||||||
|
var req UserRequest
|
||||||
|
if err = json.Unmarshal(body, &req); err != nil {
|
||||||
|
return http.StatusBadRequest, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If `Which` is not specified, default to updating all fields
|
||||||
if len(req.Which) == 0 || req.Which[0] == "all" {
|
if len(req.Which) == 0 || req.Which[0] == "all" {
|
||||||
req.Which = []string{}
|
req.Which = []string{}
|
||||||
v := reflect.ValueOf(req.Data)
|
v := reflect.ValueOf(req.Data)
|
||||||
|
@ -160,6 +231,8 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
|
||||||
v = v.Elem()
|
v = v.Elem()
|
||||||
}
|
}
|
||||||
t := v.Type()
|
t := v.Type()
|
||||||
|
|
||||||
|
// Dynamically populate fields to update
|
||||||
for i := 0; i < t.NumField(); i++ {
|
for i := 0; i < t.NumField(); i++ {
|
||||||
field := t.Field(i)
|
field := t.Field(i)
|
||||||
if field.Name == "Password" && req.Data.Password != "" {
|
if field.Name == "Password" && req.Data.Password != "" {
|
||||||
|
@ -170,10 +243,13 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range req.Which {
|
// Process the fields to update
|
||||||
v = cases.Title(language.English, cases.NoLower).String(v)
|
for _, field := range req.Which {
|
||||||
req.Which[k] = v
|
// Title case field names
|
||||||
if v == "Password" {
|
field = cases.Title(language.English, cases.NoLower).String(field)
|
||||||
|
|
||||||
|
// Handle password update
|
||||||
|
if field == "Password" {
|
||||||
if !d.user.Perm.Admin && d.user.LockPassword {
|
if !d.user.Perm.Admin && d.user.LockPassword {
|
||||||
return http.StatusForbidden, nil
|
return http.StatusForbidden, nil
|
||||||
}
|
}
|
||||||
|
@ -183,16 +259,20 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, f := range NonModifiableFieldsForNonAdmin {
|
// Prevent non-admins from modifying certain fields
|
||||||
if !d.user.Perm.Admin && v == f {
|
for _, restrictedField := range NonModifiableFieldsForNonAdmin {
|
||||||
|
if !d.user.Perm.Admin && field == restrictedField {
|
||||||
return http.StatusForbidden, nil
|
return http.StatusForbidden, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = d.store.Users.Update(req.Data, req.Which...)
|
// Perform the user update
|
||||||
|
err = store.Users.Update(req.Data, req.Which...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, err
|
return http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return http.StatusOK, nil
|
// Return the updated user (with the password hidden) as JSON response
|
||||||
})
|
req.Data.Password = ""
|
||||||
|
return renderJSON(w, r, req.Data)
|
||||||
|
}
|
||||||
|
|
|
@ -1,30 +1,13 @@
|
||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
libErrors "github.com/gtsteffaniak/filebrowser/errors"
|
libErrors "github.com/gtsteffaniak/filebrowser/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func renderJSON(w http.ResponseWriter, _ *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
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func errToStatus(err error) int {
|
func errToStatus(err error) int {
|
||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
|
@ -45,23 +28,3 @@ func errToStatus(err error) int {
|
||||||
return http.StatusInternalServerError
|
return http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is an addaptation if http.StripPrefix in which we don't
|
|
||||||
// return 404 if the page doesn't have the needed prefix.
|
|
||||||
func stripPrefix(prefix string, h http.Handler) http.Handler {
|
|
||||||
if prefix == "" || prefix == "/" {
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
p := strings.TrimPrefix(r.URL.Path, prefix)
|
|
||||||
rp := strings.TrimPrefix(r.URL.RawPath, prefix)
|
|
||||||
r2 := new(http.Request)
|
|
||||||
*r2 = *r
|
|
||||||
r2.URL = new(url.URL)
|
|
||||||
*r2.URL = *r.URL
|
|
||||||
r2.URL.Path = p
|
|
||||||
r2.URL.RawPath = rp
|
|
||||||
h.ServeHTTP(w, r2)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 72 KiB |
|
@ -1,23 +0,0 @@
|
||||||
package rules
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestMatchHidden(t *testing.T) {
|
|
||||||
cases := map[string]bool{
|
|
||||||
"/": false,
|
|
||||||
"/src": false,
|
|
||||||
"/src/": false,
|
|
||||||
"/.circleci": true,
|
|
||||||
"/a/b/c/.docker.json": true,
|
|
||||||
".docker.json": true,
|
|
||||||
"Dockerfile": false,
|
|
||||||
"/Dockerfile": false,
|
|
||||||
}
|
|
||||||
|
|
||||||
for path, want := range cases {
|
|
||||||
got := MatchHidden(path)
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("MatchHidden(%s)=%v; want %v", path, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,8 +4,10 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/goccy/go-yaml"
|
"github.com/goccy/go-yaml"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Config Settings
|
var Config Settings
|
||||||
|
@ -28,6 +30,12 @@ func Initialize(configFile string) {
|
||||||
log.Fatalf("ERROR: Configured Root Path does not exist! %v", err)
|
log.Fatalf("ERROR: Configured Root Path does not exist! %v", err)
|
||||||
}
|
}
|
||||||
Config.Server.Root = realRoot
|
Config.Server.Root = realRoot
|
||||||
|
baseurl := strings.Trim(Config.Server.BaseURL, "/")
|
||||||
|
if baseurl == "" {
|
||||||
|
Config.Server.BaseURL = "/"
|
||||||
|
} else {
|
||||||
|
Config.Server.BaseURL = "/" + baseurl + "/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfigFile(configFile string) []byte {
|
func loadConfigFile(configFile string) []byte {
|
||||||
|
@ -86,7 +94,7 @@ func setDefaults() Settings {
|
||||||
DisableSettings: false,
|
DisableSettings: false,
|
||||||
ViewMode: "normal",
|
ViewMode: "normal",
|
||||||
Locale: "en",
|
Locale: "en",
|
||||||
Permissions: Permissions{
|
Permissions: users.Permissions{
|
||||||
Create: false,
|
Create: false,
|
||||||
Rename: false,
|
Rename: false,
|
||||||
Modify: false,
|
Modify: false,
|
||||||
|
@ -94,6 +102,7 @@ func setDefaults() Settings {
|
||||||
Share: false,
|
Share: false,
|
||||||
Download: false,
|
Download: false,
|
||||||
Admin: false,
|
Admin: false,
|
||||||
|
Api: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ package settings
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/rules"
|
"github.com/gtsteffaniak/filebrowser/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSettings_MakeUserDir(t *testing.T) {
|
func TestSettings_MakeUserDir(t *testing.T) {
|
||||||
|
@ -15,7 +15,7 @@ func TestSettings_MakeUserDir(t *testing.T) {
|
||||||
Shell []string
|
Shell []string
|
||||||
AdminUsername string
|
AdminUsername string
|
||||||
AdminPassword string
|
AdminPassword string
|
||||||
Rules []rules.Rule
|
Rules []users.Rule
|
||||||
Server Server
|
Server Server
|
||||||
Auth Auth
|
Auth Auth
|
||||||
Frontend Frontend
|
Frontend Frontend
|
||||||
|
|
|
@ -2,9 +2,8 @@ package settings
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/rules"
|
"github.com/gtsteffaniak/filebrowser/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultUsersHomeBasePath = "/users"
|
const DefaultUsersHomeBasePath = "/users"
|
||||||
|
@ -12,18 +11,6 @@ const DefaultUsersHomeBasePath = "/users"
|
||||||
// AuthMethod describes an authentication method.
|
// AuthMethod describes an authentication method.
|
||||||
type AuthMethod string
|
type AuthMethod string
|
||||||
|
|
||||||
// Settings contain the main settings of the application.
|
|
||||||
// GetRules implements rules.Provider.
|
|
||||||
func (s *Settings) GetRules() []rules.Rule {
|
|
||||||
return s.Rules
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server specific settings
|
|
||||||
// Clean cleans any variables that might need cleaning.
|
|
||||||
func (s *Server) Clean() {
|
|
||||||
s.BaseURL = strings.TrimSuffix(s.BaseURL, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateKey generates a key of 512 bits.
|
// GenerateKey generates a key of 512 bits.
|
||||||
func GenerateKey() ([]byte, error) {
|
func GenerateKey() ([]byte, error) {
|
||||||
b := make([]byte, 64) //nolint:gomnd
|
b := make([]byte, 64) //nolint:gomnd
|
||||||
|
@ -40,8 +27,8 @@ func GetSettingsConfig(nameType string, Value string) string {
|
||||||
return nameType + Value
|
return nameType + Value
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminPerms() Permissions {
|
func AdminPerms() users.Permissions {
|
||||||
return Permissions{
|
return users.Permissions{
|
||||||
Create: true,
|
Create: true,
|
||||||
Rename: true,
|
Rename: true,
|
||||||
Modify: true,
|
Modify: true,
|
||||||
|
@ -49,5 +36,23 @@ func AdminPerms() Permissions {
|
||||||
Share: true,
|
Share: true,
|
||||||
Download: true,
|
Download: true,
|
||||||
Admin: true,
|
Admin: true,
|
||||||
|
Api: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply applies the default options to a user.
|
||||||
|
func ApplyUserDefaults(u users.User) users.User {
|
||||||
|
u.StickySidebar = Config.UserDefaults.StickySidebar
|
||||||
|
u.DisableSettings = Config.UserDefaults.DisableSettings
|
||||||
|
u.DarkMode = Config.UserDefaults.DarkMode
|
||||||
|
u.Scope = Config.UserDefaults.Scope
|
||||||
|
u.Locale = Config.UserDefaults.Locale
|
||||||
|
u.ViewMode = Config.UserDefaults.ViewMode
|
||||||
|
u.SingleClick = Config.UserDefaults.SingleClick
|
||||||
|
u.Perm = Config.UserDefaults.Perm
|
||||||
|
u.Sorting = Config.UserDefaults.Sorting
|
||||||
|
u.Commands = Config.UserDefaults.Commands
|
||||||
|
u.HideDotfiles = Config.UserDefaults.HideDotfiles
|
||||||
|
u.DateFormat = Config.UserDefaults.DateFormat
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ package settings
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gtsteffaniak/filebrowser/errors"
|
"github.com/gtsteffaniak/filebrowser/errors"
|
||||||
"github.com/gtsteffaniak/filebrowser/rules"
|
"github.com/gtsteffaniak/filebrowser/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StorageBackend is a settings storage backend.
|
// StorageBackend is a settings storage backend.
|
||||||
|
@ -62,7 +62,7 @@ func (s *Storage) Save(set *Settings) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if set.Rules == nil {
|
if set.Rules == nil {
|
||||||
set.Rules = []rules.Rule{}
|
set.Rules = []users.Rule{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if set.Commands == nil {
|
if set.Commands == nil {
|
||||||
|
@ -94,6 +94,5 @@ func (s *Storage) GetServer() (*Server, error) {
|
||||||
|
|
||||||
// SaveServer wraps StorageBackend.SaveServer and adds some verification.
|
// SaveServer wraps StorageBackend.SaveServer and adds some verification.
|
||||||
func (s *Storage) SaveServer(ser *Server) error {
|
func (s *Storage) SaveServer(ser *Server) error {
|
||||||
ser.Clean()
|
|
||||||
return s.back.SaveServer(ser)
|
return s.back.SaveServer(ser)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
package settings
|
package settings
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gtsteffaniak/filebrowser/rules"
|
"github.com/gtsteffaniak/filebrowser/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Settings struct {
|
type Settings struct {
|
||||||
Commands map[string][]string `json:"commands"`
|
Commands map[string][]string `json:"commands"`
|
||||||
Shell []string `json:"shell"`
|
Shell []string `json:"shell"`
|
||||||
Rules []rules.Rule `json:"rules"`
|
Rules []users.Rule `json:"rules"`
|
||||||
Server Server `json:"server"`
|
Server Server `json:"server"`
|
||||||
Auth Auth `json:"auth"`
|
Auth Auth `json:"auth"`
|
||||||
Frontend Frontend `json:"frontend"`
|
Frontend Frontend `json:"frontend"`
|
||||||
|
@ -76,25 +76,14 @@ type UserDefaults struct {
|
||||||
ViewMode string `json:"viewMode"`
|
ViewMode string `json:"viewMode"`
|
||||||
GallerySize int `json:"gallerySize"`
|
GallerySize int `json:"gallerySize"`
|
||||||
SingleClick bool `json:"singleClick"`
|
SingleClick bool `json:"singleClick"`
|
||||||
Rules []rules.Rule `json:"rules"`
|
Rules []users.Rule `json:"rules"`
|
||||||
Sorting struct {
|
Sorting struct {
|
||||||
By string `json:"by"`
|
By string `json:"by"`
|
||||||
Asc bool `json:"asc"`
|
Asc bool `json:"asc"`
|
||||||
} `json:"sorting"`
|
} `json:"sorting"`
|
||||||
Perm Permissions `json:"perm"`
|
Perm users.Permissions `json:"perm"`
|
||||||
Permissions Permissions `json:"permissions"`
|
Permissions users.Permissions `json:"permissions"`
|
||||||
Commands []string `json:"commands,omitempty"`
|
Commands []string `json:"commands,omitempty"`
|
||||||
HideDotfiles bool `json:"hideDotfiles"`
|
HideDotfiles bool `json:"hideDotfiles"`
|
||||||
DateFormat bool `json:"dateFormat"`
|
DateFormat bool `json:"dateFormat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Permissions struct {
|
|
||||||
Admin bool `json:"admin"`
|
|
||||||
Execute bool `json:"execute"`
|
|
||||||
Create bool `json:"create"`
|
|
||||||
Rename bool `json:"rename"`
|
|
||||||
Modify bool `json:"modify"`
|
|
||||||
Delete bool `json:"delete"`
|
|
||||||
Share bool `json:"share"`
|
|
||||||
Download bool `json:"download"`
|
|
||||||
}
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/errors"
|
"github.com/gtsteffaniak/filebrowser/errors"
|
||||||
"github.com/gtsteffaniak/filebrowser/users"
|
"github.com/gtsteffaniak/filebrowser/users"
|
||||||
|
"github.com/gtsteffaniak/filebrowser/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type usersBackend struct {
|
type usersBackend struct {
|
||||||
|
@ -55,14 +56,24 @@ func (st usersBackend) Update(user *users.User, fields ...string) error {
|
||||||
if len(fields) == 0 {
|
if len(fields) == 0 {
|
||||||
return st.Save(user)
|
return st.Save(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val := reflect.ValueOf(user).Elem()
|
||||||
|
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
userField := reflect.ValueOf(user).Elem().FieldByName(field)
|
// Capitalize the first letter (you can adjust this based on your field naming convention)
|
||||||
|
correctedField := utils.CapitalizeFirst(field)
|
||||||
|
|
||||||
|
userField := val.FieldByName(correctedField)
|
||||||
if !userField.IsValid() {
|
if !userField.IsValid() {
|
||||||
return fmt.Errorf("invalid field: %s", field)
|
return fmt.Errorf("invalid field: %s", field)
|
||||||
}
|
}
|
||||||
|
if !userField.CanSet() {
|
||||||
|
return fmt.Errorf("cannot update unexported field: %s", field)
|
||||||
|
}
|
||||||
|
|
||||||
val := userField.Interface()
|
val := userField.Interface()
|
||||||
if err := st.db.UpdateField(user, field, val); err != nil {
|
if err := st.db.UpdateField(user, correctedField, val); err != nil {
|
||||||
return fmt.Errorf("Error updating user field: %s, error: %v", field, err.Error())
|
return fmt.Errorf("Error updating user field: %s, error: %v", correctedField, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -20,7 +20,7 @@ import (
|
||||||
// Storage is a storage powered by a Backend which makes the necessary
|
// Storage is a storage powered by a Backend which makes the necessary
|
||||||
// verifications when fetching and saving data to ensure consistency.
|
// verifications when fetching and saving data to ensure consistency.
|
||||||
type Storage struct {
|
type Storage struct {
|
||||||
Users users.Store
|
Users *users.Storage
|
||||||
Share *share.Storage
|
Share *share.Storage
|
||||||
Auth *auth.Storage
|
Auth *auth.Storage
|
||||||
Settings *settings.Storage
|
Settings *settings.Storage
|
||||||
|
@ -92,7 +92,7 @@ func quickSetup(store *Storage) {
|
||||||
utils.CheckErr("store.Settings.Save", err)
|
utils.CheckErr("store.Settings.Save", err)
|
||||||
err = store.Settings.SaveServer(&settings.Config.Server)
|
err = store.Settings.SaveServer(&settings.Config.Server)
|
||||||
utils.CheckErr("store.Settings.SaveServer", err)
|
utils.CheckErr("store.Settings.SaveServer", err)
|
||||||
user := users.ApplyDefaults(users.User{})
|
user := settings.ApplyUserDefaults(users.User{})
|
||||||
user.Username = settings.Config.Auth.AdminUsername
|
user.Username = settings.Config.Auth.AdminUsername
|
||||||
user.Password = settings.Config.Auth.AdminPassword
|
user.Password = settings.Config.Auth.AdminPassword
|
||||||
user.Perm.Admin = true
|
user.Perm.Admin = true
|
||||||
|
@ -111,7 +111,7 @@ func CreateUser(userInfo users.User, asAdmin bool) error {
|
||||||
if userInfo.Username == "" || userInfo.Password == "" {
|
if userInfo.Username == "" || userInfo.Password == "" {
|
||||||
return errors.ErrInvalidRequestParams
|
return errors.ErrInvalidRequestParams
|
||||||
}
|
}
|
||||||
newUser := users.ApplyDefaults(userInfo)
|
newUser := settings.ApplyUserDefaults(userInfo)
|
||||||
if asAdmin {
|
if asAdmin {
|
||||||
newUser.Perm = settings.AdminPerms()
|
newUser.Perm = settings.AdminPerms()
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -1,4 +1,4 @@
|
||||||
package rules
|
package users
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
@ -11,6 +11,18 @@ type Checker interface {
|
||||||
Check(path string) bool
|
Check(path string) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check implements rules.Checker.
|
||||||
|
func (user *User) Check(path string) bool {
|
||||||
|
allow := true
|
||||||
|
for _, rule := range user.Rules {
|
||||||
|
if rule.Matches(path) {
|
||||||
|
allow = rule.Allow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allow
|
||||||
|
}
|
||||||
|
|
||||||
// Rule is a allow/disallow rule.
|
// Rule is a allow/disallow rule.
|
||||||
type Rule struct {
|
type Rule struct {
|
||||||
Regex bool `json:"regex"`
|
Regex bool `json:"regex"`
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/errors"
|
"github.com/gtsteffaniak/filebrowser/errors"
|
||||||
"github.com/gtsteffaniak/filebrowser/rules"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// StorageBackend is the interface to implement for a users storage.
|
// StorageBackend is the interface to implement for a users storage.
|
||||||
|
@ -26,7 +25,9 @@ type Store interface {
|
||||||
Save(user *User) error
|
Save(user *User) error
|
||||||
Delete(id interface{}) error
|
Delete(id interface{}) error
|
||||||
LastUpdate(id uint) int64
|
LastUpdate(id uint) int64
|
||||||
AddRule(username string, rule rules.Rule) error
|
AddApiKey(username uint, name string, key AuthToken) error
|
||||||
|
DeleteApiKey(username uint, name string) error
|
||||||
|
AddRule(username string, rule Rule) error
|
||||||
DeleteRule(username string, ruleID string) error
|
DeleteRule(username string, ruleID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,7 +80,7 @@ func (s *Storage) Update(user *User, fields ...string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddRule adds a rule to the user's rules list and updates the user in the database.
|
// AddRule adds a rule to the user's rules list and updates the user in the database.
|
||||||
func (s *Storage) AddRule(userID string, rule rules.Rule) error {
|
func (s *Storage) AddRule(userID string, rule Rule) error {
|
||||||
user, err := s.Get("", userID)
|
user, err := s.Get("", userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -95,6 +96,42 @@ func (s *Storage) AddRule(userID string, rule rules.Rule) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
|
||||||
|
user, err := s.Get("", userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Initialize the ApiKeys map if it is nil
|
||||||
|
if user.ApiKeys == nil {
|
||||||
|
user.ApiKeys = make(map[string]AuthToken)
|
||||||
|
}
|
||||||
|
user.ApiKeys[name] = key
|
||||||
|
err = s.Update(user, "ApiKeys")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) DeleteApiKey(userID uint, name string) error {
|
||||||
|
user, err := s.Get("", userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Initialize the ApiKeys map if it is nil
|
||||||
|
if user.ApiKeys == nil {
|
||||||
|
user.ApiKeys = make(map[string]AuthToken)
|
||||||
|
}
|
||||||
|
delete(user.ApiKeys, name)
|
||||||
|
err = s.Update(user, "ApiKeys")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteRule deletes a rule specified by ID from the user's rules list and updates the user in the database.
|
// DeleteRule deletes a rule specified by ID from the user's rules list and updates the user in the database.
|
||||||
func (s *Storage) DeleteRule(userID string, ruleID string) error {
|
func (s *Storage) DeleteRule(userID string, ruleID string) error {
|
||||||
user, err := s.Get("", userID)
|
user, err := s.Get("", userID)
|
||||||
|
@ -103,7 +140,7 @@ func (s *Storage) DeleteRule(userID string, ruleID string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and remove the rule with the specified ID
|
// Find and remove the rule with the specified ID
|
||||||
var updatedRules []rules.Rule
|
var updatedRules []Rule
|
||||||
for _, r := range user.Rules {
|
for _, r := range user.Rules {
|
||||||
if r.Id != ruleID {
|
if r.Id != ruleID {
|
||||||
updatedRules = append(updatedRules, r)
|
updatedRules = append(updatedRules, r)
|
||||||
|
|
|
@ -3,10 +3,31 @@ package users
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
"github.com/gtsteffaniak/filebrowser/rules"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
"github.com/gtsteffaniak/filebrowser/settings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type AuthToken struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Created int64 `json:"createdAt"`
|
||||||
|
Expires int64 `json:"expiresAt"`
|
||||||
|
BelongsTo uint `json:"belongsTo"`
|
||||||
|
Permissions Permissions `json:"Permissions"`
|
||||||
|
jwt.RegisteredClaims `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Permissions struct {
|
||||||
|
Api bool `json:"api"`
|
||||||
|
Admin bool `json:"admin"`
|
||||||
|
Execute bool `json:"execute"`
|
||||||
|
Create bool `json:"create"`
|
||||||
|
Rename bool `json:"rename"`
|
||||||
|
Modify bool `json:"modify"`
|
||||||
|
Delete bool `json:"delete"`
|
||||||
|
Share bool `json:"share"`
|
||||||
|
Download bool `json:"download"`
|
||||||
|
}
|
||||||
|
|
||||||
// SortingSettings represents the sorting settings.
|
// SortingSettings represents the sorting settings.
|
||||||
type Sorting struct {
|
type Sorting struct {
|
||||||
By string `json:"by"`
|
By string `json:"by"`
|
||||||
|
@ -20,16 +41,17 @@ type User struct {
|
||||||
DisableSettings bool `json:"disableSettings"`
|
DisableSettings bool `json:"disableSettings"`
|
||||||
ID uint `storm:"id,increment" json:"id"`
|
ID uint `storm:"id,increment" json:"id"`
|
||||||
Username string `storm:"unique" json:"username"`
|
Username string `storm:"unique" json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password,omitempty"`
|
||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
Locale string `json:"locale"`
|
Locale string `json:"locale"`
|
||||||
LockPassword bool `json:"lockPassword"`
|
LockPassword bool `json:"lockPassword"`
|
||||||
ViewMode string `json:"viewMode"`
|
ViewMode string `json:"viewMode"`
|
||||||
SingleClick bool `json:"singleClick"`
|
SingleClick bool `json:"singleClick"`
|
||||||
Perm settings.Permissions `json:"perm"`
|
|
||||||
Commands []string `json:"commands"`
|
|
||||||
Sorting Sorting `json:"sorting"`
|
Sorting Sorting `json:"sorting"`
|
||||||
Rules []rules.Rule `json:"rules"`
|
Perm Permissions `json:"perm"`
|
||||||
|
Commands []string `json:"commands"`
|
||||||
|
Rules []Rule `json:"rules"`
|
||||||
|
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"`
|
||||||
HideDotfiles bool `json:"hideDotfiles"`
|
HideDotfiles bool `json:"hideDotfiles"`
|
||||||
DateFormat bool `json:"dateFormat"`
|
DateFormat bool `json:"dateFormat"`
|
||||||
GallerySize int `json:"gallerySize"`
|
GallerySize int `json:"gallerySize"`
|
||||||
|
@ -41,19 +63,20 @@ var PublicUser = User{
|
||||||
Scope: "./",
|
Scope: "./",
|
||||||
ViewMode: "normal",
|
ViewMode: "normal",
|
||||||
LockPassword: true,
|
LockPassword: true,
|
||||||
Perm: settings.Permissions{
|
Perm: Permissions{
|
||||||
Create: false,
|
Create: false,
|
||||||
Rename: false,
|
Rename: false,
|
||||||
Modify: false,
|
Modify: false,
|
||||||
Delete: false,
|
Delete: false,
|
||||||
Share: true,
|
Share: false,
|
||||||
Download: true,
|
Download: true,
|
||||||
Admin: false,
|
Admin: false,
|
||||||
|
Api: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRules implements rules.Provider.
|
// GetRules implements rules.Provider.
|
||||||
func (u *User) GetRules() []rules.Rule {
|
func (u *User) GetRules() []Rule {
|
||||||
return u.Rules
|
return u.Rules
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,20 +94,3 @@ func (u *User) CanExecute(command string) bool {
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply applies the default options to a user.
|
|
||||||
func ApplyDefaults(u User) User {
|
|
||||||
u.StickySidebar = settings.Config.UserDefaults.StickySidebar
|
|
||||||
u.DisableSettings = settings.Config.UserDefaults.DisableSettings
|
|
||||||
u.DarkMode = settings.Config.UserDefaults.DarkMode
|
|
||||||
u.Scope = settings.Config.UserDefaults.Scope
|
|
||||||
u.Locale = settings.Config.UserDefaults.Locale
|
|
||||||
u.ViewMode = settings.Config.UserDefaults.ViewMode
|
|
||||||
u.SingleClick = settings.Config.UserDefaults.SingleClick
|
|
||||||
u.Perm = settings.Config.UserDefaults.Perm
|
|
||||||
u.Sorting = settings.Config.UserDefaults.Sorting
|
|
||||||
u.Commands = settings.Config.UserDefaults.Commands
|
|
||||||
u.HideDotfiles = settings.Config.UserDefaults.HideDotfiles
|
|
||||||
u.DateFormat = settings.Config.UserDefaults.DateFormat
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
math "math/rand"
|
||||||
"github.com/gtsteffaniak/filebrowser/settings"
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CheckErr(source string, err error) {
|
func CheckErr(source string, err error) {
|
||||||
|
@ -13,7 +17,55 @@ func CheckErr(source string, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateKey() []byte {
|
func GenerateKey() []byte {
|
||||||
k, err := settings.GenerateKey()
|
b := make([]byte, 64)
|
||||||
CheckErr("generateKey", err)
|
_, err := rand.Read(b)
|
||||||
return k
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// CapitalizeFirst returns the input string with the first letter capitalized.
|
||||||
|
func CapitalizeFirst(s string) string {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return s // Return the empty string as is
|
||||||
|
}
|
||||||
|
return strings.ToUpper(string(s[0])) + s[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateRandomHash(length int) string {
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
math.New(math.NewSource(time.Now().UnixNano()))
|
||||||
|
result := make([]byte, length)
|
||||||
|
for i := range result {
|
||||||
|
result[i] = charset[math.Intn(len(charset))]
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrintStructFields(v interface{}) {
|
||||||
|
val := reflect.ValueOf(v)
|
||||||
|
typ := reflect.TypeOf(v)
|
||||||
|
|
||||||
|
// Ensure the input is a struct
|
||||||
|
if val.Kind() != reflect.Struct {
|
||||||
|
fmt.Println("Provided value is not a struct")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate over the fields of the struct
|
||||||
|
for i := 0; i < val.NumField(); i++ {
|
||||||
|
field := val.Field(i)
|
||||||
|
fieldType := typ.Field(i)
|
||||||
|
|
||||||
|
// Convert field value to string, if possible
|
||||||
|
fieldValue := fmt.Sprintf("%v", field.Interface())
|
||||||
|
|
||||||
|
// Limit to 50 characters
|
||||||
|
if len(fieldValue) > 100 {
|
||||||
|
fieldValue = fieldValue[:100] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Field: %s, %s\n", fieldType.Name, fieldValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -220,6 +220,7 @@ userDefaults:
|
||||||
|
|
||||||
- `download`: This boolean value determines whether download permissions are granted.
|
- `download`: This boolean value determines whether download permissions are granted.
|
||||||
|
|
||||||
|
- `api`: Ability to create and manage API keys.
|
||||||
|
|
||||||
- `hideDotfiles`: This boolean value determines whether dotfiles are hidden. (`true` or `false`)
|
- `hideDotfiles`: This boolean value determines whether dotfiles are hidden. (`true` or `false`)
|
||||||
|
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
# Getting Started using FileBrowser Quantum
|
|
||||||
|
|
|
@ -20,3 +20,6 @@ Note: share links will not work and will need to be re-created after migration.
|
||||||
The filebrowser Quantum application should run with the same user and rules that
|
The filebrowser Quantum application should run with the same user and rules that
|
||||||
you have from the original. But keep in mind the differences that may not work
|
you have from the original. But keep in mind the differences that may not work
|
||||||
the same way, but all user configuration should be available.
|
the same way, but all user configuration should be available.
|
||||||
|
|
||||||
|
The windows binary is particularly untested, I would advise using docker if testing on windows.
|
||||||
|
|
||||||
|
|
|
@ -1,22 +1,21 @@
|
||||||
# Planned Roadmap
|
# Planned Roadmap
|
||||||
|
|
||||||
upcoming 0.2.x releases:
|
upcoming 0.3.x releases:
|
||||||
|
|
||||||
- Replace http routes for gorilla/mux with stdlib
|
|
||||||
- Theme configuration from settings
|
- Theme configuration from settings
|
||||||
- File syncronization improvements
|
- File synchronization improvements
|
||||||
- more filetype previews
|
- more filetype previews
|
||||||
|
|
||||||
next major 0.3.0 release :
|
|
||||||
|
|
||||||
- multiple sources https://github.com/filebrowser/filebrowser/issues/2514
|
|
||||||
- introduce jobs as replacement to runners.
|
- introduce jobs as replacement to runners.
|
||||||
- Add Job status to the sidebar
|
- Add Job status to the sidebar
|
||||||
- index status.
|
- index status.
|
||||||
- Job status from users
|
- Job status from users
|
||||||
- upload status
|
- upload status
|
||||||
|
- opentelemetry metrics
|
||||||
|
- simple search/filter for current listings.
|
||||||
|
- Enable mobile search with same features as desktop
|
||||||
|
|
||||||
Unplanned Future releases:
|
Unplanned Future releases:
|
||||||
|
- multiple sources https://github.com/filebrowser/filebrowser/issues/2514
|
||||||
- Add tools to sidebar
|
- Add tools to sidebar
|
||||||
- duplicate file detector.
|
- duplicate file detector.
|
||||||
- bulk rename https://github.com/filebrowser/filebrowser/issues/2473
|
- bulk rename https://github.com/filebrowser/filebrowser/issues/2473
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build && cp -R dist/ ../backend/cmd/",
|
"build": "vite build && cp -r dist/* ../backend/http/embed",
|
||||||
"build-docker": "vite build",
|
"build-docker": "vite build",
|
||||||
"watch": "vite build --watch",
|
"watch": "vite build --watch",
|
||||||
"typecheck": "vue-tsc -p ./tsconfig.json --noEmit",
|
"typecheck": "vue-tsc -p ./tsconfig.json --noEmit",
|
||||||
|
|
|
@ -4,32 +4,30 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||||
|
{{ if .ReCaptcha }}
|
||||||
|
<script src="{{ .ReCaptchaHost }}/recaptcha/api.js?render=explicit" data-vite-ignore></script>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
[{[ if .ReCaptcha -]}]
|
<title>{{ if .Name }}{{ .Name }}{{ else }}FileBrowser Quantum{{ end }}</title>
|
||||||
<script src="[{[ .ReCaptchaHost ]}]/recaptcha/api.js?render=explicit" data-vite-ignore></script>
|
|
||||||
[{[ end ]}]
|
|
||||||
|
|
||||||
<title>[{[ if .Name -]}][{[ .Name ]}][{[ else ]}]FileBrowser Quantum[{[ end ]}]</title>
|
<link rel="icon" type="image/png" sizes="256x256" href="{{ .StaticURL }}/img/icons/favicon-256x256.png">
|
||||||
|
|
||||||
<link rel="icon" type="image/png" sizes="256x256" href="[{[ .StaticURL ]}]/img/icons/favicon-256x256.png">
|
|
||||||
|
|
||||||
<!-- Add to home screen for Android and modern mobile browsers -->
|
<!-- Add to home screen for Android and modern mobile browsers -->
|
||||||
<link rel="manifest" id="manifestPlaceholder" crossorigin="use-credentials">
|
<link rel="manifest" id="manifestPlaceholder" crossorigin="use-credentials">
|
||||||
<meta name="theme-color" content="[{[ if .Color -]}][{[ .Color ]}][{[ else ]}]#2979ff[{[ end ]}]">
|
<meta name="theme-color" content="{{ if .Color }}{{ .Color }}{{ else }}#2979ff{{ end }}">
|
||||||
|
|
||||||
<!-- Add to home screen for Safari on iOS/iPadOS -->
|
<!-- Add to home screen for Safari on iOS/iPadOS -->
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
<meta name="apple-mobile-web-app-title" content="assets">
|
<meta name="apple-mobile-web-app-title" content="assets">
|
||||||
<link rel="apple-touch-icon" href="[{[ .StaticURL ]}]/img/icons/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="{{ .StaticURL }}/img/icons/apple-touch-icon.png">
|
||||||
|
|
||||||
<!-- Add to home screen for Windows -->
|
<!-- Add to home screen for Windows -->
|
||||||
<meta name="msapplication-TileImage" content="[{[ .StaticURL ]}]/img/icons/mstile-144x144.png">
|
<meta name="msapplication-TileImage" content="{{ .StaticURL }}/img/icons/mstile-144x144.png">
|
||||||
<meta name="msapplication-TileColor" content="[{[ if .Color -]}][{[ .Color ]}][{[ else ]}]#2979ff[{[ end ]}]">
|
<meta name="msapplication-TileColor" content="{{ if .Color }}{{ .Color }}{{ else }}#2979ff{{ end }}">
|
||||||
|
|
||||||
<!-- Inject Some Variables and generate the manifest json -->
|
<!-- Inject Some Variables and generate the manifest json -->
|
||||||
<script>
|
<script>
|
||||||
window.FileBrowser = JSON.parse('[{[ .Json ]}]');
|
window.FileBrowser = JSON.parse('{{ .globalVars }}');
|
||||||
|
|
||||||
var fullStaticURL = window.location.origin + window.FileBrowser.StaticURL;
|
var fullStaticURL = window.location.origin + window.FileBrowser.StaticURL;
|
||||||
var dynamicManifest = {
|
var dynamicManifest = {
|
||||||
|
@ -42,7 +40,7 @@
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": window.location.origin + window.FileBrowser.BaseURL,
|
"start_url": fullStaticURL,
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"theme_color": window.FileBrowser.Color || "#455a64"
|
"theme_color": window.FileBrowser.Color || "#455a64"
|
||||||
|
@ -120,7 +118,7 @@
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
||||||
[{[ if .darkMode -]}]
|
{{ if .darkMode -}}
|
||||||
<div id="loading" class="dark-mode">
|
<div id="loading" class="dark-mode">
|
||||||
<div class="spinner">
|
<div class="spinner">
|
||||||
<div class="bounce1"></div>
|
<div class="bounce1"></div>
|
||||||
|
@ -128,18 +126,19 @@
|
||||||
<div class="bounce3"></div>
|
<div class="bounce3"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
[{[ else ]}]
|
{{ else }}
|
||||||
<div id="loading">
|
<div id="loading">
|
||||||
<div class="spinner">
|
<div class="spinner">
|
||||||
<div class="bounce1"></div>
|
<div class="bounce1"></div>
|
||||||
<div class="bounce2"></div>
|
<div class="bounce2"></div>
|
||||||
<div class="bounce3"></div>
|
<div class="bounce3"></div>
|
||||||
</div>
|
</div>
|
||||||
</div> [{[ end ]}]
|
</div>
|
||||||
|
{{end}}
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
|
||||||
[{[ if .CSS -]}]
|
{{ if .CSS }}
|
||||||
<link rel="stylesheet" href="[{[ .StaticURL ]}]/custom.css" />
|
<link rel="stylesheet" href="{{ .StaticURL }}/custom.css" >
|
||||||
[{[ end ]}]
|
{{ end }}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": "/",
|
"start_url": "./",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"theme_color": "#455a64"
|
"theme_color": "#455a64"
|
||||||
|
|
|
@ -1,14 +1,10 @@
|
||||||
import { removePrefix } from "./utils";
|
|
||||||
import { baseURL } from "@/utils/constants";
|
import { baseURL } from "@/utils/constants";
|
||||||
import { state } from "@/store";
|
|
||||||
|
|
||||||
const ssl = window.location.protocol === "https:";
|
const ssl = window.location.protocol === "https:";
|
||||||
const protocol = ssl ? "wss:" : "ws:";
|
const protocol = ssl ? "wss:" : "ws:";
|
||||||
|
|
||||||
export default function command(url, command, onmessage, onclose) {
|
export default function command(url, command, onmessage, onclose) {
|
||||||
url = removePrefix(url);
|
url = `${protocol}//${window.location.host}${baseURL}api/command${url}`;
|
||||||
url = `${protocol}//${window.location.host}${baseURL}/api/command${url}?auth=${state.jwt}`;
|
|
||||||
|
|
||||||
let conn = new window.WebSocket(url);
|
let conn = new window.WebSocket(url);
|
||||||
conn.onopen = () => conn.send(command);
|
conn.onopen = () => conn.send(command);
|
||||||
conn.onmessage = onmessage;
|
conn.onmessage = onmessage;
|
||||||
|
|
|
@ -1,32 +1,17 @@
|
||||||
import { createURL, fetchURL, removePrefix } from "./utils";
|
import { createURL, fetchURL, adjustedData} from "./utils";
|
||||||
import { baseURL } from "@/utils/constants";
|
import { baseURL } from "@/utils/constants";
|
||||||
|
import { removePrefix,getApiPath } from "@/utils/url.js";
|
||||||
import { state } from "@/store";
|
import { state } from "@/store";
|
||||||
import { notify } from "@/notify";
|
import { notify } from "@/notify";
|
||||||
|
|
||||||
// Notify if errors occur
|
// Notify if errors occur
|
||||||
export async function fetch(url, content = false) {
|
export async function fetchFiles(url, content = false) {
|
||||||
try {
|
try {
|
||||||
url = removePrefix(url);
|
url = removePrefix(url,"files");
|
||||||
|
const apiPath = getApiPath("api/resources",{path: url, content: content});
|
||||||
const res = await fetchURL(`/api/resources${url}?content=${content}`, {});
|
const res = await fetchURL(apiPath);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
data.url = `/files${url}`;
|
return adjustedData(data,url);
|
||||||
|
|
||||||
if (data.isDir) {
|
|
||||||
if (!data.url.endsWith("/")) data.url += "/";
|
|
||||||
data.items = data.items.map((item, index) => {
|
|
||||||
item.index = index;
|
|
||||||
item.url = `${data.url}${encodeURIComponent(item.name)}`;
|
|
||||||
|
|
||||||
if (item.isDir) {
|
|
||||||
item.url += "/";
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify.showError(err.message || "Error fetching data");
|
notify.showError(err.message || "Error fetching data");
|
||||||
throw err;
|
throw err;
|
||||||
|
@ -35,15 +20,12 @@ export async function fetch(url, content = false) {
|
||||||
|
|
||||||
async function resourceAction(url, method, content) {
|
async function resourceAction(url, method, content) {
|
||||||
try {
|
try {
|
||||||
url = removePrefix(url);
|
|
||||||
|
|
||||||
let opts = { method };
|
let opts = { method };
|
||||||
|
|
||||||
if (content) {
|
if (content) {
|
||||||
opts.body = content;
|
opts.body = content;
|
||||||
}
|
}
|
||||||
|
const apiPath = getApiPath("api/resources", { path: url });
|
||||||
const res = await fetchURL(`/api/resources${url}`, opts);
|
const res = await fetchURL(apiPath, opts);
|
||||||
return res;
|
return res;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify.showError(err.message || "Error performing resource action");
|
notify.showError(err.message || "Error performing resource action");
|
||||||
|
@ -72,27 +54,22 @@ export async function put(url, content = "") {
|
||||||
export function download(format, ...files) {
|
export function download(format, ...files) {
|
||||||
try {
|
try {
|
||||||
let url = `${baseURL}/api/raw`;
|
let url = `${baseURL}/api/raw`;
|
||||||
|
|
||||||
if (files.length === 1) {
|
if (files.length === 1) {
|
||||||
url += removePrefix(files[0]) + "?";
|
url += "?path="+removePrefix(files[0], "files");
|
||||||
} else {
|
} else {
|
||||||
let arg = "";
|
let arg = "";
|
||||||
|
|
||||||
for (let file of files) {
|
for (let file of files) {
|
||||||
arg += removePrefix(file) + ",";
|
arg += removePrefix(file,"files") + ",";
|
||||||
}
|
}
|
||||||
|
|
||||||
arg = arg.substring(0, arg.length - 1);
|
arg = arg.substring(0, arg.length - 1);
|
||||||
arg = encodeURIComponent(arg);
|
arg = encodeURIComponent(arg);
|
||||||
url += `/?files=${arg}&`;
|
url += `?files=${arg}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format) {
|
if (format) {
|
||||||
url += `algo=${format}&`;
|
url += `&algo=${format}`;
|
||||||
}
|
|
||||||
|
|
||||||
if (state.jwt) {
|
|
||||||
url += `auth=${state.jwt}&`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.open(url);
|
window.open(url);
|
||||||
|
@ -103,7 +80,7 @@ export function download(format, ...files) {
|
||||||
|
|
||||||
export async function post(url, content = "", overwrite = false, onupload) {
|
export async function post(url, content = "", overwrite = false, onupload) {
|
||||||
try {
|
try {
|
||||||
url = removePrefix(url);
|
url = removePrefix(url,"files");
|
||||||
|
|
||||||
let bufferContent;
|
let bufferContent;
|
||||||
if (
|
if (
|
||||||
|
@ -113,11 +90,12 @@ export async function post(url, content = "", overwrite = false, onupload) {
|
||||||
bufferContent = await new Response(content).arrayBuffer();
|
bufferContent = await new Response(content).arrayBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const apiPath = getApiPath("api/resources", { path: url, override: overwrite });
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let request = new XMLHttpRequest();
|
let request = new XMLHttpRequest();
|
||||||
request.open(
|
request.open(
|
||||||
"POST",
|
"POST",
|
||||||
`${baseURL}/api/resources${url}?override=${overwrite}`,
|
apiPath,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
request.setRequestHeader("X-Auth", state.jwt);
|
request.setRequestHeader("X-Auth", state.jwt);
|
||||||
|
@ -148,30 +126,27 @@ export async function post(url, content = "", overwrite = false, onupload) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveCopy(items, copy = false, overwrite = false, rename = false) {
|
export async function moveCopy(items, action = "copy", overwrite = false, rename = false) {
|
||||||
let promises = [];
|
let promises = [];
|
||||||
|
let params = {
|
||||||
for (let item of items) {
|
overwrite: overwrite,
|
||||||
const from = item.from;
|
action: action,
|
||||||
const to = encodeURIComponent(removePrefix(item.to));
|
rename: rename,
|
||||||
const url = `${from}?action=${
|
|
||||||
copy ? "copy" : "rename"
|
|
||||||
}&destination=${to}&override=${overwrite}&rename=${rename}`;
|
|
||||||
promises.push(resourceAction(url, "PATCH"));
|
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
for (let item of items) {
|
||||||
|
let localParams = { ...params };
|
||||||
|
localParams.destination = item.to;
|
||||||
|
localParams.from = item.from;
|
||||||
|
const apiPath = getApiPath("api/resources", localParams);
|
||||||
|
promises.push(fetch(apiPath, { method: "PATCH" }));
|
||||||
|
}
|
||||||
|
return promises;
|
||||||
|
|
||||||
return Promise.all(promises).catch((err) => {
|
} catch (err) {
|
||||||
notify.showError(err.message || "Error moving/copying resources");
|
notify.showError(err.message || "Error moving/copying resources");
|
||||||
throw err;
|
throw err;
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export function move(items, overwrite = false, rename = false) {
|
|
||||||
return moveCopy(items, false, overwrite, rename);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function copy(items, overwrite = false, rename = false) {
|
|
||||||
return moveCopy(items, true, overwrite, rename);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checksum(url, algo) {
|
export async function checksum(url, algo) {
|
||||||
|
@ -184,27 +159,29 @@ export async function checksum(url, algo) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDownloadURL(file, inline) {
|
export function getDownloadURL(path, inline) {
|
||||||
try {
|
try {
|
||||||
const params = {
|
const params = {
|
||||||
|
path: path,
|
||||||
...(inline && { inline: "true" }),
|
...(inline && { inline: "true" }),
|
||||||
};
|
};
|
||||||
|
return createURL("api/raw", params);
|
||||||
return createURL("api/raw" + file.path, params);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify.showError(err.message || "Error getting download URL");
|
notify.showError(err.message || "Error getting download URL");
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPreviewURL(file, size) {
|
export function getPreviewURL(path, size, modified) {
|
||||||
try {
|
try {
|
||||||
const params = {
|
const params = {
|
||||||
|
path: path,
|
||||||
|
size: size,
|
||||||
|
key: Date.parse(modified),
|
||||||
inline: "true",
|
inline: "true",
|
||||||
key: Date.parse(file.modified),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return createURL("api/preview/" + size + file.path, params);
|
return createURL("api/preview", params);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify.showError(err.message || "Error getting preview URL");
|
notify.showError(err.message || "Error getting preview URL");
|
||||||
throw err;
|
throw err;
|
||||||
|
@ -229,11 +206,10 @@ export function getSubtitlesURL(file) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function usage(url) {
|
export async function usage(source) {
|
||||||
try {
|
try {
|
||||||
url = removePrefix(url);
|
const apiPath = getApiPath("api/usage", { source: source });
|
||||||
|
const res = await fetchURL(apiPath);
|
||||||
const res = await fetchURL(`/api/usage${url}`, {});
|
|
||||||
return await res.json();
|
return await res.json();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify.showError(err.message || "Error fetching usage data");
|
notify.showError(err.message || "Error fetching usage data");
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import * as files from "./files";
|
import * as filesApi from "./files";
|
||||||
import * as share from "./share";
|
import * as shareApi from "./share";
|
||||||
import * as users from "./users";
|
import * as usersApi from "./users";
|
||||||
import * as settings from "./settings";
|
import * as settingsApi from "./settings";
|
||||||
import * as pub from "./pub";
|
import * as publicApi from "./public";
|
||||||
import search from "./search";
|
import search from "./search";
|
||||||
import commands from "./commands";
|
import commands from "./commands";
|
||||||
|
|
||||||
export { files, share, users, settings, pub, commands, search };
|
export { filesApi, shareApi, usersApi, settingsApi, publicApi, commands, search };
|
||||||
|
|
|
@ -1,89 +0,0 @@
|
||||||
import { removePrefix, createURL } from "./utils";
|
|
||||||
import { baseURL } from "@/utils/constants";
|
|
||||||
|
|
||||||
export async function fetchPub(url, password = "") {
|
|
||||||
url = removePrefix(url);
|
|
||||||
const res = await fetch(
|
|
||||||
`/api/public/share${url}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"X-SHARE-PASSWORD": encodeURIComponent(password),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (res.status != 200) {
|
|
||||||
const error = new Error("000 No connection");
|
|
||||||
error.status = res.status;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = await res.json();
|
|
||||||
data.url = `/share${url}`;
|
|
||||||
if (data.isDir) {
|
|
||||||
if (!data.url.endsWith("/")) data.url += "/";
|
|
||||||
data.items = data.items.map((item, index) => {
|
|
||||||
item.index = index;
|
|
||||||
item.url = `${data.url}${encodeURIComponent(item.name)}`;
|
|
||||||
|
|
||||||
if (item.isDir) {
|
|
||||||
item.url += "/";
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function download(format, hash, token, ...files) {
|
|
||||||
let url = `${baseURL}/api/public/dl/${hash}`;
|
|
||||||
if (files.length === 1) {
|
|
||||||
url += encodeURIComponent(files[0]) + "?";
|
|
||||||
} else {
|
|
||||||
let arg = "";
|
|
||||||
for (let file of files) {
|
|
||||||
arg += encodeURIComponent(file) + ",";
|
|
||||||
}
|
|
||||||
|
|
||||||
arg = arg.substring(0, arg.length - 1);
|
|
||||||
arg = encodeURIComponent(arg);
|
|
||||||
url += `/?files=${arg}&`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (format) {
|
|
||||||
url += `algo=${format}&`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
url += `token=${token}&`;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.open(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPublicUser() {
|
|
||||||
return fetch("/api/public/publicUser")
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error("Error fetching public user:", error);
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDownloadURL(share, inline = false) {
|
|
||||||
const params = {
|
|
||||||
...(inline && { inline: "true" }),
|
|
||||||
...(share.token && { token: share.token }),
|
|
||||||
};
|
|
||||||
if (share.path == undefined) {
|
|
||||||
share.path = ""
|
|
||||||
}
|
|
||||||
const path = share.path.replace("/share/"+share.hash +"/","")
|
|
||||||
return createURL("api/public/dl/" + share.hash + "/"+path, params, false);
|
|
||||||
}
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { createURL, adjustedData } from "./utils";
|
||||||
|
import { getApiPath } 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) : "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download files with given parameters
|
||||||
|
export function download(path, hash, token, format, ...files) {
|
||||||
|
try {
|
||||||
|
let fileInfo = files[0]
|
||||||
|
if (files.length > 1) {
|
||||||
|
fileInfo = files.map(encodeURIComponent).join(",");
|
||||||
|
}
|
||||||
|
const params = {
|
||||||
|
path,
|
||||||
|
hash,
|
||||||
|
...(format && { format}),
|
||||||
|
...(token && { token }),
|
||||||
|
fileInfo
|
||||||
|
};
|
||||||
|
const url = createURL(`api/public/dl`, params, false);
|
||||||
|
window.open(url);
|
||||||
|
} catch (err) {
|
||||||
|
notify.showError(err.message || "Error downloading files");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the public user data
|
||||||
|
export async function getPublicUser() {
|
||||||
|
try {
|
||||||
|
const apiPath = getApiPath("api/public/publicUser");
|
||||||
|
const response = await fetch(apiPath);
|
||||||
|
return response.json();
|
||||||
|
} catch (err) {
|
||||||
|
notify.showError(err.message || "Error fetching public user");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
|
@ -1,26 +1,21 @@
|
||||||
import { fetchURL, removePrefix } from "./utils";
|
import { fetchURL } from "./utils";
|
||||||
import url from "../utils/url";
|
|
||||||
import { notify } from "@/notify"; // Import notify for error handling
|
import { notify } from "@/notify"; // Import notify for error handling
|
||||||
|
import { removePrefix, getApiPath } from "@/utils/url.js";
|
||||||
|
|
||||||
export default async function search(base, query) {
|
export default async function search(base, query) {
|
||||||
try {
|
try {
|
||||||
base = removePrefix(base);
|
base = removePrefix(base,"files");
|
||||||
query = encodeURIComponent(query);
|
query = encodeURIComponent(query);
|
||||||
|
|
||||||
if (!base.endsWith("/")) {
|
if (!base.endsWith("/")) {
|
||||||
base += "/";
|
base += "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetchURL(`/api/search${base}?query=${query}`, {});
|
const apiPath = getApiPath("api/search", { scope: base, query: query });
|
||||||
|
const res = await fetchURL(apiPath);
|
||||||
let data = await res.json();
|
let data = await res.json();
|
||||||
|
|
||||||
data = data.map((item) => {
|
return data
|
||||||
item.url = `/files${base}` + url.encodePath(item.path);
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
|
|
||||||
return data;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify.showError(err.message || "Error occurred during search");
|
notify.showError(err.message || "Error occurred during search");
|
||||||
throw err;
|
throw err;
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
import { fetchURL, fetchJSON } from "./utils";
|
import { fetchURL, fetchJSON } from "./utils";
|
||||||
|
import { getApiPath } from "@/utils/url.js";
|
||||||
|
|
||||||
|
const apiPath = getApiPath("api/settings");
|
||||||
|
|
||||||
export function get() {
|
export function get() {
|
||||||
return fetchJSON(`/api/settings`, {});
|
return fetchJSON(apiPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function update(settings) {
|
export async function update(settings) {
|
||||||
await fetchURL(`/api/settings`, {
|
|
||||||
|
await fetchURL(apiPath, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(settings),
|
body: JSON.stringify(settings),
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,27 +1,33 @@
|
||||||
import { fetchURL, fetchJSON, removePrefix, createURL } from "./utils";
|
import { fetchURL, fetchJSON, createURL, adjustedData } from "./utils";
|
||||||
|
import { notify } from "@/notify";
|
||||||
|
|
||||||
export async function list() {
|
export async function list() {
|
||||||
return fetchJSON("/api/shares");
|
return fetchJSON("api/shares");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function get(url) {
|
export async function get(path, hash) {
|
||||||
url = removePrefix(url);
|
try {
|
||||||
return fetchJSON(`/api/share${url}`);
|
const params = { path, hash };
|
||||||
|
const url = createURL(`api/share`, params, false);
|
||||||
|
let data = fetchJSON(url);
|
||||||
|
return adjustedData(data, `api/share${path}`);
|
||||||
|
} catch (err) {
|
||||||
|
notify.showError(err.message || "Error fetching data");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remove(hash) {
|
export async function remove(hash) {
|
||||||
await fetchURL(`/api/share/${hash}`, {
|
const params = { hash };
|
||||||
|
const url = createURL(`api/share`, params, false);
|
||||||
|
await fetchURL(url, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function create(url, password = "", expires = "", unit = "hours") {
|
export async function create(path, password = "", expires = "", unit = "hours") {
|
||||||
url = removePrefix(url);
|
const params = { path };
|
||||||
url = `/api/share${url}`;
|
const url = createURL(`api/share`, params, false);
|
||||||
expires = String(expires);
|
|
||||||
if (expires !== "") {
|
|
||||||
url += `?expires=${expires}&unit=${unit}`;
|
|
||||||
}
|
|
||||||
let body = "{}";
|
let body = "{}";
|
||||||
if (password != "" || expires !== "" || unit !== "hours") {
|
if (password != "" || expires !== "" || unit !== "hours") {
|
||||||
body = JSON.stringify({ password: password, expires: expires, unit: unit });
|
body = JSON.stringify({ password: password, expires: expires, unit: unit });
|
||||||
|
|
|
@ -1,27 +1,66 @@
|
||||||
import { fetchURL, fetchJSON } from "@/api/utils";
|
import { fetchURL, fetchJSON } from "@/api/utils";
|
||||||
|
import { getApiPath } from "@/utils/url.js";
|
||||||
import { notify } from "@/notify"; // Import notify for error handling
|
import { notify } from "@/notify"; // Import notify for error handling
|
||||||
|
|
||||||
export async function getAllUsers() {
|
export async function getAllUsers() {
|
||||||
try {
|
try {
|
||||||
return await fetchJSON(`/api/users`, {});
|
const apiPath = getApiPath("api/users");
|
||||||
|
return await fetchJSON(apiPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify.showError(err.message || "Failed to fetch users");
|
notify.showError(err.message || "Failed to fetch users");
|
||||||
throw err; // Re-throw to handle further if needed
|
throw err; // Re-throw to handle further if needed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function get(id) {
|
export async function get(id) {
|
||||||
try {
|
try {
|
||||||
return await fetchJSON(`/api/users/${id}`, {});
|
const apiPath = getApiPath("api/users", { id: id });
|
||||||
|
return await fetchJSON(apiPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify.showError(err.message || `Failed to fetch user with ID: ${id}`);
|
notify.showError(err.message || `Failed to fetch user with ID: ${id}`);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getApiKeys() {
|
||||||
|
try {
|
||||||
|
const apiPath = getApiPath("api/auth/tokens");
|
||||||
|
return await fetchJSON(apiPath);
|
||||||
|
} catch (err) {
|
||||||
|
notify.showError(err.message || `Failed to get api keys`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function createApiKey(params) {
|
||||||
|
try {
|
||||||
|
const apiPath = getApiPath("api/auth/token", params);
|
||||||
|
await fetchURL(apiPath, {
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
notify.showError(err.message || `Failed to create API key`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteApiKey(params) {
|
||||||
|
try {
|
||||||
|
const apiPath = getApiPath("api/auth/token", params);
|
||||||
|
fetchURL(apiPath, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
notify.showError(err.message || `Failed to delete API key`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function create(user) {
|
export async function create(user) {
|
||||||
try {
|
try {
|
||||||
const res = await fetchURL(`/api/users`, {
|
const res = await fetchURL(`api/users`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
what: "user",
|
what: "user",
|
||||||
|
@ -50,7 +89,8 @@ export async function update(user, which = ["all"]) {
|
||||||
if (user.username === "publicUser") {
|
if (user.username === "publicUser") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await fetchURL(`/api/users/${user.id}`, {
|
const apiPath = getApiPath("api/users", { id: user.id });
|
||||||
|
await fetchURL(apiPath, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
what: "user",
|
what: "user",
|
||||||
|
@ -66,7 +106,8 @@ export async function update(user, which = ["all"]) {
|
||||||
|
|
||||||
export async function remove(id) {
|
export async function remove(id) {
|
||||||
try {
|
try {
|
||||||
await fetchURL(`/api/users/${id}`, {
|
const apiPath = getApiPath("api/users", { id: id });
|
||||||
|
await fetchURL(apiPath, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { state } from "@/store";
|
import { state } from "@/store";
|
||||||
import { renew, logout } from "@/utils/auth";
|
import { renew, logout } from "@/utils/auth";
|
||||||
import { baseURL } from "@/utils/constants";
|
import { baseURL } from "@/utils/constants";
|
||||||
import { encodePath } from "@/utils/url";
|
|
||||||
import { notify } from "@/notify";
|
import { notify } from "@/notify";
|
||||||
|
|
||||||
export async function fetchURL(url, opts, auth = true) {
|
export async function fetchURL(url, opts, auth = true) {
|
||||||
|
@ -12,11 +11,14 @@ export async function fetchURL(url, opts, auth = true) {
|
||||||
|
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${baseURL}${url}`, {
|
let userScope = "";
|
||||||
|
if (state.user) {
|
||||||
|
userScope = state.user.scope;
|
||||||
|
}
|
||||||
|
res = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
"X-Auth": state.jwt,
|
|
||||||
"sessionId": state.sessionId,
|
"sessionId": state.sessionId,
|
||||||
"userScope": state.user.scope,
|
"userScope": userScope,
|
||||||
...headers,
|
...headers,
|
||||||
},
|
},
|
||||||
...rest,
|
...rest,
|
||||||
|
@ -48,30 +50,22 @@ export async function fetchURL(url, opts, auth = true) {
|
||||||
|
|
||||||
export async function fetchJSON(url, opts) {
|
export async function fetchJSON(url, opts) {
|
||||||
const res = await fetchURL(url, opts);
|
const res = await fetchURL(url, opts);
|
||||||
if (res.status === 200) {
|
if (res.status < 300) {
|
||||||
return res.json();
|
return res.json();
|
||||||
} else {
|
} else {
|
||||||
notify.showError("unable to fetch : " + url + "status" + res.status);
|
notify.showError("received status: "+res.status+" on url " + url);
|
||||||
throw new Error(res.status);
|
throw new Error(res.status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removePrefix(url) {
|
export function createURL(endpoint, params = {}) {
|
||||||
url = url.split("/").splice(2).join("/");
|
|
||||||
if (url === "") url = "/";
|
|
||||||
if (url[0] !== "/") url = "/" + url;
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createURL(endpoint, params = {}, auth = true) {
|
|
||||||
let prefix = baseURL;
|
let prefix = baseURL;
|
||||||
if (!prefix.endsWith("/")) {
|
if (!prefix.endsWith("/")) {
|
||||||
prefix = prefix + "/";
|
prefix = prefix + "/";
|
||||||
}
|
}
|
||||||
const url = new URL(prefix + encodePath(endpoint), origin);
|
const url = new URL(prefix + endpoint, origin);
|
||||||
|
|
||||||
const searchParams = {
|
const searchParams = {
|
||||||
...(auth && { auth: state.jwt }),
|
|
||||||
...params,
|
...params,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -81,3 +75,19 @@ export function createURL(endpoint, params = {}, auth = true) {
|
||||||
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function adjustedData(data, url) {
|
||||||
|
data.url = url;
|
||||||
|
if (data.type == "directory") {
|
||||||
|
if (!data.url.endsWith("/")) data.url += "/";
|
||||||
|
data.items = data.items.map((item, index) => {
|
||||||
|
item.index = index;
|
||||||
|
item.url = `${data.url}${item.name}`;
|
||||||
|
if (item.type == "directory") {
|
||||||
|
item.url += "/";
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
|
@ -33,6 +33,7 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { state, mutations, getters } from "@/store";
|
import { state, mutations, getters } from "@/store";
|
||||||
|
import { removePrefix } from "@/utils/url.js";
|
||||||
import Action from "@/components/Action.vue";
|
import Action from "@/components/Action.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -51,7 +52,11 @@ export default {
|
||||||
return getters.isCardView();
|
return getters.isCardView();
|
||||||
},
|
},
|
||||||
items() {
|
items() {
|
||||||
const relativePath = state.route.path.replace(this.base, "");
|
let relativePath = removePrefix(state.route.path, "files");
|
||||||
|
if (getters.currentView() == "share") {
|
||||||
|
// Split the path, filter out any empty elements, then join again with slashes
|
||||||
|
relativePath = removePrefix(state.route.path, "share");
|
||||||
|
}
|
||||||
let parts = relativePath.split("/");
|
let parts = relativePath.split("/");
|
||||||
|
|
||||||
if (parts[0] === "") {
|
if (parts[0] === "") {
|
||||||
|
@ -96,7 +101,9 @@ export default {
|
||||||
return "router-link";
|
return "router-link";
|
||||||
},
|
},
|
||||||
showShare() {
|
showShare() {
|
||||||
return state.user?.perm && state.user?.perm.share;
|
return (
|
||||||
|
state.user?.perm && state.user?.perm.share && state.user.username != "publicUser"
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
@ -35,17 +35,28 @@
|
||||||
<div class="button" style="width: 100%">Search Context: {{ getContext }}</div>
|
<div class="button" style="width: 100%">Search Context: {{ getContext }}</div>
|
||||||
<!-- List of search results -->
|
<!-- List of search results -->
|
||||||
<ul v-show="results.length > 0">
|
<ul v-show="results.length > 0">
|
||||||
<li v-for="(s, k) in results" :key="k" style="cursor: pointer">
|
<li v-for="(s, k) in results" :key="k" class="search-entry">
|
||||||
<router-link :to="s.url">
|
<router-link :to="s.path">
|
||||||
<i v-if="s.dir" class="material-icons folder-icons"> folder </i>
|
<i v-if="s.type == 'directory'" class="material-icons folder-icons">
|
||||||
<i v-else-if="s.audio" class="material-icons audio-icons"> volume_up </i>
|
folder
|
||||||
<i v-else-if="s.image" class="material-icons image-icons"> photo </i>
|
</i>
|
||||||
<i v-else-if="s.video" class="material-icons video-icons"> movie </i>
|
<i v-else-if="s.type == 'audio'" class="material-icons audio-icons">
|
||||||
<i v-else-if="s.archive" class="material-icons archive-icons"> archive </i>
|
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>
|
<i v-else class="material-icons file-icons"> insert_drive_file </i>
|
||||||
<span class="text-container">
|
<span class="text-container">
|
||||||
{{ basePath(s.path, s.dir) }}<b>{{ baseName(s.path) }}</b>
|
{{ basePath(s.path, s.type == "directory") }}<b>{{ baseName(s.path) }}</b>
|
||||||
</span>
|
</span>
|
||||||
|
<div class="filesize">{{ humanSize(s.size) }}</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -97,7 +108,7 @@
|
||||||
<div class="searchContext">Search Context: {{ getContext }}</div>
|
<div class="searchContext">Search Context: {{ getContext }}</div>
|
||||||
<div id="result-list">
|
<div id="result-list">
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div v-if="!isMobile && active">
|
||||||
<!-- Button groups for filtering search results -->
|
<!-- Button groups for filtering search results -->
|
||||||
<ButtonGroup
|
<ButtonGroup
|
||||||
:buttons="folderSelect"
|
:buttons="folderSelect"
|
||||||
|
@ -113,7 +124,7 @@
|
||||||
:isDisabled="isTypeSelectDisabled"
|
:isDisabled="isTypeSelectDisabled"
|
||||||
/>
|
/>
|
||||||
<!-- Inputs for filtering by file size -->
|
<!-- Inputs for filtering by file size -->
|
||||||
<div v-if="!foldersOnly" class="sizeConstraints">
|
<div class="sizeConstraints">
|
||||||
<div class="sizeInputWrapper">
|
<div class="sizeInputWrapper">
|
||||||
<p>Smaller Than:</p>
|
<p>Smaller Than:</p>
|
||||||
<input
|
<input
|
||||||
|
@ -169,17 +180,28 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- List of search results -->
|
<!-- List of search results -->
|
||||||
<ul v-show="results.length > 0">
|
<ul v-show="results.length > 0">
|
||||||
<li v-for="(s, k) in results" :key="k" style="cursor: pointer">
|
<li v-for="(s, k) in results" :key="k" class="search-entry">
|
||||||
<router-link :to="s.url">
|
<router-link :to="s.path">
|
||||||
<i v-if="s.dir" class="material-icons folder-icons"> folder </i>
|
<i v-if="s.type == 'directory'" class="material-icons folder-icons">
|
||||||
<i v-else-if="s.audio" class="material-icons audio-icons"> volume_up </i>
|
folder
|
||||||
<i v-else-if="s.image" class="material-icons image-icons"> photo </i>
|
</i>
|
||||||
<i v-else-if="s.video" class="material-icons video-icons"> movie </i>
|
<i v-else-if="s.type == 'audio'" class="material-icons audio-icons">
|
||||||
<i v-else-if="s.archive" class="material-icons archive-icons"> archive </i>
|
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>
|
<i v-else class="material-icons file-icons"> insert_drive_file </i>
|
||||||
<span class="text-container">
|
<span class="text-container">
|
||||||
{{ basePath(s.path, s.dir) }}<b>{{ baseName(s.path) }}</b>
|
{{ basePath(s.path, s.type == "directory") }}<b>{{ baseName(s.path) }}</b>
|
||||||
</span>
|
</span>
|
||||||
|
<div class="filesize">{{ humanSize(s.size) }}</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -191,6 +213,7 @@
|
||||||
import ButtonGroup from "./ButtonGroup.vue";
|
import ButtonGroup from "./ButtonGroup.vue";
|
||||||
import { search } from "@/api";
|
import { search } from "@/api";
|
||||||
import { getters, mutations, state } from "@/store";
|
import { getters, mutations, state } from "@/store";
|
||||||
|
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||||
|
|
||||||
var boxes = {
|
var boxes = {
|
||||||
folder: { label: "folders", icon: "folder" },
|
folder: { label: "folders", icon: "folder" },
|
||||||
|
@ -314,6 +337,9 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
humanSize(size) {
|
||||||
|
return getHumanReadableFilesize(size);
|
||||||
|
},
|
||||||
basePath(str, isDir) {
|
basePath(str, isDir) {
|
||||||
let parts = str.replace(/(\/$|^\/)/, "").split("/");
|
let parts = str.replace(/(\/$|^\/)/, "").split("/");
|
||||||
if (parts.length <= 1) {
|
if (parts.length <= 1) {
|
||||||
|
@ -336,11 +362,13 @@ export default {
|
||||||
},
|
},
|
||||||
open() {
|
open() {
|
||||||
if (!this.active) {
|
if (!this.active) {
|
||||||
|
this.resetSearchFilters();
|
||||||
mutations.showHover("search");
|
mutations.showHover("search");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
close(event) {
|
close(event) {
|
||||||
this.value = "";
|
this.value = "";
|
||||||
|
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
mutations.closeHovers();
|
mutations.closeHovers();
|
||||||
},
|
},
|
||||||
|
@ -390,10 +418,10 @@ export default {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let searchTypesFull = this.searchTypes;
|
let searchTypesFull = this.searchTypes;
|
||||||
if (this.largerThan != "" && !this.isTypeSelectDisabled) {
|
if (this.largerThan != "") {
|
||||||
searchTypesFull = searchTypesFull + "type:largerThan=" + this.largerThan + " ";
|
searchTypesFull = searchTypesFull + "type:largerThan=" + this.largerThan + " ";
|
||||||
}
|
}
|
||||||
if (this.smallerThan != "" && !this.isTypeSelectDisabled) {
|
if (this.smallerThan != "") {
|
||||||
searchTypesFull = searchTypesFull + "type:smallerThan=" + this.smallerThan + " ";
|
searchTypesFull = searchTypesFull + "type:smallerThan=" + this.smallerThan + " ";
|
||||||
}
|
}
|
||||||
let path = state.route.path;
|
let path = state.route.path;
|
||||||
|
@ -536,6 +564,15 @@ export default {
|
||||||
/* IE and Edge */
|
/* IE and Edge */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-entry {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-entry:hover {
|
||||||
|
background-color: var(--surfacePrimary);
|
||||||
|
}
|
||||||
|
|
||||||
.text-container {
|
.text-container {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
@ -611,10 +648,6 @@ body.rtl #search #result ul > * {
|
||||||
border-bottom-left-radius: 0;
|
border-bottom-left-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
input.sizeInput:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Search Input Placeholder */
|
/* Search Input Placeholder */
|
||||||
#search::-webkit-input-placeholder {
|
#search::-webkit-input-placeholder {
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
@ -698,31 +731,6 @@ body.rtl #search .boxes h3 {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sizeInput {
|
|
||||||
height: 100%;
|
|
||||||
text-align: center;
|
|
||||||
width: 5em;
|
|
||||||
border-radius: 1em;
|
|
||||||
padding: 1em;
|
|
||||||
backdrop-filter: invert(0.1);
|
|
||||||
border: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sizeInputWrapper {
|
|
||||||
border-radius: 1em;
|
|
||||||
margin-left: 0.5em;
|
|
||||||
margin-right: 0.5em;
|
|
||||||
display: -ms-flexbox;
|
|
||||||
display: flex;
|
|
||||||
background-color: rgb(245, 245, 245);
|
|
||||||
padding: 0.25em;
|
|
||||||
height: 3em;
|
|
||||||
-webkit-box-align: center;
|
|
||||||
-ms-flex-align: center;
|
|
||||||
align-items: center;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.helpButton {
|
.helpButton {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
|
@ -740,4 +748,13 @@ body.rtl #search .boxes h3 {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filesize {
|
||||||
|
background-color: var(--surfaceSecondary);
|
||||||
|
border-radius: 1em;
|
||||||
|
padding: 0.25em;
|
||||||
|
padding-left: 0.5em;
|
||||||
|
padding-right: 0.5em;
|
||||||
|
min-width: fit-content;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<component
|
<component
|
||||||
:is="quickNav ? 'a' : 'div'"
|
:is="quickNav ? 'a' : 'div'"
|
||||||
:href="quickNav ? url : undefined"
|
:href="quickNav ? getUrl() : undefined"
|
||||||
:class="{
|
:class="{
|
||||||
item: true,
|
item: true,
|
||||||
activebutton: isMaximized && isSelected,
|
activebutton: isMaximized && isSelected,
|
||||||
|
@ -16,6 +16,7 @@
|
||||||
:data-type="type"
|
:data-type="type"
|
||||||
:aria-label="name"
|
:aria-label="name"
|
||||||
:aria-selected="isSelected"
|
:aria-selected="isSelected"
|
||||||
|
@contextmenu="onRightClick"
|
||||||
@click="quickNav ? toggleClick() : itemClick($event)"
|
@click="quickNav ? toggleClick() : itemClick($event)"
|
||||||
>
|
>
|
||||||
<div @click="toggleClick" :class="{ activetitle: isMaximized && isSelected }">
|
<div @click="toggleClick" :class="{ activetitle: isMaximized && isSelected }">
|
||||||
|
@ -46,17 +47,21 @@
|
||||||
.activebutton {
|
.activebutton {
|
||||||
height: 10em;
|
height: 10em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activecontent {
|
.activecontent {
|
||||||
height: 5em !important;
|
height: 5em !important;
|
||||||
display: grid !important;
|
display: grid !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activeimg {
|
.activeimg {
|
||||||
width: 8em !important;
|
width: 8em !important;
|
||||||
height: 8em !important;
|
height: 8em !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconActive {
|
.iconActive {
|
||||||
font-size: 6em !important;
|
font-size: 6em !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activetitle {
|
.activetitle {
|
||||||
width: 9em !important;
|
width: 9em !important;
|
||||||
margin-right: 1em !important;
|
margin-right: 1em !important;
|
||||||
|
@ -67,9 +72,10 @@
|
||||||
import { enableThumbs } from "@/utils/constants";
|
import { enableThumbs } from "@/utils/constants";
|
||||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||||
import { fromNow } from "@/utils/moment";
|
import { fromNow } from "@/utils/moment";
|
||||||
import { files as api } from "@/api";
|
import { filesApi } from "@/api";
|
||||||
import * as upload from "@/utils/upload";
|
import * as upload from "@/utils/upload";
|
||||||
import { state, getters, mutations } from "@/store"; // Import your custom store
|
import { state, getters, mutations } from "@/store"; // Import your custom store
|
||||||
|
import { baseURL } from "@/utils/constants";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "item",
|
name: "item",
|
||||||
|
@ -129,12 +135,7 @@ export default {
|
||||||
if (state.req.path == "/") {
|
if (state.req.path == "/") {
|
||||||
path = "";
|
path = "";
|
||||||
}
|
}
|
||||||
const file = {
|
return filesApi.getPreviewURL(path + "/" + this.name, "small", state.req.modified);
|
||||||
path: path + "/" + this.name,
|
|
||||||
modified: this.modified,
|
|
||||||
};
|
|
||||||
|
|
||||||
return api.getPreviewURL(file, "thumb");
|
|
||||||
},
|
},
|
||||||
isThumbsEnabled() {
|
isThumbsEnabled() {
|
||||||
return enableThumbs;
|
return enableThumbs;
|
||||||
|
@ -157,6 +158,24 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
getUrl() {
|
||||||
|
return baseURL.slice(0, -1) + this.url;
|
||||||
|
},
|
||||||
|
onRightClick(event) {
|
||||||
|
event.preventDefault(); // Prevent default context menu
|
||||||
|
|
||||||
|
// If no items are selected, select the right-clicked item
|
||||||
|
if (getters.selectedCount() === 0) {
|
||||||
|
mutations.addSelected(this.index);
|
||||||
|
}
|
||||||
|
mutations.showHover({
|
||||||
|
name: "ContextMenu",
|
||||||
|
props: {
|
||||||
|
posX: event.clientX,
|
||||||
|
posY: event.clientY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
handleIntersect(entries, observer) {
|
handleIntersect(entries, observer) {
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
|
@ -227,21 +246,16 @@ export default {
|
||||||
name: state.req.items[i].name,
|
name: state.req.items[i].name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let response = await filesApi.fetchFiles(el.__vue__.url);
|
||||||
|
|
||||||
// Get url from ListingItem instance
|
let action = async (overwrite, rename) => {
|
||||||
let path = el.__vue__.url;
|
await filesApi.moveCopy(items, "move", overwrite, rename);
|
||||||
let baseItems = (await api.fetch(path)).items;
|
setTimeout(() => {
|
||||||
|
|
||||||
let action = (overwrite, rename) => {
|
|
||||||
api
|
|
||||||
.move(items, overwrite, rename)
|
|
||||||
.then(() => {
|
|
||||||
mutations.setReload(true);
|
mutations.setReload(true);
|
||||||
})
|
}, 50);
|
||||||
.catch(showError);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let conflict = upload.checkConflict(items, baseItems);
|
let conflict = upload.checkConflict(items, response.items);
|
||||||
|
|
||||||
let overwrite = false;
|
let overwrite = false;
|
||||||
let rename = false;
|
let rename = false;
|
||||||
|
@ -258,14 +272,12 @@ export default {
|
||||||
action(overwrite, rename);
|
action(overwrite, rename);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
action(overwrite, rename);
|
action(overwrite, rename);
|
||||||
},
|
},
|
||||||
itemClick(event) {
|
itemClick(event) {
|
||||||
console.log("should say something");
|
|
||||||
if (this.singleClick && !state.multiple) this.open();
|
if (this.singleClick && !state.multiple) this.open();
|
||||||
else this.click(event);
|
else this.click(event);
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,111 @@
|
||||||
|
<template>
|
||||||
|
<div class="card floating">
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>API Key Details</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<button
|
||||||
|
class="action copy-clipboard"
|
||||||
|
:data-clipboard-text="info.key"
|
||||||
|
:aria-label="$t('buttons.copyToClipboard')"
|
||||||
|
:title="$t('buttons.copyToClipboard')"
|
||||||
|
>
|
||||||
|
API Key Name : {{ name }}
|
||||||
|
<i class="material-icons">content_paste</i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3>Created At :</h3>
|
||||||
|
{{ formatTime(info.created) }}
|
||||||
|
<h3>Expires At :</h3>
|
||||||
|
{{ formatTime(info.expires) }}
|
||||||
|
<h3>Permissions:</h3>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(isEnabled, perm) in info.Permissions" :key="perm">
|
||||||
|
<td>{{ perm }}</td>
|
||||||
|
<td>{{ isEnabled ? "✓" : "✗" }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-action">
|
||||||
|
<button
|
||||||
|
class="button button--flat button--grey"
|
||||||
|
@click="closeHovers"
|
||||||
|
:aria-label="$t('buttons.close')"
|
||||||
|
:title="$t('buttons.close')"
|
||||||
|
>
|
||||||
|
{{ $t("buttons.close") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button button--flat button--red"
|
||||||
|
@click="deleteApi"
|
||||||
|
:title="$t('buttons.delete')"
|
||||||
|
>
|
||||||
|
{{ $t("buttons.delete") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mutations } from "@/store";
|
||||||
|
import { notify } from "@/notify";
|
||||||
|
import { usersApi } from "@/api";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "ActionApi",
|
||||||
|
props: {
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatTime(timestamp) {
|
||||||
|
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
closeHovers() {
|
||||||
|
mutations.closeHovers();
|
||||||
|
},
|
||||||
|
deleteApi() {
|
||||||
|
// Dummy delete function, to be filled in later
|
||||||
|
try {
|
||||||
|
usersApi.deleteApiKey({ name: this.name });
|
||||||
|
notify.showSuccess("API key deleted!");
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
notify.showError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Basic styling for the prompt */
|
||||||
|
.card.floating {
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.card-content {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.card-action {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.card-action .button {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -49,10 +49,10 @@
|
||||||
<script>
|
<script>
|
||||||
import { mutations, state } from "@/store";
|
import { mutations, state } from "@/store";
|
||||||
import FileList from "./FileList.vue";
|
import FileList from "./FileList.vue";
|
||||||
import { files as api } from "@/api";
|
import { filesApi } from "@/api";
|
||||||
import buttons from "@/utils/buttons";
|
import buttons from "@/utils/buttons";
|
||||||
import * as upload from "@/utils/upload";
|
import * as upload from "@/utils/upload";
|
||||||
import { notify } from "@/notify";
|
//import { notify } from "@/notify";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "copy",
|
name: "copy",
|
||||||
|
@ -77,33 +77,19 @@ export default {
|
||||||
let items = [];
|
let items = [];
|
||||||
|
|
||||||
// Create a new promise for each file.
|
// Create a new promise for each file.
|
||||||
for (let item of this.selected) {
|
for (let item of state.selected) {
|
||||||
items.push({
|
items.push({
|
||||||
from: store.req.items[item].url,
|
from: state.req.items[item].url,
|
||||||
to: this.dest + encodeURIComponent(store.req.items[item].name),
|
to: this.dest + encodeURIComponent(state.req.items[item].name),
|
||||||
name: store.req.items[item].name,
|
name: state.req.items[item].name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let action = async (overwrite, rename) => {
|
let action = async (overwrite, rename) => {
|
||||||
buttons.loading("copy");
|
buttons.loading("copy");
|
||||||
|
await filesApi.moveCopy(items, "copy", overwrite, rename);
|
||||||
await api
|
|
||||||
.copy(items, overwrite, rename)
|
|
||||||
.then(() => {
|
|
||||||
buttons.success("copy");
|
|
||||||
|
|
||||||
if (state.route.path === this.dest) {
|
|
||||||
mutations.setReload(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$router.push({ path: this.dest });
|
this.$router.push({ path: this.dest });
|
||||||
})
|
mutations.setReload(true);
|
||||||
.catch((e) => {
|
|
||||||
buttons.done("copy");
|
|
||||||
notify.showError(e);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (state.route.path === this.dest) {
|
if (state.route.path === this.dest) {
|
||||||
|
@ -113,7 +99,7 @@ export default {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dstItems = (await api.fetch(this.dest)).items;
|
let dstItems = (await filesApi.fetchFiles(this.dest)).items;
|
||||||
let conflict = upload.checkConflict(items, dstItems);
|
let conflict = upload.checkConflict(items, dstItems);
|
||||||
|
|
||||||
let overwrite = false;
|
let overwrite = false;
|
||||||
|
|
|
@ -0,0 +1,122 @@
|
||||||
|
<template>
|
||||||
|
<div class="card floating create-api__prompt__card" id="create-api">
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>Create API Key</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<!-- API Key Name Input -->
|
||||||
|
<p>API Key Name</p>
|
||||||
|
<input
|
||||||
|
class="input input--block"
|
||||||
|
type="text"
|
||||||
|
v-model.trim="apiName"
|
||||||
|
placeholder="enter a uinque api key name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Duration Input -->
|
||||||
|
<p>Token Duration</p>
|
||||||
|
<div class="inputWrapper">
|
||||||
|
<input
|
||||||
|
class="sizeInput roundedInputLeft input"
|
||||||
|
v-model.number="duration"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="number"
|
||||||
|
/>
|
||||||
|
<select v-model="unit" class="roundedInputRight input">
|
||||||
|
<option value="days">days</option>
|
||||||
|
<option value="months">months</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Permissions Input -->
|
||||||
|
<p>
|
||||||
|
Choose at least one permission for the key. Your User must also have the
|
||||||
|
permission.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<p v-for="(isEnabled, perm) in availablePermissions" :key="perm">
|
||||||
|
<input type="checkbox" v-model="permissions[perm]" />
|
||||||
|
{{ perm }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-action">
|
||||||
|
<button
|
||||||
|
@click="closeHovers"
|
||||||
|
class="button button--flat button--grey"
|
||||||
|
:aria-label="$t('buttons.cancel')"
|
||||||
|
:title="$t('buttons.cancel')"
|
||||||
|
>
|
||||||
|
{{ $t("buttons.cancel") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button button--flat button--blue"
|
||||||
|
@click="createAPIKey"
|
||||||
|
:title="$t('buttons.create')"
|
||||||
|
>
|
||||||
|
{{ $t("buttons.create") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mutations, state } from "@/store";
|
||||||
|
import { notify } from "@/notify";
|
||||||
|
import { usersApi } from "@/api";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "CreateAPI",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
apiName: "",
|
||||||
|
duration: 1,
|
||||||
|
unit: "days",
|
||||||
|
permissions: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
availablePermissions() {
|
||||||
|
return state.user.perm;
|
||||||
|
},
|
||||||
|
durationInDays() {
|
||||||
|
// Calculate duration based on unit
|
||||||
|
return this.unit === "days" ? this.duration : this.duration * 30; // assuming 30 days per month
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
// Initialize permissions with the same structure as availablePermissions
|
||||||
|
this.permissions = Object.fromEntries(
|
||||||
|
Object.keys(this.availablePermissions).map((perm) => [perm, false])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
closeHovers() {
|
||||||
|
mutations.closeHovers();
|
||||||
|
},
|
||||||
|
async createAPIKey() {
|
||||||
|
try {
|
||||||
|
// Filter to get keys of permissions set to true and join them as a comma-separated string
|
||||||
|
const permissionsString = Object.keys(this.permissions)
|
||||||
|
.filter((key) => this.permissions[key])
|
||||||
|
.join(",");
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
name: this.apiName,
|
||||||
|
days: this.durationInDays,
|
||||||
|
permissions: permissionsString,
|
||||||
|
};
|
||||||
|
|
||||||
|
usersApi.createApiKey(params);
|
||||||
|
notify.showSuccess("successfully created!");
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
notify.showError(this.$t("errors.createKeyFailed"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
|
@ -30,7 +30,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { files as api } from "@/api";
|
import { filesApi } from "@/api";
|
||||||
import buttons from "@/utils/buttons";
|
import buttons from "@/utils/buttons";
|
||||||
import { state, getters, mutations } from "@/store";
|
import { state, getters, mutations } from "@/store";
|
||||||
import { notify } from "@/notify";
|
import { notify } from "@/notify";
|
||||||
|
@ -57,7 +57,7 @@ export default {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!this.isListing) {
|
if (!this.isListing) {
|
||||||
await api.remove(state.route.path);
|
await filesApi.remove(state.route.path);
|
||||||
buttons.success("delete");
|
buttons.success("delete");
|
||||||
showSuccess("Deleted item successfully");
|
showSuccess("Deleted item successfully");
|
||||||
|
|
||||||
|
@ -74,7 +74,7 @@ export default {
|
||||||
|
|
||||||
let promises = [];
|
let promises = [];
|
||||||
for (let index of state.selected) {
|
for (let index of state.selected) {
|
||||||
promises.push(api.remove(state.req.items[index].url));
|
promises.push(filesApi.remove(state.req.items[index].url));
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import { users as api } from "@/api";
|
import { usersApi } from "@/api";
|
||||||
import { notify } from "@/notify";
|
import { notify } from "@/notify";
|
||||||
import buttons from "@/utils/buttons";
|
import buttons from "@/utils/buttons";
|
||||||
import { state, mutations, getters } from "@/store";
|
import { state, mutations, getters } from "@/store";
|
||||||
|
@ -39,7 +39,7 @@ export default {
|
||||||
async deleteUser(event) {
|
async deleteUser(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
try {
|
try {
|
||||||
await api.remove(this.user.id);
|
await usersApi.remove(this.user.id);
|
||||||
this.$router.push({ path: "/settings",hash:"#users-main" });
|
this.$router.push({ path: "/settings",hash:"#users-main" });
|
||||||
notify.showSuccess(this.$t("settings.userDeleted"));
|
notify.showSuccess(this.$t("settings.userDeleted"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -56,7 +56,7 @@ export default {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!this.isListing) {
|
if (!this.isListing) {
|
||||||
await api.remove(this.$route.path);
|
await usersApi.remove(this.$route.path);
|
||||||
buttons.success("delete");
|
buttons.success("delete");
|
||||||
|
|
||||||
this.currentPrompt?.confirm();
|
this.currentPrompt?.confirm();
|
||||||
|
@ -72,7 +72,7 @@ export default {
|
||||||
|
|
||||||
let promises = [];
|
let promises = [];
|
||||||
for (let index of this.selected) {
|
for (let index of this.selected) {
|
||||||
promises.push(api.remove(state.req.items[index].url));
|
promises.push(usersApi.remove(state.req.items[index].url));
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
|
@ -22,8 +22,8 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { state, mutations } from "@/store";
|
import { state, mutations } from "@/store";
|
||||||
import url from "@/utils/url";
|
import url from "@/utils/url.js";
|
||||||
import { files } from "@/api";
|
import { filesApi } from "@/api";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "file-list",
|
name: "file-list",
|
||||||
|
@ -71,8 +71,7 @@ export default {
|
||||||
// Otherwise we add every directory to the
|
// Otherwise we add every directory to the
|
||||||
// move options.
|
// move options.
|
||||||
for (let item of req.items) {
|
for (let item of req.items) {
|
||||||
if (!item.isDir) continue;
|
if (item.type != "directory") continue;
|
||||||
|
|
||||||
this.items.push({
|
this.items.push({
|
||||||
name: item.name,
|
name: item.name,
|
||||||
url: item.url,
|
url: item.url,
|
||||||
|
@ -84,8 +83,7 @@ export default {
|
||||||
// just clicked in and fill the options with its
|
// just clicked in and fill the options with its
|
||||||
// content.
|
// content.
|
||||||
let uri = event.currentTarget.dataset.url;
|
let uri = event.currentTarget.dataset.url;
|
||||||
|
filesApi.fetchFiles(uri).then(this.fillOptions);
|
||||||
files.fetch(uri).then(this.fillOptions);
|
|
||||||
},
|
},
|
||||||
touchstart(event) {
|
touchstart(event) {
|
||||||
let url = event.currentTarget.dataset.url;
|
let url = event.currentTarget.dataset.url;
|
||||||
|
|
|
@ -73,7 +73,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||||
import { formatTimestamp } from "@/utils/moment";
|
import { formatTimestamp } from "@/utils/moment";
|
||||||
import { files as api } from "@/api";
|
import { filesApi } from "@/api";
|
||||||
import { state, getters, mutations } from "@/store"; // Import your custom store
|
import { state, getters, mutations } from "@/store"; // Import your custom store
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -128,8 +128,8 @@ export default {
|
||||||
return (
|
return (
|
||||||
getters.selectedCount() > 1 ||
|
getters.selectedCount() > 1 ||
|
||||||
(getters.selectedCount() === 0
|
(getters.selectedCount() === 0
|
||||||
? state.req.isDir
|
? state.req.type == "directory"
|
||||||
: state.req.items[this.selected[0]].isDir)
|
: state.req.items[this.selected[0]].type == "directory")
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -145,7 +145,7 @@ export default {
|
||||||
link = state.route.path;
|
link = state.route.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = await api.checksum(link, algo);
|
const hash = await filesApi.checksum(link, algo);
|
||||||
event.target.innerHTML = hash;
|
event.target.innerHTML = hash;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -49,7 +49,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { mutations, state } from "@/store";
|
import { mutations, state } from "@/store";
|
||||||
import FileList from "./FileList.vue";
|
import FileList from "./FileList.vue";
|
||||||
import { files as api } from "@/api";
|
import { filesApi } from "@/api";
|
||||||
import buttons from "@/utils/buttons";
|
import buttons from "@/utils/buttons";
|
||||||
import * as upload from "@/utils/upload";
|
import * as upload from "@/utils/upload";
|
||||||
import { notify } from "@/notify";
|
import { notify } from "@/notify";
|
||||||
|
@ -79,32 +79,25 @@ export default {
|
||||||
for (let item of state.selected) {
|
for (let item of state.selected) {
|
||||||
items.push({
|
items.push({
|
||||||
from: state.req.items[item].url,
|
from: state.req.items[item].url,
|
||||||
to: this.dest + encodeURIComponent(state.req.items[item].name),
|
to: this.dest + state.req.items[item].name,
|
||||||
name: state.req.items[item].name,
|
name: state.req.items[item].name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let action = async (overwrite, rename) => {
|
let action = async (overwrite, rename) => {
|
||||||
buttons.loading("move");
|
buttons.loading("move");
|
||||||
await api
|
await filesApi.moveCopy(items, "move", overwrite, rename);
|
||||||
.move(items, overwrite, rename)
|
|
||||||
.then(() => {
|
|
||||||
buttons.success("move");
|
buttons.success("move");
|
||||||
this.$router.push({ path: this.dest });
|
this.$router.push({ path: this.dest });
|
||||||
mutations.setReload(true);
|
mutations.closeHovers();
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
buttons.done("move");
|
|
||||||
notify.showError(e);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let dstItems = (await api.fetch(this.dest)).items;
|
let dstItems = (await filesApi.fetchFiles(this.dest)).items;
|
||||||
let conflict = upload.checkConflict(items, dstItems);
|
let conflict = upload.checkConflict(items, dstItems);
|
||||||
|
|
||||||
let overwrite = false;
|
let overwrite = false;
|
||||||
let rename = false;
|
let rename = false;
|
||||||
|
|
||||||
|
try {
|
||||||
if (conflict) {
|
if (conflict) {
|
||||||
mutations.showHover({
|
mutations.showHover({
|
||||||
name: "replace-rename",
|
name: "replace-rename",
|
||||||
|
@ -112,15 +105,16 @@ export default {
|
||||||
overwrite = option == "overwrite";
|
overwrite = option == "overwrite";
|
||||||
rename = option == "rename";
|
rename = option == "rename";
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
mutations.closeHovers();
|
|
||||||
action(overwrite, rename);
|
action(overwrite, rename);
|
||||||
mutations.setReload(true);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
action(overwrite, rename);
|
action(overwrite, rename);
|
||||||
|
} catch (e) {
|
||||||
|
notify.error(e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -36,8 +36,8 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import { files as api } from "@/api";
|
import { filesApi } from "@/api";
|
||||||
import url from "@/utils/url";
|
import url from "@/utils/url.js";
|
||||||
import { getters, mutations, state } from "@/store"; // Import your custom store
|
import { getters, mutations, state } from "@/store"; // Import your custom store
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -86,11 +86,11 @@ export default {
|
||||||
uri += encodeURIComponent(this.name) + "/";
|
uri += encodeURIComponent(this.name) + "/";
|
||||||
uri = uri.replace("//", "/");
|
uri = uri.replace("//", "/");
|
||||||
|
|
||||||
await api.post(uri);
|
await filesApi.post(uri);
|
||||||
if (this.redirect) {
|
if (this.redirect) {
|
||||||
this.$router.push({ path: uri });
|
this.$router.push({ path: uri });
|
||||||
} else if (!this.base) {
|
} else if (!this.base) {
|
||||||
const res = await api.fetch(url.removeLastDir(uri) + "/");
|
const res = await filesApi.fetchFiles(url.removeLastDir(uri) + "/");
|
||||||
mutations.updateRequest(res);
|
mutations.updateRequest(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -37,8 +37,8 @@
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import { state } from "@/store";
|
import { state } from "@/store";
|
||||||
import { files as api } from "@/api";
|
import { filesApi } from "@/api";
|
||||||
import url from "@/utils/url";
|
import url from "@/utils/url.js";
|
||||||
import { getters, mutations } from "@/store"; // Import your custom store
|
import { getters, mutations } from "@/store"; // Import your custom store
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -73,7 +73,7 @@ export default {
|
||||||
uri += encodeURIComponent(this.name);
|
uri += encodeURIComponent(this.name);
|
||||||
uri = uri.replace("//", "/");
|
uri = uri.replace("//", "/");
|
||||||
|
|
||||||
await api.post(uri);
|
await filesApi.post(uri);
|
||||||
this.$router.push({ path: uri });
|
this.$router.push({ path: uri });
|
||||||
|
|
||||||
mutations.closeHovers();
|
mutations.closeHovers();
|
||||||
|
|
|
@ -25,6 +25,8 @@ import Share from "./Share.vue";
|
||||||
import Upload from "./Upload.vue";
|
import Upload from "./Upload.vue";
|
||||||
import ShareDelete from "./ShareDelete.vue";
|
import ShareDelete from "./ShareDelete.vue";
|
||||||
import DeleteUser from "./DeleteUser.vue";
|
import DeleteUser from "./DeleteUser.vue";
|
||||||
|
import CreateApi from "./CreateApi.vue";
|
||||||
|
import ActionApi from "./ActionApi.vue";
|
||||||
import Sidebar from "../sidebar/Sidebar.vue";
|
import Sidebar from "../sidebar/Sidebar.vue";
|
||||||
import buttons from "@/utils/buttons";
|
import buttons from "@/utils/buttons";
|
||||||
import { state, getters, mutations } from "@/store"; // Import your custom store
|
import { state, getters, mutations } from "@/store"; // Import your custom store
|
||||||
|
@ -48,6 +50,8 @@ export default {
|
||||||
ShareDelete,
|
ShareDelete,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
DeleteUser,
|
DeleteUser,
|
||||||
|
CreateApi,
|
||||||
|
ActionApi,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -40,8 +40,8 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import url from "@/utils/url";
|
import url from "@/utils/url.js";
|
||||||
import { files as api } from "@/api";
|
import { filesApi } from "@/api";
|
||||||
import { state, getters, mutations } from "@/store";
|
import { state, getters, mutations } from "@/store";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -98,13 +98,15 @@ export default {
|
||||||
|
|
||||||
newLink = url.removeLastDir(oldLink) + "/" + encodeURIComponent(this.name);
|
newLink = url.removeLastDir(oldLink) + "/" + encodeURIComponent(this.name);
|
||||||
|
|
||||||
await api.move([{ from: oldLink, to: newLink }]);
|
await filesApi.moveCopy([{ from: oldLink, to: newLink }], "move");
|
||||||
if (!this.isListing) {
|
if (!this.isListing) {
|
||||||
this.$router.push({ path: newLink });
|
this.$router.push({ path: newLink });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
mutations.setReload(true);
|
mutations.setReload(true);
|
||||||
|
}, 50);
|
||||||
|
|
||||||
mutations.closeHovers();
|
mutations.closeHovers();
|
||||||
},
|
},
|
||||||
|
|
|
@ -122,7 +122,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { notify } from "@/notify";
|
import { notify } from "@/notify";
|
||||||
import { state, getters, mutations } from "@/store";
|
import { state, getters, mutations } from "@/store";
|
||||||
import { share as api, pub as pub_api } from "@/api";
|
import { shareApi, publicApi } from "@/api";
|
||||||
import { fromNow } from "@/utils/moment";
|
import { fromNow } from "@/utils/moment";
|
||||||
import Clipboard from "clipboard";
|
import Clipboard from "clipboard";
|
||||||
|
|
||||||
|
@ -134,6 +134,7 @@ export default {
|
||||||
unit: "hours",
|
unit: "hours",
|
||||||
links: [],
|
links: [],
|
||||||
clip: null,
|
clip: null,
|
||||||
|
subpath: "",
|
||||||
password: "",
|
password: "",
|
||||||
listing: true,
|
listing: true,
|
||||||
};
|
};
|
||||||
|
@ -165,16 +166,32 @@ export default {
|
||||||
return state.req.items[this.selected[0]].url;
|
return state.req.items[this.selected[0]].url;
|
||||||
},
|
},
|
||||||
getContext() {
|
getContext() {
|
||||||
let path = state.route.path.replace("/files/", "./");
|
const prefix = `/files/`;
|
||||||
|
let path = state.route.path.replace(prefix, "./");
|
||||||
if (getters.selectedCount() === 1) {
|
if (getters.selectedCount() === 1) {
|
||||||
path = path + state.req.items[this.selected[0]].name;
|
path = path + state.req.items[this.selected[0]].name;
|
||||||
}
|
}
|
||||||
return path;
|
return decodeURIComponent(path);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async beforeMount() {
|
async beforeMount() {
|
||||||
const links = await api.get(this.url);
|
try {
|
||||||
|
const prefix = `/files`;
|
||||||
|
let path = state.route.path.startsWith(prefix)
|
||||||
|
? state.route.path.slice(prefix.length)
|
||||||
|
: state.route.path;
|
||||||
|
path = decodeURIComponent(path);
|
||||||
|
if (path == "") {
|
||||||
|
path = "/";
|
||||||
|
}
|
||||||
|
this.subpath = path;
|
||||||
|
// get last element of the path
|
||||||
|
const links = await shareApi.get(this.subpath);
|
||||||
this.links = links;
|
this.links = links;
|
||||||
|
} catch (err) {
|
||||||
|
notify.showError(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.sort();
|
this.sort();
|
||||||
|
|
||||||
if (this.links.length === 0) {
|
if (this.links.length === 0) {
|
||||||
|
@ -197,9 +214,9 @@ export default {
|
||||||
let res = null;
|
let res = null;
|
||||||
|
|
||||||
if (isPermanent) {
|
if (isPermanent) {
|
||||||
res = await api.create(this.url, this.password);
|
res = await shareApi.create(this.subpath, this.password);
|
||||||
} else {
|
} else {
|
||||||
res = await api.create(this.url, this.password, this.time, this.unit);
|
res = await shareApi.create(this.subpath, this.password, this.time, this.unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.links.push(res);
|
this.links.push(res);
|
||||||
|
@ -213,9 +230,8 @@ export default {
|
||||||
},
|
},
|
||||||
async deleteLink(event, link) {
|
async deleteLink(event, link) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
await api.remove(link.hash);
|
await shareApi.remove(link.hash);
|
||||||
this.links = this.links.filter((item) => item.hash !== link.hash);
|
this.links = this.links.filter((item) => item.hash !== link.hash);
|
||||||
|
|
||||||
if (this.links.length === 0) {
|
if (this.links.length === 0) {
|
||||||
this.listing = false;
|
this.listing = false;
|
||||||
}
|
}
|
||||||
|
@ -224,13 +240,13 @@ export default {
|
||||||
return fromNow(time, state.user.locale);
|
return fromNow(time, state.user.locale);
|
||||||
},
|
},
|
||||||
buildLink(share) {
|
buildLink(share) {
|
||||||
return api.getShareURL(share);
|
return shareApi.getShareURL(share);
|
||||||
},
|
},
|
||||||
hasDownloadLink() {
|
hasDownloadLink() {
|
||||||
return this.selected.length === 1 && !state.req.items[this.selected[0]].isDir;
|
return this.selected.length === 1 && !state.req.items[this.selected[0]].isDir;
|
||||||
},
|
},
|
||||||
buildDownloadLink(share) {
|
buildDownloadLink(share) {
|
||||||
return pub_api.getDownloadURL(share);
|
return publicApi.getDownloadURL(share);
|
||||||
},
|
},
|
||||||
sort() {
|
sort() {
|
||||||
this.links = this.links.sort((a, b) => {
|
this.links = this.links.sort((a, b) => {
|
||||||
|
|
|
@ -88,7 +88,7 @@ export default {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = getters.getRoutePath();
|
const path = getters.routePath();
|
||||||
const conflict = upload.checkConflict(uploadFiles, state.req.items);
|
const conflict = upload.checkConflict(uploadFiles, state.req.items);
|
||||||
|
|
||||||
if (conflict) {
|
if (conflict) {
|
||||||
|
|
|
@ -36,6 +36,10 @@
|
||||||
<input type="checkbox" :disabled="admin" v-model="perm.share" />
|
<input type="checkbox" :disabled="admin" v-model="perm.share" />
|
||||||
{{ $t("settings.perm.share") }}
|
{{ $t("settings.perm.share") }}
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
<input type="checkbox" v-model="perm.api" />
|
||||||
|
{{ $t("settings.perm.api") }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -101,7 +101,6 @@ export default {
|
||||||
watch: {
|
watch: {
|
||||||
user: {
|
user: {
|
||||||
handler(newUser) {
|
handler(newUser) {
|
||||||
console.log("UserForm: user changed", newUser);
|
|
||||||
this.localUser = { ...newUser }; // Watch for changes in the parent and update the local copy
|
this.localUser = { ...newUser }; // Watch for changes in the parent and update the local copy
|
||||||
},
|
},
|
||||||
immediate: true,
|
immediate: true,
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue