Compare commits

...
12 Commits
35 changed files with 1231 additions and 319 deletions

No files matched your search

-39
View File
@@ -30,42 +30,3 @@ jobs:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
push_latest_to_registry:
needs: [ test_playwright ]
name: Push latest
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Find latest beta tag
run: |
echo "LATEST_TAG=$(git describe --tags $(git rev-list --tags="*-beta*" --max-count=1))" >> $GITHUB_ENV
echo "latest tag is $LATEST_TAG"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: gtstef/filebrowser
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
build-args: |
VERSION=${{ env.LATEST_TAG }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
file: ./_docker/Dockerfile
push: true
tags: 'gtstef/filebrowser:latest'
labels: ${{ steps.meta.outputs.labels }}
+20 -10
View File
@@ -24,20 +24,30 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.actor}}
password: ${{ secrets.PAT }}
- name: Extract metadata (tags, labels) for Docker and GHCR
id: meta
uses: docker/metadata-action@v5
with:
images: gtstef/filebrowser
- name: modify version names
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Modify version names
id: modify-json
run: |
JSON="${{ steps.meta.outputs.tags }}"
# Use jq to remove 'v' from the version field
JSON=$(echo "$JSON" | sed 's/filebrowser:dev-v/filebrowser:/')
JSON=$(echo "$JSON" | sed -E 's/(filebrowser:[0-9]+\.[0-9]+\.[0-9]+)/\1-dev/g')
JSON="$JSON,gtstef/filebrowser:dev"
echo "cleaned_tag=$JSON" >> $GITHUB_OUTPUT
TAGS="${{ steps.meta.outputs.tags }}"
# Apply modifications to both docker.io and ghcr.io tags
CLEANED_TAGS=$(echo "$TAGS" | sed 's/filebrowser:dev-v/filebrowser:/g' | sed -E 's/(filebrowser:[0-9]+\.[0-9]+\.[0-9]+)/\1-dev/g')
# Add dev tag to both registries
CLEANED_TAGS="$CLEANED_TAGS,gtstef/filebrowser:dev,ghcr.io/gtsteffaniak/filebrowser:dev"
echo "cleaned_tag<<EOF" >> $GITHUB_OUTPUT
echo "$CLEANED_TAGS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
@@ -49,4 +59,4 @@ jobs:
file: ./_docker/Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
+30 -11
View File
@@ -45,26 +45,45 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.actor}}
password: ${{ secrets.PAT }}
- name: Extract metadata (tags, labels) for Docker and GHCR
id: meta
uses: docker/metadata-action@v5
with:
images: gtstef/filebrowser
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Modify tags (strip 'v' and add 'beta'/'stable' if needed)
id: modify-json
run: |
JSON='${{ steps.meta.outputs.json }}' # Assuming the JSON output is stored in steps.meta.outputs.json
# Process the JSON array using jq
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq '
.tags |= map(sub(":v"; ":")) |
if any(.tags[]; test("beta")) then .tags += ["gtstef/filebrowser:beta"] else . end |
if any(.tags[]; test("stable")) then .tags += ["gtstef/filebrowser:stable"] else . end
.tags |= map(sub(":v"; ":")) |
if any(.tags[]; test("beta")) then
.tags += [
"gtstef/filebrowser:beta",
"ghcr.io/gtsteffaniak/filebrowser:beta",
"gtstef/filebrowser:latest",
"ghcr.io/gtsteffaniak/filebrowser:latest"
]
else . end |
if any(.tags[]; test("stable")) then
.tags += [
"gtstef/filebrowser:stable",
"ghcr.io/gtsteffaniak/filebrowser:stable",
"gtstef/filebrowser:latest",
"ghcr.io/gtsteffaniak/filebrowser:latest"
]
else . end
')
# Convert JSON to a comma-separated string for Docker tags
TAGS_CSV=$(echo "$MODIFIED_JSON" | jq -r '.tags | join(",")')
echo "$TAGS_CSV" > modified_tags.txt # Save for debugging
echo "$TAGS_CSV" > modified_tags.txt # Optional debug output
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
@@ -76,4 +95,4 @@ jobs:
file: ./_docker/Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
+19
View File
@@ -2,6 +2,25 @@
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.6.8-beta
**New Features**
- environment variables are available for certain secrets.
- see wiki https://github.com/gtsteffaniak/filebrowser/wiki/Environment-Variables
- thanks @aaronkyriesenbach https://github.com/gtsteffaniak/filebrowser/pull/511
**Notes**:
- config validation (see https://github.com/gtsteffaniak/filebrowser/wiki/Full-Config-Example)
- fails when config file contains unknown fields (helps spot typos)
- some light value validation on certain fields
- removed recaptcha -- was disabled and not used before.
- moved `recaptcha` and `signup` configs to `auth.methods.password`
- also pushes to ghcr `ghcr.io/gtsteffaniak/filebrowser` https://github.com/gtsteffaniak/filebrowser/issues/470
**BugFixes**:
- fix scope reset on restart https://github.com/gtsteffaniak/filebrowser/issues/515
- Clicking empty space to deselect https://github.com/gtsteffaniak/filebrowser/issues/492
## v0.6.7-beta
**Notes**:
-1
View File
@@ -6,7 +6,6 @@ server:
sources:
- path: "../frontend/tests/playwright-files"
auth:
signup: false
methods:
noauth: true
frontend:
-1
View File
@@ -18,7 +18,6 @@ frontend:
title: "Playwright"
auth:
signup: false
methods:
proxy:
enabled: true
+24 -13
View File
@@ -3,11 +3,14 @@ package cmd
import (
"fmt"
"github.com/gtsteffaniak/filebrowser/backend/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
var createBackup = []bool{}
func validateUserInfo() {
// update source info for users if names/sources/paths might have changed
usersList, err := store.Users.Gets()
@@ -15,23 +18,37 @@ func validateUserInfo() {
logger.Fatal(fmt.Sprintf("could not load users: %v", err))
}
for _, user := range usersList {
updateUser := false
if user.Username == "publicUser" {
settings.ApplyUserDefaults(user)
updateUser = true
}
if updateUserScopes(user) {
updateUser = true
}
if updateUser {
if len(createBackup) == 1 {
logger.Warning("Incompatible user settings detected, creating backup of database before converting.")
err = fileutils.CopyFile(settings.Config.Server.Database, fmt.Sprintf("%s.bak", settings.Config.Server.Database))
if err != nil {
logger.Fatal(fmt.Sprintf("Unable to create automatic backup of database due to error: %v", err))
}
}
err := store.Users.Save(user, false)
if err != nil {
logger.Error(fmt.Sprintf("could not update user: %v", err))
}
}
updateUserScopes(user)
}
}
func updateUserScopes(user *users.User) {
func updateUserScopes(user *users.User) bool {
updateUser := false
newScopes := []users.SourceScope{}
for _, source := range settings.Config.Server.SourceMap {
scopePath, err := settings.GetScopeFromSourcePath(user.Scopes, source.Path)
if !user.Perm.Admin {
// apply default scope if it doesn't exist
if !user.Perm.Admin && scopePath == "" {
scopePath = source.Config.DefaultUserScope
}
if scopePath == "" {
@@ -49,12 +66,11 @@ func updateUserScopes(user *users.User) {
} else {
newScopes = append(newScopes, users.SourceScope{Scope: scopePath, Name: source.Path}) // backend name is path
}
user.Scopes = newScopes
}
// maintain backwards compatibility, update user scope from scopes
if len(user.Scopes) == 0 {
user.Scopes = []users.SourceScope{
if len(newScopes) == 0 {
newScopes = []users.SourceScope{
{
Scope: settings.Config.Server.DefaultSource.Config.DefaultUserScope,
Name: settings.Config.Server.DefaultSource.Path, // backend name is path
@@ -62,11 +78,6 @@ func updateUserScopes(user *users.User) {
}
updateUser = true
}
if !updateUser {
return
}
err := store.Users.Save(user, false)
if err != nil {
logger.Error(fmt.Sprintf("could not update user: %v", err))
}
user.Scopes = newScopes
return updateUser
}
-3
View File
@@ -5,12 +5,9 @@ server:
- levels: "info|debug|warning|error"
sources:
- path: "/srv"
auth:
signup: false
userDefaults:
darkMode: true
disableSettings: false
scope: "."
singleClick: false
permissions:
admin: false
+12 -14
View File
@@ -312,32 +312,30 @@ func WriteFile(opts FileOptions, in io.Reader) error {
return idx.RefreshFileInfo(opts)
}
// resolveSymlinks resolves symlinks in the given path
func resolveSymlinks(path string) (string, bool, error) {
for {
// Get the file info using os.Lstat to handle symlinks
const maxSymlinks = 25
for i := 0; i < maxSymlinks; i++ {
info, err := os.Lstat(path)
if err != nil {
return path, false, fmt.Errorf("could not stat path: %s, %v", path, err)
}
// Check if the path is a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Read the symlink target
target, err := os.Readlink(path)
if err != nil {
return path, false, fmt.Errorf("could not read symlink: %s, %v", path, err)
}
// Resolve the symlink's target relative to its directory
// This ensures the resolved path is absolute and correctly calculated
path = filepath.Join(filepath.Dir(path), target)
} else {
// Not a symlink, so return the resolved path and whether it's a directory
isDir := info.IsDir()
return path, isDir, nil
path = filepath.Clean(filepath.Join(filepath.Dir(path), target))
if !filepath.IsAbs(path) {
path, err = filepath.Abs(path)
if err != nil {
return path, false, fmt.Errorf("could not resolve absolute path: %s, %v", path, err)
}
}
continue
}
return path, info.IsDir(), nil
}
return path, false, fmt.Errorf("too many symlink resolutions for path: %s", path)
}
// addContent reads and sets content based on the file type.
+13 -8
View File
@@ -8,7 +8,8 @@ require (
github.com/asdine/storm/v3 v3.2.1
github.com/disintegration/imaging v1.6.2
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/goccy/go-yaml v1.16.0
github.com/go-playground/validator/v10 v10.26.0
github.com/goccy/go-yaml v1.17.1
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/shirou/gopsutil/v3 v3.24.5
@@ -16,9 +17,9 @@ require (
github.com/stretchr/testify v1.10.0
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.36.0
golang.org/x/image v0.25.0
golang.org/x/sys v0.31.0
golang.org/x/crypto v0.37.0
golang.org/x/image v0.26.0
golang.org/x/sys v0.32.0
)
require (
@@ -26,24 +27,28 @@ require (
github.com/davecgh/go-spew v1.1.1 // 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/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // 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.1 // indirect
github.com/golang/geo v0.0.0-20250321002858-2bb09a976f49 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang/geo v0.0.0-20250410091149-9ff723126794 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
golang.org/x/net v0.37.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.31.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/tools v0.32.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+30 -18
View File
@@ -27,6 +27,8 @@ 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-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/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
@@ -44,15 +46,23 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/goccy/go-yaml v1.16.0 h1:d7m1G7A0t+logajVtklHfDYJs2Et9g3gHwdBNNFou0w=
github.com/goccy/go-yaml v1.16.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/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-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-20250321002858-2bb09a976f49 h1:JJ32MDIC/hIQ/Fq8Ej9Hgh1s3D82FYNMt54WYViOI7Y=
github.com/golang/geo v0.0.0-20250321002858-2bb09a976f49/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA=
github.com/golang/geo v0.0.0-20250410091149-9ff723126794 h1:sYWK0P7YCim7xkB7IqMX6k4YtrcqLVSdCZqmMnqhBAI=
github.com/golang/geo v0.0.0-20250410091149-9ff723126794/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
@@ -73,6 +83,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -104,11 +116,11 @@ go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
@@ -123,12 +135,12 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
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.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
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.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -143,8 +155,8 @@ golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBc
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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -153,13 +165,13 @@ 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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
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.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
+2 -2
View File
@@ -124,7 +124,7 @@ type signupBody struct {
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/signup [post]
func signupHandler(w http.ResponseWriter, r *http.Request) {
if !settings.Config.Auth.Signup {
if !settings.Config.Auth.Methods.PasswordAuth.Signup {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
@@ -222,7 +222,7 @@ func makeSignedTokenAPI(user *users.User, name string, duration time.Duration, p
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim)
tokenString, err := token.SignedString(config.Auth.Key)
tokenString, err := token.SignedString([]byte(config.Auth.Key))
if err != nil {
return claim, err
}
+1 -1
View File
@@ -138,7 +138,7 @@ func withUserHelper(fn handleFunc) handleFunc {
return fn(w, r, data)
}
keyFunc := func(token *jwt.Token) (interface{}, error) {
return config.Auth.Key, nil
return []byte(config.Auth.Key), nil
}
tokenString, err := extractToken(r)
if err != nil {
+1 -1
View File
@@ -209,7 +209,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
handler := withHashFileHelper(publicShareHandler)
if err := store.Settings.Save(&settings.Settings{
Auth: settings.Auth{
Key: []byte("key"),
Key: "key",
},
}); err != nil {
t.Fatalf("failed to save settings: %v", err)
+2 -2
View File
@@ -230,8 +230,8 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
sizeInMB := estimatedSize / 1024 / 1024
// if larger than 100 megabytes, log it
if sizeInMB > 100 {
// if larger than 500 megabytes, log it
if sizeInMB > 500 {
logger.Debug(fmt.Sprintf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName))
}
// Stream file to response
+1 -1
View File
@@ -11,7 +11,7 @@ import (
// @Accept json
// @Produce json
// @Param property query string false "Property to retrieve: `userDefaults`, `frontend`, `auth`, `server`, `sources`"
// @Success 200 {object} settingsData "System settings data"
// @Success 200 {object} settings.Settings "System settings data"
// @Router /api/settings [get]
func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
property := r.URL.Query().Get("property")
+1 -25
View File
@@ -8,7 +8,6 @@ import (
"strings"
"text/template"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/version"
)
@@ -48,42 +47,19 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
"Version": version.Version,
"CommitSHA": version.CommitSHA,
"StaticURL": config.Server.BaseURL + "static",
"Signup": settings.Config.Auth.Signup,
"Signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
"NoAuth": config.Auth.Methods.NoAuth,
"PasswordAuth": config.Auth.Methods.PasswordAuth,
"LoginPage": auther.LoginPage(),
"CSS": false,
"ReCaptcha": false,
"EnableThumbs": config.Server.EnableThumbnails,
"ResizePreview": config.Server.ResizePreview,
"EnableExec": config.Server.EnableExec,
"ReCaptchaHost": config.Auth.Recaptcha.Host,
"ExternalLinks": config.Frontend.ExternalLinks,
"ExternalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"OnlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
"SourceCount": len(config.Server.Sources),
}
if config.Auth.Methods.PasswordAuth.Enabled {
raw, err := store.Auth.Get("password") //nolint:govet
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
auther, ok := raw.(*auth.JSONAuth)
if !ok {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if auther.ReCaptcha != nil {
data["ReCaptcha"] = auther.ReCaptcha.Key != "" && auther.ReCaptcha.Secret != ""
data["ReCaptchaHost"] = auther.ReCaptcha.Host
data["ReCaptchaKey"] = auther.ReCaptcha.Key
}
}
b, err := json.Marshal(data)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+1 -1
View File
@@ -134,7 +134,7 @@ func Error(msg string) {
if len(loggers) > 0 {
Log(levels.ERROR, msg, true, false, RED)
} else {
log.Println("[ERROR] ", msg)
log.Println("[ERROR]", msg)
}
}
+43 -13
View File
@@ -8,6 +8,7 @@ import (
"slices"
"strings"
"github.com/go-playground/validator/v10"
yaml "github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/users"
@@ -21,6 +22,7 @@ func Initialize(configFile string) {
if err != nil {
logger.Fatal(err.Error())
}
loadEnvConfig()
setupLogging()
setupAuth()
setupSources()
@@ -123,9 +125,6 @@ func setupBaseURL() {
}
func setupAuth() {
if Config.Auth.Method != "" {
logger.Warning("The `auth.method` setting is deprecated and will be removed in a future version. Please use `auth.methods` instead.")
}
if Config.Auth.Methods.PasswordAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "Password")
}
@@ -176,29 +175,60 @@ func loadConfigWithDefaults(configFile string) error {
if err != nil {
return err
}
yamlData := make([]byte, stat.Size())
_, err = yamlFile.Read(yamlData)
if err != nil && configFile != "config.yaml" {
return fmt.Errorf("could not load specified config file: " + err.Error())
return fmt.Errorf("could not load specified config file: %v", err.Error())
}
if err != nil {
logger.Warning(fmt.Sprintf("Could not load config file '%v', using default settings: %v", configFile, err))
}
err = ValidateConfig(yamlData)
if err != nil {
errmsg := "the provided config file failed validation. "
errmsg += "If you are seeing this on a config that worked previeously, "
errmsg += "this is because v0.6.8 requires a fully validated config to run. "
errmsg += "Please review your config for typos and invalid keys which are no longer supported. "
errmsg += "visit https://github.com/gtsteffaniak/filebrowser/wiki/Full-Config-Example for more information."
logger.Error(errmsg)
}
return err
}
func ValidateConfig(yamlData []byte) error {
var err error
Config = setDefaults()
err = yaml.Unmarshal(yamlData, &Config)
err = yaml.NewDecoder(strings.NewReader(string(yamlData)), yaml.DisallowUnknownField()).Decode(&Config)
if err != nil {
return fmt.Errorf("error unmarshaling YAML data: %v", err)
}
validate := validator.New()
err = validate.Struct(Config)
if err != nil {
return fmt.Errorf("could not validate %v", err)
}
return nil
}
func loadEnvConfig() {
adminPassword, ok := os.LookupEnv("FILEBROWSER_ADMIN_PASSWORD")
if ok {
logger.Info("Using admin password from FILEBROWSER_ADMIN_PASSWORD environment variable")
Config.Auth.AdminPassword = adminPassword
}
officeSecret, ok := os.LookupEnv("FILEBROWSER_ONLYOFFICE_SECRET")
if ok {
logger.Info("Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable")
Config.Integrations.OnlyOffice.Secret = officeSecret
}
}
func setDefaults() Settings {
return Settings{
Server: Server{
EnableThumbnails: true,
ResizePreview: false,
EnableExec: false,
Port: 80,
NumImageProcessors: 4,
BaseURL: "",
@@ -212,13 +242,11 @@ func setDefaults() Settings {
AdminUsername: "admin",
AdminPassword: "admin",
TokenExpirationHours: 2,
Signup: false,
Recaptcha: Recaptcha{
Host: "",
},
Methods: LoginMethods{
PasswordAuth: PasswordAuthConfig{
Enabled: true,
MinLength: 5,
Signup: false,
},
},
},
@@ -258,10 +286,12 @@ func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, er
if !strings.HasPrefix(scope.Scope, "/") {
scope.Scope = "/" + scope.Scope
}
if !strings.HasSuffix(scope.Scope, "/") {
scope.Scope = scope.Scope + "/"
}
// first check if its already a path name and keep it
source, ok := Config.Server.SourceMap[scope.Name]
if ok {
newScopes = append(newScopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
@@ -283,7 +313,7 @@ func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, er
}
func ConvertToFrontendScopes(scopes []users.SourceScope) []users.SourceScope {
newScopes := make([]users.SourceScope, 0, len(scopes)) // Preserve original order
newScopes := []users.SourceScope{}
for _, scope := range scopes {
if source, ok := Config.Server.SourceMap[scope.Name]; ok {
// Replace scope.Name with source.Path while keeping the same Scope value
+169
View File
@@ -2,7 +2,10 @@ package settings
import (
"reflect"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestInitialize(t *testing.T) {
@@ -37,3 +40,169 @@ func Test_setDefaults(t *testing.T) {
})
}
}
func TestConfigLoadChanged(t *testing.T) {
defaultConfig := setDefaults()
err := loadConfigWithDefaults("./validConfig.yaml")
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
// Use go-cmp to compare the two structs
if diff := cmp.Diff(defaultConfig, Config); diff == "" {
t.Errorf("No change when there should have been (-want +got):\n%s", diff)
}
}
func TestConfigLoadSpecificValues(t *testing.T) {
defaultConfig := setDefaults()
err := loadConfigWithDefaults("./validConfig.yaml")
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
testCases := []struct {
fieldName string
globalVal interface{}
newVal interface{}
}{
{"Server.Database", Config.Server.Database, defaultConfig.Server.Database},
}
for _, tc := range testCases {
if tc.globalVal == tc.newVal {
t.Errorf("Differences should have been found:\nConfig.%s: %v \nSetConfig: %v \n", tc.fieldName, tc.globalVal, tc.newVal)
}
}
}
func TestInvalidConfig(t *testing.T) {
configFile := "./invalidConfig.yaml"
err := loadConfigWithDefaults(configFile)
if err == nil {
t.Fatalf("expected error loading config file %s, got nil", configFile)
}
}
func TestValidationVarious(t *testing.T) {
testCases := []struct {
name string
configFile []byte
searchErr string
}{
{
name: "valid minimal config",
configFile: []byte(`
server:
port: 8080
sources:
- path: "/data"
auth:
tokenExpirationHours: 24
methods:
password:
enabled: true
signup: false
key: ""
adminUsername: "admin"
adminPassword: "password"
frontend:
name: "MyApp"
userDefaults:
stickySidebar: false
darkMode: false
lockPassword: false
locale: "en"
viewMode: "grid"
gallerySize: 5
singleClick: false
showHidden: false
dateFormat: false
themeColor: "#ffffff"
quickDownload: false
integrations:
office:
url: "https://office.example.com"
secret: "secret-key"
`),
searchErr: "",
},
{
name: "missing required server.sources",
configFile: []byte(`
server:
port: 8080
`),
searchErr: "Field validation for 'Sources' failed on the 'required'",
},
{
name: "missing onlyoffice.secret",
configFile: []byte(`
server:
port: 8080
sources:
- path: "/data"
integrations:
office:
url: "https://office.example.com"
`),
searchErr: "Field validation for 'Secret' failed on the 'required'",
},
{
name: "typo: Server with lowercase s",
configFile: []byte(`
serverx:
port: 8080
sources:
- path: "/data"
`),
searchErr: "unknown field \"serverx\"",
},
{
name: "typo in auth.methods.password",
configFile: []byte(`
server:
port: 8080
sources:
- path: "/data"
auth:
tokenExpirationHours: 24
methods:
passwrd:
enabled: true
signup: false
key: ""
adminUsername: "admin"
adminPassword: "password"
`),
searchErr: "unknown field \"passwrd\"",
},
{
name: "invalid userDefaults",
configFile: []byte(`
server:
sources:
- path: "/data"
userDefaults:
permissions:
download: false
`),
searchErr: "unknown field \"download\"",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateConfig(tc.configFile)
if !(err == nil && tc.searchErr == "") {
if err == nil || !strings.Contains(err.Error(), tc.searchErr) {
t.Fatalf("testcase %v could not find error: '%v', got: %v", tc.name, tc.searchErr, err)
}
}
if err != nil && tc.searchErr == "" {
t.Fatalf("testcase %v should not have failed but got: %v", tc.name, err)
}
if err == nil && tc.searchErr != "" {
t.Fatalf("testcase %v should have failed but got: %v", tc.name, err)
}
})
}
}
File renamed without changes.
-40
View File
@@ -1,40 +0,0 @@
package settings
import (
"testing"
"github.com/google/go-cmp/cmp"
)
func TestConfigLoadChanged(t *testing.T) {
defaultConfig := setDefaults()
err := loadConfigWithDefaults("./testingConfig.yaml")
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
// Use go-cmp to compare the two structs
if diff := cmp.Diff(defaultConfig, Config); diff == "" {
t.Errorf("No change when there should have been (-want +got):\n%s", diff)
}
}
func TestConfigLoadSpecificValues(t *testing.T) {
defaultConfig := setDefaults()
err := loadConfigWithDefaults("./testingConfig.yaml")
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
testCases := []struct {
fieldName string
globalVal interface{}
newVal interface{}
}{
{"Server.Database", Config.Server.Database, defaultConfig.Server.Database},
}
for _, tc := range testCases {
if tc.globalVal == tc.newVal {
t.Errorf("Differences should have been found:\nConfig.%s: %v \nSetConfig: %v \n", tc.fieldName, tc.globalVal, tc.newVal)
}
}
}
+26 -30
View File
@@ -23,25 +23,30 @@ type Settings struct {
type Auth struct {
TokenExpirationHours int `json:"tokenExpirationHours"`
Recaptcha Recaptcha `json:"recaptcha"`
Methods LoginMethods `json:"methods"`
Signup bool `json:"signup"`
Method string `json:"method"`
Key []byte `json:"key"`
Key string `json:"key"`
AdminUsername string `json:"adminUsername"`
AdminPassword string `json:"adminPassword"`
AuthMethods []string
AuthMethods []string `json:"-"`
}
type LoginMethods struct {
ProxyAuth ProxyAuthConfig `json:"proxy"`
NoAuth bool `json:"noauth"`
PasswordAuth PasswordAuthConfig `json:"password"`
ProxyAuth ProxyAuthConfig `json:"proxy" validate:"omitempty"`
NoAuth bool `json:"noauth" validate:"omitempty"`
PasswordAuth PasswordAuthConfig `json:"password" validate:"omitempty"`
}
type PasswordAuthConfig struct {
Enabled bool `json:"enabled"`
MinLength int `json:"minLength"`
Enabled bool `json:"enabled"`
MinLength int `json:"minLength" validate:"omitempty,min=5"`
Signup bool `json:"signup" validate:"omitempty"`
Recaptcha Recaptcha `json:"recaptcha" validate:"omitempty"`
}
type Recaptcha struct {
Host string `json:"host" validate:"required"`
Key string `json:"key" validate:"required"`
Secret string `json:"secret" validate:"required"`
}
type ProxyAuthConfig struct {
@@ -50,12 +55,6 @@ type ProxyAuthConfig struct {
Header string `json:"header"`
}
type Recaptcha struct {
Host string `json:"host"`
Key string `json:"key"`
Secret string `json:"secret"`
}
type Server struct {
NumImageProcessors int `json:"numImageProcessors"`
Socket string `json:"socket"`
@@ -63,32 +62,30 @@ type Server struct {
TLSCert string `json:"tlsCert"`
EnableThumbnails bool `json:"enableThumbnails"`
ResizePreview bool `json:"resizePreview"`
EnableExec bool `json:"enableExec"`
AuthHook string `json:"authHook"`
Port int `json:"port"`
BaseURL string `json:"baseURL"`
Logging []LogConfig `json:"logging"`
Database string `json:"database"`
Sources []Source `json:"sources"`
Sources []Source `json:"sources" validate:"required,dive"`
ExternalUrl string `json:"externalUrl"`
InternalUrl string `json:"internalUrl"` // used by integrations
CacheDir string `json:"cacheDir"`
MaxArchiveSizeGB int64 `json:"maxArchiveSize"`
// not exposed to config
SourceMap map[string]Source `json:"-"` // uses realpath as key
NameToSource map[string]Source `json:"-"` // uses name as key
DefaultSource Source `json:"-"`
SourceMap map[string]Source `json:"-" validate:"omitempty"` // uses realpath as key
NameToSource map[string]Source `json:"-" validate:"omitempty"` // uses name as key
DefaultSource Source `json:"-" validate:"omitempty"`
}
type Integrations struct {
OnlyOffice OnlyOffice `json:"office"`
OnlyOffice OnlyOffice `json:"office" validate:"omitempty"`
}
// onlyoffice secret is stored in the local.json file
// docker exec <containerID> /var/www/onlyoffice/documentserver/npm/json -f /etc/onlyoffice/documentserver/local.json 'services.CoAuthoring.secret.session.string'
type OnlyOffice struct {
Url string `json:"url"`
Secret string `json:"secret"`
Url string `json:"url" validate:"required"`
Secret string `json:"secret" validate:"required"`
}
type LogConfig struct {
@@ -100,8 +97,8 @@ type LogConfig struct {
}
type Source struct {
Path string `json:"path"` // can be relative, filesystem path
Name string `json:"name"` // display name
Path string `json:"path" validate:"required"` // file system path. (Can be relative)
Name string `json:"name"` // display name
Config SourceConfig `json:"config"`
}
@@ -133,9 +130,9 @@ type Frontend struct {
}
type ExternalLink struct {
Text string `json:"text"`
Text string `json:"text" validate:"required"`
Title string `json:"title"`
Url string `json:"url"`
Url string `json:"url" validate:"required"`
}
// UserDefaults is a type that holds the default values
@@ -145,7 +142,6 @@ type UserDefaults struct {
DarkMode bool `json:"darkMode"`
LockPassword bool `json:"lockPassword"`
DisableSettings bool `json:"disableSettings,omitempty"`
Scope string `json:"scope"` // deprecated
Locale string `json:"locale"`
ViewMode string `json:"viewMode"`
GallerySize int `json:"gallerySize"`
+35
View File
@@ -0,0 +1,35 @@
server:
numImageProcessors: 4
socket: ""
tlsKey: ""
tlsCert: ""
enableThumbnails: false
resizePreview: true
port: 80
baseURL: "/"
database: "mydb.db"
sources:
- path: "."
integrations:
office:
url: "http://localhost:9052"
secret: 6H1H7GzUk2alRkNxAPav0W02g8JS5oUd
auth:
methods:
password:
enabled: true
frontend:
name: ""
disableUsedPercentage: true
userDefaults:
locale: ""
viewMode: ""
darkMode: true
disableSettings: false
singleClick: true
permissions:
admin: true
modify: true
share: true
api: true
dateFormat: false
-8
View File
@@ -88,14 +88,6 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
return fmt.Errorf("no fields to update")
}
// converting scopes to map of paths intead of names (names can change)
if slices.Contains(fields, "scopes") || slices.Contains(fields, "Scopes") {
adjustedScopes, err := settings.ConvertToBackendScopes(user.Scopes)
if err != nil {
return err
}
user.Scopes = adjustedScopes
}
// Use reflection to access struct fields
userFields := reflect.ValueOf(user).Elem() // Get struct value
+289 -25
View File
@@ -913,7 +913,7 @@ const docTemplate = `{
"200": {
"description": "System settings data",
"schema": {
"$ref": "#/definitions/http.settingsData"
"$ref": "#/definitions/settings.Settings"
}
}
}
@@ -1689,26 +1689,6 @@ const docTemplate = `{
}
}
},
"http.settingsData": {
"type": "object",
"properties": {
"createUserDir": {
"type": "boolean"
},
"defaults": {
"$ref": "#/definitions/settings.UserDefaults"
},
"frontend": {
"$ref": "#/definitions/settings.Frontend"
},
"signup": {
"type": "boolean"
},
"userHomeBasePath": {
"type": "string"
}
}
},
"http.signupBody": {
"type": "object",
"properties": {
@@ -1720,8 +1700,38 @@ const docTemplate = `{
}
}
},
"settings.Auth": {
"type": "object",
"properties": {
"adminPassword": {
"type": "string"
},
"adminUsername": {
"type": "string"
},
"key": {
"type": "array",
"items": {
"type": "integer"
}
},
"methods": {
"$ref": "#/definitions/settings.LoginMethods"
},
"signup": {
"type": "boolean"
},
"tokenExpirationHours": {
"type": "integer"
}
}
},
"settings.ExternalLink": {
"type": "object",
"required": [
"text",
"url"
],
"properties": {
"text": {
"type": "string"
@@ -1754,6 +1764,264 @@ const docTemplate = `{
}
}
},
"settings.IndexFilter": {
"type": "object",
"properties": {
"fileEndsWith": {
"type": "array",
"items": {
"type": "string"
}
},
"files": {
"type": "array",
"items": {
"type": "string"
}
},
"folders": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"settings.Integrations": {
"type": "object",
"properties": {
"office": {
"$ref": "#/definitions/settings.OnlyOffice"
}
}
},
"settings.LogConfig": {
"type": "object",
"properties": {
"apiLevels": {
"type": "string"
},
"json": {
"type": "boolean"
},
"levels": {
"type": "string"
},
"noColors": {
"type": "boolean"
},
"output": {
"type": "string"
}
}
},
"settings.LoginMethods": {
"type": "object",
"properties": {
"noauth": {
"type": "boolean"
},
"password": {
"$ref": "#/definitions/settings.PasswordAuthConfig"
},
"proxy": {
"$ref": "#/definitions/settings.ProxyAuthConfig"
}
}
},
"settings.OnlyOffice": {
"type": "object",
"required": [
"secret",
"url"
],
"properties": {
"secret": {
"type": "string"
},
"url": {
"type": "string"
}
}
},
"settings.PasswordAuthConfig": {
"type": "object",
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
},
"minLength": {
"type": "integer"
}
}
},
"settings.ProxyAuthConfig": {
"type": "object",
"required": [
"enabled"
],
"properties": {
"createUser": {
"type": "boolean"
},
"enabled": {
"type": "boolean"
},
"header": {
"type": "string"
}
}
},
"settings.Server": {
"type": "object",
"required": [
"sources"
],
"properties": {
"baseURL": {
"type": "string"
},
"cacheDir": {
"type": "string"
},
"database": {
"type": "string"
},
"enableThumbnails": {
"type": "boolean"
},
"externalUrl": {
"type": "string"
},
"internalUrl": {
"description": "used by integrations",
"type": "string"
},
"logging": {
"type": "array",
"items": {
"$ref": "#/definitions/settings.LogConfig"
}
},
"maxArchiveSize": {
"type": "integer"
},
"numImageProcessors": {
"type": "integer"
},
"port": {
"type": "integer"
},
"resizePreview": {
"type": "boolean"
},
"socket": {
"type": "string"
},
"sources": {
"type": "array",
"items": {
"$ref": "#/definitions/settings.Source"
}
},
"tlsCert": {
"type": "string"
},
"tlsKey": {
"type": "string"
}
}
},
"settings.Settings": {
"type": "object",
"properties": {
"auth": {
"$ref": "#/definitions/settings.Auth"
},
"frontend": {
"$ref": "#/definitions/settings.Frontend"
},
"integrations": {
"$ref": "#/definitions/settings.Integrations"
},
"server": {
"$ref": "#/definitions/settings.Server"
},
"userDefaults": {
"$ref": "#/definitions/settings.UserDefaults"
},
"users": {
"type": "array",
"items": {
"$ref": "#/definitions/settings.UserDefaults"
}
}
}
},
"settings.Source": {
"type": "object",
"required": [
"path"
],
"properties": {
"config": {
"$ref": "#/definitions/settings.SourceConfig"
},
"name": {
"description": "display name",
"type": "string"
},
"path": {
"description": "file system path. (Can be relative)",
"type": "string"
}
}
},
"settings.SourceConfig": {
"type": "object",
"properties": {
"createUserDir": {
"type": "boolean"
},
"defaultEnabled": {
"type": "boolean"
},
"defaultUserScope": {
"description": "default \"\" should match folders under path",
"type": "string"
},
"disabled": {
"type": "boolean"
},
"exclude": {
"$ref": "#/definitions/settings.IndexFilter"
},
"ignoreHidden": {
"type": "boolean"
},
"ignoreZeroSizeFolders": {
"type": "boolean"
},
"include": {
"$ref": "#/definitions/settings.IndexFilter"
},
"indexingInterval": {
"type": "integer"
},
"maxWatchers": {
"type": "integer"
},
"neverWatchPaths": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"settings.UserDefaults": {
"type": "object",
"properties": {
@@ -1784,10 +2052,6 @@ const docTemplate = `{
"quickDownload": {
"type": "boolean"
},
"scope": {
"description": "deprecated",
"type": "string"
},
"showHidden": {
"type": "boolean"
},
+289 -25
View File
@@ -902,7 +902,7 @@
"200": {
"description": "System settings data",
"schema": {
"$ref": "#/definitions/http.settingsData"
"$ref": "#/definitions/settings.Settings"
}
}
}
@@ -1678,26 +1678,6 @@
}
}
},
"http.settingsData": {
"type": "object",
"properties": {
"createUserDir": {
"type": "boolean"
},
"defaults": {
"$ref": "#/definitions/settings.UserDefaults"
},
"frontend": {
"$ref": "#/definitions/settings.Frontend"
},
"signup": {
"type": "boolean"
},
"userHomeBasePath": {
"type": "string"
}
}
},
"http.signupBody": {
"type": "object",
"properties": {
@@ -1709,8 +1689,38 @@
}
}
},
"settings.Auth": {
"type": "object",
"properties": {
"adminPassword": {
"type": "string"
},
"adminUsername": {
"type": "string"
},
"key": {
"type": "array",
"items": {
"type": "integer"
}
},
"methods": {
"$ref": "#/definitions/settings.LoginMethods"
},
"signup": {
"type": "boolean"
},
"tokenExpirationHours": {
"type": "integer"
}
}
},
"settings.ExternalLink": {
"type": "object",
"required": [
"text",
"url"
],
"properties": {
"text": {
"type": "string"
@@ -1743,6 +1753,264 @@
}
}
},
"settings.IndexFilter": {
"type": "object",
"properties": {
"fileEndsWith": {
"type": "array",
"items": {
"type": "string"
}
},
"files": {
"type": "array",
"items": {
"type": "string"
}
},
"folders": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"settings.Integrations": {
"type": "object",
"properties": {
"office": {
"$ref": "#/definitions/settings.OnlyOffice"
}
}
},
"settings.LogConfig": {
"type": "object",
"properties": {
"apiLevels": {
"type": "string"
},
"json": {
"type": "boolean"
},
"levels": {
"type": "string"
},
"noColors": {
"type": "boolean"
},
"output": {
"type": "string"
}
}
},
"settings.LoginMethods": {
"type": "object",
"properties": {
"noauth": {
"type": "boolean"
},
"password": {
"$ref": "#/definitions/settings.PasswordAuthConfig"
},
"proxy": {
"$ref": "#/definitions/settings.ProxyAuthConfig"
}
}
},
"settings.OnlyOffice": {
"type": "object",
"required": [
"secret",
"url"
],
"properties": {
"secret": {
"type": "string"
},
"url": {
"type": "string"
}
}
},
"settings.PasswordAuthConfig": {
"type": "object",
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
},
"minLength": {
"type": "integer"
}
}
},
"settings.ProxyAuthConfig": {
"type": "object",
"required": [
"enabled"
],
"properties": {
"createUser": {
"type": "boolean"
},
"enabled": {
"type": "boolean"
},
"header": {
"type": "string"
}
}
},
"settings.Server": {
"type": "object",
"required": [
"sources"
],
"properties": {
"baseURL": {
"type": "string"
},
"cacheDir": {
"type": "string"
},
"database": {
"type": "string"
},
"enableThumbnails": {
"type": "boolean"
},
"externalUrl": {
"type": "string"
},
"internalUrl": {
"description": "used by integrations",
"type": "string"
},
"logging": {
"type": "array",
"items": {
"$ref": "#/definitions/settings.LogConfig"
}
},
"maxArchiveSize": {
"type": "integer"
},
"numImageProcessors": {
"type": "integer"
},
"port": {
"type": "integer"
},
"resizePreview": {
"type": "boolean"
},
"socket": {
"type": "string"
},
"sources": {
"type": "array",
"items": {
"$ref": "#/definitions/settings.Source"
}
},
"tlsCert": {
"type": "string"
},
"tlsKey": {
"type": "string"
}
}
},
"settings.Settings": {
"type": "object",
"properties": {
"auth": {
"$ref": "#/definitions/settings.Auth"
},
"frontend": {
"$ref": "#/definitions/settings.Frontend"
},
"integrations": {
"$ref": "#/definitions/settings.Integrations"
},
"server": {
"$ref": "#/definitions/settings.Server"
},
"userDefaults": {
"$ref": "#/definitions/settings.UserDefaults"
},
"users": {
"type": "array",
"items": {
"$ref": "#/definitions/settings.UserDefaults"
}
}
}
},
"settings.Source": {
"type": "object",
"required": [
"path"
],
"properties": {
"config": {
"$ref": "#/definitions/settings.SourceConfig"
},
"name": {
"description": "display name",
"type": "string"
},
"path": {
"description": "file system path. (Can be relative)",
"type": "string"
}
}
},
"settings.SourceConfig": {
"type": "object",
"properties": {
"createUserDir": {
"type": "boolean"
},
"defaultEnabled": {
"type": "boolean"
},
"defaultUserScope": {
"description": "default \"\" should match folders under path",
"type": "string"
},
"disabled": {
"type": "boolean"
},
"exclude": {
"$ref": "#/definitions/settings.IndexFilter"
},
"ignoreHidden": {
"type": "boolean"
},
"ignoreZeroSizeFolders": {
"type": "boolean"
},
"include": {
"$ref": "#/definitions/settings.IndexFilter"
},
"indexingInterval": {
"type": "integer"
},
"maxWatchers": {
"type": "integer"
},
"neverWatchPaths": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"settings.UserDefaults": {
"type": "object",
"properties": {
@@ -1773,10 +2041,6 @@
"quickDownload": {
"type": "boolean"
},
"scope": {
"description": "deprecated",
"type": "string"
},
"showHidden": {
"type": "boolean"
},
+191 -17
View File
@@ -86,19 +86,6 @@ definitions:
token:
type: string
type: object
http.settingsData:
properties:
createUserDir:
type: boolean
defaults:
$ref: '#/definitions/settings.UserDefaults'
frontend:
$ref: '#/definitions/settings.Frontend'
signup:
type: boolean
userHomeBasePath:
type: string
type: object
http.signupBody:
properties:
password:
@@ -106,6 +93,23 @@ definitions:
username:
type: string
type: object
settings.Auth:
properties:
adminPassword:
type: string
adminUsername:
type: string
key:
items:
type: integer
type: array
methods:
$ref: '#/definitions/settings.LoginMethods'
signup:
type: boolean
tokenExpirationHours:
type: integer
type: object
settings.ExternalLink:
properties:
text:
@@ -114,6 +118,9 @@ definitions:
type: string
url:
type: string
required:
- text
- url
type: object
settings.Frontend:
properties:
@@ -128,6 +135,176 @@ definitions:
name:
type: string
type: object
settings.IndexFilter:
properties:
fileEndsWith:
items:
type: string
type: array
files:
items:
type: string
type: array
folders:
items:
type: string
type: array
type: object
settings.Integrations:
properties:
office:
$ref: '#/definitions/settings.OnlyOffice'
type: object
settings.LogConfig:
properties:
apiLevels:
type: string
json:
type: boolean
levels:
type: string
noColors:
type: boolean
output:
type: string
type: object
settings.LoginMethods:
properties:
noauth:
type: boolean
password:
$ref: '#/definitions/settings.PasswordAuthConfig'
proxy:
$ref: '#/definitions/settings.ProxyAuthConfig'
type: object
settings.OnlyOffice:
properties:
secret:
type: string
url:
type: string
required:
- secret
- url
type: object
settings.PasswordAuthConfig:
properties:
enabled:
type: boolean
minLength:
type: integer
required:
- enabled
type: object
settings.ProxyAuthConfig:
properties:
createUser:
type: boolean
enabled:
type: boolean
header:
type: string
required:
- enabled
type: object
settings.Server:
properties:
baseURL:
type: string
cacheDir:
type: string
database:
type: string
enableThumbnails:
type: boolean
externalUrl:
type: string
internalUrl:
description: used by integrations
type: string
logging:
items:
$ref: '#/definitions/settings.LogConfig'
type: array
maxArchiveSize:
type: integer
numImageProcessors:
type: integer
port:
type: integer
resizePreview:
type: boolean
socket:
type: string
sources:
items:
$ref: '#/definitions/settings.Source'
type: array
tlsCert:
type: string
tlsKey:
type: string
required:
- sources
type: object
settings.Settings:
properties:
auth:
$ref: '#/definitions/settings.Auth'
frontend:
$ref: '#/definitions/settings.Frontend'
integrations:
$ref: '#/definitions/settings.Integrations'
server:
$ref: '#/definitions/settings.Server'
userDefaults:
$ref: '#/definitions/settings.UserDefaults'
users:
items:
$ref: '#/definitions/settings.UserDefaults'
type: array
type: object
settings.Source:
properties:
config:
$ref: '#/definitions/settings.SourceConfig'
name:
description: display name
type: string
path:
description: file system path. (Can be relative)
type: string
required:
- path
type: object
settings.SourceConfig:
properties:
createUserDir:
type: boolean
defaultEnabled:
type: boolean
defaultUserScope:
description: default "" should match folders under path
type: string
disabled:
type: boolean
exclude:
$ref: '#/definitions/settings.IndexFilter'
ignoreHidden:
type: boolean
ignoreZeroSizeFolders:
type: boolean
include:
$ref: '#/definitions/settings.IndexFilter'
indexingInterval:
type: integer
maxWatchers:
type: integer
neverWatchPaths:
items:
type: string
type: array
type: object
settings.UserDefaults:
properties:
darkMode:
@@ -148,9 +325,6 @@ definitions:
$ref: '#/definitions/users.Permissions'
quickDownload:
type: boolean
scope:
description: deprecated
type: string
showHidden:
type: boolean
singleClick:
@@ -892,7 +1066,7 @@ paths:
"200":
description: System settings data
schema:
$ref: '#/definitions/http.settingsData'
$ref: '#/definitions/settings.Settings'
summary: Get system settings
tags:
- Settings
+3 -3
View File
@@ -21,13 +21,13 @@ func CheckErr(source string, err error) {
}
}
func GenerateKey() []byte {
func GenerateKey() string {
b := make([]byte, 64)
_, err := rand.Read(b)
if err != nil {
return nil
return ""
}
return b
return string(b)
}
// CapitalizeFirst returns the input string with the first letter capitalized.
-4
View File
@@ -5,10 +5,6 @@
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<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 }}
<title>{{ if .Name }}{{ .Name }}{{ else }}FileBrowser Quantum{{ end }}</title>
<link rel="icon" type="image/png" sizes="256x256" href="{{ .StaticURL }}/img/icons/favicon-256x256.png">
+2 -2
View File
@@ -5,8 +5,8 @@ const disableUsedPercentage = window.FileBrowser.DisableUsedPercentage;
const baseURL = window.FileBrowser.BaseURL;
const staticURL = window.FileBrowser.StaticURL;
const darkMode = window.FileBrowser.darkMode;
const recaptcha = window.FileBrowser.ReCaptcha;
const recaptchaKey = window.FileBrowser.ReCaptchaKey;
const recaptcha = false;
const recaptchaKey = "";
const signup = window.FileBrowser.Signup;
const version = window.FileBrowser.Version;
const commitSHA = window.FileBrowser.CommitSHA;
+1 -1
View File
@@ -919,7 +919,7 @@ export default {
// if control or shift is pressed, do not clear the selection
if (this.ctrKeyPressed || event.shiftKey) return;
const sameAsBefore = state.selected == this.lastSelected;
if (sameAsBefore && !state.multiple && getters.currentPromptName == null) {
if (sameAsBefore && !state.multiple && getters.currentPromptName() == null) {
mutations.resetSelected();
}
this.lastSelected = state.selected;
@@ -39,6 +39,30 @@ test("open from search", async({ page, checkForErrors, context }) => {
checkForErrors();
})
test("open nested file in /files dir from search", async({ page, checkForErrors, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('#search').click()
await page.locator('#main-input').fill('graham');
await expect(page.locator('#result-list')).toHaveCount(1);
await page.locator('li[aria-label="graham.xlsx"]').click();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - graham.xlsx");
await expect(page.locator('#previewer')).toContainText('Preview is not available for this file.');
checkForErrors();
})
test("open markdown file from search", async({ page, checkForErrors, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('#search').click()
await page.locator('#main-input').fill('for testing');
await expect(page.locator('#result-list')).toHaveCount(1);
await page.locator('li[aria-label="for testing.md"]').click();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - for testing.md");
await expect(page.locator('#markedown-viewer')).toContainText('this is a test');
checkForErrors();
})
test("2x copy from listing to new folder", async({ page, checkForErrors, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
Whitespace-only changes.
+2
View File
@@ -7,6 +7,8 @@ setup:
fi
echo "installing swagger needed to generate backend api docs..."
go install github.com/swaggo/swag/cmd/swag@latest
echo "installing golangci-lint needed to lint backend code..."
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
echo "installing npm requirements for frontend..."
cd frontend && npm i