Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
103ff1c924 | ||
|
|
3226990c6f | ||
|
|
eed3652f75 | ||
|
|
baf40e9f54 | ||
|
|
075db41ab6 | ||
|
|
bedd445b9f | ||
|
|
390b381b4f | ||
|
|
adc50d04b0 | ||
|
|
ec1b12f709 | ||
|
|
3683f37077 | ||
|
|
7e68db9858 | ||
|
|
34f6bea461 | ||
|
|
1e35473998 | ||
|
|
bd4d108b58 | ||
|
|
288ea8f59b | ||
|
|
f18c388c88 | ||
|
|
e922a92aa6 | ||
|
|
237769fa9e | ||
|
|
9a33e7b379 | ||
|
|
cd3e111e39 | ||
|
|
9cc30f25e4 | ||
|
|
7ab400eb7f | ||
|
|
4a4eb7ce31 | ||
|
|
f864790175 | ||
|
|
6494ca1991 | ||
|
|
93925d8430 | ||
|
|
0270711a22 | ||
|
|
d2a3e50d37 | ||
|
|
266a76459d | ||
|
|
a79d44582f | ||
|
|
f5450b06e8 | ||
|
|
15e8b4602f | ||
|
|
21c9e321b3 | ||
|
|
9a011d3807 | ||
|
|
1ef28e1a1b |
No files matched your search
@@ -6,7 +6,32 @@ on:
|
||||
- "main"
|
||||
|
||||
jobs:
|
||||
test_frontend:
|
||||
name: Test Playwright
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- 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
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go build -o filebrowser .
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.playwright
|
||||
push: false
|
||||
push_latest_to_registry:
|
||||
needs: [ test_frontend ]
|
||||
name: Push latest
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -14,9 +39,9 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Find latest tag
|
||||
- name: Find latest beta tag
|
||||
run: |
|
||||
echo "LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`)" >> $GITHUB_ENV
|
||||
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
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
name: pr-request
|
||||
name: PR Request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "main"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "dev/v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "beta/v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "stable/v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
jobs:
|
||||
test_frontend:
|
||||
name: Push release
|
||||
name: Test Playwright
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -17,6 +18,14 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go build -o filebrowser .
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: dev tests
|
||||
name: regular tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "*"
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
test-backend:
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22.5'
|
||||
go-version: 'stable'
|
||||
- uses: golangci/golangci-lint-action@v5
|
||||
with:
|
||||
version: v1.60
|
||||
@@ -41,4 +41,11 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i eslint && npm run lint
|
||||
run: npm i && npm run lint
|
||||
test-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run test
|
||||
@@ -0,0 +1,107 @@
|
||||
name: beta release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "beta/v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test_frontend:
|
||||
name: Test Playwright
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- 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
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go build -o filebrowser .
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.playwright
|
||||
push: false
|
||||
create_release_tag:
|
||||
needs: [ test_frontend ]
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
- name: Extract branch name
|
||||
shell: bash
|
||||
run: |
|
||||
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
|
||||
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
|
||||
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
|
||||
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
|
||||
id: extract_branch
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
target_commitish: ${{ steps.extract_branch.outputs.branch_name }}
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
prerelease: false # change this to false when stable gets released
|
||||
make_latest: true # change this to false when stable gets released
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
|
||||
push_release_to_registry:
|
||||
needs: [ test_frontend ]
|
||||
name: Push release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- 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: Strip v from version number
|
||||
id: modify-json
|
||||
run: |
|
||||
JSON="${{ steps.meta.outputs.tags }}"
|
||||
# Use jq to remove 'v' from the version field
|
||||
JSON=$(echo "$JSON" | sed 's/filebrowser:beta\/v/filebrowser:beta_v/')
|
||||
echo "cleaned_tag=$JSON" >> $GITHUB_OUTPUT
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -3,14 +3,14 @@ name: dev release
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "dev_v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "dev/v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
push_release_to_registry:
|
||||
name: Push dev release
|
||||
name: Push release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -34,17 +34,17 @@ jobs:
|
||||
run: |
|
||||
JSON="${{ steps.meta.outputs.tags }}"
|
||||
# Use jq to remove 'v' from the version field
|
||||
JSON=$(echo "$JSON" | sed 's/filebrowser:v/filebrowser:/')
|
||||
echo "CLEANED_TAG=$JSON" >> $GITHUB_ENV
|
||||
JSON=$(echo "$JSON" | sed 's/filebrowser:dev\/v/filebrowser:dev_v/')
|
||||
echo "cleaned_tag=$JSON" >> $GITHUB_OUTPUT
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ env.CLEANED_TAG }}
|
||||
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -1,15 +1,40 @@
|
||||
name: version release
|
||||
name: stable release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "stable/v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
create_release:
|
||||
test_frontend:
|
||||
name: Test Playwright
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- 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
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go build -o filebrowser .
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.playwright
|
||||
push: false
|
||||
create_release_tag:
|
||||
needs: [ test_frontend ]
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -19,23 +44,28 @@ jobs:
|
||||
token: ${{ secrets.PAT }}
|
||||
- name: Extract branch name
|
||||
shell: bash
|
||||
run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
|
||||
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
|
||||
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-stable
|
||||
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
|
||||
id: extract_branch
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.22.2
|
||||
go-version: 'stable'
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
target_commitish: ${{ steps.extract_branch.outputs.branch }}
|
||||
target_commitish: ${{ steps.extract_branch.outputs.branch_name }}
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_branch.outputs.branch }}
|
||||
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
prerelease: false
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
name: ${{ steps.extract_branch.outputs.branch }}
|
||||
name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
|
||||
push_release_to_registry:
|
||||
needs: [ test_frontend ]
|
||||
name: Push release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -60,7 +90,7 @@ jobs:
|
||||
run: |
|
||||
JSON="${{ steps.meta.outputs.tags }}"
|
||||
# Use jq to remove 'v' from the version field
|
||||
JSON=$(echo "$JSON" | sed 's/filebrowser:v/filebrowser:/')
|
||||
JSON=$(echo "$JSON" | sed 's/filebrowser:stable\/v/filebrowser:/')
|
||||
echo "cleaned_tag=$JSON" >> $GITHUB_OUTPUT
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -1,9 +1,9 @@
|
||||
name: tag
|
||||
name: tag update
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
+10
-2
@@ -3,17 +3,19 @@
|
||||
_old
|
||||
rice-box.go
|
||||
.idea/
|
||||
/filebrowser
|
||||
/filebrowser.exe
|
||||
/backend
|
||||
/backend.exe
|
||||
/frontend/dist
|
||||
/frontend/pkg
|
||||
/frontend/test-results
|
||||
/frontend/loginAuth.json
|
||||
/frontend/package-lock.json
|
||||
/backend/vendor
|
||||
/backend/*.cov
|
||||
/backend/test_config.yaml
|
||||
/backend/srv
|
||||
/backend/http/dist
|
||||
/backend/http/embed/*
|
||||
|
||||
.DS_Store
|
||||
node_modules
|
||||
@@ -37,3 +39,9 @@ yarn-error.log*
|
||||
*.sw*
|
||||
bin/
|
||||
build/
|
||||
|
||||
# docs
|
||||
|
||||
/autodocs/public
|
||||
/autodocs/themes
|
||||
/autodocs/resources
|
||||
Whitespace-only changes.
+135
-1
@@ -2,6 +2,140 @@
|
||||
|
||||
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.4.0-beta
|
||||
|
||||
**New Features**
|
||||
- Better logging
|
||||
- highly configurable
|
||||
- api logs include user
|
||||
- openoffice support
|
||||
|
||||
## v0.3.7-beta
|
||||
|
||||
**Notes**:
|
||||
- Adding windows builds back to automated process... will replace manually if they throw malicious defender warnings.
|
||||
- Adding playwright tests to all pr's against dev/beta/release branches.
|
||||
- These playwright tests should help keep release more reliably stable.
|
||||
|
||||
**Bugfixes**:
|
||||
- closing with the default bar issue.
|
||||
- tar.gz archive creation issue
|
||||
|
||||
## v0.3.6-beta
|
||||
|
||||
**New Features**
|
||||
- Adds "externalUrl" server config https://github.com/gtsteffaniak/filebrowser/issues/272
|
||||
|
||||
**Notes**:
|
||||
- All views modes to show header bar for sorting.
|
||||
- other small style changes
|
||||
|
||||
**Bugfixes**:
|
||||
- select and info bug after sorting https://github.com/gtsteffaniak/filebrowser/issues/277
|
||||
- downloading from shares with public user
|
||||
- Ctrl and Shift key modifiers work on listing views as expected.
|
||||
- copy/move file/folder error and show errors https://github.com/gtsteffaniak/filebrowser/issues/278
|
||||
- file move/copy context fix.
|
||||
|
||||
## v0.3.5
|
||||
|
||||
**New Features**
|
||||
- More indexing configuration options possible. However consider waiting on using this feature, because I will soon have a full onboarding experience in the UI to manage sources instead.
|
||||
- added config file options "sources" in the server config.
|
||||
- can enable/disable indexing a specified list of directories/files
|
||||
- can enable/disable indexing hidden files
|
||||
- prepped for multiple sources (not supported yet!)
|
||||
- Theme and Branding support (see updates to [configuration wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration) on how to use)
|
||||
- Automatically expire shares https://github.com/gtsteffaniak/filebrowser/issues/208
|
||||
|
||||
**Notes**:
|
||||
- MacOS application files (ending in ".app") were previously treated as folders, now they are treated as a single file.
|
||||
- No longer indexes "$RECYCLE.BIN" or "System Volume Information" directories.
|
||||
- Icon styling tweaked so all icons have a background.
|
||||
- Updated Login page styling.
|
||||
- Settings profile menu has been simplified, password changes happen in user management.
|
||||
- Improved windows compatibility and built on windows platform to fix false windows defender warning.
|
||||
- If no "root" location is provided in the server config, the default is the **current directory** (rather than `/srv` like before)
|
||||
|
||||
**Bugfixes**:
|
||||
- Fixed setting share expiration time would not work due to type conversion error.
|
||||
- More safari fixes related to text-selection.
|
||||
- Sort by name value sorting ignores the extension, only sorts by name https://github.com/gtsteffaniak/filebrowser/issues/230
|
||||
- Fixed manual language selection issue.
|
||||
- Fixed exact date time issue.
|
||||
|
||||
New login page:
|
||||
|
||||
<img width="300" alt="image" src="https://github.com/user-attachments/assets/d3ed359e-a969-4f6a-9f72-94d2b68aba49" />
|
||||
|
||||
|
||||
Example branding in sidebar:
|
||||
|
||||
<img width="500" alt="image2" src="https://github.com/user-attachments/assets/d8ee14ca-4495-4106-9d26-631a5937e134" />
|
||||
|
||||
Example user settings page:
|
||||
|
||||
<img width="500" alt="image3" src="https://github.com/user-attachments/assets/79757a11-669e-4597-bd3d-e41efd667a1e" />
|
||||
|
||||
## v0.3.4
|
||||
|
||||
**Bugfixes**:
|
||||
- Safari right-click actions.
|
||||
- Some small image viewer behavior
|
||||
- Progressive webapp "install to homescreen" fix.
|
||||
|
||||
## v0.3.3
|
||||
|
||||
**New Features**
|
||||
- Navigating remembers your previous scroll position when opening items and then navigating backwards.
|
||||
- New Icons with larger selection of file types
|
||||
- file "type" is shown on item info page.
|
||||
- added optional non-root "filebrowser" user for docker image. See https://github.com/gtsteffaniak/filebrowser/issues/251
|
||||
- File preview supports more file types:
|
||||
- images: jpg, bmp, gif, tiff, png, svg, heic, webp
|
||||
|
||||
**Notes**:
|
||||
- The file "type" is now either "directory" or a specific mimetype such as "text/xml".
|
||||
- update safari styling
|
||||
|
||||
**Bugfixes**:
|
||||
- Delete/move file/folders sometimes wouldn't work.
|
||||
- Possible fix for context menu not showing issue. See https://github.com/gtsteffaniak/filebrowser/issues/251
|
||||
- Fixed drag/drop not refreshing immediately to reflect changes.
|
||||
|
||||
## v0.3.2
|
||||
|
||||
**New Features**
|
||||
- Mobile search has the same features as desktop.
|
||||
|
||||
**Notes**:
|
||||
- Added compression. Helpful for browsing folders with a large number of items. Considering https://github.com/gtsteffaniak/filebrowser/issues/201 resolved, although future pagination support will still come.
|
||||
- Compressed download options limited to `.zip` and `.tar.gz`
|
||||
- right-click context menu stays in view.
|
||||
|
||||
**Bugfixes**:
|
||||
- search result links when non-default baseUrl configured
|
||||
- frontend sort bug squashed https://github.com/gtsteffaniak/filebrowser/issues/230
|
||||
- bug which caused "noauth" method not to work after v0.3.0 routes update
|
||||
|
||||
## v0.3.1
|
||||
|
||||
**New Features**
|
||||
- Adds Smart Indexing by default.
|
||||
|
||||
**Notes**:
|
||||
- Optimized api request response times via improved caching and simplified actions.
|
||||
- User information persists more reliably.
|
||||
- Added [indexing doc](./docs/indexing.md) to explain the expectations around indexing and how it works.
|
||||
- The index should also use less RAM than it did in v0.3.0.
|
||||
|
||||
**Bugfixes**:
|
||||
- Tweaked sorting by name, fixes case sensitive and numeric sorting. https://github.com/gtsteffaniak/filebrowser/issues/230
|
||||
- Fixed unnecessary authentication status checks each route change
|
||||
- Fix create file action issue.
|
||||
- some small javascript related issues.
|
||||
- Fixes pretty big bug viewing raw content in v0.3.0 (utf format message)
|
||||
|
||||
## v0.3.0
|
||||
|
||||
This Release focuses on the API and making it more accessible for developers to access functions without the UI.
|
||||
@@ -153,7 +287,7 @@ This change focuses on minimizing and simplifying build process.
|
||||
- The shell feature has been deprecated.
|
||||
- Custom commands can be executed within the Docker container if needed.
|
||||
- The JSON config file is no longer used.
|
||||
- All configurations are now performed via the advanced `filebrowser.yaml`.
|
||||
- All configurations are now performed via the advanced `config.yaml`.
|
||||
- The only allowed flag is specifying the config file.
|
||||
- Removed old code for migrating database versions.
|
||||
- Eliminated all unused `cmd` code.
|
||||
|
||||
+8
-4
@@ -6,8 +6,8 @@ COPY ./backend ./
|
||||
#RUN swag init --output swagger/docs
|
||||
RUN ln -s swagger /usr/local/go/src/
|
||||
RUN go build -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/version.CommitSHA=${REVISION}'" \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA=${REVISION}'" \
|
||||
-o filebrowser .
|
||||
|
||||
FROM node:slim AS nbuild
|
||||
@@ -20,8 +20,12 @@ RUN npm run build-docker
|
||||
FROM alpine:latest
|
||||
ENV FILEBROWSER_NO_EMBEDED="true"
|
||||
RUN apk --no-cache add ca-certificates mailcap
|
||||
COPY --from=base /app/filebrowser* ./
|
||||
WORKDIR /home/filebrowser
|
||||
RUN adduser -D -s /bin/true -u 1000 filebrowser
|
||||
USER filebrowser
|
||||
COPY --from=base --chown=filebrowser:1000 /app/filebrowser* ./
|
||||
COPY --from=nbuild --chown=filebrowser:1000 /app/dist/ ./http/dist/
|
||||
USER root
|
||||
# exposing default port for auto discovery.
|
||||
EXPOSE 80
|
||||
COPY --from=nbuild /app/dist/ ./http/dist/
|
||||
ENTRYPOINT [ "./filebrowser" ]
|
||||
+4
-18
@@ -1,19 +1,5 @@
|
||||
FROM golang:1.23-alpine AS base
|
||||
FROM gtstef/playwright-base
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN go build -ldflags="-w -s" -o filebrowser .
|
||||
|
||||
FROM node:slim
|
||||
WORKDIR /app
|
||||
COPY ./frontend/package.json ./
|
||||
RUN npm i --maxsockets 1
|
||||
RUN npx playwright install --with-deps firefox
|
||||
COPY [ "backend/filebrowser.yaml", "./" ]
|
||||
COPY ./frontend/ ./frontend
|
||||
WORKDIR /app/frontend
|
||||
RUN npm run build-docker
|
||||
WORKDIR /app
|
||||
COPY --from=base /app/filebrowser* ./
|
||||
RUN cp -R frontend/tests/ srv
|
||||
ENV FILEBROWSER_NO_EMBEDED="true"
|
||||
RUN ./filebrowser & sleep 2 && cd frontend && npx playwright test
|
||||
COPY [ "./backend/filebrowser*", "./"]
|
||||
COPY [ "./frontend/", "./" ]
|
||||
RUN ./filebrowser -c filebrowser-playwright.yaml & sleep 2 && npx playwright test
|
||||
@@ -0,0 +1,5 @@
|
||||
FROM node:22-slim
|
||||
WORKDIR /app
|
||||
COPY ./frontend/package.json ./
|
||||
RUN npm i @playwright/test
|
||||
RUN npx playwright install --with-deps firefox
|
||||
@@ -6,45 +6,51 @@
|
||||
</p>
|
||||
<h3 align="center">FileBrowser Quantum - A modern web-based file manager</h3>
|
||||
<p align="center">
|
||||
<img width="800" src="https://github.com/user-attachments/assets/e4a47229-66f8-4838-9575-dd2413596688" title="Main Screenshot">
|
||||
<img width="800" src="https://github.com/user-attachments/assets/b16acd67-0292-437a-a06c-bc83f95758e6" title="Main Screenshot">
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> Starting with `v0.3.0` API routes have been slightly altered for friendly usage outside of the UI.
|
||||
> If on windows, please use docker. The windows binary is unstable and may not work.
|
||||
> There is no stable version yet.
|
||||
> (planned for later this year after these are complete: multiple sources support, initial onboarding page, official automated docs website)
|
||||
|
||||
FileBrowser Quantum is a fork of the file browser opensource project with the following changes:
|
||||
FileBrowser Quantum is a fork of the file browser open-source project with the following changes:
|
||||
|
||||
1. [x] Efficiently indexed files
|
||||
1. ✅ Indexes files efficiently. (See [indexing Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.)
|
||||
- Real-time search results as you type
|
||||
- Search Works with more type filters
|
||||
- Enhanced interactive results page.
|
||||
- file/folder sizes are shown in the response
|
||||
1. [x] Revamped and simplified GUI navbar and sidebar menu.
|
||||
- Additional compact view mode as well as refreshed view mode
|
||||
styles.
|
||||
1. [x] Revamped and simplified configuration via `filebrowser.yml` config file.
|
||||
1. [x] Better listing browsing
|
||||
- Switching view modes is instant
|
||||
- Folder sizes are shown as well
|
||||
- Changing Sort order is instant
|
||||
- The entire directory is loaded in 1/3 the time
|
||||
1. Developer API support
|
||||
- Can create long-live API Tokens.
|
||||
- Search supports file/folder sizes and many file type filters.
|
||||
- Enhanced interactive results that show file/folder sizes.
|
||||
2. ✅ Revamped and simplified GUI navbar and sidebar menu.
|
||||
- Additional compact view mode as well as refreshed view mode styles.
|
||||
- Many graphical and user experience improvements.
|
||||
- right-click context menu
|
||||
3. ✅ Revamped and simplified configuration via `config.yaml` config file.
|
||||
4. ✅ Better listing browsing
|
||||
- Instantly Switches view modes and sort order without reloading data.
|
||||
- Folder sizes are displayed
|
||||
- Navigating remembers the scroll position, navigating back keeps the last scroll position.
|
||||
5. ✅ Developer API support
|
||||
- Ability to create long-live API Tokens.
|
||||
- Helpful Swagger page available at `/swagger` endpoint.
|
||||
|
||||
Notable features that this fork *does not* have (removed):
|
||||
|
||||
- ❌ jobs/runners are not supported yet (planned).
|
||||
- ❌ per-user rules are not supported yet (planned).
|
||||
- ❌ pagination for directory items for extremely large directories.
|
||||
- ❌ shell commands are completely removed and will not be returned.
|
||||
- see feature matrix below for more.
|
||||
|
||||
## About
|
||||
|
||||
FileBrowser Quantum provides a file-managing interface within a specified directory
|
||||
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
|
||||
directory.
|
||||
It allows the creation of multiple users and each user can have its directory.
|
||||
|
||||
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
|
||||
aesthetics and performance. Improved search, simplified UI
|
||||
(without removing features) and more secure and up-to-date
|
||||
build are just a few examples.
|
||||
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
|
||||
aesthetics and performance. Improved search, simplified UI
|
||||
(without removing features) and more secure and up-to-date
|
||||
build are just a few examples.
|
||||
|
||||
FileBrowser Quantum differs significantly from the original.
|
||||
There are hundreds of thousands of lines changed and they are generally
|
||||
@@ -55,10 +61,11 @@ focus of this fork is on a few key principles:
|
||||
- Minimize external dependencies and standard library usage.
|
||||
- Of course -- adding much-needed features.
|
||||
|
||||
For more, see the [Q&A Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A)
|
||||
|
||||
## Look
|
||||
|
||||
One way you can observe the improved user experience is how I changed
|
||||
the UI. The Navbar is simplified to a three-component system :
|
||||
The UI has a simple three-component navigation system :
|
||||
|
||||
1. (Left) The slide-out action panel button
|
||||
2. (Middle) The powerful search bar.
|
||||
@@ -72,107 +79,29 @@ a popup menu.
|
||||
<p align="center">
|
||||
<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/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/93b019de-d38f-4aaa-bde3-3ba4e99ecd25" title="Main Screenshot">
|
||||
</p>
|
||||
|
||||
## Install
|
||||
## Install and Configuration
|
||||
|
||||
Using docker:
|
||||
|
||||
1. docker run (no persistent db):
|
||||
|
||||
```
|
||||
docker run -it -v /path/to/folder:/srv -p 80:80 gtstef/filebrowser
|
||||
```
|
||||
|
||||
1. docker compose:
|
||||
|
||||
- with local storage
|
||||
|
||||
```
|
||||
services:
|
||||
filebrowser:
|
||||
volumes:
|
||||
- '/path/to/folder:/srv' # required (for now not configurable)
|
||||
- './database:/database' # optional if you want db to persist - configure a path under "database" dir in config file.
|
||||
- './filebrowser.yaml:/filebrowser.yaml' # required
|
||||
ports:
|
||||
- '80:80'
|
||||
image: gtstef/filebrowser
|
||||
restart: always
|
||||
```
|
||||
|
||||
- with network share
|
||||
|
||||
```
|
||||
services:
|
||||
filebrowser:
|
||||
volumes:
|
||||
- 'storage:/srv' # required (for now not configurable)
|
||||
- './database:/database' # optional if you want db to persist - configure a path under "database" dir in config file.
|
||||
- './filebrowser.yaml:/filebrowser.yaml' # required
|
||||
ports:
|
||||
- '80:80'
|
||||
image: gtstef/filebrowser
|
||||
restart: always
|
||||
volumes:
|
||||
storage:
|
||||
driver_opts:
|
||||
type: cifs
|
||||
o: "username=admin,password=password,rw" # enter valid info here
|
||||
device: "//192.168.1.100/share/" # enter valid info here
|
||||
|
||||
```
|
||||
|
||||
Not using docker (not recommended), download your binary from releases and run with your custom config file:
|
||||
|
||||
```
|
||||
./filebrowser -c <filebrowser.yml or other /path/to/config.yaml>
|
||||
```
|
||||
See the [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration)
|
||||
|
||||
## Command Line Usage
|
||||
|
||||
There are very few commands available. There are 3 actions done via command line:
|
||||
|
||||
1. Running the program, as shown on the install step. The only argument used is the config file, if you choose to override default "filebrowser.yaml"
|
||||
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"]`
|
||||
See the [CLI Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/CLI)
|
||||
|
||||
## 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:
|
||||
|
||||
<p align="center"><img width="500" alt="image" src="https://github.com/user-attachments/assets/4f18fa8a-8d87-4f40-9dc7-3d4407769b59"></p>
|
||||
|
||||
Failed Request
|
||||
|
||||
<p align="center"><img width="500" alt="image" src="https://github.com/user-attachments/assets/4da0deae-f93d-4d94-83b1-68806afb343a"></p>
|
||||
|
||||
See the [API Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/API)
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is now done via a single configuration file:
|
||||
`filebrowser.yaml`, here is an example of minimal [configuration
|
||||
file](./backend/filebrowser.yaml).
|
||||
|
||||
View the [Configuration Help Page](./docs/configuration.md) for available
|
||||
configuration options and other help.
|
||||
Configuration is done via the `config.yaml`, see the [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration) for available configuration options and other help.
|
||||
|
||||
|
||||
## Migration from the original filebrowser
|
||||
|
||||
If you currently use the original filebrowser but want to try using this.
|
||||
I would recommend that you start fresh without reusing the database. However,
|
||||
If you want to migrate your existing database to FileBrowser Quantum,
|
||||
visit the [migration
|
||||
readme](./docs/migration.md)
|
||||
See the [Migration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Migration)
|
||||
|
||||
## Comparison Chart
|
||||
|
||||
@@ -189,12 +118,12 @@ webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
ftp support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Dedicated docs site? | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Multiple sources at once | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Docker image size | 22 MB | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
|
||||
Docker image size | 31 MB | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
|
||||
Min. Memory Requirements | 128 MB | 128 MB | 128 MB (main image) | 128 MB | ❌ | 4 GB |
|
||||
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
price | free | free | free | free tier | free tier | $99+ |
|
||||
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Advanced Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
|
||||
Indexed Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
|
||||
Content-aware search? | ❌ | ❌ | ❌ | configurable | ✅ | ✅ |
|
||||
@@ -215,7 +144,7 @@ file space quotas | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
|
||||
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
office file support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
Branding support | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
Branding support | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
activity log | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
Comments support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
collaboration on same file | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
@@ -224,7 +153,6 @@ Starred/pinned files | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
|
||||
Content preview icons | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ |
|
||||
Plugins support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
|
||||
## Roadmap
|
||||
|
||||
see [Roadmap Page](./docs/roadmap.md)
|
||||
Share collections of files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can archive selected files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can browse archive files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅
|
||||
Whitespace-only changes.
@@ -0,0 +1,5 @@
|
||||
+++
|
||||
date = '{{ .Date }}'
|
||||
draft = true
|
||||
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
|
||||
+++
|
||||
@@ -1,6 +1,109 @@
|
||||
# Configuration Help
|
||||
---
|
||||
weight: 200
|
||||
title: "Installation"
|
||||
description: "How to install Filebrowser"
|
||||
icon: "settings"
|
||||
date: "2025-01-15"
|
||||
lastmod: "2025-01-15"
|
||||
draft: false
|
||||
---
|
||||
|
||||
This document covers the available configuration options, their defaults, and how they affect the functionality of filebrowser.
|
||||
## Install
|
||||
|
||||
Using docker:
|
||||
|
||||
1. docker run (no persistent db):
|
||||
|
||||
```
|
||||
docker run -it -v /path/to/folder:/srv -v $(pwd)/config.yaml:/home/filebrowser/config.yaml -p 80:80 gtstef/filebrowser
|
||||
```
|
||||
|
||||
or optionally, as non-root filebrowser user:
|
||||
|
||||
```
|
||||
docker run -u filebrowser -it -v $(pwd)/config.yaml:/home/filebrowser/config.yaml -v /path/to/folder:/srv -p 80:80 gtstef/filebrowser
|
||||
```
|
||||
|
||||
1. docker compose:
|
||||
|
||||
- with local storage
|
||||
|
||||
```
|
||||
services:
|
||||
filebrowser:
|
||||
volumes:
|
||||
- '/path/to/folder:/srv' # required (for now not configurable)
|
||||
# optional if you want db to persist - configure a path under "database" dir in config file.
|
||||
- './database:/home/filebrowser/database'
|
||||
- './config.yaml:/home/filebrowser/config.yaml'
|
||||
ports:
|
||||
- '80:80'
|
||||
image: gtstef/filebrowser
|
||||
# optionally run as non-root filebrowser user
|
||||
#user: filebrowser
|
||||
restart: always
|
||||
```
|
||||
|
||||
- with network share
|
||||
|
||||
```
|
||||
services:
|
||||
filebrowser:
|
||||
volumes:
|
||||
- 'storage:/srv' # required (for now not configurable)
|
||||
# optional if you want db to persist - configure a path under "database" dir in config file.
|
||||
- './database:/home/filebrowser/database'
|
||||
- './config.yaml:/home/filebrowser/config.yaml'
|
||||
ports:
|
||||
- '80:80'
|
||||
image: gtstef/filebrowser
|
||||
restart: always
|
||||
volumes:
|
||||
storage:
|
||||
driver_opts:
|
||||
type: cifs
|
||||
o: "username=admin,password=password,rw" # enter valid info here
|
||||
device: "//192.168.1.100/share/" # enter valid info here
|
||||
|
||||
```
|
||||
|
||||
Not using docker (not recommended), download your binary from releases and run with your custom config file:
|
||||
|
||||
```
|
||||
./filebrowser -c <config.yaml or other /path/to/config.yaml>
|
||||
```
|
||||
|
||||
## Example frontend config:
|
||||
```
|
||||
frontend:
|
||||
name: "Your custom name"
|
||||
disableDefaultLinks: true
|
||||
externalLinks:
|
||||
- text: "Your custom link"
|
||||
url: "https://example.com"
|
||||
```
|
||||
|
||||
## Example advanced source config:
|
||||
```
|
||||
server:
|
||||
port: 80
|
||||
sources:
|
||||
- name: "default"
|
||||
path: "/mnt/folder"
|
||||
config:
|
||||
ignoreHidden: true
|
||||
ignoreZeroSizeFolders: true
|
||||
exclude:
|
||||
files:
|
||||
- "/myfile.txt" # for example, corresponds to "/mnt/folder/myfile.txt"
|
||||
- "/subfolder/another.txt"
|
||||
folders:
|
||||
- "/subfolder/ignoreMe"
|
||||
fileEndsWith:
|
||||
- ".zip" # excludes any files that end with ".zip"
|
||||
- ".tar.gz"
|
||||
- "-hidden.jpg"
|
||||
```
|
||||
|
||||
## All possible configurations
|
||||
|
||||
@@ -10,7 +113,6 @@ Here is an expanded config file which includes all possible configurations:
|
||||
server:
|
||||
CreateUserDir: false
|
||||
UserHomeBasePath: ""
|
||||
indexingInterval: 5
|
||||
indexing: true
|
||||
numImageProcessors: 4
|
||||
socket: ""
|
||||
@@ -25,6 +127,7 @@ server:
|
||||
log: "stdout"
|
||||
database: "/database/database.db"
|
||||
root: "/srv"
|
||||
sources: # optional only needed for custom index configuration, can use root still for default index config.
|
||||
auth:
|
||||
adminUsername: admin
|
||||
adminPassword: admin
|
||||
@@ -38,9 +141,12 @@ auth:
|
||||
signup: false
|
||||
frontend:
|
||||
name: ""
|
||||
disableExternal: false
|
||||
disableDefaultLinks: false
|
||||
disableUsedPercentage: true
|
||||
files: ""
|
||||
externalLinks:
|
||||
- text: "Your custom link"
|
||||
url: "https://example.com"
|
||||
title: "this text appears when hovering over link"
|
||||
color: ""
|
||||
userDefaults:
|
||||
settingsAllowed: true
|
||||
@@ -49,9 +155,6 @@ userDefaults:
|
||||
locale: "en"
|
||||
viewMode: ""
|
||||
singleClick: true
|
||||
sorting:
|
||||
by: ""
|
||||
asc: true
|
||||
permissions:
|
||||
admin: true
|
||||
execute: true
|
||||
@@ -61,6 +164,7 @@ userDefaults:
|
||||
delete: true
|
||||
share: true
|
||||
download: true
|
||||
api: true
|
||||
hideDotfiles: false
|
||||
dateFormat: false
|
||||
```
|
||||
@@ -71,13 +175,13 @@ Here are the defaults if nothing is set:
|
||||
server:
|
||||
enableThumbnails: true
|
||||
enableExec: false
|
||||
indexingInterval: 5
|
||||
port: 80
|
||||
numImageProcessors: 4
|
||||
baseURL: ""
|
||||
database: database.db
|
||||
log: stdout
|
||||
root: /srv
|
||||
sources:
|
||||
auth:
|
||||
adminUsername: admin
|
||||
adminPassword: admin
|
||||
@@ -87,7 +191,6 @@ auth:
|
||||
userDefaults:
|
||||
settingsAllowed: true
|
||||
darkMode: false
|
||||
scope: ""
|
||||
locale: "en"
|
||||
scope: "."
|
||||
lockPassword: false
|
||||
@@ -99,6 +202,7 @@ userDefaults:
|
||||
delete: true
|
||||
share: true
|
||||
download: true
|
||||
api: false
|
||||
```
|
||||
|
||||
## About each configuration
|
||||
@@ -109,7 +213,7 @@ userDefaults:
|
||||
|
||||
### Server configuration settings
|
||||
|
||||
- `indexingInterval`: This is the time in minutes the system waits before checking for filesystem changes. Default: `5`
|
||||
- `indexingInterval`: This optional paramter disables smart indexing and specifies a time in minutes the system waits before checking for filesystem changes. See [indexing readme](indexing.md) for more information.
|
||||
|
||||
- `indexing`: This enables or disables indexing. (Note: search will not work without indexing) Default: `true`
|
||||
|
||||
@@ -143,6 +247,9 @@ userDefaults:
|
||||
|
||||
- `UserHomeBasePath`: String to define user home directory base path. Default: `""`
|
||||
|
||||
- `externalUrl`: String to define a domain and potentially additional subpath for shared links to use. Example "https://mydomain.com/", Default: `""`
|
||||
|
||||
|
||||
### Auth configuration settings
|
||||
|
||||
- `recaptcha`:
|
||||
@@ -224,4 +331,4 @@ userDefaults:
|
||||
|
||||
- `hideDotfiles`: This boolean value determines whether dotfiles are hidden. (`true` or `false`)
|
||||
|
||||
- `dateFormat`: This boolean value determines whether date formatting is enabled. (`true` or `false`)
|
||||
- `dateFormat`: This boolean value determines whether date formatting is enabled. (`true` or `false`)
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
weight: 200
|
||||
title: "API"
|
||||
description: "API Help"
|
||||
icon: "edit"
|
||||
date: "2025-01-15"
|
||||
lastmod: "2025-01-15"
|
||||
draft: false
|
||||
---
|
||||
|
||||
API tokens can be created to perform actions, access file information, and update user settings just like what can be done from the UI. You can create API tokens from the settings page via "API Management" section. This section will only show up if the user has "API" permissions, which can be granted by editing the user in user management.
|
||||
|
||||
Regardless of whether a user has API permissions, anyone can visit the swagger page which is found at `/swagger`. This swagger page uses a short-live token (2-hour exp) that the UI uses, but allows for quick access to all the API's and their described usage and requirements:
|
||||
|
||||

|
||||
|
||||
When using the API outside of swagger, you will need to set the API token as a bearer token authentication type. This means the authorization header will look like `Authorization: Bearer <token>`. For example in Postman:
|
||||
|
||||
Successful Request:
|
||||
|
||||
<p align="center"><img width="500" alt="image" src="https://github.com/user-attachments/assets/4f18fa8a-8d87-4f40-9dc7-3d4407769b59"></p>
|
||||
|
||||
Failed Request
|
||||
|
||||
<p align="center"><img width="500" alt="image" src="https://github.com/user-attachments/assets/4da0deae-f93d-4d94-83b1-68806afb343a"></p>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
weight: 100
|
||||
date: "2023-05-03T22:37:22+01:00"
|
||||
draft: false
|
||||
author: "Colin Wilson"
|
||||
title: "Quickstart"
|
||||
icon: "rocket_launch"
|
||||
description: "A quickstart guide to creating new content in Lotus Docs"
|
||||
publishdate: "2023-05-03T22:37:22+01:00"
|
||||
tags: ["Beginners"]
|
||||
---
|
||||
|
||||
## Requirements
|
||||
@@ -0,0 +1,89 @@
|
||||
# Note: Template blocks require a 'weight' parameter so they're correctly ordered on the landing page
|
||||
|
||||
# Hero
|
||||
hero:
|
||||
enable: true
|
||||
weight: 10
|
||||
width: 100%
|
||||
template: hero
|
||||
|
||||
|
||||
badge:
|
||||
text: "v0.4.0"
|
||||
color: success # primary, secondary, success, danger, warning, info, light, dark
|
||||
pill: true # boolean
|
||||
soft: false # boolean
|
||||
|
||||
titleLogo:
|
||||
path: "images/logos"
|
||||
filename: "title_logo.png"
|
||||
alt: "Lotus Docs Logo"
|
||||
height: 80px
|
||||
|
||||
title: "FileBrowser Quantum"
|
||||
subtitle: A modern self-hosted **file manager** for the web.
|
||||
|
||||
ctaButton:
|
||||
color: success
|
||||
btnText: "Go to the Docs"
|
||||
url: "docs"
|
||||
info: "**Open Source** Apache 2.0 Licensed."
|
||||
|
||||
# Image compare
|
||||
imageCompare:
|
||||
enable: true
|
||||
weight: 30
|
||||
template: image compare
|
||||
|
||||
title: Beautifully Customizable.
|
||||
subtitle: You can make it your own.
|
||||
|
||||
items:
|
||||
- title: Dark Mode
|
||||
config: {
|
||||
startingPoint: 50,
|
||||
addCircle: true,
|
||||
addCircleBlur: false,
|
||||
showLabels: true,
|
||||
labelOptions: {
|
||||
before: 'Dark',
|
||||
after: 'Light',
|
||||
onHover: false
|
||||
}
|
||||
}
|
||||
imagePath: "images/screenshots"
|
||||
imageBefore: "lotusdocs_dark_v0.8.webp"
|
||||
imageAfter: "lotusdocs_light_v0.8.webp"
|
||||
|
||||
- title: Branding
|
||||
config: {
|
||||
controlColor: "#3C4257",
|
||||
startingPoint: 25,
|
||||
addCircle: true,
|
||||
addCircleBlur: false,
|
||||
showLabels: true,
|
||||
labelOptions: {
|
||||
before: 'Inter',
|
||||
after: 'Life Saver',
|
||||
onHover: false
|
||||
}
|
||||
}
|
||||
imagePath: "images/screenshots"
|
||||
imageBefore: "lotusdocs_google_font_demo_inter_screenshot.webp"
|
||||
imageAfter: "lotusdocs_google_font_demo_lifesavers_screenshot.webp"
|
||||
|
||||
- title: Accent Color
|
||||
config: {
|
||||
startingPoint: 25,
|
||||
addCircle: true,
|
||||
addCircleBlur: true,
|
||||
showLabels: true,
|
||||
labelOptions: {
|
||||
before: 'Blue',
|
||||
after: 'Cardinal',
|
||||
onHover: false
|
||||
}
|
||||
}
|
||||
imagePath: "images/screenshots"
|
||||
imageBefore: "lotusdocs_blue_theme_colour.webp"
|
||||
imageAfter: "lotusdocs_cardinal_theme_colour.webp"
|
||||
@@ -0,0 +1,8 @@
|
||||
module github.com/gtsteffaniak/filebrowser
|
||||
|
||||
go 1.22.5
|
||||
|
||||
require (
|
||||
github.com/gohugoio/hugo-mod-bootstrap-scss/v5 v5.20300.20200 // indirect
|
||||
github.com/gtsteffaniak/filebrowserDocsTheme v0.0.0-20250118144715-40025a28c8d5 // indirect
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
github.com/gohugoio/hugo-mod-bootstrap-scss/v5 v5.20300.20200 h1:SmpwwN3DNzJWbV+IT8gaFu07ENUFpCvKou5BHYUKuVs=
|
||||
github.com/gohugoio/hugo-mod-bootstrap-scss/v5 v5.20300.20200/go.mod h1:kx8MBj9T7SFR8ZClWvKZPmmUxBaltkoXvnWlZZcSnYA=
|
||||
github.com/gohugoio/hugo-mod-jslibs-dist/popperjs/v2 v2.21100.20000/go.mod h1:mFberT6ZtcchrsDtfvJM7aAH2bDKLdOnruUHl0hlapI=
|
||||
github.com/gtsteffaniak/filebrowserDocsTheme v0.0.0-20250118144715-40025a28c8d5 h1:dwZ1GlVhtfgigYUoPHZd+PBczUgl7FpSJrxMhPQXXJQ=
|
||||
github.com/gtsteffaniak/filebrowserDocsTheme v0.0.0-20250118144715-40025a28c8d5/go.mod h1:9Ee1u+ac9+utA3Tt3SnoIYmoewMlx7uf/WLb0CVZ2ys=
|
||||
github.com/twbs/bootstrap v5.3.2+incompatible/go.mod h1:fZTSrkpSf0/HkL0IIJzvVspTt1r9zuf7XlZau8kpcY0=
|
||||
@@ -0,0 +1,8 @@
|
||||
baseURL = 'https://example.org/'
|
||||
languageCode = 'en-us'
|
||||
title = 'Docs'
|
||||
|
||||
[module]
|
||||
[[module.imports]]
|
||||
path = "github.com/gtsteffaniak/filebrowserDocsTheme"
|
||||
disable = false
|
||||
@@ -6,7 +6,7 @@ builds:
|
||||
# Build configuration for darwin and linux
|
||||
- id: default
|
||||
ldflags: &ldflags
|
||||
- -s -w -X github.com/gtsteffaniak/filebrowser/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/version.CommitSHA={{ .ShortCommit }}
|
||||
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA={{ .ShortCommit }}
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
goos:
|
||||
@@ -17,11 +17,11 @@ builds:
|
||||
- arm
|
||||
- arm64
|
||||
goarm:
|
||||
- 6
|
||||
- 7
|
||||
- "6"
|
||||
- "7"
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }} # Compress the binary with UPX
|
||||
post:
|
||||
- upx --force-macos {{ .Path }} # Compress the binary with UPX
|
||||
|
||||
# Build configuration for windows without arm
|
||||
- id: windows
|
||||
@@ -32,7 +32,9 @@ builds:
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
hooks:
|
||||
post:
|
||||
- upx {{ .Path }} # Compress the binary with UPX
|
||||
|
||||
archives:
|
||||
- name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
|
||||
|
||||
@@ -3,7 +3,7 @@ package auth
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
// Auther is the authentication interface.
|
||||
|
||||
@@ -3,15 +3,15 @@ package auth
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
type hookCred struct {
|
||||
@@ -164,7 +164,7 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
|
||||
return nil, fmt.Errorf("user: failed to mkdir user home dir: [%s]", userHome)
|
||||
}
|
||||
u.Scope = userHome
|
||||
log.Printf("user: %s, home dir: [%s].", u.Username, userHome)
|
||||
logger.Debug(fmt.Sprintf("user: %s, home dir: [%s].", u.Username, userHome))
|
||||
|
||||
err = a.Users.Save(u)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
type jsonCred struct {
|
||||
@@ -24,7 +24,6 @@ type JSONAuth struct {
|
||||
|
||||
// Auth authenticates the user via a json in content body.
|
||||
func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
|
||||
config := &settings.Config
|
||||
var cred jsonCred
|
||||
|
||||
if r.Body == nil {
|
||||
@@ -47,7 +46,7 @@ func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User,
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
}
|
||||
u, err := userStore.Get(config.Server.Root, cred.Username)
|
||||
u, err := userStore.Get(files.RootPaths["default"], cred.Username)
|
||||
if err != nil || !users.CheckPwd(cred.Password, u.Password) {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ package auth
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
// MethodNoAuth is used to identify no auth.
|
||||
@@ -15,7 +15,7 @@ type NoAuth struct{}
|
||||
|
||||
// Auth uses authenticates user 1.
|
||||
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(files.RootPaths["default"], uint(1))
|
||||
}
|
||||
|
||||
// LoginPage tells that no auth doesn't require a login page.
|
||||
|
||||
@@ -4,10 +4,9 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
// MethodProxyAuth is used to identify no auth.
|
||||
@@ -21,7 +20,7 @@ type ProxyAuth struct {
|
||||
// Auth authenticates the user via an HTTP header.
|
||||
func (a ProxyAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
|
||||
username := r.Header.Get(a.Header)
|
||||
user, err := usr.Get(settings.Config.Server.Root, username)
|
||||
user, err := usr.Get(files.RootPaths["default"], username)
|
||||
if err == errors.ErrNotExist {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
// StorageBackend is a storage backend for auth storage.
|
||||
|
||||
@@ -1,43 +1,66 @@
|
||||
|
||||
== Running benchmark ==
|
||||
/usr/local/go/bin/go
|
||||
? github.com/gtsteffaniak/filebrowser [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/auth [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/cmd [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/auth [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/cmd [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/diskcache 0.004s
|
||||
? github.com/gtsteffaniak/filebrowser/errors [no test files]
|
||||
2024/10/07 12:46:34 could not update unknown type: unknown
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/diskcache 0.005s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/errors [no test files]
|
||||
/home/graham/git/filebrowser /home/graham/git/filebrowser
|
||||
/home/graham/git/filebrowser/files/file.go /home/graham/git/filebrowser
|
||||
/home/graham/git/filebrowser/mnt/doesnt/exist /home/graham/git/filebrowser
|
||||
2025/01/04 14:04:55 Initializing index and assessing file system complexity
|
||||
2025/01/04 14:04:55 Starting full scan
|
||||
2025/01/04 14:04:55 Index assessment : complexity=simple directories=0 files=0
|
||||
2025/01/04 14:04:55 Time Spent Indexing : 0 seconds
|
||||
2025/01/04 14:04:55 Next scan in 1m0s
|
||||
2025/01/04 14:04:56 Initializing index and assessing file system complexity
|
||||
2025/01/04 14:04:56 Starting full scan
|
||||
2025/01/04 14:04:56 Index assessment : complexity=simple directories=0 files=0
|
||||
2025/01/04 14:04:56 Time Spent Indexing : 0 seconds
|
||||
2025/01/04 14:04:56 Next scan in 1m0s
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
pkg: github.com/gtsteffaniak/filebrowser/files
|
||||
pkg: github.com/gtsteffaniak/filebrowser/backend/files
|
||||
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
|
||||
BenchmarkFillIndex-8 10 3847878 ns/op 758424 B/op 5567 allocs/op
|
||||
BenchmarkSearchAllIndexes-8 10 780431 ns/op 173444 B/op 2014 allocs/op
|
||||
BenchmarkFillIndex-8 2025/01/04 14:04:57 Initializing index and assessing file system complexity
|
||||
2025/01/04 14:04:57 Starting full scan
|
||||
2025/01/04 14:04:57 Index assessment : complexity=simple directories=0 files=0
|
||||
2025/01/04 14:04:57 Time Spent Indexing : 0 seconds
|
||||
2025/01/04 14:04:57 Next scan in 1m0s
|
||||
10 3515090 ns/op 34273 B/op 451 allocs/op
|
||||
BenchmarkCheckIndexExclude-8 10 156.2 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkCheckIndexConditionsInclude-8 10 98.00 ns/op 0 B/op 0 allocs/op
|
||||
2025/01/04 14:04:58 Initializing index and assessing file system complexity
|
||||
2025/01/04 14:04:58 Starting full scan
|
||||
2025/01/04 14:04:58 Index assessment : complexity=simple directories=0 files=0
|
||||
2025/01/04 14:04:58 Time Spent Indexing : 0 seconds
|
||||
2025/01/04 14:04:58 Next scan in 1m0s
|
||||
BenchmarkSearchAllIndexes-8 2025/01/04 14:04:59 Initializing index and assessing file system complexity
|
||||
2025/01/04 14:04:59 Starting full scan
|
||||
2025/01/04 14:04:59 Index assessment : complexity=simple directories=0 files=0
|
||||
2025/01/04 14:04:59 Time Spent Indexing : 0 seconds
|
||||
2025/01/04 14:04:59 Next scan in 1m0s
|
||||
10 766822 ns/op 34230 B/op 900 allocs/op
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/files 0.073s
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/files 5.094s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/fileutils 0.003s
|
||||
2024/10/07 12:46:34 h: 401 <nil>
|
||||
2024/10/07 12:46:34 h: 401 <nil>
|
||||
2024/10/07 12:46:34 h: 401 <nil>
|
||||
2024/10/07 12:46:34 h: 401 <nil>
|
||||
2024/10/07 12:46:34 h: 401 <nil>
|
||||
2024/10/07 12:46:34 h: 401 <nil>
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/fileutils 0.002s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/http 0.080s
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/http 0.184s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/img 0.137s
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/img 0.123s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/rules 0.002s
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/runner 0.004s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/runner 0.003s
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/settings 0.005s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/share [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/storage [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/storage/bolt [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/swagger/docs [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/settings 0.004s
|
||||
? github.com/gtsteffaniak/filebrowser/share [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/storage [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/storage/bolt [no test files]
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/users 0.003s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/users 0.002s
|
||||
? github.com/gtsteffaniak/filebrowser/utils [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/version [no test files]
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/utils 0.002s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/version [no test files]
|
||||
+50
-42
@@ -3,21 +3,21 @@ package cmd
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/diskcache"
|
||||
"github.com/gtsteffaniak/filebrowser/files"
|
||||
fbhttp "github.com/gtsteffaniak/filebrowser/http"
|
||||
"github.com/gtsteffaniak/filebrowser/img"
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/swagger/docs"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/diskcache"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/files"
|
||||
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/img"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
|
||||
"github.com/swaggo/swag"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/version"
|
||||
)
|
||||
|
||||
func getStore(config string) (*storage.Storage, bool) {
|
||||
@@ -25,20 +25,22 @@ func getStore(config string) (*storage.Storage, bool) {
|
||||
settings.Initialize(config)
|
||||
store, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
|
||||
if err != nil {
|
||||
log.Fatal("could not load db info: ", err)
|
||||
logger.Fatal(fmt.Sprintf("could not load db info: %v", err))
|
||||
}
|
||||
return store, hasDB
|
||||
}
|
||||
|
||||
func generalUsage() {
|
||||
fmt.Printf(`usage: ./html-web-crawler <command> [options] --urls <urls>
|
||||
commands:
|
||||
collect Collect data from URLs
|
||||
crawl Crawl URLs and collect data
|
||||
install Install chrome browser for javascript enabled scraping.
|
||||
Note: Consider instead to install via native package manager,
|
||||
then set "CHROME_EXECUTABLE" in the environment
|
||||
` + "\n")
|
||||
fmt.Printf(`usage: ./filebrowser <command> [options]
|
||||
commands:
|
||||
-h Print help
|
||||
-c Print the default config file
|
||||
version Print version information
|
||||
set -u Username and password for the new user
|
||||
set -a Create user as admin
|
||||
set -s Specify a user scope
|
||||
set -h Print this help message
|
||||
`)
|
||||
}
|
||||
|
||||
func StartFilebrowser() {
|
||||
@@ -47,7 +49,7 @@ func StartFilebrowser() {
|
||||
var help bool
|
||||
// Override the default usage output to use generalUsage()
|
||||
flag.Usage = generalUsage
|
||||
flag.StringVar(&configPath, "c", "filebrowser.yaml", "Path to the config file.")
|
||||
flag.StringVar(&configPath, "c", "config.yaml", "Path to the config file, default: config.yaml")
|
||||
flag.BoolVar(&help, "h", false, "Get help about commands")
|
||||
|
||||
// Parse global flags (before subcommands)
|
||||
@@ -67,7 +69,7 @@ func StartFilebrowser() {
|
||||
setCmd.StringVar(&user, "u", "", "Comma-separated username and password: \"set -u <username>,<password>\"")
|
||||
setCmd.BoolVar(&asAdmin, "a", false, "Create user as admin user, used in combination with -u")
|
||||
setCmd.StringVar(&scope, "s", "", "Specify a user scope, otherwise default user config scope is used")
|
||||
setCmd.StringVar(&dbConfig, "c", "filebrowser.yaml", "Path to the config file.")
|
||||
setCmd.StringVar(&dbConfig, "c", "config.yaml", "Path to the config file, default: config.yaml")
|
||||
|
||||
// Parse subcommand flags only if a subcommand is specified
|
||||
if len(os.Args) > 1 {
|
||||
@@ -89,9 +91,9 @@ func StartFilebrowser() {
|
||||
getStore(dbConfig)
|
||||
// Create the user logic
|
||||
if asAdmin {
|
||||
log.Printf("Creating user as admin: %s\n", username)
|
||||
logger.Info(fmt.Sprintf("Creating user as admin: %s\n", username))
|
||||
} else {
|
||||
log.Printf("Creating user: %s\n", username)
|
||||
logger.Info(fmt.Sprintf("Creating non-admin user: %s\n", username))
|
||||
}
|
||||
newUser := users.User{
|
||||
Username: username,
|
||||
@@ -102,47 +104,53 @@ func StartFilebrowser() {
|
||||
}
|
||||
err = storage.CreateUser(newUser, asAdmin)
|
||||
if err != nil {
|
||||
log.Fatal("Could not create user: ", err)
|
||||
logger.Fatal(fmt.Sprintf("could not create user: %v", err))
|
||||
}
|
||||
return
|
||||
case "version":
|
||||
fmt.Println("FileBrowser Quantum - A modern web-based file manager")
|
||||
fmt.Printf("Version : %v\n", version.Version)
|
||||
fmt.Printf("Commit : %v\n", version.CommitSHA)
|
||||
fmt.Printf("Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v\n", version.Version)
|
||||
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
|
||||
Version : %v
|
||||
Commit : %v
|
||||
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
|
||||
`, version.Version, version.CommitSHA, version.Version)
|
||||
return
|
||||
}
|
||||
}
|
||||
store, dbExists := getStore(configPath)
|
||||
indexingInterval := fmt.Sprint(settings.Config.Server.IndexingInterval, " minutes")
|
||||
if !settings.Config.Server.Indexing {
|
||||
indexingInterval = "disabled"
|
||||
}
|
||||
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
|
||||
if !dbExists {
|
||||
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
|
||||
}
|
||||
log.Printf("Initializing FileBrowser Quantum (%v)\n", version.Version)
|
||||
log.Printf("Using Config file : %v", configPath)
|
||||
log.Println("Embeded frontend :", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true")
|
||||
log.Println(database)
|
||||
log.Println("Sources :", settings.Config.Server.Root)
|
||||
log.Println("Indexing interval :", indexingInterval)
|
||||
sources := []string{}
|
||||
for _, v := range settings.Config.Server.Sources {
|
||||
sources = append(sources, v.Name+": "+v.Path)
|
||||
}
|
||||
logger.Info(fmt.Sprintf("Initializing FileBrowser Quantum (%v)", version.Version))
|
||||
logger.Info(fmt.Sprintf("Using Config file : %v", configPath))
|
||||
logger.Debug(fmt.Sprintf("Embeded frontend : %v", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true"))
|
||||
logger.Info(database)
|
||||
logger.Info(fmt.Sprintf("Sources : %v", sources))
|
||||
|
||||
serverConfig := settings.Config.Server
|
||||
swagInfo := docs.SwaggerInfo
|
||||
swagInfo.BasePath = serverConfig.BaseURL
|
||||
swag.Register(docs.SwaggerInfo.InstanceName(), swagInfo)
|
||||
// initialize indexing and schedule indexing ever n minutes (default 5)
|
||||
go files.InitializeIndex(serverConfig.IndexingInterval, serverConfig.Indexing)
|
||||
sourceConfigs := settings.Config.Server.Sources
|
||||
if len(sourceConfigs) == 0 {
|
||||
logger.Fatal("No sources configured, exiting...")
|
||||
}
|
||||
for _, source := range sourceConfigs {
|
||||
go files.Initialize(source)
|
||||
}
|
||||
if err := rootCMD(store, &serverConfig); err != nil {
|
||||
log.Fatal("Error starting filebrowser:", err)
|
||||
logger.Fatal(fmt.Sprintf("Error starting filebrowser: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
func rootCMD(store *storage.Storage, serverConfig *settings.Server) error {
|
||||
if serverConfig.NumImageProcessors < 1 {
|
||||
log.Fatal("Image resize workers count could not be < 1")
|
||||
logger.Fatal("Image resize workers count could not be < 1")
|
||||
}
|
||||
imgSvc := img.New(serverConfig.NumImageProcessors)
|
||||
|
||||
@@ -154,7 +162,7 @@ func rootCMD(store *storage.Storage, serverConfig *settings.Server) error {
|
||||
var err error
|
||||
fileCache, err = diskcache.NewFileCache(cacheDir)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create file cache: %v", err)
|
||||
logger.Fatal(fmt.Sprintf("failed to create file cache: %v", err))
|
||||
}
|
||||
} else {
|
||||
// No-op cache if no cacheDir is specified
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/utils"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rulesCmd.AddCommand(rulesRmCommand)
|
||||
rulesRmCommand.Flags().Uint("index", 0, "index of rule to remove")
|
||||
_ = rulesRmCommand.MarkFlagRequired("index")
|
||||
}
|
||||
|
||||
var rulesRmCommand = &cobra.Command{
|
||||
Use: "rm <index> [index_end]",
|
||||
Short: "Remove a global rule or user rule",
|
||||
Long: `Remove a global rule or user rule. The provided index
|
||||
is the same that's printed when you run 'rules ls'. Note
|
||||
that after each removal/addition, the index of the
|
||||
commands change. So be careful when removing them after each
|
||||
other.
|
||||
|
||||
You can also specify an optional parameter (index_end) so
|
||||
you can remove all commands from 'index' to 'index_end',
|
||||
including 'index_end'.`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if err := cobra.RangeArgs(1, 2)(cmd, args); err != nil { //nolint:gomnd
|
||||
return err
|
||||
}
|
||||
|
||||
for _, arg := range args {
|
||||
if _, err := strconv.Atoi(arg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
|
||||
i, err := strconv.Atoi(args[0])
|
||||
utils.CheckErr("strconv.Atoi", err)
|
||||
f := i
|
||||
if len(args) == 2 { //nolint:gomnd
|
||||
f, err = strconv.Atoi(args[1])
|
||||
utils.CheckErr("strconv.Atoi", err)
|
||||
}
|
||||
|
||||
user := func(u *users.User) {
|
||||
u.Rules = append(u.Rules[:i], u.Rules[f+1:]...)
|
||||
err := store.Users.Save(u)
|
||||
utils.CheckErr("store.Users.Save", err)
|
||||
}
|
||||
|
||||
global := func(s *settings.Settings) {
|
||||
s.Rules = append(s.Rules[:i], s.Rules[f+1:]...)
|
||||
err := store.Settings.Save(s)
|
||||
utils.CheckErr("store.Settings.Save", err)
|
||||
}
|
||||
|
||||
runRules(store, cmd, user, global)
|
||||
}),
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/utils"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rulesCmd.PersistentFlags().StringP("username", "u", "", "username of user to which the rules apply")
|
||||
rulesCmd.PersistentFlags().UintP("id", "i", 0, "id of user to which the rules apply")
|
||||
}
|
||||
|
||||
var rulesCmd = &cobra.Command{
|
||||
Use: "rules",
|
||||
Short: "Rules management utility",
|
||||
Long: `On each subcommand you'll have available at least two flags:
|
||||
"username" and "id". You must either set only one of them
|
||||
or none. If you set one of them, the command will apply to
|
||||
an user, otherwise it will be applied to the global set or
|
||||
rules.`,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
|
||||
func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User), globalFn func(*settings.Settings)) {
|
||||
id := getUserIdentifier(cmd.Flags())
|
||||
if id != nil {
|
||||
user, err := st.Users.Get("", id)
|
||||
utils.CheckErr("st.Users.Get", err)
|
||||
|
||||
if usersFn != nil {
|
||||
usersFn(user)
|
||||
}
|
||||
|
||||
printRules(user.Rules, id)
|
||||
return
|
||||
}
|
||||
|
||||
s, err := st.Settings.Get()
|
||||
utils.CheckErr("st.Settings.Get", err)
|
||||
|
||||
if globalFn != nil {
|
||||
globalFn(s)
|
||||
}
|
||||
|
||||
printRules(s.Rules, id)
|
||||
}
|
||||
|
||||
func getUserIdentifier(flags *pflag.FlagSet) interface{} {
|
||||
id := mustGetUint(flags, "id")
|
||||
username := mustGetString(flags, "username")
|
||||
|
||||
if id != 0 {
|
||||
return id
|
||||
} else if username != "" {
|
||||
return username
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printRules(rulez []users.Rule, id interface{}) {
|
||||
|
||||
for id, rule := range rulez {
|
||||
fmt.Printf("(%d) ", id)
|
||||
if rule.Regex {
|
||||
if rule.Allow {
|
||||
fmt.Printf("Allow Regex: \t%s\n", rule.Regexp.Raw)
|
||||
} else {
|
||||
fmt.Printf("Disallow Regex: \t%s\n", rule.Regexp.Raw)
|
||||
}
|
||||
} else {
|
||||
if rule.Allow {
|
||||
fmt.Printf("Allow Path: \t%s\n", rule.Path)
|
||||
} else {
|
||||
fmt.Printf("Disallow Path: \t%s\n", rule.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/utils"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rulesCmd.AddCommand(rulesAddCmd)
|
||||
rulesAddCmd.Flags().BoolP("allow", "a", false, "indicates this is an allow rule")
|
||||
rulesAddCmd.Flags().BoolP("regex", "r", false, "indicates this is a regex rule")
|
||||
}
|
||||
|
||||
var rulesAddCmd = &cobra.Command{
|
||||
Use: "add <path|expression>",
|
||||
Short: "Add a global rule or user rule",
|
||||
Long: `Add a global rule or user rule.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
|
||||
allow := mustGetBool(cmd.Flags(), "allow")
|
||||
regex := mustGetBool(cmd.Flags(), "regex")
|
||||
exp := args[0]
|
||||
|
||||
if regex {
|
||||
regexp.MustCompile(exp)
|
||||
}
|
||||
|
||||
rule := users.Rule{
|
||||
Allow: allow,
|
||||
Regex: regex,
|
||||
}
|
||||
|
||||
if regex {
|
||||
rule.Regexp = &users.Regexp{Raw: exp}
|
||||
} else {
|
||||
rule.Path = exp
|
||||
}
|
||||
|
||||
user := func(u *users.User) {
|
||||
u.Rules = append(u.Rules, rule)
|
||||
err := store.Users.Save(u)
|
||||
utils.CheckErr("store.Users.Save", err)
|
||||
}
|
||||
|
||||
global := func(s *settings.Settings) {
|
||||
s.Rules = append(s.Rules, rule)
|
||||
err := store.Settings.Save(s)
|
||||
utils.CheckErr("store.Settings.Save", err)
|
||||
}
|
||||
|
||||
runRules(store, cmd, user, global)
|
||||
}),
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rulesCmd.AddCommand(rulesLsCommand)
|
||||
}
|
||||
|
||||
var rulesLsCommand = &cobra.Command{
|
||||
Use: "ls",
|
||||
Short: "List global rules or user specific rules",
|
||||
Long: `List global rules or user specific rules.`,
|
||||
Args: cobra.NoArgs,
|
||||
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
|
||||
runRules(store, cmd, nil, nil)
|
||||
}),
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
)
|
||||
|
||||
var usersCmd = &cobra.Command{
|
||||
Use: "users",
|
||||
Short: "Users management utility",
|
||||
Long: `Users management utility.`,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
|
||||
func printUsers(usrs []*users.User) {
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) //nolint:gomnd
|
||||
fmt.Fprintln(w, "ID\tUsername\tScope\tLocale\tV. Mode\tS.Click\tAdmin\tExecute\tCreate\tRename\tModify\tDelete\tShare\tDownload\tPwd Lock")
|
||||
|
||||
for _, u := range usrs {
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t\n",
|
||||
u.ID,
|
||||
u.Username,
|
||||
u.Scope,
|
||||
u.Locale,
|
||||
u.ViewMode,
|
||||
u.SingleClick,
|
||||
u.Perm.Admin,
|
||||
u.Perm.Execute,
|
||||
u.Perm.Create,
|
||||
u.Perm.Rename,
|
||||
u.Perm.Modify,
|
||||
u.Perm.Delete,
|
||||
u.Perm.Share,
|
||||
u.Perm.Download,
|
||||
u.LockPassword,
|
||||
)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func parseUsernameOrID(arg string) (username string, id uint) {
|
||||
id64, err := strconv.ParseUint(arg, 10, 64)
|
||||
if err != nil {
|
||||
return arg, 0
|
||||
}
|
||||
return "", uint(id64)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/utils"
|
||||
)
|
||||
|
||||
func init() {
|
||||
usersCmd.AddCommand(usersAddCmd)
|
||||
}
|
||||
|
||||
var usersAddCmd = &cobra.Command{
|
||||
Use: "add <username> <password>",
|
||||
Short: "Create a new user",
|
||||
Long: `Create a new user and add it to the database.`,
|
||||
Args: cobra.ExactArgs(2), //nolint:gomnd
|
||||
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
|
||||
user := &users.User{
|
||||
Username: args[0],
|
||||
Password: args[1],
|
||||
LockPassword: mustGetBool(cmd.Flags(), "lockPassword"),
|
||||
}
|
||||
servSettings, err := store.Settings.GetServer()
|
||||
utils.CheckErr("store.Settings.GetServer()", err)
|
||||
// since getUserDefaults() polluted s.Defaults.Scope
|
||||
// which makes the Scope not the one saved in the db
|
||||
// we need the right s.Defaults.Scope here
|
||||
s2, err := store.Settings.Get()
|
||||
utils.CheckErr("store.Settings.Get()", err)
|
||||
|
||||
userHome, err := s2.MakeUserDir(user.Username, user.Scope, servSettings.Root)
|
||||
utils.CheckErr("s2.MakeUserDir", err)
|
||||
user.Scope = userHome
|
||||
|
||||
err = store.Users.Save(user)
|
||||
utils.CheckErr("store.Users.Save", err)
|
||||
printUsers([]*users.User{user})
|
||||
}),
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
usersCmd.AddCommand(usersExportCmd)
|
||||
}
|
||||
|
||||
var usersExportCmd = &cobra.Command{
|
||||
Use: "export <path>",
|
||||
Short: "Export all users to a file.",
|
||||
Long: `Export all users to a json or yaml file. Please indicate the
|
||||
path to the file where you want to write the users.`,
|
||||
Args: jsonYamlArg,
|
||||
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
|
||||
list, err := store.Users.Gets("")
|
||||
utils.CheckErr("store.Users.Gets", err)
|
||||
|
||||
err = marshal(args[0], list)
|
||||
utils.CheckErr("marshal", err)
|
||||
}),
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/utils"
|
||||
)
|
||||
|
||||
func init() {
|
||||
usersCmd.AddCommand(usersFindCmd)
|
||||
usersCmd.AddCommand(usersLsCmd)
|
||||
}
|
||||
|
||||
var usersFindCmd = &cobra.Command{
|
||||
Use: "find <id|username>",
|
||||
Short: "Find a user by username or id",
|
||||
Long: `Find a user by username or id. If no flag is set, all users will be printed.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: findUsers,
|
||||
}
|
||||
|
||||
var usersLsCmd = &cobra.Command{
|
||||
Use: "ls",
|
||||
Short: "List all users.",
|
||||
Args: cobra.NoArgs,
|
||||
Run: findUsers,
|
||||
}
|
||||
|
||||
var findUsers = cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
|
||||
var (
|
||||
list []*users.User
|
||||
user *users.User
|
||||
err error
|
||||
)
|
||||
|
||||
if len(args) == 1 {
|
||||
username, id := parseUsernameOrID(args[0])
|
||||
if username != "" {
|
||||
user, err = store.Users.Get("", username)
|
||||
} else {
|
||||
user, err = store.Users.Get("", id)
|
||||
}
|
||||
|
||||
list = []*users.User{user}
|
||||
} else {
|
||||
list, err = store.Users.Gets("")
|
||||
}
|
||||
|
||||
utils.CheckErr("findUsers", err)
|
||||
printUsers(list)
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/utils"
|
||||
)
|
||||
|
||||
func init() {
|
||||
usersCmd.AddCommand(usersImportCmd)
|
||||
usersImportCmd.Flags().Bool("overwrite", false, "overwrite users with the same id/username combo")
|
||||
usersImportCmd.Flags().Bool("replace", false, "replace the entire user base")
|
||||
}
|
||||
|
||||
var usersImportCmd = &cobra.Command{
|
||||
Use: "import <path>",
|
||||
Short: "Import users from a file",
|
||||
Long: `Import users from a file. The path must be for a json or yaml
|
||||
file. You can use this command to import new users to your
|
||||
installation. For that, just don't place their ID on the files
|
||||
list or set it to 0.`,
|
||||
Args: jsonYamlArg,
|
||||
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
|
||||
fd, err := os.Open(args[0])
|
||||
utils.CheckErr("os.Open", err)
|
||||
defer fd.Close()
|
||||
|
||||
list := []*users.User{}
|
||||
err = unmarshal(args[0], &list)
|
||||
utils.CheckErr("unmarshal", err)
|
||||
|
||||
if mustGetBool(cmd.Flags(), "replace") {
|
||||
oldUsers, err := store.Users.Gets("")
|
||||
utils.CheckErr("store.Users.Gets", err)
|
||||
|
||||
err = marshal("users.backup.json", list)
|
||||
utils.CheckErr("marshal users.backup.json", err)
|
||||
|
||||
for _, user := range oldUsers {
|
||||
err = store.Users.Delete(user.ID)
|
||||
utils.CheckErr("store.Users.Delete", err)
|
||||
}
|
||||
}
|
||||
|
||||
overwrite := mustGetBool(cmd.Flags(), "overwrite")
|
||||
|
||||
for _, user := range list {
|
||||
onDB, err := store.Users.Get("", user.ID)
|
||||
|
||||
// User exists in DB.
|
||||
if err == nil {
|
||||
if !overwrite {
|
||||
newErr := errors.New("user " + strconv.Itoa(int(user.ID)) + " is already registered")
|
||||
utils.CheckErr("", newErr)
|
||||
}
|
||||
|
||||
// If the usernames mismatch, check if there is another one in the DB
|
||||
// with the new username. If there is, print an error and cancel the
|
||||
// operation
|
||||
if user.Username != onDB.Username {
|
||||
if conflictuous, err := store.Users.Get("", user.Username); err == nil { //nolint:govet
|
||||
newErr := usernameConflictError(user.Username, conflictuous.ID, user.ID)
|
||||
utils.CheckErr("usernameConflictError", newErr)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If it doesn't exist, set the ID to 0 to automatically get a new
|
||||
// one that make sense in this DB.
|
||||
user.ID = 0
|
||||
}
|
||||
|
||||
err = store.Users.Save(user)
|
||||
utils.CheckErr("store.Users.Save", err)
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
func usernameConflictError(username string, originalID, newID uint) error {
|
||||
return fmt.Errorf(`can't import user with ID %d and username "%s" because the username is already registred with the user %d`,
|
||||
newID, username, originalID)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
usersCmd.AddCommand(usersRmCmd)
|
||||
}
|
||||
|
||||
var usersRmCmd = &cobra.Command{
|
||||
Use: "rm <id|username>",
|
||||
Short: "Delete a user by username or id",
|
||||
Long: `Delete a user by username or id`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
|
||||
username, id := parseUsernameOrID(args[0])
|
||||
var err error
|
||||
|
||||
if username != "" {
|
||||
err = store.Users.Delete(username)
|
||||
} else {
|
||||
err = store.Users.Delete(id)
|
||||
}
|
||||
|
||||
utils.CheckErr("usersRmCmd", err)
|
||||
log.Println("user deleted successfully")
|
||||
}),
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/utils"
|
||||
)
|
||||
|
||||
func init() {
|
||||
usersCmd.AddCommand(usersUpdateCmd)
|
||||
}
|
||||
|
||||
var usersUpdateCmd = &cobra.Command{
|
||||
Use: "update <id|username>",
|
||||
Short: "Updates an existing user",
|
||||
Long: `Updates an existing user. Set the flags for the
|
||||
options you want to change.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
|
||||
username, id := parseUsernameOrID(args[0])
|
||||
|
||||
var (
|
||||
err error
|
||||
user *users.User
|
||||
)
|
||||
|
||||
if id != 0 {
|
||||
user, err = store.Users.Get("", id)
|
||||
} else {
|
||||
user, err = store.Users.Get("", username)
|
||||
}
|
||||
utils.CheckErr("store.Users.Get", err)
|
||||
|
||||
err = store.Users.Update(user)
|
||||
utils.CheckErr("store.Users.Update", err)
|
||||
printUsers([]*users.User{user})
|
||||
}),
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/utils"
|
||||
)
|
||||
|
||||
func mustGetString(flags *pflag.FlagSet, flag string) string {
|
||||
s, err := flags.GetString(flag)
|
||||
utils.CheckErr("mustGetString", err)
|
||||
return s
|
||||
}
|
||||
|
||||
func mustGetBool(flags *pflag.FlagSet, flag string) bool {
|
||||
b, err := flags.GetBool(flag)
|
||||
utils.CheckErr("mustGetBool", err)
|
||||
return b
|
||||
}
|
||||
|
||||
func mustGetUint(flags *pflag.FlagSet, flag string) uint {
|
||||
b, err := flags.GetUint(flag)
|
||||
utils.CheckErr("mustGetUint", err)
|
||||
return b
|
||||
}
|
||||
|
||||
type cobraFunc func(cmd *cobra.Command, args []string)
|
||||
type pythonFunc func(cmd *cobra.Command, args []string, store *storage.Storage)
|
||||
|
||||
func marshal(filename string, data interface{}) error {
|
||||
fd, err := os.Create(filename)
|
||||
|
||||
utils.CheckErr("os.Create", err)
|
||||
defer fd.Close()
|
||||
|
||||
switch ext := filepath.Ext(filename); ext {
|
||||
case ".json":
|
||||
encoder := json.NewEncoder(fd)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(data)
|
||||
case ".yml", ".yaml": //nolint:goconst
|
||||
_, err := yaml.Marshal(fd)
|
||||
return err
|
||||
default:
|
||||
return errors.New("invalid format: " + ext)
|
||||
}
|
||||
}
|
||||
|
||||
func unmarshal(filename string, data interface{}) error {
|
||||
fd, err := os.Open(filename)
|
||||
utils.CheckErr("os.Open", err)
|
||||
defer fd.Close()
|
||||
|
||||
switch ext := filepath.Ext(filename); ext {
|
||||
case ".json":
|
||||
return json.NewDecoder(fd).Decode(data)
|
||||
case ".yml", ".yaml":
|
||||
return yaml.NewDecoder(fd).Decode(data)
|
||||
default:
|
||||
return errors.New("invalid format: " + ext)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonYamlArg(cmd *cobra.Command, args []string) error {
|
||||
if err := cobra.ExactArgs(1)(cmd, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch ext := filepath.Ext(args[0]); ext {
|
||||
case ".json", ".yml", ".yaml":
|
||||
return nil
|
||||
default:
|
||||
return errors.New("invalid format: " + ext)
|
||||
}
|
||||
}
|
||||
|
||||
func cobraCmd(fn pythonFunc) cobraFunc {
|
||||
return func(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
root: "./srv"
|
||||
root: "/srv"
|
||||
auth:
|
||||
method: password
|
||||
signup: false
|
||||
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
root: "./tests/playwright-files"
|
||||
auth:
|
||||
method: password
|
||||
signup: false
|
||||
userDefaults:
|
||||
darkMode: true
|
||||
disableSettings: false
|
||||
scope: "."
|
||||
hideDotfiles: true
|
||||
singleClick: false
|
||||
permissions:
|
||||
admin: false
|
||||
create: false
|
||||
rename: false
|
||||
modify: false
|
||||
delete: false
|
||||
share: false
|
||||
download: false
|
||||
+92
-17
@@ -14,24 +14,85 @@ var AllFiletypeOptions = []string{
|
||||
"archive",
|
||||
"video",
|
||||
"doc",
|
||||
"dir",
|
||||
"text",
|
||||
}
|
||||
|
||||
// Document file extensions
|
||||
var documentTypes = []string{
|
||||
".word",
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
}
|
||||
var textTypes = []string{
|
||||
".text",
|
||||
".sh",
|
||||
".yaml",
|
||||
".yml",
|
||||
".json",
|
||||
".env",
|
||||
// Common Document Formats
|
||||
".doc", ".docx", // Microsoft Word
|
||||
".pdf", // Portable Document Format
|
||||
".odt", // OpenDocument Text
|
||||
".rtf", // Rich Text Format
|
||||
".conf",
|
||||
".bash_history",
|
||||
".gitignore",
|
||||
".htpasswd",
|
||||
".profile",
|
||||
".dockerignore",
|
||||
".editorconfig",
|
||||
|
||||
// Presentation Formats
|
||||
".ppt", ".pptx", // Microsoft PowerPoint
|
||||
".odp", // OpenDocument Presentation
|
||||
|
||||
// google docs
|
||||
".gdoc",
|
||||
|
||||
// google sheet
|
||||
".gsheet",
|
||||
|
||||
// Spreadsheet Formats
|
||||
".xls", ".xlsx", // Microsoft Excel
|
||||
".ods", // OpenDocument Spreadsheet
|
||||
|
||||
// Other Document Formats
|
||||
".epub", // Electronic Publication
|
||||
".mobi", // Amazon Kindle
|
||||
".fb2", // FictionBook
|
||||
}
|
||||
|
||||
// Text-based file extensions
|
||||
var textTypes = []string{
|
||||
// Common Text Formats
|
||||
".txt",
|
||||
".md", // Markdown
|
||||
|
||||
// Scripting and Programming Languages
|
||||
".sh", // Bash script
|
||||
".py", // Python
|
||||
".js", // JavaScript
|
||||
".ts", // TypeScript
|
||||
".php", // PHP
|
||||
".rb", // Ruby
|
||||
".go", // Go
|
||||
".java", // Java
|
||||
".c", ".cpp", // C/C++
|
||||
".cs", // C#
|
||||
".swift", // Swift
|
||||
|
||||
// Configuration Files
|
||||
".yaml", ".yml", // YAML
|
||||
".json", // JSON
|
||||
".xml", // XML
|
||||
".ini", // INI
|
||||
".toml", // TOML
|
||||
".cfg", // Configuration file
|
||||
|
||||
// Other Text-Based Formats
|
||||
".css", // Cascading Style Sheets
|
||||
".html", ".htm", // HyperText Markup Language
|
||||
".sql", // SQL
|
||||
".csv", // Comma-Separated Values
|
||||
".tsv", // Tab-Separated Values
|
||||
".log", // Log file
|
||||
".bat", // Batch file
|
||||
".ps1", // PowerShell script
|
||||
".tex", // LaTeX
|
||||
".bib", // BibTeX
|
||||
}
|
||||
|
||||
// Compressed file extensions
|
||||
var compressedFile = []string{
|
||||
".7z",
|
||||
".rar",
|
||||
@@ -39,6 +100,12 @@ var compressedFile = []string{
|
||||
".tar",
|
||||
".gz",
|
||||
".xz",
|
||||
".bz2",
|
||||
".tgz", // tar.gz
|
||||
".tbz2", // tar.bz2
|
||||
".lzma",
|
||||
".lz4",
|
||||
".zstd",
|
||||
}
|
||||
|
||||
type SearchOptions struct {
|
||||
@@ -48,8 +115,18 @@ type SearchOptions struct {
|
||||
Terms []string
|
||||
}
|
||||
|
||||
func ParseSearch(value string) *SearchOptions {
|
||||
opts := &SearchOptions{
|
||||
func extendedMimeTypeCheck(extension string) string {
|
||||
if isDoc(extension) {
|
||||
return "application/document"
|
||||
}
|
||||
if isText(extension) {
|
||||
return "text/plain"
|
||||
}
|
||||
return "blob"
|
||||
}
|
||||
|
||||
func ParseSearch(value string) SearchOptions {
|
||||
opts := SearchOptions{
|
||||
Conditions: map[string]bool{
|
||||
"exact": strings.Contains(value, "case:exact"),
|
||||
},
|
||||
@@ -145,8 +222,6 @@ func IsMatchingType(extension string, matchType string) bool {
|
||||
switch matchType {
|
||||
case "doc":
|
||||
return isDoc(extension)
|
||||
case "pdf":
|
||||
return extension == ".pdf"
|
||||
case "text":
|
||||
return isText(extension)
|
||||
case "archive":
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestIsMatchingType(t *testing.T) {
|
||||
extension string
|
||||
expectedType string
|
||||
}{
|
||||
{".pdf", "pdf"},
|
||||
{".pdf", "doc"},
|
||||
{".doc", "doc"},
|
||||
{".docx", "doc"},
|
||||
{".json", "text"},
|
||||
|
||||
+154
-338
@@ -11,17 +11,19 @@ import (
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -29,44 +31,41 @@ var (
|
||||
pathMutexesMu sync.Mutex // Mutex to protect the pathMutexes map
|
||||
)
|
||||
|
||||
type ReducedItem struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime time.Time `json:"modified"`
|
||||
Type string `json:"type"`
|
||||
Mode os.FileMode `json:"-"`
|
||||
Content string `json:"content,omitempty"`
|
||||
type ItemInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime time.Time `json:"modified"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// FileInfo describes a file.
|
||||
// reduced item is non-recursive reduced "Items", used to pass flat items array
|
||||
type FileInfo struct {
|
||||
Files []ReducedItem `json:"-"`
|
||||
Dirs map[string]*FileInfo `json:"-"`
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Items []ReducedItem `json:"items"`
|
||||
Size int64 `json:"size"`
|
||||
Extension string `json:"-"`
|
||||
ModTime time.Time `json:"modified"`
|
||||
CacheTime time.Time `json:"-"`
|
||||
Mode os.FileMode `json:"-"`
|
||||
IsSymlink bool `json:"isSymlink,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Subtitles []string `json:"subtitles,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Checksums map[string]string `json:"checksums,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
ItemInfo
|
||||
Files []ItemInfo `json:"files"`
|
||||
Folders []ItemInfo `json:"folders"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// for efficiency, a response will be a pointer to the data
|
||||
// extra calculated fields can be added here
|
||||
type ExtendedFileInfo struct {
|
||||
*FileInfo
|
||||
Content string `json:"content,omitempty"`
|
||||
Subtitles []string `json:"subtitles,omitempty"`
|
||||
Checksums map[string]string `json:"checksums,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
RealPath string `json:"-"`
|
||||
}
|
||||
|
||||
// FileOptions are the options when getting a file info.
|
||||
type FileOptions struct {
|
||||
Path string // realpath
|
||||
Source string
|
||||
IsDir bool
|
||||
Modify bool
|
||||
Expand bool
|
||||
ReadHeader bool
|
||||
Token string
|
||||
Checker users.Checker
|
||||
Content bool
|
||||
}
|
||||
@@ -75,206 +74,76 @@ func (f FileOptions) Components() (string, string) {
|
||||
return filepath.Dir(f.Path), filepath.Base(f.Path)
|
||||
}
|
||||
|
||||
func FileInfoFaster(opts FileOptions) (*FileInfo, error) {
|
||||
index := GetIndex(rootPath)
|
||||
func FileInfoFaster(opts FileOptions) (ExtendedFileInfo, error) {
|
||||
response := ExtendedFileInfo{}
|
||||
if opts.Source == "" {
|
||||
opts.Source = "default"
|
||||
}
|
||||
index := GetIndex(opts.Source)
|
||||
if index == nil {
|
||||
return response, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
opts.Path = index.makeIndexPath(opts.Path)
|
||||
|
||||
// Lock access for the specific path
|
||||
pathMutex := getMutex(opts.Path)
|
||||
pathMutex.Lock()
|
||||
defer pathMutex.Unlock()
|
||||
if !opts.Checker.Check(opts.Path) {
|
||||
return nil, os.ErrPermission
|
||||
return response, os.ErrPermission
|
||||
}
|
||||
_, isDir, err := GetRealPath(opts.Path)
|
||||
|
||||
realPath, isDir, err := index.GetRealPath(opts.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return response, err
|
||||
}
|
||||
opts.IsDir = isDir
|
||||
// TODO : whats the best way to save trips to disk here?
|
||||
// disabled using cache because its not clear if this is helping or hurting
|
||||
// check if the file exists in the index
|
||||
info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
|
||||
if exists {
|
||||
// Let's not refresh if less than a second has passed
|
||||
if time.Since(info.CacheTime) > time.Second {
|
||||
RefreshFileInfo(opts) //nolint:errcheck
|
||||
}
|
||||
if opts.Content {
|
||||
content := ""
|
||||
content, err = getContent(opts.Path)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
info.Content = content
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
err = RefreshFileInfo(opts)
|
||||
//info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
|
||||
//if exists {
|
||||
// err := RefreshFileInfo(opts)
|
||||
// if err != nil {
|
||||
// return info, err
|
||||
// }
|
||||
// if opts.Content {
|
||||
// content := ""
|
||||
// content, err = getContent(opts.Path)
|
||||
// if err != nil {
|
||||
// return info, err
|
||||
// }
|
||||
// info.Content = content
|
||||
// }
|
||||
// return info, nil
|
||||
//}
|
||||
|
||||
err = index.RefreshFileInfo(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return response, err
|
||||
}
|
||||
info, exists = index.GetReducedMetadata(opts.Path, opts.IsDir)
|
||||
info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
|
||||
if !exists {
|
||||
return nil, err
|
||||
return response, err
|
||||
}
|
||||
if opts.Content {
|
||||
content, err := getContent(opts.Path)
|
||||
content, err := getContent("default", opts.Path)
|
||||
if err != nil {
|
||||
return info, err
|
||||
return response, err
|
||||
}
|
||||
info.Content = content
|
||||
response.Content = content
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func RefreshFileInfo(opts FileOptions) error {
|
||||
refreshOptions := FileOptions{
|
||||
Path: opts.Path,
|
||||
IsDir: opts.IsDir,
|
||||
Token: opts.Token,
|
||||
}
|
||||
index := GetIndex(rootPath)
|
||||
|
||||
if !refreshOptions.IsDir {
|
||||
refreshOptions.Path = index.makeIndexPath(filepath.Dir(refreshOptions.Path))
|
||||
refreshOptions.IsDir = true
|
||||
} else {
|
||||
refreshOptions.Path = index.makeIndexPath(refreshOptions.Path)
|
||||
}
|
||||
|
||||
current, exists := index.GetMetadataInfo(refreshOptions.Path, true)
|
||||
|
||||
file, err := stat(refreshOptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file/folder does not exist to refresh data: %s", refreshOptions.Path)
|
||||
}
|
||||
|
||||
//utils.PrintStructFields(*file)
|
||||
result := index.UpdateMetadata(file)
|
||||
if !result {
|
||||
return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path)
|
||||
}
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
if current.Size != file.Size {
|
||||
index.recursiveUpdateDirSizes(filepath.Dir(refreshOptions.Path), file, current.Size)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stat(opts FileOptions) (*FileInfo, error) {
|
||||
realPath, _, err := GetRealPath(rootPath, opts.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Lstat(realPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := &FileInfo{
|
||||
Path: opts.Path,
|
||||
Name: filepath.Base(opts.Path),
|
||||
ModTime: info.ModTime(),
|
||||
Mode: info.Mode(),
|
||||
Size: info.Size(),
|
||||
Extension: filepath.Ext(info.Name()),
|
||||
Token: opts.Token,
|
||||
}
|
||||
if info.IsDir() {
|
||||
// Open and read directory contents
|
||||
dir, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer dir.Close()
|
||||
|
||||
dirInfo, err := dir.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index := GetIndex(rootPath)
|
||||
// Check cached metadata to decide if refresh is needed
|
||||
cachedParentDir, exists := index.GetMetadataInfo(opts.Path, true)
|
||||
if exists && dirInfo.ModTime().Before(cachedParentDir.CacheTime) {
|
||||
return cachedParentDir, nil
|
||||
}
|
||||
|
||||
// Read directory contents and process
|
||||
files, err := dir.Readdir(-1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file.Files = []ReducedItem{}
|
||||
file.Dirs = map[string]*FileInfo{}
|
||||
|
||||
var totalSize int64
|
||||
for _, item := range files {
|
||||
itemPath := filepath.Join(realPath, item.Name())
|
||||
|
||||
if item.IsDir() {
|
||||
itemInfo := &FileInfo{
|
||||
Name: item.Name(),
|
||||
ModTime: item.ModTime(),
|
||||
Mode: item.Mode(),
|
||||
}
|
||||
|
||||
if exists {
|
||||
// if directory size was already cached use that.
|
||||
cachedDir, ok := cachedParentDir.Dirs[item.Name()]
|
||||
if ok {
|
||||
itemInfo.Size = cachedDir.Size
|
||||
}
|
||||
}
|
||||
file.Dirs[item.Name()] = itemInfo
|
||||
totalSize += itemInfo.Size
|
||||
} else {
|
||||
itemInfo := ReducedItem{
|
||||
Name: item.Name(),
|
||||
Size: item.Size(),
|
||||
ModTime: item.ModTime(),
|
||||
Mode: item.Mode(),
|
||||
}
|
||||
if IsSymlink(item.Mode()) {
|
||||
itemInfo.Type = "symlink"
|
||||
info, err := os.Stat(itemPath)
|
||||
if err == nil {
|
||||
itemInfo.Name = info.Name()
|
||||
itemInfo.ModTime = info.ModTime()
|
||||
itemInfo.Size = info.Size()
|
||||
itemInfo.Mode = info.Mode()
|
||||
} else {
|
||||
file.Type = "invalid_link"
|
||||
}
|
||||
}
|
||||
if file.Type != "invalid_link" {
|
||||
err := itemInfo.detectType(itemPath, true, opts.Content, opts.ReadHeader)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to detect type for %v: %v \n", itemPath, err)
|
||||
}
|
||||
file.Files = append(file.Files, itemInfo)
|
||||
}
|
||||
totalSize += itemInfo.Size
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
file.Size = totalSize
|
||||
}
|
||||
return file, nil
|
||||
response.FileInfo = info
|
||||
response.RealPath = realPath
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Checksum checksums a given File for a given User, using a specific
|
||||
// algorithm. The checksums data is saved on File object.
|
||||
func (i *FileInfo) Checksum(algo string) error {
|
||||
|
||||
if i.Checksums == nil {
|
||||
i.Checksums = map[string]string{}
|
||||
}
|
||||
fullpath := filepath.Join(i.Path, i.Name)
|
||||
reader, err := os.Open(fullpath)
|
||||
func GetChecksum(fullPath, algo string) (map[string]string, error) {
|
||||
subs := map[string]string{}
|
||||
reader, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
return err
|
||||
return subs, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
@@ -287,62 +156,40 @@ func (i *FileInfo) Checksum(algo string) error {
|
||||
|
||||
h, ok := hashFuncs[algo]
|
||||
if !ok {
|
||||
return errors.ErrInvalidOption
|
||||
return subs, errors.ErrInvalidOption
|
||||
}
|
||||
|
||||
_, err = io.Copy(h, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
return subs, err
|
||||
}
|
||||
|
||||
i.Checksums[algo] = hex.EncodeToString(h.Sum(nil))
|
||||
return nil
|
||||
subs[algo] = hex.EncodeToString(h.Sum(nil))
|
||||
return subs, nil
|
||||
}
|
||||
|
||||
// RealPath gets the real path for the file, resolving symlinks if supported.
|
||||
func (i *FileInfo) RealPath() string {
|
||||
realPath, err := filepath.EvalSymlinks(i.Path)
|
||||
if err == nil {
|
||||
return realPath
|
||||
}
|
||||
return i.Path
|
||||
}
|
||||
|
||||
func GetRealPath(relativePath ...string) (string, bool, error) {
|
||||
combined := []string{settings.Config.Server.Root}
|
||||
for _, path := range relativePath {
|
||||
combined = append(combined, strings.TrimPrefix(path, settings.Config.Server.Root))
|
||||
}
|
||||
joinedPath := filepath.Join(combined...)
|
||||
// Convert relative path to absolute path
|
||||
absolutePath, err := filepath.Abs(joinedPath)
|
||||
if err != nil {
|
||||
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", combined, err)
|
||||
}
|
||||
// Resolve symlinks and get the real path
|
||||
return resolveSymlinks(absolutePath)
|
||||
}
|
||||
|
||||
func DeleteFiles(absPath string, opts FileOptions) error {
|
||||
func DeleteFiles(source, absPath string, dirPath string) error {
|
||||
err := os.RemoveAll(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = RefreshFileInfo(opts)
|
||||
index := GetIndex(source)
|
||||
refreshConfig := FileOptions{Path: dirPath, IsDir: true}
|
||||
err = index.RefreshFileInfo(refreshConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MoveResource(realsrc, realdst string, isSrcDir bool) error {
|
||||
func MoveResource(source, realsrc, realdst string, isSrcDir bool) error {
|
||||
err := fileutils.MoveFile(realsrc, realdst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
index := GetIndex(source)
|
||||
// refresh info for source and dest
|
||||
err = RefreshFileInfo(FileOptions{
|
||||
Path: realsrc,
|
||||
err = index.RefreshFileInfo(FileOptions{
|
||||
Path: filepath.Dir(realsrc),
|
||||
IsDir: isSrcDir,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -352,24 +199,24 @@ func MoveResource(realsrc, realdst string, isSrcDir bool) error {
|
||||
if !isSrcDir {
|
||||
refreshConfig.Path = filepath.Dir(realdst)
|
||||
}
|
||||
err = RefreshFileInfo(refreshConfig)
|
||||
err = index.RefreshFileInfo(refreshConfig)
|
||||
if err != nil {
|
||||
return errors.ErrEmptyKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CopyResource(realsrc, realdst string, isSrcDir bool) error {
|
||||
func CopyResource(source, realsrc, realdst string, isSrcDir bool) error {
|
||||
err := fileutils.CopyFile(realsrc, realdst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
index := GetIndex(source)
|
||||
refreshConfig := FileOptions{Path: realdst, IsDir: true}
|
||||
if !isSrcDir {
|
||||
refreshConfig.Path = filepath.Dir(realdst)
|
||||
}
|
||||
err = RefreshFileInfo(refreshConfig)
|
||||
err = index.RefreshFileInfo(refreshConfig)
|
||||
if err != nil {
|
||||
return errors.ErrEmptyKey
|
||||
}
|
||||
@@ -377,13 +224,14 @@ func CopyResource(realsrc, realdst string, isSrcDir bool) error {
|
||||
}
|
||||
|
||||
func WriteDirectory(opts FileOptions) error {
|
||||
realPath, _, _ := GetRealPath(rootPath, opts.Path)
|
||||
idx := GetIndex(opts.Source)
|
||||
realPath, _, _ := idx.GetRealPath(opts.Path)
|
||||
// Ensure the parent directories exist
|
||||
err := os.MkdirAll(realPath, 0775)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = RefreshFileInfo(opts)
|
||||
err = idx.RefreshFileInfo(opts)
|
||||
if err != nil {
|
||||
return errors.ErrEmptyKey
|
||||
}
|
||||
@@ -391,13 +239,11 @@ func WriteDirectory(opts FileOptions) error {
|
||||
}
|
||||
|
||||
func WriteFile(opts FileOptions, in io.Reader) error {
|
||||
dst := opts.Path
|
||||
idx := GetIndex(opts.Source)
|
||||
dst, _, _ := idx.GetRealPath(opts.Path)
|
||||
parentDir := filepath.Dir(dst)
|
||||
// Split the directory from the destination path
|
||||
dir := filepath.Dir(dst)
|
||||
|
||||
// Create the directory and all necessary parents
|
||||
err := os.MkdirAll(dir, 0775)
|
||||
err := os.MkdirAll(parentDir, 0775)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -415,42 +261,42 @@ func WriteFile(opts FileOptions, in io.Reader) error {
|
||||
return err
|
||||
}
|
||||
opts.Path = parentDir
|
||||
err = RefreshFileInfo(opts)
|
||||
if err != nil {
|
||||
return errors.ErrEmptyKey
|
||||
}
|
||||
return nil
|
||||
opts.IsDir = true
|
||||
return idx.RefreshFileInfo(opts)
|
||||
}
|
||||
|
||||
// resolveSymlinks resolves symlinks in the given path
|
||||
func resolveSymlinks(path string) (string, bool, error) {
|
||||
for {
|
||||
// Get the file info
|
||||
// Get the file info using os.Lstat to handle symlinks
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return path, false, fmt.Errorf("could not stat path: %v, %s", path, err)
|
||||
return path, false, fmt.Errorf("could not stat path: %s, %v", path, err)
|
||||
}
|
||||
|
||||
// Check if it's a symlink
|
||||
// Check if the path is a symlink
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
// Read the symlink target
|
||||
target, err := os.Readlink(path)
|
||||
if err != nil {
|
||||
return path, false, err
|
||||
return path, false, fmt.Errorf("could not read symlink: %s, %v", path, err)
|
||||
}
|
||||
|
||||
// Resolve the target relative to the symlink's directory
|
||||
// Resolve the symlink's target relative to its directory
|
||||
// This ensures the resolved path is absolute and correctly calculated
|
||||
path = filepath.Join(filepath.Dir(path), target)
|
||||
} else {
|
||||
// Not a symlink, so return the resolved path and check if it's a directory
|
||||
return path, info.IsDir(), nil
|
||||
// Not a symlink, so return the resolved path and whether it's a directory
|
||||
isDir := info.IsDir()
|
||||
return path, isDir, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addContent reads and sets content based on the file type.
|
||||
func getContent(path string) (string, error) {
|
||||
realPath, _, err := GetRealPath(rootPath, path)
|
||||
func getContent(source, path string) (string, error) {
|
||||
idx := GetIndex(source)
|
||||
realPath, _, err := idx.GetRealPath(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -461,7 +307,7 @@ func getContent(path string) (string, error) {
|
||||
}
|
||||
stringContent := string(content)
|
||||
if !utf8.ValidString(stringContent) {
|
||||
return "", fmt.Errorf("file is not utf8 encoded")
|
||||
return "", nil
|
||||
}
|
||||
if stringContent == "" {
|
||||
return "empty-file-x6OlSil", nil
|
||||
@@ -469,88 +315,31 @@ func getContent(path string) (string, error) {
|
||||
return stringContent, nil
|
||||
}
|
||||
|
||||
// detectType detects the file type.
|
||||
func (i *ReducedItem) detectType(path string, modify, saveContent, readHeader bool) error {
|
||||
// DetectType detects the MIME type of a file and updates the ItemInfo struct.
|
||||
func (i *ItemInfo) DetectType(realPath string, saveContent bool) {
|
||||
name := i.Name
|
||||
var contentErr error
|
||||
var contentString string
|
||||
if saveContent {
|
||||
contentString, contentErr = getContent(path)
|
||||
if contentErr == nil {
|
||||
i.Content = contentString
|
||||
}
|
||||
}
|
||||
|
||||
if IsNamedPipe(i.Mode) {
|
||||
i.Type = "blob"
|
||||
return contentErr
|
||||
}
|
||||
|
||||
ext := filepath.Ext(name)
|
||||
var buffer []byte
|
||||
if readHeader {
|
||||
buffer = i.readFirstBytes(path)
|
||||
mimetype := mime.TypeByExtension(ext)
|
||||
if mimetype == "" {
|
||||
http.DetectContentType(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
for _, fileType := range AllFiletypeOptions {
|
||||
if IsMatchingType(ext, fileType) {
|
||||
i.Type = fileType
|
||||
}
|
||||
switch i.Type {
|
||||
case "text":
|
||||
if !modify {
|
||||
i.Type = "textImmutable"
|
||||
}
|
||||
if saveContent {
|
||||
return contentErr
|
||||
}
|
||||
case "video":
|
||||
// TODO add back somewhere else, not during metadata fetch
|
||||
//parentDir := strings.TrimRight(path, name)
|
||||
//i.detectSubtitles(parentDir)
|
||||
case "doc":
|
||||
if ext == ".pdf" {
|
||||
i.Type = "pdf"
|
||||
return nil
|
||||
}
|
||||
if saveContent {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// Attempt MIME detection by file extension
|
||||
i.Type = strings.Split(mime.TypeByExtension(ext), ";")[0]
|
||||
if i.Type == "" {
|
||||
i.Type = "blob"
|
||||
if saveContent {
|
||||
return contentErr
|
||||
i.Type = extendedMimeTypeCheck(ext)
|
||||
}
|
||||
if i.Type == "blob" {
|
||||
// Read only the first 512 bytes for efficient MIME detection
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
|
||||
} else {
|
||||
defer file.Close()
|
||||
buffer := make([]byte, 512)
|
||||
n, _ := file.Read(buffer) // Ignore errors from Read
|
||||
i.Type = strings.Split(http.DetectContentType(buffer[:n]), ";")[0]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readFirstBytes reads the first bytes of the file.
|
||||
func (i *ReducedItem) readFirstBytes(path string) []byte {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
i.Type = "blob"
|
||||
return nil
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buffer := make([]byte, 512) //nolint:gomnd
|
||||
n, err := file.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
i.Type = "blob"
|
||||
return nil
|
||||
}
|
||||
|
||||
return buffer[:n]
|
||||
}
|
||||
|
||||
// TODO add subtitles back
|
||||
// detectSubtitles detects subtitles for video files.
|
||||
//func (i *FileInfo) detectSubtitles(path string) {
|
||||
// if i.Type != "video" {
|
||||
@@ -620,3 +409,30 @@ func Exists(path string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (info *FileInfo) SortItems() {
|
||||
sort.Slice(info.Folders, func(i, j int) bool {
|
||||
nameWithoutExt := strings.Split(info.Folders[i].Name, ".")[0]
|
||||
nameWithoutExt2 := strings.Split(info.Folders[j].Name, ".")[0]
|
||||
// Convert strings to integers for numeric sorting if both are numeric
|
||||
numI, errI := strconv.Atoi(nameWithoutExt)
|
||||
numJ, errJ := strconv.Atoi(nameWithoutExt2)
|
||||
if errI == nil && errJ == nil {
|
||||
return numI < numJ
|
||||
}
|
||||
// Fallback to case-insensitive lexicographical sorting
|
||||
return strings.ToLower(info.Folders[i].Name) < strings.ToLower(info.Folders[j].Name)
|
||||
})
|
||||
sort.Slice(info.Files, func(i, j int) bool {
|
||||
nameWithoutExt := strings.Split(info.Files[i].Name, ".")[0]
|
||||
nameWithoutExt2 := strings.Split(info.Files[j].Name, ".")[0]
|
||||
// Convert strings to integers for numeric sorting if both are numeric
|
||||
numI, errI := strconv.Atoi(nameWithoutExt)
|
||||
numJ, errJ := strconv.Atoi(nameWithoutExt2)
|
||||
if errI == nil && errJ == nil {
|
||||
return numI < numJ
|
||||
}
|
||||
// Fallback to case-insensitive lexicographical sorting
|
||||
return strings.ToLower(info.Files[i].Name) < strings.ToLower(info.Files[j].Name)
|
||||
})
|
||||
}
|
||||
+109
-5
@@ -3,8 +3,11 @@ package files
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
)
|
||||
|
||||
func Test_GetRealPath(t *testing.T) {
|
||||
@@ -12,7 +15,7 @@ func Test_GetRealPath(t *testing.T) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
trimPrefix := filepath.Dir(filepath.Dir(cwd)) + "/"
|
||||
trimPrefix := filepath.Dir(filepath.Dir(cwd))
|
||||
tests := []struct {
|
||||
name string
|
||||
paths []string
|
||||
@@ -30,20 +33,20 @@ func Test_GetRealPath(t *testing.T) {
|
||||
path string
|
||||
isDir bool
|
||||
}{
|
||||
path: "backend/files",
|
||||
path: "",
|
||||
isDir: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "current directory",
|
||||
paths: []string{
|
||||
"./file.go",
|
||||
"./files/file.go",
|
||||
},
|
||||
want: struct {
|
||||
path string
|
||||
isDir bool
|
||||
}{
|
||||
path: "backend/files/file.go",
|
||||
path: "/files/file.go",
|
||||
isDir: false,
|
||||
},
|
||||
},
|
||||
@@ -61,9 +64,15 @@ func Test_GetRealPath(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
idx := Index{
|
||||
Source: settings.Source{
|
||||
Path: trimPrefix,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
realPath, isDir, _ := GetRealPath(tt.paths...)
|
||||
realPath, isDir, _ := idx.GetRealPath(tt.paths...)
|
||||
adjustedRealPath := strings.TrimPrefix(realPath, trimPrefix)
|
||||
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)
|
||||
@@ -71,3 +80,98 @@ func Test_GetRealPath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortItems(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input FileInfo
|
||||
expected FileInfo
|
||||
}{
|
||||
{
|
||||
name: "Numeric and Lexicographical Sorting",
|
||||
input: FileInfo{
|
||||
Folders: []ItemInfo{
|
||||
{Name: "10.txt"},
|
||||
{Name: "2.txt"},
|
||||
{Name: "apple"},
|
||||
{Name: "Banana"},
|
||||
},
|
||||
Files: []ItemInfo{
|
||||
{Name: "File2.txt"},
|
||||
{Name: "File10.txt"},
|
||||
{Name: "File1"},
|
||||
{Name: "banana"},
|
||||
},
|
||||
},
|
||||
expected: FileInfo{
|
||||
Folders: []ItemInfo{
|
||||
{Name: "2.txt"},
|
||||
{Name: "10.txt"},
|
||||
{Name: "apple"},
|
||||
{Name: "Banana"},
|
||||
},
|
||||
Files: []ItemInfo{
|
||||
{Name: "banana"},
|
||||
{Name: "File1"},
|
||||
{Name: "File10.txt"},
|
||||
{Name: "File2.txt"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Only Lexicographical Sorting",
|
||||
input: FileInfo{
|
||||
Folders: []ItemInfo{
|
||||
{Name: "dog.txt"},
|
||||
{Name: "Cat.txt"},
|
||||
{Name: "apple"},
|
||||
},
|
||||
Files: []ItemInfo{
|
||||
{Name: "Zebra"},
|
||||
{Name: "apple"},
|
||||
{Name: "cat"},
|
||||
},
|
||||
},
|
||||
expected: FileInfo{
|
||||
Folders: []ItemInfo{
|
||||
{Name: "apple"},
|
||||
{Name: "Cat.txt"},
|
||||
{Name: "dog.txt"},
|
||||
},
|
||||
Files: []ItemInfo{
|
||||
{Name: "apple"},
|
||||
{Name: "cat"},
|
||||
{Name: "Zebra"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
test.input.SortItems()
|
||||
|
||||
getNames := func(items []ItemInfo) []string {
|
||||
names := []string{}
|
||||
for _, folder := range items {
|
||||
names = append(names, folder.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
actualFolderNames := getNames(test.input.Folders)
|
||||
expectedFolderNames := getNames(test.expected.Folders)
|
||||
|
||||
if !reflect.DeepEqual(actualFolderNames, expectedFolderNames) {
|
||||
t.Errorf("Folders not sorted correctly.\nGot: %v\nExpected: %v", actualFolderNames, expectedFolderNames)
|
||||
}
|
||||
|
||||
actualFileNames := getNames(test.input.Files)
|
||||
expectedFileNames := getNames(test.expected.Files)
|
||||
|
||||
if !reflect.DeepEqual(actualFileNames, expectedFileNames) {
|
||||
t.Errorf("Files not sorted correctly.\nGot: %v\nExpected: %v", actualFileNames, expectedFileNames)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
)
|
||||
|
||||
type Index struct {
|
||||
Root string
|
||||
Directories map[string]*FileInfo
|
||||
NumDirs int
|
||||
NumFiles int
|
||||
inProgress bool
|
||||
LastIndexed time.Time
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
rootPath string = "/srv"
|
||||
indexes []*Index
|
||||
indexesMutex sync.RWMutex
|
||||
)
|
||||
|
||||
func InitializeIndex(intervalMinutes uint32, schedule bool) {
|
||||
if schedule {
|
||||
go indexingScheduler(intervalMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
func indexingScheduler(intervalMinutes uint32) {
|
||||
if settings.Config.Server.Root != "" {
|
||||
rootPath = settings.Config.Server.Root
|
||||
}
|
||||
si := GetIndex(rootPath)
|
||||
for {
|
||||
startTime := time.Now()
|
||||
// Set the indexing flag to indicate that indexing is in progress
|
||||
si.resetCount()
|
||||
// Perform the indexing operation
|
||||
err := si.indexFiles("/")
|
||||
// Reset the indexing flag to indicate that indexing has finished
|
||||
si.inProgress = false
|
||||
// Update the LastIndexed time
|
||||
si.LastIndexed = time.Now()
|
||||
if err != nil {
|
||||
log.Printf("Error during indexing: %v", err)
|
||||
}
|
||||
if si.NumFiles+si.NumDirs > 0 {
|
||||
timeIndexedInSeconds := int(time.Since(startTime).Seconds())
|
||||
log.Println("Successfully indexed files.")
|
||||
log.Printf("Time spent indexing: %v seconds\n", timeIndexedInSeconds)
|
||||
log.Printf("Files found: %v\n", si.NumFiles)
|
||||
log.Printf("Directories found: %v\n", si.NumDirs)
|
||||
}
|
||||
// Sleep for the specified interval
|
||||
time.Sleep(time.Duration(intervalMinutes) * time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
// Define a function to recursively index files and directories
|
||||
func (si *Index) indexFiles(adjustedPath string) error {
|
||||
realPath := strings.TrimRight(si.Root, "/") + adjustedPath
|
||||
|
||||
// Open the directory
|
||||
dir, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
si.RemoveDirectory(adjustedPath) // Remove if it can't be opened
|
||||
return err
|
||||
}
|
||||
defer dir.Close()
|
||||
|
||||
dirInfo, err := dir.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip directories that haven't been modified since the last index
|
||||
if dirInfo.ModTime().Before(si.LastIndexed) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read directory contents
|
||||
files, err := dir.Readdir(-1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var totalSize int64
|
||||
var numDirs, numFiles int
|
||||
fileInfos := []ReducedItem{}
|
||||
dirInfos := map[string]*FileInfo{}
|
||||
combinedPath := adjustedPath + "/"
|
||||
if adjustedPath == "/" {
|
||||
combinedPath = "/"
|
||||
}
|
||||
|
||||
// Process each file and directory in the current directory
|
||||
for _, file := range files {
|
||||
itemInfo := &FileInfo{
|
||||
ModTime: file.ModTime(),
|
||||
}
|
||||
if file.IsDir() {
|
||||
itemInfo.Name = file.Name()
|
||||
itemInfo.Path = combinedPath + file.Name()
|
||||
// Recursively index the subdirectory
|
||||
err := si.indexFiles(itemInfo.Path)
|
||||
if err != nil {
|
||||
log.Printf("Failed to index directory %s: %v", itemInfo.Path, err)
|
||||
continue
|
||||
}
|
||||
// Fetch the metadata for the subdirectory after indexing
|
||||
subDirInfo, exists := si.GetMetadataInfo(itemInfo.Path, true)
|
||||
if exists {
|
||||
itemInfo.Size = subDirInfo.Size
|
||||
totalSize += subDirInfo.Size // Add subdirectory size to the total
|
||||
}
|
||||
dirInfos[itemInfo.Name] = itemInfo
|
||||
numDirs++
|
||||
} else {
|
||||
itemInfo := &ReducedItem{
|
||||
Name: file.Name(),
|
||||
ModTime: file.ModTime(),
|
||||
Size: file.Size(),
|
||||
Mode: file.Mode(),
|
||||
}
|
||||
_ = itemInfo.detectType(combinedPath+file.Name(), true, false, false)
|
||||
fileInfos = append(fileInfos, *itemInfo)
|
||||
totalSize += itemInfo.Size
|
||||
numFiles++
|
||||
}
|
||||
}
|
||||
|
||||
// Create FileInfo for the current directory
|
||||
dirFileInfo := &FileInfo{
|
||||
Path: adjustedPath,
|
||||
Files: fileInfos,
|
||||
Dirs: dirInfos,
|
||||
Size: totalSize,
|
||||
ModTime: dirInfo.ModTime(),
|
||||
}
|
||||
|
||||
// Update the current directory metadata in the index
|
||||
si.UpdateMetadata(dirFileInfo)
|
||||
si.NumDirs += numDirs
|
||||
si.NumFiles += numFiles
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (si *Index) makeIndexPath(subPath string) string {
|
||||
if strings.HasPrefix(subPath, "./") {
|
||||
subPath = strings.TrimPrefix(subPath, ".")
|
||||
}
|
||||
if strings.HasPrefix(subPath, ".") || si.Root == subPath {
|
||||
return "/"
|
||||
}
|
||||
// clean path
|
||||
subPath = strings.TrimSuffix(subPath, "/")
|
||||
// remove index prefix
|
||||
adjustedPath := strings.TrimPrefix(subPath, si.Root)
|
||||
// remove trailing slash
|
||||
adjustedPath = strings.TrimSuffix(adjustedPath, "/")
|
||||
if !strings.HasPrefix(adjustedPath, "/") {
|
||||
adjustedPath = "/" + adjustedPath
|
||||
}
|
||||
return adjustedPath
|
||||
}
|
||||
|
||||
//func getParentPath(path string) string {
|
||||
// // Trim trailing slash for consistency
|
||||
// path = strings.TrimSuffix(path, "/")
|
||||
// if path == "" || path == "/" {
|
||||
// return "" // Root has no parent
|
||||
// }
|
||||
//
|
||||
// lastSlash := strings.LastIndex(path, "/")
|
||||
// if lastSlash == -1 {
|
||||
// return "/" // Parent of a top-level directory
|
||||
// }
|
||||
// return path[:lastSlash]
|
||||
//}
|
||||
|
||||
func (si *Index) recursiveUpdateDirSizes(parentDir string, childInfo *FileInfo, previousSize int64) {
|
||||
childDirName := filepath.Base(childInfo.Path)
|
||||
if parentDir == childDirName {
|
||||
return
|
||||
}
|
||||
dir, exists := si.GetMetadataInfo(parentDir, true)
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
dir.Dirs[childDirName] = childInfo
|
||||
newSize := dir.Size - previousSize + childInfo.Size
|
||||
dir.Size += newSize
|
||||
si.UpdateMetadata(dir)
|
||||
dir, _ = si.GetMetadataInfo(parentDir, true)
|
||||
si.recursiveUpdateDirSizes(filepath.Dir(parentDir), dir, newSize)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/utils"
|
||||
)
|
||||
|
||||
type Index struct {
|
||||
settings.Source
|
||||
Directories map[string]*FileInfo
|
||||
NumDirs uint64
|
||||
NumFiles uint64
|
||||
NumDeleted uint64
|
||||
FilesChangedDuringIndexing bool
|
||||
currentSchedule int
|
||||
assessment string
|
||||
indexingTime int
|
||||
LastIndexed time.Time
|
||||
SmartModifier time.Duration
|
||||
mu sync.RWMutex
|
||||
scannerMu sync.Mutex
|
||||
}
|
||||
|
||||
var (
|
||||
indexes map[string]*Index
|
||||
indexesMutex sync.RWMutex
|
||||
RootPaths map[string]string
|
||||
)
|
||||
|
||||
func Initialize(source settings.Source) {
|
||||
indexesMutex.RLock()
|
||||
newIndex := Index{
|
||||
Source: source,
|
||||
Directories: make(map[string]*FileInfo),
|
||||
}
|
||||
if RootPaths == nil {
|
||||
RootPaths = make(map[string]string)
|
||||
}
|
||||
RootPaths[source.Name] = source.Path
|
||||
indexes = make(map[string]*Index)
|
||||
indexes[newIndex.Source.Name] = &newIndex
|
||||
indexesMutex.RUnlock()
|
||||
|
||||
if !newIndex.Source.Config.Disabled {
|
||||
time.Sleep(time.Second)
|
||||
logger.Info("Initializing index and assessing file system complexity")
|
||||
newIndex.RunIndexing("/", false)
|
||||
go newIndex.setupIndexingScanners()
|
||||
} else {
|
||||
logger.Debug("Indexing disabled for source: " + newIndex.Source.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Define a function to recursively index files and directories
|
||||
func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) error {
|
||||
realPath := strings.TrimRight(idx.Source.Path, "/") + adjustedPath
|
||||
// Open the directory
|
||||
dir, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
idx.RemoveDirectory(adjustedPath) // Remove, must have been deleted
|
||||
return err
|
||||
}
|
||||
defer dir.Close()
|
||||
|
||||
dirInfo, err := dir.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
combinedPath := adjustedPath + "/"
|
||||
if adjustedPath == "/" {
|
||||
combinedPath = "/"
|
||||
}
|
||||
// get whats currently in cache
|
||||
idx.mu.RLock()
|
||||
cacheDirItems := []ItemInfo{}
|
||||
modChange := true // default to true
|
||||
cachedDir, exists := idx.Directories[adjustedPath]
|
||||
if exists && quick {
|
||||
modChange = dirInfo.ModTime() != cachedDir.ModTime
|
||||
cacheDirItems = cachedDir.Folders
|
||||
}
|
||||
idx.mu.RUnlock()
|
||||
|
||||
// If the directory has not been modified since the last index, skip expensive readdir
|
||||
// recursively check cached dirs for mod time changes as well
|
||||
if !modChange && recursive {
|
||||
for _, item := range cacheDirItems {
|
||||
err = idx.indexDirectory(combinedPath+item.Name, quick, true)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("error indexing directory %v : %v", combinedPath+item.Name, err))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if quick {
|
||||
idx.mu.Lock()
|
||||
idx.FilesChangedDuringIndexing = true
|
||||
idx.mu.Unlock()
|
||||
}
|
||||
|
||||
// Read directory contents
|
||||
files, err := dir.Readdir(-1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var totalSize int64
|
||||
fileInfos := []ItemInfo{}
|
||||
dirInfos := []ItemInfo{}
|
||||
|
||||
// Process each file and directory in the current directory
|
||||
for _, file := range files {
|
||||
|
||||
isDir := file.IsDir()
|
||||
fullCombined := combinedPath + file.Name()
|
||||
if idx.shouldSkip(isDir, isHidden(file, ""), fullCombined) {
|
||||
continue
|
||||
}
|
||||
itemInfo := &ItemInfo{
|
||||
Name: file.Name(),
|
||||
ModTime: file.ModTime(),
|
||||
}
|
||||
|
||||
// fix for .app files on macos which are technically directories, but we don't want to treat them as such
|
||||
if isDir && strings.HasSuffix(file.Name(), ".app") {
|
||||
isDir = false
|
||||
}
|
||||
if isDir {
|
||||
|
||||
// skip non-indexable dirs.
|
||||
if file.Name() == "$RECYCLE.BIN" || file.Name() == "System Volume Information" {
|
||||
continue
|
||||
}
|
||||
|
||||
dirPath := combinedPath + file.Name()
|
||||
if recursive {
|
||||
// Recursively index the subdirectory
|
||||
err = idx.indexDirectory(dirPath, quick, recursive)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("Failed to index directory %s: %v", dirPath, err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
realDirInfo, exists := idx.GetMetadataInfo(dirPath, true)
|
||||
if exists {
|
||||
itemInfo.Size = realDirInfo.Size
|
||||
}
|
||||
totalSize += itemInfo.Size
|
||||
itemInfo.Type = "directory"
|
||||
dirInfos = append(dirInfos, *itemInfo)
|
||||
idx.NumDirs++
|
||||
} else {
|
||||
itemInfo.DetectType(fullCombined, false)
|
||||
itemInfo.Size = file.Size()
|
||||
fileInfos = append(fileInfos, *itemInfo)
|
||||
totalSize += itemInfo.Size
|
||||
idx.NumFiles++
|
||||
}
|
||||
}
|
||||
if totalSize == 0 && idx.Source.Config.IgnoreZeroSizeFolders {
|
||||
return nil
|
||||
}
|
||||
// Create FileInfo for the current directory
|
||||
dirFileInfo := &FileInfo{
|
||||
Path: adjustedPath,
|
||||
Files: fileInfos,
|
||||
Folders: dirInfos,
|
||||
}
|
||||
dirFileInfo.ItemInfo = ItemInfo{
|
||||
Name: dirInfo.Name(),
|
||||
Type: "directory",
|
||||
Size: totalSize,
|
||||
ModTime: dirInfo.ModTime(),
|
||||
}
|
||||
|
||||
dirFileInfo.SortItems()
|
||||
|
||||
// Update the current directory metadata in the index
|
||||
idx.UpdateMetadata(dirFileInfo)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (idx *Index) makeIndexPath(subPath string) string {
|
||||
if strings.HasPrefix(subPath, "./") {
|
||||
subPath = strings.TrimPrefix(subPath, ".")
|
||||
}
|
||||
if idx.Source.Path == subPath || subPath == "." {
|
||||
return "/"
|
||||
}
|
||||
// clean path
|
||||
subPath = strings.TrimSuffix(subPath, "/")
|
||||
// remove index prefix
|
||||
adjustedPath := strings.TrimPrefix(subPath, idx.Source.Path)
|
||||
adjustedPath = strings.ReplaceAll(adjustedPath, "\\", "/")
|
||||
// remove trailing slash
|
||||
adjustedPath = strings.TrimSuffix(adjustedPath, "/")
|
||||
if !strings.HasPrefix(adjustedPath, "/") {
|
||||
adjustedPath = "/" + adjustedPath
|
||||
}
|
||||
return adjustedPath
|
||||
}
|
||||
|
||||
func (idx *Index) recursiveUpdateDirSizes(childInfo *FileInfo, previousSize int64) {
|
||||
parentDir := utils.GetParentDirectoryPath(childInfo.Path)
|
||||
parentInfo, exists := idx.GetMetadataInfo(parentDir, true)
|
||||
if !exists || parentDir == "" {
|
||||
return
|
||||
}
|
||||
newSize := parentInfo.Size - previousSize + childInfo.Size
|
||||
parentInfo.Size += newSize
|
||||
idx.UpdateMetadata(parentInfo)
|
||||
idx.recursiveUpdateDirSizes(parentInfo, newSize)
|
||||
}
|
||||
|
||||
func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
|
||||
combined := append([]string{idx.Source.Path}, relativePath...)
|
||||
joinedPath := filepath.Join(combined...)
|
||||
isDir, _ := utils.RealPathCache.Get(joinedPath + ":isdir").(bool)
|
||||
cached, ok := utils.RealPathCache.Get(joinedPath).(string)
|
||||
if ok && cached != "" {
|
||||
return cached, isDir, nil
|
||||
}
|
||||
// Convert relative path to absolute path
|
||||
absolutePath, err := filepath.Abs(joinedPath)
|
||||
if err != nil {
|
||||
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err)
|
||||
}
|
||||
// Resolve symlinks and get the real path
|
||||
realPath, isDir, err := resolveSymlinks(absolutePath)
|
||||
if err == nil {
|
||||
utils.RealPathCache.Set(joinedPath, realPath)
|
||||
utils.RealPathCache.Set(joinedPath+":isdir", isDir)
|
||||
}
|
||||
return realPath, isDir, err
|
||||
}
|
||||
|
||||
func (idx *Index) RefreshFileInfo(opts FileOptions) error {
|
||||
refreshOptions := FileOptions{
|
||||
Path: opts.Path,
|
||||
IsDir: opts.IsDir,
|
||||
}
|
||||
if !refreshOptions.IsDir {
|
||||
refreshOptions.Path = idx.makeIndexPath(filepath.Dir(refreshOptions.Path))
|
||||
refreshOptions.IsDir = true
|
||||
} else {
|
||||
refreshOptions.Path = idx.makeIndexPath(refreshOptions.Path)
|
||||
}
|
||||
err := idx.indexDirectory(refreshOptions.Path, false, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file/folder does not exist to refresh data: %s", refreshOptions.Path)
|
||||
}
|
||||
file, exists := idx.GetMetadataInfo(refreshOptions.Path, true)
|
||||
if !exists {
|
||||
return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path)
|
||||
}
|
||||
|
||||
current, firstExisted := idx.GetMetadataInfo(refreshOptions.Path, true)
|
||||
refreshParentInfo := firstExisted && current.Size != file.Size
|
||||
//utils.PrintStructFields(*file)
|
||||
result := idx.UpdateMetadata(file)
|
||||
if !result {
|
||||
return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path)
|
||||
}
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
if refreshParentInfo {
|
||||
idx.recursiveUpdateDirSizes(file, current.Size)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isHidden(file os.FileInfo, realpath string) bool {
|
||||
return file.Name()[0] == '.'
|
||||
}
|
||||
|
||||
func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined string) bool {
|
||||
// check inclusions first
|
||||
if isDir && len(idx.Source.Config.Include.Folders) > 0 {
|
||||
if !slices.Contains(idx.Source.Config.Include.Folders, fullCombined) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if !isDir && len(idx.Source.Config.Include.Files) > 0 {
|
||||
if !slices.Contains(idx.Source.Config.Include.Files, fullCombined) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// check exclusions
|
||||
if isDir && slices.Contains(idx.Source.Config.Exclude.Folders, fullCombined) {
|
||||
return true
|
||||
}
|
||||
if !isDir && slices.Contains(idx.Source.Config.Exclude.Files, fullCombined) {
|
||||
return true
|
||||
}
|
||||
if idx.Source.Config.IgnoreHidden && isHidden {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/logger"
|
||||
)
|
||||
|
||||
// schedule in minutes
|
||||
var scanSchedule = []time.Duration{
|
||||
5 * time.Minute, // 5 minute quick scan & 25 minutes for a full scan
|
||||
10 * time.Minute,
|
||||
20 * time.Minute, // [3] element is 20 minutes, reset anchor for full scan
|
||||
40 * time.Minute,
|
||||
1 * time.Hour,
|
||||
2 * time.Hour,
|
||||
3 * time.Hour,
|
||||
4 * time.Hour, // 4 hours for quick scan & 20 hours for a full scan
|
||||
}
|
||||
|
||||
func (idx *Index) newScanner(origin string) {
|
||||
fullScanAnchor := 3
|
||||
fullScanCounter := 0 // every 5th scan is a full scan
|
||||
for {
|
||||
// Determine sleep time with modifiers
|
||||
fullScanCounter++
|
||||
sleepTime := scanSchedule[idx.currentSchedule] + idx.SmartModifier
|
||||
if idx.assessment == "simple" {
|
||||
sleepTime = scanSchedule[idx.currentSchedule] - idx.SmartModifier
|
||||
}
|
||||
if idx.Source.Config.IndexingInterval > 0 {
|
||||
sleepTime = time.Duration(idx.Source.Config.IndexingInterval) * time.Minute
|
||||
}
|
||||
|
||||
// Log and sleep before indexing
|
||||
logger.Debug(fmt.Sprintf("Next scan in %v\n", sleepTime))
|
||||
time.Sleep(sleepTime)
|
||||
|
||||
idx.scannerMu.Lock()
|
||||
if fullScanCounter == 5 {
|
||||
idx.RunIndexing(origin, false) // Full scan
|
||||
fullScanCounter = 0
|
||||
} else {
|
||||
idx.RunIndexing(origin, true) // Quick scan
|
||||
}
|
||||
idx.scannerMu.Unlock()
|
||||
|
||||
// Adjust schedule based on file changes
|
||||
if idx.FilesChangedDuringIndexing {
|
||||
// Move to at least the full-scan anchor or reduce interval
|
||||
if idx.currentSchedule > fullScanAnchor {
|
||||
idx.currentSchedule = fullScanAnchor
|
||||
} else if idx.currentSchedule > 0 {
|
||||
idx.currentSchedule--
|
||||
}
|
||||
} else {
|
||||
// Increment toward the longest interval if no changes
|
||||
if idx.currentSchedule < len(scanSchedule)-1 {
|
||||
idx.currentSchedule++
|
||||
}
|
||||
}
|
||||
if idx.assessment == "simple" && idx.currentSchedule > 3 {
|
||||
idx.currentSchedule = 3
|
||||
}
|
||||
// Ensure `currentSchedule` stays within bounds
|
||||
if idx.currentSchedule < 0 {
|
||||
idx.currentSchedule = 0
|
||||
} else if idx.currentSchedule >= len(scanSchedule) {
|
||||
idx.currentSchedule = len(scanSchedule) - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (idx *Index) RunIndexing(origin string, quick bool) {
|
||||
prevNumDirs := idx.NumDirs
|
||||
prevNumFiles := idx.NumFiles
|
||||
if quick {
|
||||
logger.Debug("Starting quick scan")
|
||||
} else {
|
||||
logger.Debug("Starting full scan")
|
||||
idx.NumDirs = 0
|
||||
idx.NumFiles = 0
|
||||
}
|
||||
startTime := time.Now()
|
||||
idx.FilesChangedDuringIndexing = false
|
||||
// Perform the indexing operation
|
||||
err := idx.indexDirectory("/", quick, true)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("Error during indexing: %v", err))
|
||||
}
|
||||
firstRun := idx.LastIndexed == time.Time{}
|
||||
// Update the LastIndexed time
|
||||
idx.LastIndexed = time.Now()
|
||||
idx.indexingTime = int(time.Since(startTime).Seconds())
|
||||
if !quick {
|
||||
// update smart indexing
|
||||
if idx.indexingTime < 3 || idx.NumDirs < 10000 {
|
||||
idx.assessment = "simple"
|
||||
idx.SmartModifier = 4 * time.Minute
|
||||
} else if idx.indexingTime > 120 || idx.NumDirs > 500000 {
|
||||
idx.assessment = "complex"
|
||||
modifier := idx.indexingTime / 10 // seconds
|
||||
idx.SmartModifier = time.Duration(modifier) * time.Minute
|
||||
} else {
|
||||
idx.assessment = "normal"
|
||||
}
|
||||
if firstRun {
|
||||
logger.Info(fmt.Sprintf("Index assessment : complexity=%v directories=%v files=%v", idx.assessment, idx.NumDirs, idx.NumFiles))
|
||||
} else {
|
||||
logger.Debug(fmt.Sprintf("Index assessment : complexity=%v directories=%v files=%v", idx.assessment, idx.NumDirs, idx.NumFiles))
|
||||
}
|
||||
if idx.NumDirs != prevNumDirs || idx.NumFiles != prevNumFiles {
|
||||
idx.FilesChangedDuringIndexing = true
|
||||
}
|
||||
}
|
||||
if firstRun {
|
||||
logger.Info(fmt.Sprintf("Time spent indexing : %v seconds", idx.indexingTime))
|
||||
} else {
|
||||
logger.Debug(fmt.Sprintf("Time spent indexing : %v seconds", idx.indexingTime))
|
||||
}
|
||||
}
|
||||
|
||||
func (idx *Index) setupIndexingScanners() {
|
||||
go idx.newScanner("/")
|
||||
}
|
||||
+139
-65
@@ -3,32 +3,34 @@ package files
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
)
|
||||
|
||||
func BenchmarkFillIndex(b *testing.B) {
|
||||
InitializeIndex(5, false)
|
||||
si := GetIndex(settings.Config.Server.Root)
|
||||
Initialize(settings.Source{
|
||||
Name: "test",
|
||||
Path: "/srv",
|
||||
})
|
||||
idx := GetIndex("test")
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
si.createMockData(50, 3) // 1000 dirs, 3 files per dir
|
||||
idx.createMockData(50, 3) // 1000 dirs, 3 files per dir
|
||||
}
|
||||
}
|
||||
|
||||
func (si *Index) createMockData(numDirs, numFilesPerDir int) {
|
||||
func (idx *Index) createMockData(numDirs, numFilesPerDir int) {
|
||||
for i := 0; i < numDirs; i++ {
|
||||
dirPath := generateRandomPath(rand.Intn(3) + 1)
|
||||
files := []ReducedItem{} // Slice of FileInfo
|
||||
files := []ItemInfo{} // Slice of FileInfo
|
||||
|
||||
// Simulating files and directories with FileInfo
|
||||
for j := 0; j < numFilesPerDir; j++ {
|
||||
newFile := ReducedItem{
|
||||
newFile := ItemInfo{
|
||||
Name: "file-" + getRandomTerm() + getRandomExtension(),
|
||||
Size: rand.Int63n(1000), // Random size
|
||||
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
|
||||
@@ -37,54 +39,14 @@ func (si *Index) createMockData(numDirs, numFilesPerDir int) {
|
||||
files = append(files, newFile)
|
||||
}
|
||||
dirInfo := &FileInfo{
|
||||
Name: filepath.Base(dirPath),
|
||||
Path: dirPath,
|
||||
Files: files,
|
||||
}
|
||||
|
||||
si.UpdateMetadata(dirInfo)
|
||||
idx.UpdateMetadata(dirInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func generateRandomPath(levels int) string {
|
||||
rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
dirName := "srv"
|
||||
for i := 0; i < levels; i++ {
|
||||
dirName += "/" + getRandomTerm()
|
||||
}
|
||||
return dirName
|
||||
}
|
||||
|
||||
func getRandomTerm() string {
|
||||
wordbank := []string{
|
||||
"hi", "test", "other", "name",
|
||||
"cool", "things", "more", "items",
|
||||
}
|
||||
rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
index := rand.Intn(len(wordbank))
|
||||
return wordbank[index]
|
||||
}
|
||||
|
||||
func getRandomExtension() string {
|
||||
wordbank := []string{
|
||||
".txt", ".mp3", ".mov", ".doc",
|
||||
".mp4", ".bak", ".zip", ".jpg",
|
||||
}
|
||||
rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
index := rand.Intn(len(wordbank))
|
||||
return wordbank[index]
|
||||
}
|
||||
|
||||
func generateRandomSearchTerms(numTerms int) []string {
|
||||
// Generate random search terms
|
||||
searchTerms := make([]string, numTerms)
|
||||
for i := 0; i < numTerms; i++ {
|
||||
searchTerms[i] = getRandomTerm()
|
||||
}
|
||||
return searchTerms
|
||||
}
|
||||
|
||||
// JSONBytesEqual compares the JSON in two byte slices.
|
||||
func JSONBytesEqual(a, b []byte) (bool, error) {
|
||||
var j, j2 interface{}
|
||||
@@ -113,36 +75,148 @@ func TestGetIndex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeIndex(t *testing.T) {
|
||||
type args struct {
|
||||
intervalMinutes uint32
|
||||
}
|
||||
func TestMakeIndexPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
name string
|
||||
subPath string
|
||||
expected string
|
||||
}{
|
||||
// TODO: Add test cases.
|
||||
// Linux
|
||||
{"Root path returns slash", "/", "/"},
|
||||
{"Dot-prefixed returns slash", ".", "/"},
|
||||
{"Double-dot prefix ignored", "./", "/"},
|
||||
{"Dot prefix followed by text", "./test", "/test"},
|
||||
{"Dot prefix followed by text", ".test", "/.test"},
|
||||
{"Hidden file at root", "/.test", "/.test"},
|
||||
{"Trailing slash removed", "/test/", "/test"},
|
||||
{"Subpath without root prefix", "/other/test", "/other/test"},
|
||||
{"Complex nested paths", "/nested/path", "/nested/path"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
InitializeIndex(tt.args.intervalMinutes, false)
|
||||
idx := &Index{Source: settings.Source{Path: "/"}}
|
||||
result := idx.makeIndexPath(tt.subPath)
|
||||
if result != tt.expected {
|
||||
t.Errorf("makeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
tests = []struct {
|
||||
name string
|
||||
subPath string
|
||||
expected string
|
||||
}{
|
||||
// Windows
|
||||
{"Mixed slash", "/first\\second", "/first/second"},
|
||||
{"Windows slash", "\\first\\second", "/first/second"},
|
||||
{"Windows full path", "C:\\Users\\testfolder\\nestedfolder", "/testfolder/nestedfolder"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
idx := &Index{Source: settings.Source{Path: "C:\\Users"}}
|
||||
result := idx.makeIndexPath(tt.subPath)
|
||||
if result != tt.expected {
|
||||
t.Errorf("makeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_indexingScheduler(t *testing.T) {
|
||||
type args struct {
|
||||
intervalMinutes uint32
|
||||
}
|
||||
func TestMakeIndexPathRoot(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
name string
|
||||
subPath string
|
||||
expected string
|
||||
}{
|
||||
// TODO: Add test cases.
|
||||
// Linux
|
||||
{"Root path returns slash", "/rootpath", "/"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
indexingScheduler(tt.args.intervalMinutes)
|
||||
idx := &Index{Source: settings.Source{Path: "/rootpath", Name: "default"}}
|
||||
result := idx.makeIndexPath(tt.subPath)
|
||||
if result != tt.expected {
|
||||
t.Errorf("makeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCheckIndexExclude(b *testing.B) {
|
||||
tests := []struct {
|
||||
isDir bool
|
||||
isHidden bool
|
||||
fullPath string
|
||||
}{
|
||||
{false, false, "/test/.test"},
|
||||
{true, false, "/test/.test"},
|
||||
{true, true, "/test/.test"},
|
||||
{false, false, "/test/filepath"},
|
||||
{false, true, "/test/filepath"},
|
||||
{true, true, "/test/filepath"},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
idx := Index{
|
||||
Source: settings.Source{
|
||||
Name: "files",
|
||||
Config: settings.IndexConfig{
|
||||
IgnoreHidden: true,
|
||||
Exclude: settings.IndexFilter{
|
||||
Files: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
|
||||
Folders: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
|
||||
FileEndsWith: []string{".zip", ".tar", ".jpeg"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
for _, v := range tests {
|
||||
idx.shouldSkip(v.isDir, v.isHidden, v.fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
func BenchmarkCheckIndexConditionsInclude(b *testing.B) {
|
||||
tests := []struct {
|
||||
isDir bool
|
||||
isHidden bool
|
||||
fullPath string
|
||||
}{
|
||||
{false, false, "/test/.test"},
|
||||
{true, false, "/test/.test"},
|
||||
{true, true, "/test/.test"},
|
||||
{false, false, "/test/filepath"},
|
||||
{false, true, "/test/filepath"},
|
||||
{true, true, "/test/filepath"},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
idx2 := Index{
|
||||
Source: settings.Source{
|
||||
Name: "files",
|
||||
Config: settings.IndexConfig{
|
||||
IgnoreHidden: true,
|
||||
Include: settings.IndexFilter{
|
||||
Files: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
|
||||
Folders: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
|
||||
FileEndsWith: []string{".zip", ".tar", ".jpeg"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
for _, v := range tests {
|
||||
idx2.shouldSkip(v.isDir, v.isHidden, v.fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
func CreateMockData(numDirs, numFilesPerDir int) FileInfo {
|
||||
dir := FileInfo{}
|
||||
dir.Path = "/here/is/your/mock/dir"
|
||||
for i := 0; i < numDirs; i++ {
|
||||
newFile := ItemInfo{
|
||||
Name: "file-" + getRandomTerm() + getRandomExtension(),
|
||||
Size: rand.Int63n(1000), // Random size
|
||||
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
|
||||
Type: "blob",
|
||||
}
|
||||
dir.Folders = append(dir.Folders, newFile)
|
||||
}
|
||||
// Simulating files and directories with FileInfo
|
||||
for j := 0; j < numFilesPerDir; j++ {
|
||||
newFile := ItemInfo{
|
||||
Name: "file-" + getRandomTerm() + getRandomExtension(),
|
||||
Size: rand.Int63n(1000), // Random size
|
||||
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
|
||||
Type: "blob",
|
||||
}
|
||||
dir.Files = append(dir.Files, newFile)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func generateRandomPath(levels int) string {
|
||||
rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
dirName := "srv"
|
||||
for i := 0; i < levels; i++ {
|
||||
dirName += "/" + getRandomTerm()
|
||||
}
|
||||
return dirName
|
||||
}
|
||||
|
||||
func getRandomTerm() string {
|
||||
wordbank := []string{
|
||||
"hi", "test", "other", "name",
|
||||
"cool", "things", "more", "items",
|
||||
}
|
||||
rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
index := rand.Intn(len(wordbank))
|
||||
return wordbank[index]
|
||||
}
|
||||
|
||||
func getRandomExtension() string {
|
||||
wordbank := []string{
|
||||
".txt", ".mp3", ".mov", ".doc",
|
||||
".mp4", ".bak", ".zip", ".jpg",
|
||||
}
|
||||
rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
index := rand.Intn(len(wordbank))
|
||||
return wordbank[index]
|
||||
}
|
||||
|
||||
func generateRandomSearchTerms(numTerms int) []string {
|
||||
// Generate random search terms
|
||||
searchTerms := make([]string, numTerms)
|
||||
for i := 0; i < numTerms; i++ {
|
||||
searchTerms[i] = getRandomTerm()
|
||||
}
|
||||
return searchTerms
|
||||
}
|
||||
+33
-29
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -14,21 +14,28 @@ var (
|
||||
maxSearchResults = 100
|
||||
)
|
||||
|
||||
type searchResult struct {
|
||||
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 {
|
||||
func (idx *Index) Search(search string, scope string, sourceSession string) []SearchResult {
|
||||
// Remove slashes
|
||||
scope = si.makeIndexPath(scope)
|
||||
scope = idx.makeIndexPath(scope)
|
||||
runningHash := utils.GenerateRandomHash(4)
|
||||
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
|
||||
searchOptions := ParseSearch(search)
|
||||
results := make(map[string]searchResult, 0)
|
||||
results := make(map[string]SearchResult, 0)
|
||||
count := 0
|
||||
directories := si.getDirsInScope(scope)
|
||||
var directories []string
|
||||
cachedDirs, ok := utils.SearchResultsCache.Get(idx.Source.Path + scope).([]string)
|
||||
if ok {
|
||||
directories = cachedDirs
|
||||
} else {
|
||||
directories = idx.getDirsInScope(scope)
|
||||
utils.SearchResultsCache.Set(idx.Source.Path+scope, directories)
|
||||
}
|
||||
for _, searchTerm := range searchOptions.Terms {
|
||||
if searchTerm == "" {
|
||||
continue
|
||||
@@ -36,58 +43,55 @@ func (si *Index) Search(search string, scope string, sourceSession string) []sea
|
||||
if count > maxSearchResults {
|
||||
break
|
||||
}
|
||||
si.mu.Lock()
|
||||
idx.mu.Lock()
|
||||
for _, dirName := range directories {
|
||||
si.mu.Unlock()
|
||||
dir, found := si.GetReducedMetadata(dirName, true)
|
||||
si.mu.Lock()
|
||||
scopedPath := strings.TrimPrefix(strings.TrimPrefix(dirName, scope), "/") + "/"
|
||||
idx.mu.Unlock()
|
||||
dir, found := idx.GetReducedMetadata(dirName, true)
|
||||
idx.mu.Lock()
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if count > maxSearchResults {
|
||||
break
|
||||
}
|
||||
reducedDir := ReducedItem{
|
||||
reducedDir := ItemInfo{
|
||||
Name: filepath.Base(dirName),
|
||||
Type: "directory",
|
||||
Size: dir.Size,
|
||||
}
|
||||
|
||||
matches := reducedDir.containsSearchTerm(searchTerm, searchOptions)
|
||||
if matches {
|
||||
scopedPath := strings.TrimPrefix(strings.TrimPrefix(dirName, scope), "/") + "/"
|
||||
results[scopedPath] = searchResult{Path: scopedPath, Type: "directory", Size: dir.Size}
|
||||
results[scopedPath] = SearchResult{Path: scopedPath, Type: "directory", Size: dir.Size}
|
||||
count++
|
||||
}
|
||||
|
||||
// search files first
|
||||
for _, item := range dir.Items {
|
||||
|
||||
for _, item := range dir.Files {
|
||||
fullPath := dirName + "/" + item.Name
|
||||
scopedPath := strings.TrimPrefix(strings.TrimPrefix(fullPath, scope), "/")
|
||||
if item.Type == "directory" {
|
||||
fullPath += "/"
|
||||
scopedPath += "/"
|
||||
}
|
||||
value, found := sessionInProgress.Load(sourceSession)
|
||||
if !found || value != runningHash {
|
||||
si.mu.Unlock()
|
||||
return []searchResult{}
|
||||
idx.mu.Unlock()
|
||||
return []SearchResult{}
|
||||
}
|
||||
if count > maxSearchResults {
|
||||
break
|
||||
}
|
||||
matches := item.containsSearchTerm(searchTerm, searchOptions)
|
||||
if matches {
|
||||
scopedPath := strings.TrimPrefix(strings.TrimPrefix(fullPath, scope), "/")
|
||||
results[scopedPath] = searchResult{Path: scopedPath, Type: item.Type, Size: item.Size}
|
||||
results[scopedPath] = SearchResult{Path: scopedPath, Type: item.Type, Size: item.Size}
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
si.mu.Unlock()
|
||||
idx.mu.Unlock()
|
||||
}
|
||||
|
||||
// Sort keys based on the number of elements in the path after splitting by "/"
|
||||
sortedKeys := make([]searchResult, 0, len(results))
|
||||
sortedKeys := make([]SearchResult, 0, len(results))
|
||||
for _, v := range results {
|
||||
sortedKeys = append(sortedKeys, v)
|
||||
}
|
||||
@@ -103,7 +107,7 @@ func (si *Index) Search(search string, scope string, sourceSession string) []sea
|
||||
// returns true if the file name contains the search term
|
||||
// returns file type if the file name contains the search term
|
||||
// returns size of file/dir if the file name contains the search term
|
||||
func (fi ReducedItem) containsSearchTerm(searchTerm string, options *SearchOptions) bool {
|
||||
func (fi ItemInfo) containsSearchTerm(searchTerm string, options SearchOptions) bool {
|
||||
|
||||
fileTypes := map[string]bool{}
|
||||
largerThan := int64(options.LargerThan) * 1024 * 1024
|
||||
@@ -165,11 +169,11 @@ func (fi ReducedItem) containsSearchTerm(searchTerm string, options *SearchOptio
|
||||
return true
|
||||
}
|
||||
|
||||
func (si *Index) getDirsInScope(scope string) []string {
|
||||
func (idx *Index) getDirsInScope(scope string) []string {
|
||||
newList := []string{}
|
||||
si.mu.Lock()
|
||||
defer si.mu.Unlock()
|
||||
for k := range si.Directories {
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
for k := range idx.Directories {
|
||||
if strings.HasPrefix(k, scope) || scope == "" {
|
||||
newList = append(newList, k)
|
||||
}
|
||||
|
||||
@@ -4,14 +4,15 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func BenchmarkSearchAllIndexes(b *testing.B) {
|
||||
InitializeIndex(5, false)
|
||||
si := GetIndex(rootPath)
|
||||
Initialize(settings.Source{Name: "test", Path: "/srv"})
|
||||
idx := GetIndex("test")
|
||||
|
||||
si.createMockData(50, 3) // 50 dirs, 3 files per dir
|
||||
idx.createMockData(50, 3) // 50 dirs, 3 files per dir
|
||||
|
||||
// Generate 100 random search terms
|
||||
searchTerms := generateRandomSearchTerms(100)
|
||||
@@ -21,7 +22,7 @@ func BenchmarkSearchAllIndexes(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Execute the SearchAllIndexes function
|
||||
for _, term := range searchTerms {
|
||||
si.Search(term, "/", "test")
|
||||
idx.Search(term, "/", "test")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,25 +30,25 @@ func BenchmarkSearchAllIndexes(b *testing.B) {
|
||||
func TestParseSearch(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want *SearchOptions
|
||||
want SearchOptions
|
||||
}{
|
||||
{
|
||||
input: "my test search",
|
||||
want: &SearchOptions{
|
||||
want: SearchOptions{
|
||||
Conditions: map[string]bool{"exact": false},
|
||||
Terms: []string{"my test search"},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "case:exact my|test|search",
|
||||
want: &SearchOptions{
|
||||
want: SearchOptions{
|
||||
Conditions: map[string]bool{"exact": true},
|
||||
Terms: []string{"my", "test", "search"},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "type:largerThan=100 type:smallerThan=1000 test",
|
||||
want: &SearchOptions{
|
||||
want: SearchOptions{
|
||||
Conditions: map[string]bool{"exact": false, "larger": true, "smaller": true},
|
||||
Terms: []string{"test"},
|
||||
LargerThan: 100,
|
||||
@@ -56,7 +57,7 @@ func TestParseSearch(t *testing.T) {
|
||||
},
|
||||
{
|
||||
input: "type:audio thisfile",
|
||||
want: &SearchOptions{
|
||||
want: SearchOptions{
|
||||
Conditions: map[string]bool{"exact": false, "audio": true},
|
||||
Terms: []string{"thisfile"},
|
||||
},
|
||||
@@ -74,14 +75,14 @@ func TestParseSearch(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSearchWhileIndexing(t *testing.T) {
|
||||
InitializeIndex(5, false)
|
||||
si := GetIndex(rootPath)
|
||||
Initialize(settings.Source{Name: "test", Path: "/srv"})
|
||||
idx := GetIndex("test")
|
||||
|
||||
searchTerms := generateRandomSearchTerms(10)
|
||||
for i := 0; i < 5; i++ {
|
||||
go si.createMockData(100, 100) // Creating mock data concurrently
|
||||
go idx.createMockData(100, 100) // Creating mock data concurrently
|
||||
for _, term := range searchTerms {
|
||||
go si.Search(term, "/", "test") // Search concurrently
|
||||
go idx.Search(term, "/", "test") // Search concurrently
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,27 +90,29 @@ func TestSearchWhileIndexing(t *testing.T) {
|
||||
func TestSearchIndexes(t *testing.T) {
|
||||
index := Index{
|
||||
Directories: map[string]*FileInfo{
|
||||
"/test": {Files: []ReducedItem{{Name: "audio1.wav", Type: "audio"}}},
|
||||
"/test/path": {Files: []ReducedItem{{Name: "file.txt", Type: "text"}}},
|
||||
"/new/test": {Files: []ReducedItem{
|
||||
"/test": {Files: []ItemInfo{{Name: "audio1.wav", Type: "audio"}}},
|
||||
"/test/path": {Files: []ItemInfo{{Name: "file.txt", Type: "text"}}},
|
||||
"/new/test": {Files: []ItemInfo{
|
||||
{Name: "audio.wav", Type: "audio"},
|
||||
{Name: "video.mp4", Type: "video"},
|
||||
{Name: "video.MP4", Type: "video"},
|
||||
}},
|
||||
"/new/test/path": {Files: []ReducedItem{{Name: "archive.zip", Type: "archive"}}},
|
||||
"/new/test/path": {Files: []ItemInfo{{Name: "archive.zip", Type: "archive"}}},
|
||||
"/firstDir": {
|
||||
Files: []ReducedItem{
|
||||
Files: []ItemInfo{
|
||||
{Name: "archive.zip", Size: 100, Type: "archive"},
|
||||
},
|
||||
Dirs: map[string]*FileInfo{
|
||||
"thisIsDir": {Name: "thisIsDir", Size: 2 * 1024 * 1024},
|
||||
Folders: []ItemInfo{
|
||||
{Name: "thisIsDir", Type: "directory", Size: 2 * 1024 * 1024},
|
||||
},
|
||||
},
|
||||
"/firstDir/thisIsDir": {
|
||||
Files: []ReducedItem{
|
||||
Files: []ItemInfo{
|
||||
{Name: "hi.txt", Type: "text"},
|
||||
},
|
||||
Size: 2 * 1024 * 1024,
|
||||
ItemInfo: ItemInfo{
|
||||
Size: 2 * 1024 * 1024,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -117,12 +120,12 @@ func TestSearchIndexes(t *testing.T) {
|
||||
tests := []struct {
|
||||
search string
|
||||
scope string
|
||||
expectedResult []searchResult
|
||||
expectedResult []SearchResult
|
||||
}{
|
||||
{
|
||||
search: "audio",
|
||||
scope: "/new/",
|
||||
expectedResult: []searchResult{
|
||||
expectedResult: []SearchResult{
|
||||
{
|
||||
Path: "test/audio.wav",
|
||||
Type: "audio",
|
||||
@@ -133,7 +136,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
{
|
||||
search: "test",
|
||||
scope: "/",
|
||||
expectedResult: []searchResult{
|
||||
expectedResult: []SearchResult{
|
||||
{
|
||||
Path: "test/",
|
||||
Type: "directory",
|
||||
@@ -149,7 +152,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
{
|
||||
search: "archive",
|
||||
scope: "/",
|
||||
expectedResult: []searchResult{
|
||||
expectedResult: []SearchResult{
|
||||
{
|
||||
Path: "firstDir/archive.zip",
|
||||
Type: "archive",
|
||||
@@ -165,7 +168,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
{
|
||||
search: "arch",
|
||||
scope: "/firstDir",
|
||||
expectedResult: []searchResult{
|
||||
expectedResult: []SearchResult{
|
||||
{
|
||||
Path: "archive.zip",
|
||||
Type: "archive",
|
||||
@@ -176,7 +179,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
{
|
||||
search: "isdir",
|
||||
scope: "/",
|
||||
expectedResult: []searchResult{
|
||||
expectedResult: []SearchResult{
|
||||
{
|
||||
Path: "firstDir/thisIsDir/",
|
||||
Type: "directory",
|
||||
@@ -187,7 +190,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
{
|
||||
search: "IsDir type:largerThan=1",
|
||||
scope: "/",
|
||||
expectedResult: []searchResult{
|
||||
expectedResult: []SearchResult{
|
||||
{
|
||||
Path: "firstDir/thisIsDir/",
|
||||
Type: "directory",
|
||||
@@ -198,7 +201,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
{
|
||||
search: "video",
|
||||
scope: "/",
|
||||
expectedResult: []searchResult{
|
||||
expectedResult: []SearchResult{
|
||||
{
|
||||
Path: "new/test/video.MP4",
|
||||
Type: "video",
|
||||
|
||||
+44
-107
@@ -1,138 +1,75 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"log"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
)
|
||||
|
||||
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
|
||||
func (si *Index) UpdateMetadata(info *FileInfo) bool {
|
||||
si.mu.Lock()
|
||||
defer si.mu.Unlock()
|
||||
info.CacheTime = time.Now()
|
||||
si.Directories[info.Path] = info
|
||||
func (idx *Index) UpdateMetadata(info *FileInfo) bool {
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
idx.Directories[info.Path] = info
|
||||
return true
|
||||
}
|
||||
|
||||
// GetMetadataInfo retrieves the FileInfo from the specified directory in the index.
|
||||
func (si *Index) GetReducedMetadata(target string, isDir bool) (*FileInfo, bool) {
|
||||
si.mu.RLock()
|
||||
defer si.mu.RUnlock()
|
||||
checkDir := si.makeIndexPath(target)
|
||||
func (idx *Index) GetReducedMetadata(target string, isDir bool) (*FileInfo, bool) {
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
checkDir := idx.makeIndexPath(target)
|
||||
if !isDir {
|
||||
checkDir = si.makeIndexPath(filepath.Dir(target))
|
||||
checkDir = idx.makeIndexPath(filepath.Dir(target))
|
||||
}
|
||||
dir, exists := si.Directories[checkDir]
|
||||
dir, exists := idx.Directories[checkDir]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
if !isDir {
|
||||
if checkDir == "/" {
|
||||
checkDir = ""
|
||||
}
|
||||
|
||||
baseName := filepath.Base(target)
|
||||
for _, item := range dir.Files {
|
||||
if item.Name == baseName {
|
||||
return &FileInfo{
|
||||
Name: item.Name,
|
||||
Size: item.Size,
|
||||
ModTime: item.ModTime,
|
||||
Type: item.Type,
|
||||
Path: checkDir + "/" + item.Name,
|
||||
}, true
|
||||
}
|
||||
if isDir {
|
||||
return dir, true
|
||||
}
|
||||
// handle file
|
||||
if checkDir == "/" {
|
||||
checkDir = ""
|
||||
}
|
||||
baseName := filepath.Base(target)
|
||||
for _, item := range dir.Files {
|
||||
if item.Name == baseName {
|
||||
return &FileInfo{
|
||||
Path: checkDir + "/" + item.Name,
|
||||
ItemInfo: item,
|
||||
}, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
cleanedItems := []ReducedItem{}
|
||||
for name, item := range dir.Dirs {
|
||||
cleanedItems = append(cleanedItems, ReducedItem{
|
||||
Name: name,
|
||||
Size: item.Size,
|
||||
ModTime: item.ModTime,
|
||||
Type: "directory",
|
||||
})
|
||||
}
|
||||
cleanedItems = append(cleanedItems, dir.Files...)
|
||||
sort.Slice(cleanedItems, func(i, j int) bool {
|
||||
return cleanedItems[i].Name < cleanedItems[j].Name
|
||||
})
|
||||
dirname := filepath.Base(dir.Path)
|
||||
if dirname == "." {
|
||||
dirname = "/"
|
||||
}
|
||||
// construct file info
|
||||
return &FileInfo{
|
||||
Name: dirname,
|
||||
Type: "directory",
|
||||
Items: cleanedItems,
|
||||
ModTime: dir.ModTime,
|
||||
Size: dir.Size,
|
||||
}, true
|
||||
return nil, false
|
||||
|
||||
}
|
||||
|
||||
// GetMetadataInfo retrieves the FileInfo from the specified directory in the index.
|
||||
func (si *Index) GetMetadataInfo(target string, isDir bool) (*FileInfo, bool) {
|
||||
si.mu.RLock()
|
||||
defer si.mu.RUnlock()
|
||||
checkDir := si.makeIndexPath(target)
|
||||
func (idx *Index) GetMetadataInfo(target string, isDir bool) (*FileInfo, bool) {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
checkDir := idx.makeIndexPath(target)
|
||||
if !isDir {
|
||||
checkDir = si.makeIndexPath(filepath.Dir(target))
|
||||
checkDir = idx.makeIndexPath(filepath.Dir(target))
|
||||
}
|
||||
dir, exists := si.Directories[checkDir]
|
||||
dir, exists := idx.Directories[checkDir]
|
||||
return dir, exists
|
||||
}
|
||||
|
||||
func (si *Index) RemoveDirectory(path string) {
|
||||
si.mu.Lock()
|
||||
defer si.mu.Unlock()
|
||||
delete(si.Directories, path)
|
||||
func (idx *Index) RemoveDirectory(path string) {
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
idx.NumDeleted++
|
||||
delete(idx.Directories, path)
|
||||
}
|
||||
|
||||
func (si *Index) UpdateCount(given string) {
|
||||
si.mu.Lock()
|
||||
defer si.mu.Unlock()
|
||||
if given == "files" {
|
||||
si.NumFiles++
|
||||
} else if given == "dirs" {
|
||||
si.NumDirs++
|
||||
} else {
|
||||
log.Println("could not update unknown type: ", given)
|
||||
}
|
||||
}
|
||||
|
||||
func (si *Index) resetCount() {
|
||||
si.mu.Lock()
|
||||
defer si.mu.Unlock()
|
||||
si.NumDirs = 0
|
||||
si.NumFiles = 0
|
||||
si.inProgress = true
|
||||
}
|
||||
|
||||
func GetIndex(root string) *Index {
|
||||
for _, index := range indexes {
|
||||
if index.Root == root {
|
||||
return index
|
||||
}
|
||||
}
|
||||
if settings.Config.Server.Root != "" {
|
||||
rootPath = settings.Config.Server.Root
|
||||
}
|
||||
newIndex := &Index{
|
||||
Root: rootPath,
|
||||
Directories: map[string]*FileInfo{},
|
||||
NumDirs: 0,
|
||||
NumFiles: 0,
|
||||
inProgress: false,
|
||||
}
|
||||
newIndex.Directories["/"] = &FileInfo{}
|
||||
func GetIndex(name string) *Index {
|
||||
indexesMutex.Lock()
|
||||
indexes = append(indexes, newIndex)
|
||||
indexesMutex.Unlock()
|
||||
return newIndex
|
||||
defer indexesMutex.Unlock()
|
||||
index, ok := indexes[name]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return index
|
||||
}
|
||||
+35
-51
@@ -3,6 +3,7 @@ package files
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -34,7 +35,7 @@ func TestGetFileMetadataSize(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fileInfo, _ := testIndex.GetReducedMetadata(tt.adjustedPath, true)
|
||||
// Iterate over fileInfo.Items to look for expectedName
|
||||
for _, item := range fileInfo.Items {
|
||||
for _, item := range fileInfo.Files {
|
||||
// Assert the existence and the name
|
||||
if item.Name == tt.expectedName {
|
||||
assert.Equal(t, tt.expectedSize, item.Size)
|
||||
@@ -89,8 +90,8 @@ func TestGetFileMetadata(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fileInfo, _ := testIndex.GetReducedMetadata(tt.adjustedPath, tt.isDir)
|
||||
if fileInfo == nil {
|
||||
fileInfo, exists := testIndex.GetReducedMetadata(tt.adjustedPath, tt.isDir)
|
||||
if !exists {
|
||||
found := false
|
||||
assert.Equal(t, tt.expectedExists, found)
|
||||
return
|
||||
@@ -98,7 +99,7 @@ func TestGetFileMetadata(t *testing.T) {
|
||||
found := false
|
||||
if tt.isDir {
|
||||
// Iterate over fileInfo.Items to look for expectedName
|
||||
for _, item := range fileInfo.Items {
|
||||
for _, item := range fileInfo.Files {
|
||||
// Assert the existence and the name
|
||||
if item.Name == tt.expectedName {
|
||||
found = true
|
||||
@@ -120,9 +121,7 @@ func TestGetFileMetadata(t *testing.T) {
|
||||
func TestUpdateFileMetadata(t *testing.T) {
|
||||
info := &FileInfo{
|
||||
Path: "/testpath",
|
||||
Name: "testpath",
|
||||
Type: "directory",
|
||||
Files: []ReducedItem{
|
||||
Files: []ItemInfo{
|
||||
{Name: "testfile.txt"},
|
||||
{Name: "anotherfile.txt"},
|
||||
},
|
||||
@@ -165,9 +164,11 @@ func TestSetDirectoryInfo(t *testing.T) {
|
||||
Directories: map[string]*FileInfo{
|
||||
"/testpath": {
|
||||
Path: "/testpath",
|
||||
Name: "testpath",
|
||||
Type: "directory",
|
||||
Items: []ReducedItem{
|
||||
ItemInfo: ItemInfo{
|
||||
Name: "testpath",
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []ItemInfo{
|
||||
{Name: "testfile.txt"},
|
||||
{Name: "anotherfile.txt"},
|
||||
},
|
||||
@@ -176,15 +177,17 @@ func TestSetDirectoryInfo(t *testing.T) {
|
||||
}
|
||||
dir := &FileInfo{
|
||||
Path: "/newPath",
|
||||
Name: "newPath",
|
||||
Type: "directory",
|
||||
Items: []ReducedItem{
|
||||
ItemInfo: ItemInfo{
|
||||
Name: "newPath",
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []ItemInfo{
|
||||
{Name: "testfile.txt"},
|
||||
},
|
||||
}
|
||||
index.UpdateMetadata(dir)
|
||||
storedDir, exists := index.Directories["/newPath"]
|
||||
if !exists || storedDir.Items[0].Name != "testfile.txt" {
|
||||
if !exists || storedDir.Files[0].Name != "testfile.txt" {
|
||||
t.Fatalf("expected SetDirectoryInfo to store directory info correctly")
|
||||
}
|
||||
}
|
||||
@@ -203,56 +206,37 @@ func TestRemoveDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test for UpdateCount
|
||||
func TestUpdateCount(t *testing.T) {
|
||||
index := &Index{}
|
||||
index.UpdateCount("files")
|
||||
if index.NumFiles != 1 {
|
||||
t.Fatalf("expected NumFiles to be 1 after UpdateCount('files')")
|
||||
}
|
||||
if index.NumFiles != 1 {
|
||||
t.Fatalf("expected NumFiles to be 1 after UpdateCount('files')")
|
||||
}
|
||||
index.UpdateCount("dirs")
|
||||
if index.NumDirs != 1 {
|
||||
t.Fatalf("expected NumDirs to be 1 after UpdateCount('dirs')")
|
||||
}
|
||||
index.UpdateCount("unknown")
|
||||
// Just ensure it does not panic or update any counters
|
||||
if index.NumFiles != 1 || index.NumDirs != 1 {
|
||||
t.Fatalf("expected counts to remain unchanged for unknown type")
|
||||
}
|
||||
index.resetCount()
|
||||
if index.NumFiles != 0 || index.NumDirs != 0 || !index.inProgress {
|
||||
t.Fatalf("expected resetCount to reset counts and set inProgress to true")
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
testIndex = Index{
|
||||
Root: "/",
|
||||
NumFiles: 10,
|
||||
NumDirs: 5,
|
||||
inProgress: false,
|
||||
Source: settings.Source{
|
||||
Path: "/",
|
||||
Name: "test",
|
||||
},
|
||||
NumFiles: 10,
|
||||
NumDirs: 5,
|
||||
Directories: map[string]*FileInfo{
|
||||
"/testpath": {
|
||||
Path: "/testpath",
|
||||
Name: "testpath",
|
||||
Type: "directory",
|
||||
Files: []ReducedItem{
|
||||
ItemInfo: ItemInfo{
|
||||
Name: "testpath",
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []ItemInfo{
|
||||
{Name: "testfile.txt", Size: 100},
|
||||
{Name: "anotherfile.txt", Size: 100},
|
||||
},
|
||||
},
|
||||
"/anotherpath": {
|
||||
Path: "/anotherpath",
|
||||
Name: "anotherpath",
|
||||
Type: "directory",
|
||||
Files: []ReducedItem{
|
||||
ItemInfo: ItemInfo{
|
||||
Name: "anotherpath",
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []ItemInfo{
|
||||
{Name: "afile.txt", Size: 100},
|
||||
},
|
||||
Dirs: map[string]*FileInfo{
|
||||
"directory": {Name: "directory", Type: "directory", Size: 100},
|
||||
Folders: []ItemInfo{
|
||||
{Name: "directory", Type: "directory", Size: 100},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -26,9 +26,25 @@ func MoveFile(src, dst string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyFile copies a file from source to dest and returns
|
||||
// an error if any.
|
||||
// CopyFile copies a file or directory from source to dest and returns an error if any.
|
||||
func CopyFile(source, dest string) error {
|
||||
// Check if the source exists and whether it's a file or directory.
|
||||
info, err := os.Stat(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
// If the source is a directory, copy it recursively.
|
||||
return copyDirectory(source, dest)
|
||||
}
|
||||
|
||||
// If the source is a file, copy the file.
|
||||
return copySingleFile(source, dest)
|
||||
}
|
||||
|
||||
// copySingleFile handles copying a single file.
|
||||
func copySingleFile(source, dest string) error {
|
||||
// Open the source file.
|
||||
src, err := os.Open(source)
|
||||
if err != nil {
|
||||
@@ -36,7 +52,7 @@ func CopyFile(source, dest string) error {
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// Makes the directory needed to create the dst file.
|
||||
// Create the destination directory if needed.
|
||||
err = os.MkdirAll(filepath.Dir(dest), 0775) //nolint:gomnd
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -68,6 +84,43 @@ func CopyFile(source, dest string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyDirectory handles copying directories recursively.
|
||||
func copyDirectory(source, dest string) error {
|
||||
// Create the destination directory.
|
||||
err := os.MkdirAll(dest, 0775) //nolint:gomnd
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read the contents of the source directory.
|
||||
entries, err := os.ReadDir(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Iterate over each entry in the directory.
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(source, entry.Name())
|
||||
destPath := filepath.Join(dest, entry.Name())
|
||||
|
||||
if entry.IsDir() {
|
||||
// Recursively copy subdirectories.
|
||||
err = copyDirectory(srcPath, destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Copy files.
|
||||
err = copySingleFile(srcPath, destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommonPrefix returns the common directory path of provided files.
|
||||
func CommonPrefix(sep byte, paths ...string) string {
|
||||
// Handle special cases.
|
||||
|
||||
+9
-19
@@ -1,4 +1,4 @@
|
||||
module github.com/gtsteffaniak/filebrowser
|
||||
module github.com/gtsteffaniak/filebrowser/backend
|
||||
|
||||
go 1.22.5
|
||||
|
||||
@@ -7,11 +7,9 @@ require (
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/dsoprea/go-exif/v3 v3.0.1
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568
|
||||
github.com/goccy/go-yaml v1.14.3
|
||||
github.com/goccy/go-yaml v1.15.13
|
||||
github.com/golang-jwt/jwt/v4 v4.5.1
|
||||
github.com/google/go-cmp v0.6.0
|
||||
github.com/marusama/semaphore/v2 v2.5.0
|
||||
github.com/mholt/archiver/v3 v3.5.1
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/spf13/afero v1.11.0
|
||||
github.com/spf13/cobra v1.8.1
|
||||
@@ -19,16 +17,14 @@ require (
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.4
|
||||
golang.org/x/crypto v0.29.0
|
||||
golang.org/x/image v0.22.0
|
||||
golang.org/x/text v0.20.0
|
||||
golang.org/x/crypto v0.32.0
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/text v0.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
|
||||
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
@@ -41,21 +37,15 @@ require (
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/nwaples/rardecode v1.1.3 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/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/ulikunitz/xz v0.5.12 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.etcd.io/bbolt v1.3.11 // indirect
|
||||
golang.org/x/net v0.31.0 // indirect
|
||||
golang.org/x/sys v0.27.0 // indirect
|
||||
golang.org/x/tools v0.27.0 // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/tools v0.29.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
+18
-53
@@ -4,9 +4,6 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
|
||||
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
|
||||
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
@@ -14,9 +11,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
|
||||
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
|
||||
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
|
||||
github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
|
||||
@@ -53,8 +47,8 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z
|
||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/goccy/go-yaml v1.14.3 h1:8tVD+aqqPLWisSEhM+6wWoiURWXCx6BwaTKS6ZeITgM=
|
||||
github.com/goccy/go-yaml v1.14.3/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.15.13 h1:Xd87Yddmr2rC1SLLTm2MNDcTjeO/GYo0JGiww6gSTDg=
|
||||
github.com/goccy/go-yaml v1.15.13/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
@@ -67,10 +61,8 @@ github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
@@ -79,14 +71,6 @@ github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJS
|
||||
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -94,18 +78,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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/marusama/semaphore/v2 v2.5.0 h1:o/1QJD9DBYOWRnDhPwDVAXQn6mQYD0gZaS1Tpx6DJGM=
|
||||
github.com/marusama/semaphore/v2 v2.5.0/go.mod h1:z9nMiNUekt/LTpTUQdpp+4sJeYqUGpwMHfW0Z8V8fnQ=
|
||||
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
|
||||
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
|
||||
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
|
||||
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/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=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
@@ -130,16 +104,8 @@ github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64
|
||||
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
|
||||
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
||||
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
||||
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
@@ -148,11 +114,11 @@ go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
|
||||
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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
|
||||
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g=
|
||||
golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
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=
|
||||
@@ -167,12 +133,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.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
|
||||
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
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/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -187,8 +153,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.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -197,15 +163,14 @@ 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.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
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/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
|
||||
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
|
||||
+39
-8
@@ -2,9 +2,10 @@ package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
libError "errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -12,11 +13,15 @@ import (
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v4/request"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -122,14 +127,14 @@ func signupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
user.Username = info.Username
|
||||
user.Password = info.Password
|
||||
|
||||
userHome, err := config.MakeUserDir(user.Username, user.Scope, config.Server.Root)
|
||||
userHome, err := config.MakeUserDir(user.Username, user.Scope, files.RootPaths["default"])
|
||||
if err != nil {
|
||||
log.Printf("create user: failed to mkdir user home dir: [%s]", userHome)
|
||||
logger.Error(fmt.Sprintf("create user: failed to mkdir user home dir: [%s]", userHome))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
user.Scope = userHome
|
||||
log.Printf("new user: %s, home dir: [%s].", user.Username, userHome)
|
||||
logger.Debug(fmt.Sprintf("new user: %s, home dir: [%s].", user.Username, userHome))
|
||||
err = store.Users.Save(&user)
|
||||
if err == errors.ErrExist {
|
||||
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
|
||||
@@ -207,3 +212,29 @@ func makeSignedTokenAPI(user *users.User, name string, duration time.Duration, p
|
||||
}
|
||||
return claim, err
|
||||
}
|
||||
|
||||
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
|
||||
if l.PasswordHash == "" {
|
||||
return 200, nil
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("token") == l.Token {
|
||||
return 200, nil
|
||||
}
|
||||
|
||||
password := r.Header.Get("X-SHARE-PASSWORD")
|
||||
password, err := url.QueryUnescape(password)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
if password == "" {
|
||||
return http.StatusUnauthorized, nil
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
|
||||
if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||
return http.StatusUnauthorized, nil
|
||||
}
|
||||
return 401, err
|
||||
}
|
||||
return 200, nil
|
||||
}
|
||||
+82
-38
@@ -1,17 +1,20 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/files"
|
||||
"github.com/gtsteffaniak/filebrowser/runner"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/runner"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
type requestContext struct {
|
||||
@@ -26,6 +29,8 @@ type HttpResponse struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
var FileInfoFasterFunc = files.FileInfoFaster
|
||||
|
||||
// Updated handleFunc to match the new signature
|
||||
type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error)
|
||||
|
||||
@@ -39,30 +44,30 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
// Get the file link by hash
|
||||
link, err := store.Share.GetByHash(hash)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, err
|
||||
return http.StatusNotFound, fmt.Errorf("share not found")
|
||||
}
|
||||
// Authenticate the share request if needed
|
||||
var status int
|
||||
if link.Hash != "" {
|
||||
status, err = authenticateShareRequest(r, link)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return status, err
|
||||
return status, fmt.Errorf("could not authenticate share request")
|
||||
}
|
||||
}
|
||||
// Retrieve the user (using the public user by default)
|
||||
user := &users.PublicUser
|
||||
|
||||
// Get file information with options
|
||||
file, err := files.FileInfoFaster(files.FileOptions{
|
||||
file, err := FileInfoFasterFunc(files.FileOptions{
|
||||
Path: filepath.Join(user.Scope, link.Path+"/"+path),
|
||||
Modify: user.Perm.Modify,
|
||||
Expand: true,
|
||||
ReadHeader: config.Server.TypeDetectionByHeader,
|
||||
Checker: user, // Call your checker function here
|
||||
Token: link.Token,
|
||||
})
|
||||
file.Token = link.Token
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
return errToStatus(err), fmt.Errorf("error fetching share from server")
|
||||
}
|
||||
|
||||
// Set the file info in the `data` object
|
||||
@@ -89,6 +94,15 @@ func withAdminHelper(fn handleFunc) handleFunc {
|
||||
// Middleware to retrieve and authenticate user
|
||||
func withUserHelper(fn handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||
if settings.Config.Auth.Method == "noauth" {
|
||||
var err error
|
||||
// Retrieve the user from the store and store it in the context
|
||||
data.user, err = store.Users.Get(files.RootPaths["default"], "admin")
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
return fn(w, r, data)
|
||||
}
|
||||
keyFunc := func(token *jwt.Token) (interface{}, error) {
|
||||
return config.Auth.Key, nil
|
||||
}
|
||||
@@ -113,10 +127,12 @@ func withUserHelper(fn handleFunc) handleFunc {
|
||||
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)
|
||||
data.user, err = store.Users.Get(files.RootPaths["default"], tk.BelongsTo)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
setUserInResponseWriter(w, data.user)
|
||||
|
||||
// Call the handler function, passing in the context
|
||||
return fn(w, r, data)
|
||||
}
|
||||
@@ -158,14 +174,14 @@ func wrapHandler(fn handleFunc) http.HandlerFunc {
|
||||
// Marshal the error response to JSON
|
||||
errorBytes, marshalErr := json.Marshal(response)
|
||||
if marshalErr != nil {
|
||||
log.Printf("Error marshalling error response: %v", marshalErr)
|
||||
logger.Error(fmt.Sprintf("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)
|
||||
logger.Error(fmt.Sprintf("Error writing error response: %v", writeErr))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -218,6 +234,8 @@ type ResponseWriterWrapper struct {
|
||||
http.ResponseWriter
|
||||
StatusCode int
|
||||
wroteHeader bool
|
||||
PayloadSize int
|
||||
User string
|
||||
}
|
||||
|
||||
// WriteHeader captures the status code and ensures it's only written once
|
||||
@@ -240,52 +258,78 @@ func (w *ResponseWriterWrapper) Write(b []byte) (int, error) {
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// LoggingMiddleware logs each request and its status code
|
||||
// Helper function to set the user in the ResponseWriterWrapper
|
||||
func setUserInResponseWriter(w http.ResponseWriter, user *users.User) {
|
||||
// Wrap the response writer to set the user field
|
||||
if wrappedWriter, ok := w.(*ResponseWriterWrapper); ok {
|
||||
if user != nil {
|
||||
wrappedWriter.User = user.Username
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LoggingMiddleware logs each request and its status code.
|
||||
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Wrap the ResponseWriter to capture the status code
|
||||
// Wrap the ResponseWriter to capture the status code.
|
||||
wrappedWriter := &ResponseWriterWrapper{ResponseWriter: w, StatusCode: http.StatusOK}
|
||||
|
||||
// Call the next handler
|
||||
// Call the next handler.
|
||||
next.ServeHTTP(wrappedWriter, r)
|
||||
|
||||
// Determine the color based on the status code
|
||||
color := "\033[32m" // Default green color
|
||||
if wrappedWriter.StatusCode >= 300 && wrappedWriter.StatusCode < 500 {
|
||||
color = "\033[33m" // Yellow for client errors (4xx)
|
||||
} else if wrappedWriter.StatusCode >= 500 {
|
||||
color = "\033[31m" // Red for server errors (5xx)
|
||||
}
|
||||
// Capture the full URL path including the query parameters
|
||||
// Capture the full URL path including the query parameters.
|
||||
fullURL := r.URL.Path
|
||||
if r.URL.RawQuery != "" {
|
||||
fullURL += "?" + r.URL.RawQuery
|
||||
}
|
||||
|
||||
// Log the request and its status code
|
||||
log.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
|
||||
)
|
||||
truncUser := wrappedWriter.User
|
||||
if len(truncUser) > 12 {
|
||||
truncUser = truncUser[:10] + ".."
|
||||
}
|
||||
logger.Api(
|
||||
fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"",
|
||||
r.Method,
|
||||
wrappedWriter.StatusCode, // Captured status code
|
||||
r.RemoteAddr,
|
||||
truncUser,
|
||||
time.Since(start).String(),
|
||||
fullURL), wrappedWriter.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func renderJSON(w http.ResponseWriter, _ *http.Request, data interface{}) (int, error) {
|
||||
func renderJSON(w http.ResponseWriter, r *http.Request, data interface{}) (int, error) {
|
||||
marsh, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if _, err := w.Write(marsh); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
// Calculate size in KB
|
||||
payloadSizeKB := len(marsh) / 1024
|
||||
// Check if the client accepts gzip encoding and hasn't explicitly disabled it
|
||||
if acceptsGzip(r) && payloadSizeKB > 10 {
|
||||
// Enable gzip compression
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
|
||||
if _, err := gz.Write(marsh); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
} else {
|
||||
// Normal response without compression
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if _, err := w.Write(marsh); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func acceptsGzip(r *http.Request) bool {
|
||||
ae := r.Header.Get("Accept-Encoding")
|
||||
return ae != "" && strings.Contains(ae, "gzip")
|
||||
}
|
||||
@@ -8,14 +8,15 @@ import (
|
||||
"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"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/diskcache"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/img"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/storage/bolt"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/utils"
|
||||
)
|
||||
|
||||
func setupTestEnv(t *testing.T) {
|
||||
@@ -37,6 +38,27 @@ func setupTestEnv(t *testing.T) {
|
||||
fileCache = diskcache.NewNoOp() // mocked
|
||||
imgSvc = img.New(1) // mocked
|
||||
config = &settings.Config // mocked
|
||||
mockFileInfoFaster(t) // Mock FileInfoFasterFunc for this test
|
||||
}
|
||||
|
||||
func mockFileInfoFaster(t *testing.T) {
|
||||
// Backup the original function
|
||||
originalFileInfoFaster := FileInfoFasterFunc
|
||||
// Defer restoration of the original function
|
||||
t.Cleanup(func() { FileInfoFasterFunc = originalFileInfoFaster })
|
||||
|
||||
// Mock the function to skip execution
|
||||
FileInfoFasterFunc = func(opts files.FileOptions) (files.ExtendedFileInfo, error) {
|
||||
return files.ExtendedFileInfo{
|
||||
FileInfo: &files.FileInfo{
|
||||
Path: opts.Path,
|
||||
ItemInfo: files.ItemInfo{
|
||||
Name: "mocked_file",
|
||||
Size: 12345,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithAdminHelper(t *testing.T) {
|
||||
@@ -197,10 +219,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
req := newTestRequest(t, tc.share.Hash, tc.token, tc.password, tc.extraHeaders)
|
||||
|
||||
// Serve the request
|
||||
status, err := handler(recorder, req, &requestContext{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
status, _ := handler(recorder, req, &requestContext{})
|
||||
|
||||
// Check if the response matches the expected status code
|
||||
if status != tc.expectedStatusCode {
|
||||
|
||||
+41
-26
@@ -8,9 +8,12 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/files"
|
||||
"github.com/gtsteffaniak/filebrowser/img"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/img"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/logger"
|
||||
)
|
||||
|
||||
type ImgService interface {
|
||||
@@ -41,35 +44,32 @@ type FileCache interface {
|
||||
// @Router /api/preview [get]
|
||||
func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
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{
|
||||
response, err := files.FileInfoFaster(files.FileOptions{
|
||||
Path: filepath.Join(d.user.Scope, path),
|
||||
Modify: d.user.Perm.Modify,
|
||||
Source: source,
|
||||
Expand: true,
|
||||
ReadHeader: config.Server.TypeDetectionByHeader,
|
||||
Checker: d.user,
|
||||
})
|
||||
fileInfo := response.FileInfo
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
realPath, _, err := files.GetRealPath(file.Path)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
file.Path = realPath
|
||||
if file.Type == "directory" {
|
||||
if fileInfo.Type == "directory" {
|
||||
return http.StatusBadRequest, fmt.Errorf("can't create preview for directory")
|
||||
}
|
||||
setContentDisposition(w, r, file)
|
||||
if file.Type != "image" {
|
||||
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", file.Type)
|
||||
setContentDisposition(w, r, fileInfo.Name)
|
||||
if !strings.HasPrefix(fileInfo.Type, "image") {
|
||||
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", fileInfo.Type)
|
||||
}
|
||||
|
||||
if (previewSize == "large" && !config.Server.ResizePreview) ||
|
||||
@@ -77,40 +77,39 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
if !d.user.Perm.Download {
|
||||
return http.StatusAccepted, nil
|
||||
}
|
||||
return rawFileHandler(w, r, file)
|
||||
return rawFileHandler(w, r, fileInfo)
|
||||
}
|
||||
|
||||
format, err := imgSvc.FormatFromExtension(filepath.Ext(file.Name))
|
||||
format, err := imgSvc.FormatFromExtension(filepath.Ext(fileInfo.Name))
|
||||
// Unsupported extensions directly return the raw data
|
||||
if err == img.ErrUnsupportedFormat || format == img.FormatGif {
|
||||
if !d.user.Perm.Download {
|
||||
return http.StatusAccepted, nil
|
||||
}
|
||||
return rawFileHandler(w, r, file)
|
||||
return rawFileHandler(w, r, fileInfo)
|
||||
}
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
cacheKey := previewCacheKey(file, previewSize)
|
||||
cacheKey := previewCacheKey(response.RealPath, previewSize, fileInfo.ModTime)
|
||||
resizedImage, ok, err := fileCache.Load(r.Context(), cacheKey)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
if !ok {
|
||||
resizedImage, err = createPreview(imgSvc, fileCache, file, previewSize)
|
||||
resizedImage, err = createPreview(imgSvc, fileCache, response, previewSize)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "private")
|
||||
http.ServeContent(w, r, file.Path, file.ModTime, bytes.NewReader(resizedImage))
|
||||
|
||||
http.ServeContent(w, r, response.RealPath, fileInfo.ModTime, bytes.NewReader(resizedImage))
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func createPreview(imgSvc ImgService, fileCache FileCache, file *files.FileInfo, previewSize string) ([]byte, error) {
|
||||
fd, err := os.Open(file.Path)
|
||||
func createPreview(imgSvc ImgService, fileCache FileCache, file files.ExtendedFileInfo, previewSize string) ([]byte, error) {
|
||||
fd, err := os.Open(file.RealPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -141,9 +140,9 @@ func createPreview(imgSvc ImgService, fileCache FileCache, file *files.FileInfo,
|
||||
}
|
||||
|
||||
go func() {
|
||||
cacheKey := previewCacheKey(file, previewSize)
|
||||
cacheKey := previewCacheKey(file.RealPath, previewSize, file.FileInfo.ModTime)
|
||||
if err := fileCache.Store(context.Background(), cacheKey, buf.Bytes()); err != nil {
|
||||
fmt.Printf("failed to cache resized image: %v", err)
|
||||
logger.Error(fmt.Sprintf("failed to cache resized image: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -151,6 +150,22 @@ func createPreview(imgSvc ImgService, fileCache FileCache, file *files.FileInfo,
|
||||
}
|
||||
|
||||
// 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)
|
||||
func previewCacheKey(realPath, previewSize string, modTime time.Time) string {
|
||||
return fmt.Sprintf("%x%x%x", realPath, modTime.Unix(), previewSize)
|
||||
}
|
||||
|
||||
func rawFileHandler(w http.ResponseWriter, r *http.Request, file *files.FileInfo) (int, error) {
|
||||
idx := files.GetIndex("default")
|
||||
realPath, _, _ := idx.GetRealPath(file.Path)
|
||||
fd, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
setContentDisposition(w, r, file.Name)
|
||||
|
||||
w.Header().Set("Cache-Control", "private")
|
||||
http.ServeContent(w, r, file.Name, file.ModTime, fd)
|
||||
return 0, nil
|
||||
}
|
||||
+6
-55
@@ -2,28 +2,22 @@ package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/files"
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/share"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
|
||||
_ "github.com/gtsteffaniak/filebrowser/swagger/docs"
|
||||
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
|
||||
)
|
||||
|
||||
func publicShareHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
file, ok := d.raw.(*files.FileInfo)
|
||||
file, ok := d.raw.(files.ExtendedFileInfo)
|
||||
if !ok {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to assert type *files.FileInfo")
|
||||
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, files.RootPaths["default"])
|
||||
return renderJSON(w, r, file)
|
||||
}
|
||||
|
||||
@@ -34,49 +28,6 @@ func publicUserGetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(status), status)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func publicDlHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
file, _ := d.raw.(*files.FileInfo)
|
||||
if file == nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to assert type files.FileInfo")
|
||||
}
|
||||
if d.user == nil {
|
||||
return http.StatusUnauthorized, fmt.Errorf("failed to get user")
|
||||
}
|
||||
|
||||
if file.Type == "directory" {
|
||||
return rawDirHandler(w, r, d, file)
|
||||
}
|
||||
|
||||
return rawFileHandler(w, r, file)
|
||||
}
|
||||
|
||||
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
|
||||
if l.PasswordHash == "" {
|
||||
return 200, nil
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("token") == l.Token {
|
||||
return 200, nil
|
||||
}
|
||||
|
||||
password := r.Header.Get("X-SHARE-PASSWORD")
|
||||
password, err := url.QueryUnescape(password)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
if password == "" {
|
||||
return http.StatusUnauthorized, nil
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
|
||||
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||
return http.StatusUnauthorized, nil
|
||||
}
|
||||
return 401, err
|
||||
}
|
||||
return 200, nil
|
||||
}
|
||||
|
||||
// health godoc
|
||||
|
||||
+197
-155
@@ -1,79 +1,28 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"log"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
gopath "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mholt/archiver/v3"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/files"
|
||||
"github.com/gtsteffaniak/filebrowser/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/logger"
|
||||
)
|
||||
|
||||
func slashClean(name string) string {
|
||||
if name == "" || name[0] != '/' {
|
||||
name = "/" + name
|
||||
}
|
||||
return gopath.Clean(name)
|
||||
}
|
||||
|
||||
func parseQueryFiles(r *http.Request, f *files.FileInfo, _ *users.User) ([]string, error) {
|
||||
var fileSlice []string
|
||||
names := strings.Split(r.URL.Query().Get("files"), ",")
|
||||
|
||||
if len(names) == 0 {
|
||||
fileSlice = append(fileSlice, f.Path)
|
||||
} else {
|
||||
for _, name := range names {
|
||||
name, err := url.QueryUnescape(strings.Replace(name, "+", "%2B", -1)) //nolint:govet
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name = slashClean(name)
|
||||
fileSlice = append(fileSlice, filepath.Join(f.Path, name))
|
||||
}
|
||||
}
|
||||
|
||||
return fileSlice, nil
|
||||
}
|
||||
|
||||
// nolint: goconst,nolintlint
|
||||
func parseQueryAlgorithm(r *http.Request) (string, archiver.Writer, error) {
|
||||
// TODO: use enum
|
||||
switch r.URL.Query().Get("algo") {
|
||||
case "zip", "true", "":
|
||||
return ".zip", archiver.NewZip(), nil
|
||||
case "tar":
|
||||
return ".tar", archiver.NewTar(), nil
|
||||
case "targz":
|
||||
return ".tar.gz", archiver.NewTarGz(), nil
|
||||
case "tarbz2":
|
||||
return ".tar.bz2", archiver.NewTarBz2(), nil
|
||||
case "tarxz":
|
||||
return ".tar.xz", archiver.NewTarXz(), nil
|
||||
case "tarlz4":
|
||||
return ".tar.lz4", archiver.NewTarLz4(), nil
|
||||
case "tarsz":
|
||||
return ".tar.sz", archiver.NewTarSz(), nil
|
||||
default:
|
||||
return "", nil, errors.New("format not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
func setContentDisposition(w http.ResponseWriter, r *http.Request, file *files.FileInfo) {
|
||||
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
|
||||
if r.URL.Query().Get("inline") == "true" {
|
||||
w.Header().Set("Content-Disposition", "inline")
|
||||
} else {
|
||||
// As per RFC6266 section 4.3
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(file.Name))
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(fileName))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,146 +32,239 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, file *files.F
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "Path to the file or directory"
|
||||
// @Param files query string false "Comma-separated list of specific files within the directory (optional)"
|
||||
// @Param files query string true "Comma-separated list of specific files within the directory (required)"
|
||||
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
|
||||
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip', 'tar', 'targz', 'tarbz2', 'tarxz', 'tarlz4', 'tarsz'. Default is 'zip'."
|
||||
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
|
||||
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
|
||||
// @Failure 202 {object} map[string]string "Download permissions required"
|
||||
// @Failure 400 {object} map[string]string "Invalid request path"
|
||||
// @Failure 404 {object} map[string]string "File or directory not found"
|
||||
// @Failure 415 {object} map[string]string "Unsupported file type for preview"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/raw [get]
|
||||
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !d.user.Perm.Download {
|
||||
return http.StatusAccepted, nil
|
||||
}
|
||||
path := r.URL.Query().Get("path")
|
||||
file, err := files.FileInfoFaster(files.FileOptions{
|
||||
Path: filepath.Join(d.user.Scope, path),
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
ReadHeader: config.Server.TypeDetectionByHeader,
|
||||
Checker: d.user,
|
||||
})
|
||||
|
||||
filePrefix := ""
|
||||
file, ok := d.raw.(files.ExtendedFileInfo)
|
||||
if ok {
|
||||
filePrefix = file.Path
|
||||
}
|
||||
encodedFiles := r.URL.Query().Get("files")
|
||||
// Decode the URL-encoded path
|
||||
files, err := url.QueryUnescape(encodedFiles)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
if files.IsNamedPipe(file.Mode) {
|
||||
setContentDisposition(w, r, file)
|
||||
return 0, nil
|
||||
fileList := strings.Split(files, ",")
|
||||
for i, f := range fileList {
|
||||
fileList[i] = filepath.Join(filePrefix, f)
|
||||
}
|
||||
if file.Type == "directory" {
|
||||
return rawDirHandler(w, r, d, file)
|
||||
}
|
||||
|
||||
return rawFileHandler(w, r, file)
|
||||
return rawFilesHandler(w, r, d, fileList)
|
||||
}
|
||||
|
||||
func addFile(ar archiver.Writer, d *requestContext, path, commonPath string) error {
|
||||
if !d.user.Check(path) {
|
||||
func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *zip.Writer, flatten bool) error {
|
||||
idx := files.GetIndex("default")
|
||||
realPath, _, _ := idx.GetRealPath(d.user.Scope, path)
|
||||
if !d.user.Check(realPath) {
|
||||
return nil
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() && !info.Mode().IsRegular() {
|
||||
return nil
|
||||
// Get the base name of the top-level folder or file
|
||||
baseName := filepath.Base(realPath)
|
||||
|
||||
if info.IsDir() {
|
||||
// Walk through directory contents
|
||||
return filepath.Walk(realPath, func(filePath string, fileInfo os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate the relative path
|
||||
relPath, err := filepath.Rel(realPath, filePath) // Use realPath directly
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Normalize for tar: convert \ to /
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
|
||||
// Skip adding `.` (current directory)
|
||||
if relPath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prepend base folder name unless flatten is true
|
||||
if !flatten {
|
||||
relPath = filepath.Join(baseName, relPath)
|
||||
relPath = filepath.ToSlash(relPath) // Ensure normalized separators
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
if tarWriter != nil {
|
||||
header := &tar.Header{
|
||||
Name: relPath + "/",
|
||||
Mode: 0755,
|
||||
Typeflag: tar.TypeDir,
|
||||
ModTime: fileInfo.ModTime(),
|
||||
}
|
||||
return tarWriter.WriteHeader(header)
|
||||
}
|
||||
if zipWriter != nil {
|
||||
_, err := zipWriter.Create(relPath + "/")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return addSingleFile(filePath, relPath, zipWriter, tarWriter)
|
||||
})
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
// For a single file, use the base name as the archive path
|
||||
return addSingleFile(realPath, baseName, zipWriter, tarWriter)
|
||||
}
|
||||
|
||||
func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWriter *tar.Writer) error {
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if path != commonPath {
|
||||
filename := strings.TrimPrefix(path, commonPath)
|
||||
filename = strings.TrimPrefix(filename, string(filepath.Separator))
|
||||
err = ar.Write(archiver.File{
|
||||
FileInfo: archiver.FileInfo{
|
||||
FileInfo: info,
|
||||
CustomName: filename,
|
||||
},
|
||||
ReadCloser: file,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
names, err := file.Readdirnames(0)
|
||||
if tarWriter != nil {
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Ensure correct relative path
|
||||
header.Name = filepath.ToSlash(archivePath)
|
||||
if err = tarWriter.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(tarWriter, file)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
fPath := filepath.Join(path, name)
|
||||
err = addFile(ar, d, fPath, commonPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to archive %s: %v", fPath, err)
|
||||
}
|
||||
if zipWriter != nil {
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = archivePath
|
||||
writer, err := zipWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(writer, file)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext, fileList []string) (int, error) {
|
||||
filePath := fileList[0]
|
||||
fileName := filepath.Base(filePath)
|
||||
idx := files.GetIndex("default")
|
||||
realPath, isDir, err := idx.GetRealPath(d.user.Scope, filePath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
if len(fileList) == 1 && !isDir {
|
||||
fd, err2 := os.Open(realPath)
|
||||
if err2 != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
// Get file information
|
||||
fileInfo, err3 := fd.Stat()
|
||||
if err3 != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// Set headers and serve the file
|
||||
setContentDisposition(w, r, fileName)
|
||||
w.Header().Set("Cache-Control", "private")
|
||||
|
||||
// Serve the content
|
||||
http.ServeContent(w, r, fileName, fileInfo.ModTime(), fd)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
algo := r.URL.Query().Get("algo")
|
||||
var extension string
|
||||
switch algo {
|
||||
case "zip", "true", "":
|
||||
extension = ".zip"
|
||||
case "tar.gz":
|
||||
extension = ".tar.gz"
|
||||
default:
|
||||
return http.StatusInternalServerError, errors.New("format not implemented")
|
||||
}
|
||||
baseDirName := filepath.Base(filepath.Dir(realPath))
|
||||
if baseDirName == "" || baseDirName == "/" {
|
||||
baseDirName = "download"
|
||||
}
|
||||
if len(fileList) == 1 && isDir {
|
||||
baseDirName = filepath.Base(realPath)
|
||||
}
|
||||
downloadFileName := url.PathEscape(baseDirName + extension)
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+downloadFileName)
|
||||
// Create the archive and stream it directly to the response
|
||||
if extension == ".zip" {
|
||||
err = createZip(w, d, fileList...)
|
||||
} else {
|
||||
err = createTarGz(w, d, fileList...)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func createZip(w io.Writer, d *requestContext, filenames ...string) error {
|
||||
zipWriter := zip.NewWriter(w)
|
||||
defer zipWriter.Close()
|
||||
|
||||
// Check if we have exactly one directory
|
||||
//flatten := len(filenames) == 1
|
||||
for _, fname := range filenames {
|
||||
err := addFile(fname, d, nil, zipWriter, false)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("Failed to add %s to ZIP: %v", fname, err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func rawDirHandler(w http.ResponseWriter, r *http.Request, d *requestContext, file *files.FileInfo) (int, error) {
|
||||
filenames, err := parseQueryFiles(r, file, d.user)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
extension, ar, err := parseQueryAlgorithm(r)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
func createTarGz(w io.Writer, d *requestContext, filenames ...string) error {
|
||||
gzWriter := gzip.NewWriter(w)
|
||||
defer gzWriter.Close()
|
||||
|
||||
err = ar.Create(w)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer ar.Close()
|
||||
|
||||
commonDir := fileutils.CommonPrefix(filepath.Separator, filenames...)
|
||||
|
||||
name := filepath.Base(commonDir)
|
||||
if name == "." || name == "" || name == string(filepath.Separator) {
|
||||
name = file.Name
|
||||
}
|
||||
// Prefix used to distinguish a filelist generated
|
||||
// archive from the full directory archive
|
||||
if len(filenames) > 1 {
|
||||
name = "_" + name
|
||||
}
|
||||
name += extension
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(name))
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
defer tarWriter.Close()
|
||||
|
||||
// Check if we have exactly one directory
|
||||
//flatten := len(filenames) == 1
|
||||
for _, fname := range filenames {
|
||||
err = addFile(ar, d, fname, commonDir)
|
||||
err := addFile(fname, d, tarWriter, nil, false)
|
||||
if err != nil {
|
||||
log.Printf("Failed to archive %s: %v", fname, err)
|
||||
logger.Error(fmt.Sprintf("Failed to add %s to TAR.GZ: %v", fname, err))
|
||||
}
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func rawFileHandler(w http.ResponseWriter, r *http.Request, file *files.FileInfo) (int, error) {
|
||||
realPath, _, _ := files.GetRealPath(file.Path)
|
||||
fd, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
setContentDisposition(w, r, file)
|
||||
|
||||
w.Header().Set("Cache-Control", "private")
|
||||
http.ServeContent(w, r, file.Name, file.ModTime, fd)
|
||||
return 0, nil
|
||||
return nil
|
||||
}
|
||||
+147
-76
@@ -8,12 +8,14 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/utils"
|
||||
)
|
||||
|
||||
// resourceGetHandler retrieves information about a resource.
|
||||
@@ -23,6 +25,7 @@ import (
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "Path to the resource"
|
||||
// @Param source query string false "Source name for the desired source, default is used if not provided"
|
||||
// @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"
|
||||
@@ -31,11 +34,20 @@ import (
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/resources [get]
|
||||
func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// TODO source := r.URL.Query().Get("source")
|
||||
path := r.URL.Query().Get("path")
|
||||
file, err := files.FileInfoFaster(files.FileOptions{
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
if source == "" {
|
||||
source = "default"
|
||||
}
|
||||
// Decode the URL-encoded path
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
fileInfo, err := files.FileInfoFaster(files.FileOptions{
|
||||
Path: filepath.Join(d.user.Scope, path),
|
||||
Modify: d.user.Perm.Modify,
|
||||
Source: source,
|
||||
Expand: true,
|
||||
ReadHeader: config.Server.TypeDetectionByHeader,
|
||||
Checker: d.user,
|
||||
@@ -44,18 +56,19 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
if file.Type == "directory" {
|
||||
return renderJSON(w, r, file)
|
||||
if fileInfo.Type == "directory" {
|
||||
return renderJSON(w, r, fileInfo)
|
||||
}
|
||||
if checksum := r.URL.Query().Get("checksum"); checksum != "" {
|
||||
err := file.Checksum(checksum)
|
||||
if algo := r.URL.Query().Get("checksum"); algo != "" {
|
||||
checksums, err := files.GetChecksum(fileInfo.Path, algo)
|
||||
if err == errors.ErrInvalidOption {
|
||||
return http.StatusBadRequest, nil
|
||||
} else if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
fileInfo.Checksums = checksums
|
||||
}
|
||||
return renderJSON(w, r, file)
|
||||
return renderJSON(w, r, fileInfo)
|
||||
|
||||
}
|
||||
|
||||
@@ -66,6 +79,7 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "Path to the resource"
|
||||
// @Param source query string false "Source name for the desired source, default is used if not provided"
|
||||
// @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"
|
||||
@@ -74,34 +88,39 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
// @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")
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
if source == "" {
|
||||
source = "default"
|
||||
}
|
||||
// Decode the URL-encoded path
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
if path == "/" || !d.user.Perm.Delete {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
realPath, isDir, err := files.GetRealPath(d.user.Scope, path)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
fileOpts := files.FileOptions{
|
||||
Path: filepath.Join(d.user.Scope, path),
|
||||
IsDir: isDir,
|
||||
Source: source,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
ReadHeader: config.Server.TypeDetectionByHeader,
|
||||
Checker: d.user,
|
||||
}
|
||||
file, err := files.FileInfoFaster(fileOpts)
|
||||
fileInfo, err := files.FileInfoFaster(fileOpts)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
// delete thumbnails
|
||||
err = delThumbs(r.Context(), fileCache, file)
|
||||
err = delThumbs(r.Context(), fileCache, fileInfo)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
err = files.DeleteFiles(realPath, fileOpts)
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath))
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
@@ -116,6 +135,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "Path to the resource"
|
||||
// @Param source query string false "Source name for the desired source, default is used if not provided"
|
||||
// @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"
|
||||
@@ -125,27 +145,35 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
// @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")
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
if source == "" {
|
||||
source = "default"
|
||||
}
|
||||
// Decode the URL-encoded path
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
if !d.user.Perm.Create || !d.user.Check(path) {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
fileOpts := files.FileOptions{
|
||||
Path: filepath.Join(d.user.Scope, path),
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
ReadHeader: config.Server.TypeDetectionByHeader,
|
||||
Checker: d.user,
|
||||
Path: filepath.Join(d.user.Scope, path),
|
||||
Source: source,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
Checker: d.user,
|
||||
}
|
||||
// Directories creation on POST.
|
||||
if strings.HasSuffix(path, "/") {
|
||||
err := files.WriteDirectory(fileOpts)
|
||||
err = files.WriteDirectory(fileOpts)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
file, err := files.FileInfoFaster(fileOpts)
|
||||
fileInfo, err := files.FileInfoFaster(fileOpts)
|
||||
if err == nil {
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
return http.StatusConflict, nil
|
||||
@@ -156,13 +184,17 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
err = delThumbs(r.Context(), fileCache, file)
|
||||
err = delThumbs(r.Context(), fileCache, fileInfo)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
}
|
||||
err = files.WriteFile(fileOpts, r.Body)
|
||||
return errToStatus(err), err
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
|
||||
}
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
// resourcePutHandler updates an existing file resource.
|
||||
@@ -172,6 +204,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "Path to the resource"
|
||||
// @Param source query string false "Source name for the desired source, default is used if not provided"
|
||||
// @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"
|
||||
@@ -180,8 +213,18 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// @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")
|
||||
source := r.URL.Query().Get("source")
|
||||
if source == "" {
|
||||
source = "default"
|
||||
}
|
||||
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
|
||||
// Decode the URL-encoded path
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
if !d.user.Perm.Modify || !d.user.Check(path) {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
@@ -191,13 +234,9 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
return http.StatusMethodNotAllowed, nil
|
||||
}
|
||||
|
||||
realPath, isDir, err := files.GetRealPath(d.user.Scope, path)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
fileOpts := files.FileOptions{
|
||||
Path: realPath,
|
||||
IsDir: isDir,
|
||||
Path: filepath.Join(d.user.Scope, path),
|
||||
Source: source,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
ReadHeader: config.Server.TypeDetectionByHeader,
|
||||
@@ -214,6 +253,7 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param from query string true "Path from resource"
|
||||
// @Param source query string false "Source name for the desired source, default is used if not provided"
|
||||
// @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"
|
||||
@@ -226,27 +266,37 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
// @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")
|
||||
action := r.URL.Query().Get("action")
|
||||
dst, err := url.QueryUnescape(dst)
|
||||
if !d.user.Check(src) || !d.user.Check(dst) {
|
||||
return http.StatusForbidden, nil
|
||||
source := r.URL.Query().Get("source")
|
||||
if source == "" {
|
||||
source = "default"
|
||||
}
|
||||
encodedFrom := r.URL.Query().Get("from")
|
||||
// Decode the URL-encoded path
|
||||
src, err := url.QueryUnescape(encodedFrom)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
dst := r.URL.Query().Get("destination")
|
||||
dst, err = url.QueryUnescape(dst)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
if !d.user.Check(src) || !d.user.Check(dst) {
|
||||
return http.StatusForbidden, fmt.Errorf("forbidden: user rules deny access to source or destination")
|
||||
}
|
||||
if dst == "/" || src == "/" {
|
||||
return http.StatusForbidden, nil
|
||||
return http.StatusForbidden, fmt.Errorf("forbidden: source or destination is attempting to modify root")
|
||||
}
|
||||
|
||||
idx := files.GetIndex(source)
|
||||
// check target dir exists
|
||||
parentDir, _, err := files.GetRealPath(d.user.Scope, filepath.Dir(dst))
|
||||
parentDir, _, err := idx.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)
|
||||
realSrc, isSrcDir, err := idx.GetRealPath(d.user.Scope, src)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
@@ -257,10 +307,10 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
}
|
||||
// Permission for overwriting the file
|
||||
if overwrite && !d.user.Perm.Modify {
|
||||
return http.StatusForbidden, nil
|
||||
return http.StatusForbidden, fmt.Errorf("forbidden: user does not have permission to overwrite file")
|
||||
}
|
||||
err = d.RunHook(func() error {
|
||||
return patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir)
|
||||
return patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir, source)
|
||||
}, action, realSrc, realDest, d.user)
|
||||
|
||||
return errToStatus(err), err
|
||||
@@ -282,27 +332,29 @@ func addVersionSuffix(source string) string {
|
||||
return source
|
||||
}
|
||||
|
||||
func delThumbs(ctx context.Context, fileCache FileCache, file *files.FileInfo) error {
|
||||
if err := fileCache.Delete(ctx, previewCacheKey(file, "small")); err != nil {
|
||||
func delThumbs(ctx context.Context, fileCache FileCache, file files.ExtendedFileInfo) error {
|
||||
if err := fileCache.Delete(ctx, previewCacheKey(file.RealPath, "small", file.FileInfo.ModTime)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, fileCache FileCache, isSrcDir bool) error {
|
||||
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, fileCache FileCache, isSrcDir bool, index string) error {
|
||||
switch action {
|
||||
// TODO: use enum
|
||||
case "copy":
|
||||
if !d.user.Perm.Create {
|
||||
return errors.ErrPermissionDenied
|
||||
}
|
||||
return files.CopyResource(src, dst, isSrcDir)
|
||||
err := files.CopyResource(index, src, dst, isSrcDir)
|
||||
return err
|
||||
case "rename", "move":
|
||||
|
||||
if !d.user.Perm.Rename {
|
||||
return errors.ErrPermissionDenied
|
||||
}
|
||||
file, err := files.FileInfoFaster(files.FileOptions{
|
||||
fileInfo, err := files.FileInfoFaster(files.FileOptions{
|
||||
Path: src,
|
||||
Source: index,
|
||||
IsDir: isSrcDir,
|
||||
Modify: d.user.Perm.Modify,
|
||||
Expand: false,
|
||||
@@ -314,11 +366,11 @@ func patchAction(ctx context.Context, action, src, dst string, d *requestContext
|
||||
}
|
||||
|
||||
// delete thumbnails
|
||||
err = delThumbs(ctx, fileCache, file)
|
||||
err = delThumbs(ctx, fileCache, fileInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return files.MoveResource(src, dst, isSrcDir)
|
||||
return files.MoveResource(index, src, dst, isSrcDir)
|
||||
default:
|
||||
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
|
||||
}
|
||||
@@ -335,7 +387,7 @@ type DiskUsageResponse struct {
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param source query string false "Name for the desired source, default is used if not provided"
|
||||
// @Param source query string false "Source 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"
|
||||
@@ -343,33 +395,52 @@ type DiskUsageResponse struct {
|
||||
func diskUsage(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
source := r.URL.Query().Get("source")
|
||||
if source == "" {
|
||||
source = "/"
|
||||
source = "default"
|
||||
}
|
||||
file, err := files.FileInfoFaster(files.FileOptions{
|
||||
Path: source,
|
||||
Checker: d.user,
|
||||
})
|
||||
value, ok := utils.DiskUsageCache.Get(source).(DiskUsageResponse)
|
||||
if ok {
|
||||
return renderJSON(w, r, &value)
|
||||
}
|
||||
|
||||
rootPath, ok := files.RootPaths[source]
|
||||
if !ok {
|
||||
return 400, fmt.Errorf("bad source path provided: %v", source)
|
||||
}
|
||||
|
||||
usage, err := disk.UsageWithContext(r.Context(), rootPath)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
fPath := file.RealPath()
|
||||
if file.Type != "directory" {
|
||||
return http.StatusBadRequest, fmt.Errorf("path is not a directory")
|
||||
}
|
||||
usage, err := disk.UsageWithContext(r.Context(), fPath)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
return renderJSON(w, r, &DiskUsageResponse{
|
||||
latestUsage := DiskUsageResponse{
|
||||
Total: usage.Total,
|
||||
Used: usage.Used,
|
||||
})
|
||||
}
|
||||
utils.DiskUsageCache.Set(source, latestUsage)
|
||||
return renderJSON(w, r, &latestUsage)
|
||||
}
|
||||
|
||||
func inspectIndex(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Query().Get("path")
|
||||
isDir := r.URL.Query().Get("isDir") == "true"
|
||||
index := files.GetIndex(config.Server.Root)
|
||||
info, _ := index.GetReducedMetadata(path, isDir)
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
if source == "" {
|
||||
source = "default"
|
||||
}
|
||||
// Decode the URL-encoded path
|
||||
path, _ := url.QueryUnescape(encodedPath)
|
||||
isNotDir := r.URL.Query().Get("isDir") == "false" // default to isDir true
|
||||
index := files.GetIndex(source)
|
||||
info, _ := index.GetReducedMetadata(path, !isNotDir)
|
||||
renderJSON(w, r, info) // nolint:errcheck
|
||||
}
|
||||
|
||||
func mockData(w http.ResponseWriter, r *http.Request) {
|
||||
d := r.URL.Query().Get("numDirs")
|
||||
f := r.URL.Query().Get("numFiles")
|
||||
NumDirs, err := strconv.Atoi(d)
|
||||
numFiles, err2 := strconv.Atoi(f)
|
||||
if err != nil || err2 != nil {
|
||||
return
|
||||
}
|
||||
mockDir := files.CreateMockData(NumDirs, numFiles)
|
||||
renderJSON(w, r, mockDir) // nolint:errcheck
|
||||
}
|
||||
+15
-14
@@ -5,14 +5,14 @@ import (
|
||||
"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"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/version"
|
||||
|
||||
httpSwagger "github.com/swaggo/http-swagger" // http-swagger middleware
|
||||
)
|
||||
@@ -56,7 +56,7 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
|
||||
// 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?")
|
||||
logger.Fatal("Could not embed frontend. Does dist exist?")
|
||||
}
|
||||
} else {
|
||||
assetFs = dirFS{Dir: http.Dir("http/dist")}
|
||||
@@ -96,6 +96,7 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
|
||||
api.HandleFunc("GET /preview", withUser(previewHandler))
|
||||
if version.Version == "testing" || version.Version == "untracked" {
|
||||
api.HandleFunc("GET /inspectIndex", inspectIndex)
|
||||
api.HandleFunc("GET /mockData", mockData)
|
||||
}
|
||||
|
||||
// Share routes
|
||||
@@ -106,7 +107,7 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
|
||||
|
||||
// Public routes
|
||||
api.HandleFunc("GET /public/publicUser", publicUserGetHandler)
|
||||
api.HandleFunc("GET /public/dl", withHashFile(publicDlHandler))
|
||||
api.HandleFunc("GET /public/dl", withHashFile(rawHandler))
|
||||
api.HandleFunc("GET /public/share", withHashFile(publicShareHandler))
|
||||
|
||||
// Settings routes
|
||||
@@ -122,7 +123,7 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
|
||||
router.HandleFunc(config.Server.BaseURL, indexHandler)
|
||||
|
||||
// health
|
||||
router.HandleFunc(fmt.Sprintf("GET %vhealth/", config.Server.BaseURL), healthHandler)
|
||||
router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler)
|
||||
|
||||
// Swagger
|
||||
router.Handle(fmt.Sprintf("%vswagger/", config.Server.BaseURL),
|
||||
@@ -142,7 +143,7 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
|
||||
// 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)
|
||||
logger.Fatal(fmt.Sprintf("could not load certificate: %v", err))
|
||||
}
|
||||
|
||||
// Create a custom TLS listener
|
||||
@@ -157,30 +158,30 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
|
||||
// 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)
|
||||
logger.Fatal(fmt.Sprintf("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)
|
||||
logger.Info(fmt.Sprintf("Running at : %s", fullURL))
|
||||
err = http.Serve(listener, muxWithMiddleware(router))
|
||||
if err != nil {
|
||||
log.Fatalf("could not start server: %v", err)
|
||||
logger.Fatal(fmt.Sprintf("could not start server: %v", err))
|
||||
}
|
||||
} else {
|
||||
// Set HTTP scheme and the default port for HTTP
|
||||
scheme = "http"
|
||||
if config.Server.Port != 443 {
|
||||
if config.Server.Port != 80 {
|
||||
port = fmt.Sprintf(":%d", config.Server.Port)
|
||||
}
|
||||
// Build the full URL with host and port
|
||||
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
|
||||
log.Printf("Running at : %s", fullURL)
|
||||
logger.Info(fmt.Sprintf("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)
|
||||
logger.Fatal(fmt.Sprintf("could not start server: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/files"
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/files"
|
||||
)
|
||||
|
||||
// searchHandler handles search requests for files based on the provided query.
|
||||
@@ -49,16 +48,20 @@ import (
|
||||
// @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"
|
||||
// @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")
|
||||
source := r.URL.Query().Get("source")
|
||||
if source == "" {
|
||||
source = "default"
|
||||
}
|
||||
searchScope := strings.TrimPrefix(r.URL.Query().Get("scope"), ".")
|
||||
searchScope = strings.TrimPrefix(searchScope, "/")
|
||||
// Retrieve the User-Agent and X-Auth headers from the request
|
||||
sessionId := r.Header.Get("SessionId")
|
||||
index := files.GetIndex(settings.Config.Server.Root)
|
||||
index := files.GetIndex(source)
|
||||
userScope := strings.TrimPrefix(d.user.Scope, ".")
|
||||
combinedScope := strings.TrimPrefix(userScope+"/"+searchScope, "/")
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
type settingsData struct {
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/share"
|
||||
)
|
||||
|
||||
// shareListHandler returns a list of all share links.
|
||||
@@ -68,8 +68,9 @@ func shareGetsHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if err == errors.ErrNotExist {
|
||||
return renderJSON(w, r, []*share.Link{})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
return http.StatusInternalServerError, fmt.Errorf("error getting share info from server")
|
||||
}
|
||||
return renderJSON(w, r, s)
|
||||
}
|
||||
@@ -188,7 +189,7 @@ func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, e
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, fmt.Errorf("failed to hash password: %w", err)
|
||||
return nil, http.StatusInternalServerError, fmt.Errorf("failed to hash password")
|
||||
}
|
||||
|
||||
return hash, 0, nil
|
||||
|
||||
+10
-8
@@ -4,17 +4,16 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/version"
|
||||
)
|
||||
|
||||
var templateRenderer *TemplateRenderer
|
||||
@@ -45,14 +44,14 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Name": config.Frontend.Name,
|
||||
"DisableExternal": config.Frontend.DisableExternal,
|
||||
"DisableExternal": config.Frontend.DisableDefaultLinks,
|
||||
"DisableUsedPercentage": config.Frontend.DisableUsedPercentage,
|
||||
"darkMode": settings.Config.UserDefaults.DarkMode,
|
||||
"Color": config.Frontend.Color,
|
||||
"BaseURL": config.Server.BaseURL,
|
||||
"Version": version.Version,
|
||||
"CommitSHA": version.CommitSHA,
|
||||
"StaticURL": path.Join(config.Server.BaseURL, "static"),
|
||||
"StaticURL": config.Server.BaseURL + "static",
|
||||
"Signup": settings.Config.Auth.Signup,
|
||||
"NoAuth": config.Auth.Method == "noauth",
|
||||
"AuthMethod": config.Auth.Method,
|
||||
@@ -62,6 +61,9 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
|
||||
"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, "/"),
|
||||
}
|
||||
|
||||
if config.Frontend.Files != "" {
|
||||
@@ -69,7 +71,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
|
||||
_, err := os.Stat(fPath) //nolint:govet
|
||||
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("couldn't load custom styles: %v", err)
|
||||
logger.Error(fmt.Sprintf("couldn't load custom styles: %v", err))
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
|
||||
+22
-17
@@ -12,10 +12,10 @@ import (
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/files"
|
||||
"github.com/gtsteffaniak/filebrowser/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -55,26 +55,29 @@ func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
if givenUserIdString == "self" {
|
||||
givenUserId = d.user.ID
|
||||
} else if givenUserIdString == "" {
|
||||
if !d.user.Perm.Admin {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
users, err := store.Users.Gets(config.Server.Root)
|
||||
|
||||
userList, err := store.Users.Gets(files.RootPaths["default"])
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
for _, u := range users {
|
||||
selfUserList := []*users.User{}
|
||||
for _, u := range userList {
|
||||
u.Password = ""
|
||||
}
|
||||
for _, u := range users {
|
||||
u.ApiKeys = nil
|
||||
if u.ID == d.user.ID {
|
||||
selfUserList = append(selfUserList, u)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(users, func(i, j int) bool {
|
||||
return users[i].ID < users[j].ID
|
||||
sort.Slice(userList, func(i, j int) bool {
|
||||
return userList[i].ID < userList[j].ID
|
||||
})
|
||||
|
||||
return renderJSON(w, r, users)
|
||||
if !d.user.Perm.Admin {
|
||||
userList = selfUserList
|
||||
}
|
||||
return renderJSON(w, r, userList)
|
||||
} else {
|
||||
num, _ := strconv.ParseUint(givenUserIdString, 10, 32)
|
||||
givenUserId = uint(num)
|
||||
@@ -85,7 +88,7 @@ func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
}
|
||||
|
||||
// Fetch the user details
|
||||
u, err := store.Users.Get(config.Server.Root, givenUserId)
|
||||
u, err := store.Users.Get(files.RootPaths["default"], givenUserId)
|
||||
if err == errors.ErrNotExist {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
@@ -156,7 +159,8 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
}
|
||||
|
||||
// Validate the user's scope
|
||||
_, _, err := files.GetRealPath(config.Server.Root, d.user.Scope)
|
||||
idx := files.GetIndex("default")
|
||||
_, _, err := idx.GetRealPath(d.user.Scope)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
@@ -214,7 +218,8 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
}
|
||||
|
||||
// Validate the user's scope
|
||||
_, _, err := files.GetRealPath(config.Server.Root, d.user.Scope)
|
||||
idx := files.GetIndex("default")
|
||||
_, _, err := idx.GetRealPath(d.user.Scope)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
libErrors "github.com/gtsteffaniak/filebrowser/errors"
|
||||
libErrors "github.com/gtsteffaniak/filebrowser/backend/errors"
|
||||
)
|
||||
|
||||
func errToStatus(err error) int {
|
||||
|
||||
+43
-5
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/dsoprea/go-exif/v3"
|
||||
"github.com/marusama/semaphore/v2"
|
||||
|
||||
exifcommon "github.com/dsoprea/go-exif/v3/common"
|
||||
)
|
||||
@@ -21,12 +20,32 @@ var ErrUnsupportedFormat = errors.New("unsupported image format")
|
||||
|
||||
// Service
|
||||
type Service struct {
|
||||
sem semaphore.Semaphore
|
||||
sem chan struct{}
|
||||
}
|
||||
|
||||
// New initializes the service with a specified number of workers (concurrency limit).
|
||||
func New(workers int) *Service {
|
||||
return &Service{
|
||||
sem: semaphore.New(workers),
|
||||
sem: make(chan struct{}, workers), // Buffered channel to limit concurrency.
|
||||
}
|
||||
}
|
||||
|
||||
// acquire blocks until a worker is available or the context is canceled.
|
||||
func (s *Service) acquire(ctx context.Context) error {
|
||||
select {
|
||||
case s.sem <- struct{}{}: // Reserve a worker.
|
||||
return nil
|
||||
case <-ctx.Done(): // Context canceled or deadline exceeded.
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// release frees up a worker slot.
|
||||
func (s *Service) release() {
|
||||
select {
|
||||
case <-s.sem: // Free a worker slot.
|
||||
default:
|
||||
// Shouldn't happen, but guard against releasing more than acquired.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,10 +155,10 @@ func WithQuality(quality Quality) Option {
|
||||
}
|
||||
|
||||
func (s *Service) Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...Option) error {
|
||||
if err := s.sem.Acquire(ctx, 1); err != nil {
|
||||
if err := s.acquire(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.sem.Release(1)
|
||||
defer s.release()
|
||||
|
||||
format, wrappedReader, err := s.detectFormat(in)
|
||||
if err != nil {
|
||||
@@ -242,3 +261,22 @@ func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
|
||||
thm, err := ifd.Thumbnail()
|
||||
return thm, wrappedReader, err
|
||||
}
|
||||
|
||||
// CreateThumbnail takes raw image data and creates a thumbnail image.
|
||||
func CreateThumbnail(rawData io.Reader, width, height int) (image.Image, error) {
|
||||
// Decode the raw image to get an image.Image.
|
||||
img, _, err := image.Decode(rawData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
|
||||
// Resize the image to create a thumbnail using the specified dimensions.
|
||||
thumb := imaging.Fit(img, width, height, imaging.Lanczos)
|
||||
|
||||
// Optionally, convert the thumbnail to grayscale if needed.
|
||||
// Uncomment the line below if you want the result to be grayscale.
|
||||
// thumb = imaging.Grayscale(thumb)
|
||||
|
||||
// Return the resized thumbnail image.
|
||||
return thumb, nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Logger wraps the standard log.Logger with log level functionality
|
||||
type Logger struct {
|
||||
logger *log.Logger
|
||||
levels []LogLevel
|
||||
apiLevels []LogLevel
|
||||
stdout bool
|
||||
disabled bool
|
||||
disabledAPI bool
|
||||
colors bool
|
||||
}
|
||||
|
||||
var stdOutLoggerExists bool
|
||||
|
||||
// NewLogger creates a new Logger instance with separate file and stdout loggers
|
||||
func NewLogger(filepath string, levels, apiLevels []LogLevel, noColors bool) (*Logger, error) {
|
||||
var fileWriter io.Writer = io.Discard
|
||||
stdout := filepath == ""
|
||||
// Configure file logging
|
||||
if !stdout {
|
||||
file, err := os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open log file: %v", err)
|
||||
}
|
||||
fileWriter = file
|
||||
}
|
||||
flags := log.Ldate | log.Ltime
|
||||
if slices.Contains(levels, DEBUG) {
|
||||
flags |= log.Lshortfile
|
||||
}
|
||||
logger := log.New(os.Stdout, "", flags)
|
||||
if filepath != "" {
|
||||
logger = log.New(fileWriter, "", flags)
|
||||
}
|
||||
if stdout {
|
||||
stdOutLoggerExists = true
|
||||
}
|
||||
return &Logger{
|
||||
logger: logger,
|
||||
levels: levels,
|
||||
apiLevels: apiLevels,
|
||||
disabled: slices.Contains(levels, DISABLED),
|
||||
disabledAPI: slices.Contains(apiLevels, DISABLED),
|
||||
colors: !noColors,
|
||||
stdout: stdout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetupLogger configures the logger with file and stdout options and their respective log levels
|
||||
func SetupLogger(output, levels, apiLevels string, noColors bool) error {
|
||||
upperLevels := []LogLevel{}
|
||||
for _, level := range SplitByMultiple(levels) {
|
||||
if level == "" {
|
||||
break
|
||||
}
|
||||
upperLevel := strings.ToUpper(level)
|
||||
if upperLevel == "WARNING" || upperLevel == "WARN" {
|
||||
upperLevel = "WARN "
|
||||
}
|
||||
// Convert level strings to LogLevel
|
||||
level, ok := stringToLevel[upperLevel]
|
||||
if !ok {
|
||||
loggers = []*Logger{}
|
||||
return fmt.Errorf("invalid file log level: %s", upperLevel)
|
||||
}
|
||||
upperLevels = append(upperLevels, level)
|
||||
}
|
||||
if len(upperLevels) == 0 {
|
||||
upperLevels = []LogLevel{INFO, ERROR, WARNING}
|
||||
}
|
||||
upperApiLevels := []LogLevel{}
|
||||
for _, level := range SplitByMultiple(apiLevels) {
|
||||
if level == "" {
|
||||
break
|
||||
}
|
||||
upperLevel := strings.ToUpper(level)
|
||||
if upperLevel == "WARNING" || upperLevel == "WARN" {
|
||||
upperLevel = "WARN "
|
||||
}
|
||||
// Convert level strings to LogLevel
|
||||
level, ok := stringToLevel[strings.ToUpper(upperLevel)]
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid api log level: %s", upperLevel)
|
||||
}
|
||||
upperApiLevels = append(upperApiLevels, level)
|
||||
}
|
||||
if len(upperApiLevels) == 0 {
|
||||
upperApiLevels = []LogLevel{INFO, ERROR, WARNING}
|
||||
}
|
||||
if slices.Contains(upperLevels, DISABLED) && slices.Contains(upperApiLevels, DISABLED) {
|
||||
// both disabled, not creating a logger
|
||||
loggers = []*Logger{}
|
||||
return nil
|
||||
}
|
||||
outputStdout := strings.ToUpper(output)
|
||||
if outputStdout == "STDOUT" {
|
||||
output = ""
|
||||
}
|
||||
if output == "" && stdOutLoggerExists {
|
||||
// stdout logger already exists... don't create another
|
||||
return fmt.Errorf("stdout logger already exists, could not set config levels=[%v] apiLevels=[%v] noColors=[%v]", levels, apiLevels, noColors)
|
||||
}
|
||||
// Create the logger
|
||||
logger, err := NewLogger(output, upperLevels, upperApiLevels, noColors)
|
||||
if err != nil {
|
||||
loggers = []*Logger{}
|
||||
return err
|
||||
}
|
||||
loggers = append(loggers, logger)
|
||||
return nil
|
||||
}
|
||||
func SplitByMultiple(str string) []string {
|
||||
delimiters := []rune{'|', ',', ' '}
|
||||
return strings.FieldsFunc(str, func(r rune) bool {
|
||||
for _, d := range delimiters {
|
||||
if r == d {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type LogLevel int
|
||||
|
||||
const (
|
||||
DISABLED LogLevel = 0
|
||||
ERROR LogLevel = 1
|
||||
FATAL LogLevel = 1
|
||||
WARNING LogLevel = 2
|
||||
INFO LogLevel = 3
|
||||
DEBUG LogLevel = 4
|
||||
API LogLevel = 10
|
||||
// COLORS
|
||||
RED = "\033[31m"
|
||||
GREEN = "\033[32m"
|
||||
YELLOW = "\033[33m"
|
||||
GRAY = "\033[37m"
|
||||
)
|
||||
|
||||
var (
|
||||
loggers []*Logger
|
||||
)
|
||||
|
||||
type levelConsts struct {
|
||||
INFO string
|
||||
FATAL string
|
||||
ERROR string
|
||||
WARNING string
|
||||
DEBUG string
|
||||
API string
|
||||
DISABLED string
|
||||
}
|
||||
|
||||
var levels = levelConsts{
|
||||
INFO: "INFO",
|
||||
FATAL: "FATAL",
|
||||
ERROR: "ERROR",
|
||||
WARNING: "WARN ", // with consistent space padding
|
||||
DEBUG: "DEBUG",
|
||||
DISABLED: "DISABLED",
|
||||
API: "API",
|
||||
}
|
||||
|
||||
// stringToLevel maps string representation to LogLevel
|
||||
var stringToLevel = map[string]LogLevel{
|
||||
"DEBUG": DEBUG,
|
||||
"INFO": INFO,
|
||||
"ERROR": ERROR,
|
||||
"DISABLED": DISABLED,
|
||||
"WARN ": WARNING, // with consistent space padding
|
||||
"FATAL": FATAL,
|
||||
"API": API,
|
||||
}
|
||||
|
||||
// Log prints a log message if its level is greater than or equal to the logger's levels
|
||||
func Log(level string, msg string, prefix, api bool, color string) {
|
||||
LEVEL := stringToLevel[level]
|
||||
for _, logger := range loggers {
|
||||
if api {
|
||||
if logger.disabledAPI || !slices.Contains(logger.apiLevels, LEVEL) {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if logger.disabled || !slices.Contains(logger.levels, LEVEL) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if logger.stdout && LEVEL == FATAL {
|
||||
continue
|
||||
}
|
||||
writeOut := msg
|
||||
if prefix {
|
||||
writeOut = fmt.Sprintf("[%s] ", level) + writeOut
|
||||
}
|
||||
if logger.colors && color != "" {
|
||||
writeOut = color + writeOut + "\033[0m"
|
||||
}
|
||||
err := logger.logger.Output(3, writeOut) // 3 skips this function for correct file:line
|
||||
if err != nil {
|
||||
log.Printf("failed to log message '%v' with error `%v`", msg, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Api(msg string, statusCode int) {
|
||||
if statusCode >= 300 && statusCode < 500 {
|
||||
Log(levels.WARNING, msg, false, true, YELLOW)
|
||||
} else if statusCode >= 500 {
|
||||
Log(levels.ERROR, msg, false, true, RED)
|
||||
} else {
|
||||
Log(levels.INFO, msg, false, true, GREEN)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for specific log levels
|
||||
func Debug(msg string) {
|
||||
if len(loggers) > 0 {
|
||||
Log(levels.DEBUG, msg, true, false, GRAY)
|
||||
}
|
||||
}
|
||||
|
||||
func Info(msg string) {
|
||||
if len(loggers) > 0 {
|
||||
Log(levels.INFO, msg, false, false, "")
|
||||
} else {
|
||||
log.Println(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func Warning(msg string) {
|
||||
if len(loggers) > 0 {
|
||||
Log(levels.WARNING, msg, true, false, YELLOW)
|
||||
} else {
|
||||
log.Println("[WARN ]: " + msg)
|
||||
}
|
||||
}
|
||||
|
||||
func Error(msg string) {
|
||||
if len(loggers) > 0 {
|
||||
Log(levels.ERROR, msg, true, false, RED)
|
||||
} else {
|
||||
log.Println("[ERROR] : ", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func Fatal(msg string) {
|
||||
if len(loggers) > 0 {
|
||||
Log(levels.FATAL, msg, true, false, RED)
|
||||
}
|
||||
log.Fatal("[FATAL] : ", msg)
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/cmd"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB |
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
## TEST file used by docker testing containers
|
||||
checkExit() {
|
||||
if [ "$?" -ne 0 ];then
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if command -v go &> /dev/null
|
||||
then
|
||||
printf "\n == Running tests == \n"
|
||||
go test -race -parallel -v ./...
|
||||
checkExit
|
||||
else
|
||||
echo "ERROR: unable to perform tests"
|
||||
exit 1
|
||||
fi
|
||||
@@ -3,7 +3,7 @@ package runner
|
||||
import (
|
||||
"os/exec"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
)
|
||||
|
||||
// ParseCommand parses the command taking in account if the current
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/files"
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
// Runner is a commands runner.
|
||||
@@ -20,8 +20,9 @@ type Runner struct {
|
||||
|
||||
// RunHook runs the hooks for the before and after event.
|
||||
func (r *Runner) RunHook(fn func() error, evt, path, dst string, user *users.User) error {
|
||||
path, _, _ = files.GetRealPath(user.Scope, path)
|
||||
dst, _, _ = files.GetRealPath(user.Scope, dst)
|
||||
idx := files.GetIndex("default")
|
||||
path, _, _ = idx.GetRealPath(user.Scope, path)
|
||||
dst, _, _ = idx.GetRealPath(user.Scope, dst)
|
||||
|
||||
if r.Enabled {
|
||||
if val, ok := r.Commands["before_"+evt]; ok {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
if ! [ -x "$(command -v standard-version)" ]; then
|
||||
echo "standard-version is not installed. please run 'npm i -g standard-version'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
standard-version --dry-run --skip
|
||||
read -p "Continue (y/n)? " -n 1 -r
|
||||
echo ;
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
standard-version -s ;
|
||||
fi
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
if ! [ -x "$(command -v commitlint)" ]; then
|
||||
echo "commitlint is not installed. please run 'npm i -g commitlint'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for commit_hash in $(git log --pretty=format:%H origin/master..HEAD); do
|
||||
commitlint -f ${commit_hash}~1 -t ${commit_hash}
|
||||
done
|
||||
+68
-18
@@ -1,13 +1,16 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/version"
|
||||
)
|
||||
|
||||
var Config Settings
|
||||
@@ -17,45 +20,91 @@ func Initialize(configFile string) {
|
||||
Config = setDefaults()
|
||||
err := yaml.Unmarshal(yamlData, &Config)
|
||||
if err != nil {
|
||||
log.Fatalf("Error unmarshaling YAML data: %v", err)
|
||||
logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err))
|
||||
}
|
||||
Config.UserDefaults.Perm = Config.UserDefaults.Permissions
|
||||
// Convert relative path to absolute path
|
||||
realRoot, err := filepath.Abs(Config.Server.Root)
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting root path: %v", err)
|
||||
if len(Config.Server.Sources) > 0 {
|
||||
// TODO allow multipe sources not named default
|
||||
for _, source := range Config.Server.Sources {
|
||||
realPath, err2 := filepath.Abs(source.Path)
|
||||
if err2 != nil {
|
||||
logger.Fatal(fmt.Sprintf("Error getting source path: %v", err2))
|
||||
}
|
||||
source.Path = realPath
|
||||
source.Name = "default" // Modify the local copy of the map value
|
||||
Config.Server.Sources["default"] = source // Assign the modified value back to the map
|
||||
}
|
||||
} else {
|
||||
realPath, err2 := filepath.Abs(Config.Server.Root)
|
||||
if err2 != nil {
|
||||
logger.Fatal(fmt.Sprintf("Error getting source path: %v", err2))
|
||||
}
|
||||
Config.Server.Sources = map[string]Source{
|
||||
"default": {
|
||||
Name: "default",
|
||||
Path: realPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
_, err = os.Stat(realRoot)
|
||||
if err != nil {
|
||||
log.Fatalf("ERROR: Configured Root Path does not exist! %v", err)
|
||||
}
|
||||
Config.Server.Root = realRoot
|
||||
baseurl := strings.Trim(Config.Server.BaseURL, "/")
|
||||
if baseurl == "" {
|
||||
Config.Server.BaseURL = "/"
|
||||
} else {
|
||||
Config.Server.BaseURL = "/" + baseurl + "/"
|
||||
}
|
||||
if !Config.Frontend.DisableDefaultLinks {
|
||||
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
|
||||
Text: "FileBrowser Quantum",
|
||||
Url: "https://github.com/gtsteffaniak/filebrowser",
|
||||
})
|
||||
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
|
||||
Text: fmt.Sprintf("(%v)", version.Version),
|
||||
Title: version.CommitSHA,
|
||||
Url: "https://github.com/gtsteffaniak/filebrowser/releases/",
|
||||
})
|
||||
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
|
||||
Text: "Help",
|
||||
Url: "https://github.com/gtsteffaniak/filebrowser/wiki",
|
||||
})
|
||||
}
|
||||
if len(Config.Server.Logging) == 0 {
|
||||
Config.Server.Logging = []LogConfig{
|
||||
{
|
||||
Output: "stdout",
|
||||
},
|
||||
}
|
||||
}
|
||||
for _, logConfig := range Config.Server.Logging {
|
||||
err = logger.SetupLogger(
|
||||
logConfig.Output,
|
||||
logConfig.Levels,
|
||||
logConfig.ApiLevels,
|
||||
logConfig.NoColors,
|
||||
)
|
||||
if err != nil {
|
||||
log.Println("[ERROR] Failed to set up logger:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfigFile(configFile string) []byte {
|
||||
// Open and read the YAML file
|
||||
yamlFile, err := os.Open(configFile)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
os.Exit(1)
|
||||
logger.Fatal(fmt.Sprintf("%v", err))
|
||||
}
|
||||
defer yamlFile.Close()
|
||||
|
||||
stat, err := yamlFile.Stat()
|
||||
if err != nil {
|
||||
log.Fatalf("error getting file information: %s", err.Error())
|
||||
logger.Fatal(fmt.Sprintf("error getting file information: %s", err.Error()))
|
||||
}
|
||||
|
||||
yamlData := make([]byte, stat.Size())
|
||||
_, err = yamlFile.Read(yamlData)
|
||||
if err != nil {
|
||||
log.Fatalf("Error reading YAML data: %v", err)
|
||||
logger.Fatal(fmt.Sprintf("Error reading YAML data: %v", err))
|
||||
}
|
||||
return yamlData
|
||||
}
|
||||
@@ -66,14 +115,11 @@ func setDefaults() Settings {
|
||||
EnableThumbnails: true,
|
||||
ResizePreview: false,
|
||||
EnableExec: false,
|
||||
IndexingInterval: 5,
|
||||
Port: 80,
|
||||
NumImageProcessors: 4,
|
||||
BaseURL: "",
|
||||
Database: "database.db",
|
||||
Log: "stdout",
|
||||
Root: "/srv",
|
||||
Indexing: true,
|
||||
Root: ".",
|
||||
},
|
||||
Auth: Auth{
|
||||
TokenExpirationTime: "2h",
|
||||
@@ -85,6 +131,9 @@ func setDefaults() Settings {
|
||||
Host: "",
|
||||
},
|
||||
},
|
||||
Frontend: Frontend{
|
||||
Name: "FileBrowser Quantum",
|
||||
},
|
||||
UserDefaults: UserDefaults{
|
||||
StickySidebar: true,
|
||||
Scope: ".",
|
||||
@@ -94,6 +143,7 @@ func setDefaults() Settings {
|
||||
DisableSettings: false,
|
||||
ViewMode: "normal",
|
||||
Locale: "en",
|
||||
GallerySize: 3,
|
||||
Permissions: users.Permissions{
|
||||
Create: false,
|
||||
Rename: false,
|
||||
|
||||
@@ -3,12 +3,13 @@ package settings
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -22,7 +23,7 @@ func (s *Settings) MakeUserDir(username, userScope, serverRoot string) (string,
|
||||
if userScope == "" && s.Server.CreateUserDir {
|
||||
username = cleanUsername(username)
|
||||
if username == "" || username == "-" || username == "." {
|
||||
log.Printf("create user: invalid user for home dir creation: [%s]", username)
|
||||
logger.Error(fmt.Sprintf("create user: invalid user for home dir creation: [%s]", username))
|
||||
return "", errors.New("invalid user for home dir creation")
|
||||
}
|
||||
userScope = path.Join(s.Server.UserHomeBasePath, username)
|
||||
|
||||
@@ -3,7 +3,7 @@ package settings
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
func TestSettings_MakeUserDir(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,7 @@ package settings
|
||||
import (
|
||||
"crypto/rand"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
const DefaultUsersHomeBasePath = "/users"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"log"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/logger"
|
||||
)
|
||||
|
||||
func TestConfigLoadChanged(t *testing.T) {
|
||||
@@ -16,7 +17,7 @@ func TestConfigLoadChanged(t *testing.T) {
|
||||
|
||||
err := yaml.Unmarshal(yamlData, &newConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Error unmarshaling YAML data: %v", err)
|
||||
logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err))
|
||||
}
|
||||
// Use go-cmp to compare the two structs
|
||||
if diff := cmp.Diff(newConfig, Config); diff == "" {
|
||||
@@ -32,7 +33,7 @@ func TestConfigLoadSpecificValues(t *testing.T) {
|
||||
|
||||
err := yaml.Unmarshal(yamlData, &newConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Error unmarshaling YAML data: %v", err)
|
||||
logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err))
|
||||
}
|
||||
testCases := []struct {
|
||||
fieldName string
|
||||
@@ -41,14 +42,13 @@ func TestConfigLoadSpecificValues(t *testing.T) {
|
||||
}{
|
||||
{"Auth.Method", Config.Auth.Method, newConfig.Auth.Method},
|
||||
{"Auth.Method", Config.Auth.Method, newConfig.Auth.Method},
|
||||
{"Frontend.disableExternal", Config.Frontend.DisableExternal, newConfig.Frontend.DisableExternal},
|
||||
{"UserDefaults.HideDotfiles", Config.UserDefaults.HideDotfiles, newConfig.UserDefaults.HideDotfiles},
|
||||
{"Server.Database", Config.Server.Database, newConfig.Server.Database},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
if tc.globalVal == tc.newVal {
|
||||
t.Errorf("Differences should have been found:\n\tConfig.%s: %v \n\tSetConfig: %v \n", tc.fieldName, tc.globalVal, tc.newVal)
|
||||
t.Errorf("Differences should have been found:\nConfig.%s: %v \nSetConfig: %v \n", tc.fieldName, tc.globalVal, tc.newVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
// StorageBackend is a settings storage backend.
|
||||
|
||||
+64
-25
@@ -1,7 +1,7 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
@@ -35,33 +35,71 @@ type Recaptcha struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
IndexingInterval uint32 `json:"indexingInterval"`
|
||||
NumImageProcessors int `json:"numImageProcessors"`
|
||||
Socket string `json:"socket"`
|
||||
TLSKey string `json:"tlsKey"`
|
||||
TLSCert string `json:"tlsCert"`
|
||||
EnableThumbnails bool `json:"enableThumbnails"`
|
||||
ResizePreview bool `json:"resizePreview"`
|
||||
EnableExec bool `json:"enableExec"`
|
||||
TypeDetectionByHeader bool `json:"typeDetectionByHeader"`
|
||||
AuthHook string `json:"authHook"`
|
||||
Port int `json:"port"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
Address string `json:"address"`
|
||||
Log string `json:"log"`
|
||||
Database string `json:"database"`
|
||||
Root string `json:"root"`
|
||||
UserHomeBasePath string `json:"userHomeBasePath"`
|
||||
CreateUserDir bool `json:"createUserDir"`
|
||||
Indexing bool `json:"indexing"`
|
||||
NumImageProcessors int `json:"numImageProcessors"`
|
||||
Socket string `json:"socket"`
|
||||
TLSKey string `json:"tlsKey"`
|
||||
TLSCert string `json:"tlsCert"`
|
||||
EnableThumbnails bool `json:"enableThumbnails"`
|
||||
ResizePreview bool `json:"resizePreview"`
|
||||
EnableExec bool `json:"enableExec"`
|
||||
TypeDetectionByHeader bool `json:"typeDetectionByHeader"`
|
||||
AuthHook string `json:"authHook"`
|
||||
Port int `json:"port"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
Address string `json:"address"`
|
||||
Logging []LogConfig `json:"logging"`
|
||||
Database string `json:"database"`
|
||||
Root string `json:"root"`
|
||||
UserHomeBasePath string `json:"userHomeBasePath"`
|
||||
CreateUserDir bool `json:"createUserDir"`
|
||||
Sources map[string]Source `json:"sources"`
|
||||
ExternalUrl string `json:"externalUrl"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Levels string `json:"levels"`
|
||||
ApiLevels string `json:"apiLevels"`
|
||||
Output string `json:"output"`
|
||||
NoColors bool `json:"noColors"`
|
||||
Json bool `json:"json"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
Path string `json:"path"`
|
||||
Name string
|
||||
Config IndexConfig `json:"config"`
|
||||
}
|
||||
|
||||
type IndexConfig struct {
|
||||
IndexingInterval uint32 `json:"indexingInterval"`
|
||||
Disabled bool `json:"disabled"`
|
||||
MaxWatchers int `json:"maxWatchers"`
|
||||
NeverWatch []string `json:"neverWatchPaths"`
|
||||
IgnoreHidden bool `json:"ignoreHidden"`
|
||||
IgnoreZeroSizeFolders bool `json:"ignoreZeroSizeFolders"`
|
||||
Exclude IndexFilter `json:"exclude"`
|
||||
Include IndexFilter `json:"include"`
|
||||
}
|
||||
|
||||
type IndexFilter struct {
|
||||
Files []string `json:"files"`
|
||||
Folders []string `json:"folders"`
|
||||
FileEndsWith []string `json:"fileEndsWith"`
|
||||
}
|
||||
|
||||
type Frontend struct {
|
||||
Name string `json:"name"`
|
||||
DisableExternal bool `json:"disableExternal"`
|
||||
DisableUsedPercentage bool `json:"disableUsedPercentage"`
|
||||
Files string `json:"files"`
|
||||
Color string `json:"color"`
|
||||
Name string `json:"name"`
|
||||
DisableDefaultLinks bool `json:"disableDefaultLinks"`
|
||||
DisableUsedPercentage bool `json:"disableUsedPercentage"`
|
||||
Files string `json:"files"`
|
||||
Color string `json:"color"`
|
||||
ExternalLinks []ExternalLink `json:"externalLinks"`
|
||||
}
|
||||
|
||||
type ExternalLink struct {
|
||||
Text string `json:"text"`
|
||||
Title string `json:"title"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
// UserDefaults is a type that holds the default values
|
||||
@@ -86,4 +124,5 @@ type UserDefaults struct {
|
||||
Commands []string `json:"commands,omitempty"`
|
||||
HideDotfiles bool `json:"hideDotfiles"`
|
||||
DateFormat bool `json:"dateFormat"`
|
||||
ThemeColor string `json:"themeColor"`
|
||||
}
|
||||
@@ -10,7 +10,6 @@ server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
address: ""
|
||||
log: "stdout"
|
||||
database: "mydb.db"
|
||||
root: "/srv"
|
||||
auth:
|
||||
|
||||
@@ -3,7 +3,7 @@ package share
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/errors"
|
||||
)
|
||||
|
||||
// StorageBackend is the interface to implement for a share storage.
|
||||
|
||||
@@ -2,8 +2,8 @@ package bolt
|
||||
|
||||
import (
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/errors"
|
||||
)
|
||||
|
||||
type authBackend struct {
|
||||
|
||||
@@ -3,10 +3,10 @@ package bolt
|
||||
import (
|
||||
"github.com/asdine/storm/v3"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/share"
|
||||
"github.com/gtsteffaniak/filebrowser/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/users"
|
||||
)
|
||||
|
||||
// NewStorage creates a storage.Storage based on Bolt DB.
|
||||
|
||||
@@ -2,7 +2,7 @@ package bolt
|
||||
|
||||
import (
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/settings"
|
||||
)
|
||||
|
||||
type settingsBackend struct {
|
||||
|
||||
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user