Compare commits

...
23 Commits
Author SHA1 Message Date
Graham Steffaniak 91b9515056 updated with small change (#616) 2025-05-09 11:47:24 -05:00
Graham Steffaniak 224a273066 revert enforced login method (#612) 2025-05-09 11:00:54 -05:00
Graham Steffaniak 5adbc065ed Beta/v0.7.1 release (#607) 2025-05-08 10:27:19 -05:00
Graham Steffaniak ad06eaed3e Update release_docker.yaml (#581) 2025-05-03 11:16:32 -05:00
Graham Steffaniak d2ed17e4d9 Update release_docker.yaml (#580) 2025-05-03 10:48:16 -05:00
Graham Steffaniak 3813ced6bd Rename release_docker to release_docker.yaml (#579) 2025-05-03 10:26:57 -05:00
Graham Steffaniak 5c41bbd7a7 Create release_docker (#578) 2025-05-03 10:24:26 -05:00
Graham Steffaniak 7da6115efe Workflow update (#577) 2025-05-03 09:38:00 -05:00
Graham SteffaniakandAaron Ky-Riesenbach fb5bab79e1 Beta/v0.7.0 (#572)
Co-authored-by: Aaron Ky-Riesenbach <aaron@kyriesenba.ch>
2025-05-02 19:15:58 -05:00
Graham Steffaniak 2e2fe5200c Update .goreleaser.yaml (#570) 2025-05-01 19:02:24 -05:00
Graham Steffaniak 418b187acf sync validation change with beta v0.6.8 (#533) 2025-04-14 16:49:19 -05:00
Graham SteffaniakandAaron Ky-Riesenbach 2972772dda Beta/v0.6.8 (#526)
Co-authored-by: Aaron Ky-Riesenbach <aaron@kyriesenba.ch>
2025-04-12 10:32:28 -05:00
Graham Steffaniak a83f39d41f Beta/v0.6.7 (#507) 2025-04-08 10:05:52 -05:00
Graham Steffaniak 2de5dbaf68 updated docker directory perms (#500) 2025-04-06 16:57:14 -05:00
Graham Steffaniak db77552bd8 bump v0.6.6 release with share fix for file (#489) 2025-04-04 09:37:31 -05:00
Graham Steffaniak 2c26b6fc45 Beta/v0.6.6 (#485) 2025-04-03 18:29:31 -05:00
Graham Steffaniak 948add54fc Beta/v0.6.5 (#469) 2025-04-01 12:00:07 -05:00
Graham Steffaniak f56fa85fa4 fixed issues for 0.6.4 (#463) (#464) 2025-03-28 18:48:45 -05:00
Graham Steffaniak 23db04bacb Update README.md (#462) 2025-03-28 09:44:30 -05:00
Christoph Auer dfe024ab62 i18n: Small improvements to de.json (#453) 2025-03-26 18:47:34 -05:00
Graham SteffaniakandNguyễn Trần Chung ce292c7be1 Beta/v0.6.3 (#450)
Co-authored-by: Nguyễn Trần Chung <nguyentranchung52th@gmail.com>
2025-03-24 18:12:06 -05:00
Nguyễn Trần Chung e39cf2ddf9 Update makefile (#445) 2025-03-22 10:29:38 -07:00
Graham Steffaniak 6909df0071 Beta/v0.6.2 (#443) 2025-03-20 18:29:42 -05:00
252 changed files with 11800 additions and 5309 deletions

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
backend/**.db
backend/tmp
backend/vendor/**
frontend/dist/**
frontend/node_modules/**
-39
View File
@@ -30,42 +30,3 @@ jobs:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
push_latest_to_registry:
needs: [ test_playwright ]
name: Push latest
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Find latest beta tag
run: |
echo "LATEST_TAG=$(git describe --tags $(git rev-list --tags="*-beta*" --max-count=1))" >> $GITHUB_ENV
echo "latest tag is $LATEST_TAG"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: gtstef/filebrowser
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
build-args: |
VERSION=${{ env.LATEST_TAG }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
file: ./_docker/Dockerfile
push: true
tags: 'gtstef/filebrowser:latest'
labels: ${{ steps.meta.outputs.labels }}
+10 -10
View File
@@ -87,16 +87,16 @@ jobs:
echo "✅ Successfully merged '$BETA_BRANCH' into '$STABLE_BRANCH'."
- name: Delete beta branch after successful merge
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
# Delete beta branch locally and remotely
git branch -d "$BETA_BRANCH"
git push origin --delete "$BETA_BRANCH"
echo "✅ Successfully deleted '$BETA_BRANCH'."
# - name: Delete beta branch after successful merge
# run: |
# VERSION="${{ github.event.inputs.version }}"
# BETA_BRANCH="beta/v$VERSION"
#
# # Delete beta branch locally and remotely
# git branch -d "$BETA_BRANCH"
# git push origin --delete "$BETA_BRANCH"
#
# echo "✅ Successfully deleted '$BETA_BRANCH'."
- name: Create Pull Request to Main
uses: peter-evans/create-pull-request@v6
+10 -10
View File
@@ -87,13 +87,13 @@ jobs:
echo "✅ Successfully merged '$DEV_BRANCH' into '$BETA_BRANCH'."
- name: Delete dev branch after successful merge
run: |
VERSION="${{ github.event.inputs.version }}"
DEV_BRANCH="dev/v$VERSION"
# Delete dev branch locally and remotely
git branch -d "$DEV_BRANCH"
git push origin --delete "$DEV_BRANCH"
echo "✅ Successfully deleted '$DEV_BRANCH'."
# - name: Delete dev branch after successful merge
# run: |
# VERSION="${{ github.event.inputs.version }}"
# DEV_BRANCH="dev/v$VERSION"
#
# # Delete dev branch locally and remotely
# git branch -d "$DEV_BRANCH"
# git push origin --delete "$DEV_BRANCH"
#
# echo "✅ Successfully deleted '$DEV_BRANCH'."
+1 -1
View File
@@ -63,4 +63,4 @@ jobs:
make_latest: true # change this to false when stable gets released
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.tag_name }}
name: ${{ steps.extract_branch.outputs.tag_name }}
+19 -9
View File
@@ -24,20 +24,30 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.actor}}
password: ${{ secrets.PAT }}
- name: Extract metadata (tags, labels) for Docker and GHCR
id: meta
uses: docker/metadata-action@v5
with:
images: gtstef/filebrowser
- name: modify version names
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Modify version names
id: modify-json
run: |
JSON="${{ steps.meta.outputs.tags }}"
# Use jq to remove 'v' from the version field
JSON=$(echo "$JSON" | sed 's/filebrowser:dev-v/filebrowser:/')
JSON=$(echo "$JSON" | sed -E 's/(filebrowser:[0-9]+\.[0-9]+\.[0-9]+)/\1-dev/g')
JSON="$JSON,gtstef/filebrowser:dev"
echo "cleaned_tag=$JSON" >> $GITHUB_OUTPUT
TAGS="${{ steps.meta.outputs.tags }}"
# Apply modifications to both docker.io and ghcr.io tags
CLEANED_TAGS=$(echo "$TAGS" | sed 's/filebrowser:dev-v/filebrowser:/g' | sed -E 's/(filebrowser:[0-9]+\.[0-9]+\.[0-9]+)/\1-dev/g')
# Add dev tag to both registries
CLEANED_TAGS="$CLEANED_TAGS,gtstef/filebrowser:dev,ghcr.io/gtsteffaniak/filebrowser:dev"
echo "cleaned_tag<<EOF" >> $GITHUB_OUTPUT
echo "$CLEANED_TAGS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
+125
View File
@@ -0,0 +1,125 @@
name: Manual Docker Build
on:
workflow_dispatch:
inputs:
branch:
description: "Tag or Branch (format: v0.0.0-beta)"
required: true
type: string
permissions:
contents: write
jobs:
push_release_to_registry:
name: Push release
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }} # Uses the Personal Access Token
ref: "${{ github.event.inputs.branch }}"
- 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: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.actor}}
password: ${{ secrets.PAT }}
- name: Extract metadata (tags, labels) for Docker and GHCR
id: meta
uses: docker/metadata-action@v5
with:
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
tags: |
type=raw,value=${{ github.event.inputs.branch }}
- name: Modify tags (strip 'v' and add 'beta'/'stable' if needed)
id: modify-json
run: |
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq '
.tags |= map(sub(":v"; ":")) |
if any(.tags[]; test("beta")) then
.tags += [
"gtstef/filebrowser:beta",
"ghcr.io/gtsteffaniak/filebrowser:beta",
"gtstef/filebrowser:latest",
"ghcr.io/gtsteffaniak/filebrowser:latest"
]
else . end |
if any(.tags[]; test("stable")) then
.tags += [
"gtstef/filebrowser:stable",
"ghcr.io/gtsteffaniak/filebrowser:stable",
"gtstef/filebrowser:latest",
"ghcr.io/gtsteffaniak/filebrowser:latest"
]
else . end
')
TAGS_CSV=$(echo "$MODIFIED_JSON" | jq -r '.tags | join(",")')
echo "$TAGS_CSV" > modified_tags.txt # Optional debug output
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
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: ./_docker/Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
- name: Modify version names
id: modify-json-media
run: |
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq '
.tags |= map(
sub(":v"; ":") + "-media"
) |
if any(.tags[]; test("beta-media")) then
.tags += [
"gtstef/filebrowser:beta-media",
"ghcr.io/gtsteffaniak/filebrowser:beta-media",
"gtstef/filebrowser:media",
"ghcr.io/gtsteffaniak/filebrowser:media"
]
else . end |
if any(.tags[]; test("stable-media")) then
.tags += [
"gtstef/filebrowser:stable-media",
"ghcr.io/gtsteffaniak/filebrowser:stable-media",
"gtstef/filebrowser:media",
"ghcr.io/gtsteffaniak/filebrowser:media"
]
else . end
')
TAGS_CSV=$(echo "$MODIFIED_JSON" | jq -r '.tags | join(",")')
echo "$TAGS_CSV" > modified_tags.txt # Optional debug output
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
- name: Build and push media image
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'] }}
file: ./_docker/Dockerfile.media
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.modify-json-media.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+29 -10
View File
@@ -45,26 +45,45 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.actor}}
password: ${{ secrets.PAT }}
- name: Extract metadata (tags, labels) for Docker and GHCR
id: meta
uses: docker/metadata-action@v5
with:
images: gtstef/filebrowser
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Modify tags (strip 'v' and add 'beta'/'stable' if needed)
id: modify-json
run: |
JSON='${{ steps.meta.outputs.json }}' # Assuming the JSON output is stored in steps.meta.outputs.json
# Process the JSON array using jq
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq '
.tags |= map(sub(":v"; ":")) |
if any(.tags[]; test("beta")) then .tags += ["gtstef/filebrowser:beta"] else . end |
if any(.tags[]; test("stable")) then .tags += ["gtstef/filebrowser:stable"] else . end
.tags |= map(sub(":v"; ":")) |
if any(.tags[]; test("beta")) then
.tags += [
"gtstef/filebrowser:beta",
"ghcr.io/gtsteffaniak/filebrowser:beta",
"gtstef/filebrowser:latest",
"ghcr.io/gtsteffaniak/filebrowser:latest"
]
else . end |
if any(.tags[]; test("stable")) then
.tags += [
"gtstef/filebrowser:stable",
"ghcr.io/gtsteffaniak/filebrowser:stable",
"gtstef/filebrowser:latest",
"ghcr.io/gtsteffaniak/filebrowser:latest"
]
else . end
')
# Convert JSON to a comma-separated string for Docker tags
TAGS_CSV=$(echo "$MODIFIED_JSON" | jq -r '.tags | join(",")')
echo "$TAGS_CSV" > modified_tags.txt # Save for debugging
echo "$TAGS_CSV" > modified_tags.txt # Optional debug output
echo "cleaned_tag=$TAGS_CSV" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
+3
View File
@@ -9,9 +9,11 @@ rice-box.go
/backend/filebrowser
/backend/filebrowser.exe
/backend/backend.exe
/backend/tmp
/frontend/dist
/frontend/pkg
/frontend/test-results
/frontend/tests/playwright-files/demo*
/frontend/loginAuth.json
/frontend/package-lock.json
/backend/vendor
@@ -43,3 +45,4 @@ yarn-error.log*
*.sw*
bin/
build/
*__debug*
+176
View File
@@ -2,6 +2,182 @@
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.7.2-beta
The `media` tags introduced in 0.7.0 have been removed -- all docker images have media enabled now.
**Notes**:
- Reverts enforced user login methods types -- until suitable methods to alter are available.
- When updating a user, updating scope always sets to the exact scope specified on updated.
- Redirect api messages are INFO instead of WARN
- Settings has close button instead of back https://github.com/gtsteffaniak/filebrowser/issues/583
**Bug Fixes**:
- Hover bug when exact timestamp setting enabled https://github.com/gtsteffaniak/filebrowser/issues/585
## v0.7.1-beta
The `media` tags introduced in 0.7.0 have been removed -- all docker images have media enabled now.
**Notes**:
- changes to support jwks url needed for authelia - still needs testing to ensure it works https://github.com/gtsteffaniak/filebrowser/issues/575, added debug logs to help identify any further issues.
- added apache license file back https://github.com/gtsteffaniak/filebrowser/discussions/599
- updated toggle view icons to better match.
- adjusted popup preview position on mobile.
- updated createUserDir logic, https://github.com/gtsteffaniak/filebrowser/issues/541
- it always creats user dir (even for admins)
- scope path must exist if it doesn't end in username, and if it does, the parent dir must exist
- enforced user login methods types -- can't be changed. a password user cannot login as oidc, etc.
**Bug Fixes**:
- right click context menu issue https://github.com/gtsteffaniak/filebrowser/issues/598
- upload file issue https://github.com/gtsteffaniak/filebrowser/issues/597
- defaultUserScope is not respected https://github.com/gtsteffaniak/filebrowser/issues/589
- defaultEnabled is not respected https://github.com/gtsteffaniak/filebrowser/issues/603
- user has weird navigation barhttps://github.com/gtsteffaniak/filebrowser/issues/593
- fix multibutton state issue for close overlay https://github.com/gtsteffaniak/filebrowser/issues/596
## v0.7.0-beta
**New Features**:
- New authentication method: OIDC (OpenID Connect)
- UI refresh
- Refreshed icons and styles to provide more contrast https://github.com/gtsteffaniak/filebrowser/issues/493
- New scrollbar which includes information about the listing https://github.com/gtsteffaniak/filebrowser/issues/304
- User-configurable popup previewer and user can control preview size of images.
- Enhanced user settings page with more toggle options.
- Replaced checkboxes with toggles switches https://github.com/gtsteffaniak/filebrowser/issues/461
- Refreshed Breadcrumbs style.
- Main navbar icon is multipurpose menu, close, back and animates
- Enhanced source info on the UI
- User must have permission `realtime: true` property to get realtime events.
- Sources shows status of the directory `ready`, `indexing`, and `unavailable`
- Top-right overflow menu for deleting / editing files in peview https://github.com/gtsteffaniak/filebrowser/issues/456
- Helpful UI animation for drag and drop files, to get feedback where the drop target is.
- More consistent theme color https://github.com/gtsteffaniak/filebrowser/issues/538
- New file preview types:
- Video thumbnails available via new media integration (see configuration wiki for help) https://github.com/gtsteffaniak/filebrowser/issues/351
- Office file previews if you have office integration enabled. https://github.com/gtsteffaniak/filebrowser/issues/460
**Notes**:
- sesssionId is now unique per window. Previously it was shared accross browser tabs.
- DisableUsedPercentage is a backend property now, so users can't "hack" the information to be shown.
- Updated documentation for resources api https://github.com/gtsteffaniak/filebrowser/issues/560
- Updated placeholder for scopes https://github.com/gtsteffaniak/filebrowser/issues/475
- When user's API permissions are removed, any api keys the user had will be revoked.
- `server.enableThumbnails` moved to `server.disablePreviews` defaulting to false.
- `server.resizePreview` moved to `server.resizePreviews` (with an "s" at the end)
**Bug Fixes**:
- Nil pointer error when source media is disconnected while running.
- Source selection buggy https://github.com/gtsteffaniak/filebrowser/issues/537
- Upload folder structure https://github.com/gtsteffaniak/filebrowser/issues/539
- Editing files on multiple sources https://github.com/gtsteffaniak/filebrowser/issues/535
- Prevent the user from changing the password https://github.com/gtsteffaniak/filebrowser/issues/550
- Links in setting page does not navigate to correct location https://github.com/gtsteffaniak/filebrowser/issues/474
- Url encoding issue https://github.com/gtsteffaniak/filebrowser/issues/530
- Certain file types being treated as folders https://github.com/gtsteffaniak/filebrowser/issues/555
- Source name with special characters https://github.com/gtsteffaniak/filebrowser/issues/557
- Onlyoffice support on proxy auth https://github.com/gtsteffaniak/filebrowser/issues/559
- Downloading with user scope https://github.com/gtsteffaniak/filebrowser/issues/564
- User disableSettings property to be respected.
- Non admin users updating admin settings.
- Right click context issue on safari desktop.
- office save file issue.
## v0.6.8-beta
**New Features**
- environment variables are available for certain secrets.
- see wiki https://github.com/gtsteffaniak/filebrowser/wiki/Environment-Variables
- thanks @aaronkyriesenbach https://github.com/gtsteffaniak/filebrowser/pull/511
**Notes**:
- config validation (see https://github.com/gtsteffaniak/filebrowser/wiki/Full-Config-Example)
- fails when config file contains unknown fields (helps spot typos)
- some light value validation on certain fields
- removed recaptcha -- was disabled and not used before.
- moved `recaptcha` and `signup` configs to `auth.methods.password`
**BugFixes**:
- fix scope reset on restart https://github.com/gtsteffaniak/filebrowser/issues/515
- Clicking empty space to deselect https://github.com/gtsteffaniak/filebrowser/issues/492
## v0.6.7-beta
**Notes**:
- added full tests for single source example.
- adds descriptive error if temp dir can't be created on fatal startup
- clears temp directory on shutdown.
- removed put settings api (unused)
- removed more unused config properties.
**BugFixes**:
- fix url encoding issue for search links when theres only one source https://github.com/gtsteffaniak/filebrowser/issues/501
- files with # could have problems, double encoded.
## v0.6.6-beta
**New Feature**:
- limit tar size creation to limit server burden. For example, don't let customers try to download the entire filesystem as a zip. see `server.maxArchiveSize` on config wiki.
**Notes**:
- disableUsedPercentage also hides text and source bar.
- share errors show up in logs in more verbose way.
- archive creation occurs on disk rather than in memory, use `server.cacheDir` to determine where temp files are stored.
- automatically ensures leading slash for scope
- https://github.com/gtsteffaniak/filebrowser/issues/472
- https://github.com/gtsteffaniak/filebrowser/issues/476
**BugFixes**:
- fix proxy user creation issue https://github.com/gtsteffaniak/filebrowser/issues/478
- externalUrl prefix issue fixed for shares. https://github.com/gtsteffaniak/filebrowser/issues/465
- fix File Opens Instead of Just Downloading https://github.com/gtsteffaniak/filebrowser/issues/480
- fix Download file name https://github.com/gtsteffaniak/filebrowser/issues/481
## v0.6.5-beta
**Notes**:
- added more share and download tests
**BugFixes**:
- fix share download issue https://github.com/gtsteffaniak/filebrowser/issues/465
- fix content length size calculation issue when downloading multiple files.
## v0.6.4-beta
**BugFixes**:
- fix preview arow issue. https://github.com/gtsteffaniak/filebrowser/issues/457
- fix password change issue.
- apply user defaults to publi user on startup https://github.com/gtsteffaniak/filebrowser/issues/451
## v0.6.3-beta
**Notes**:
- windows directories get better naming, root directories like "D:\ get named "D", otherwise base filepath is the name when unselected "D:\path\to\folder" gets named "folder" (just like linux)
- `.pdf` files added to default onlyoffice exclusion list.
**BugFixes**:
- windows would not refresh file info automatically when viewing because of path issue.
- windows paths without name for "D:\" would cause issues.
- share path error https://github.com/gtsteffaniak/filebrowser/issues/429
- fix bug where resource content flag would load entire file into memory.
## v0.6.2-beta
**Notes**:
- Added playwright tests for bugfixes for permantent fix for stability.
(except onlyoffice since it requires integrations)
**BugFixes**:
- Context menu should only be available inside the folder/files container https://github.com/gtsteffaniak/filebrowser/issues/430
- drag and drop files from desktop to browser is fixed.
- replace prompt cancel button didn't work.
- key events on listing page not working (like delete key)
- fixed share viewing issue https://github.com/gtsteffaniak/filebrowser/issues/429
- disableUsedPercentage hides entire source https://github.com/gtsteffaniak/filebrowser/issues/438
- createUserDir fix for proxy users and new users https://github.com/gtsteffaniak/filebrowser/issues/440
## v0.6.1-beta
**New Feature**:
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2018 File Browser contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+41 -42
View File
@@ -6,38 +6,36 @@
[![DockerHub Pulls](https://img.shields.io/docker/pulls/gtstef/filebrowser?label=latest%20Docker%20pulls)](https://hub.docker.com/r/gtstef/filebrowser)
[![Apache-2.0 License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
[![Poll](https://img.shields.io/badge/poll-vote_most_important_features-purple)](https://github.com/gtsteffaniak/filebrowser/discussions/368)
[![Donate with PayPal](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://www.paypal.com/donate/?business=W5XKNXHJM2WPE&no_recurring=0&currency_code=USD)
[![Donate](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://www.paypal.com/donate/?business=W5XKNXHJM2WPE&no_recurring=0&currency_code=USD)
<img width="150" src="https://github.com/user-attachments/assets/59986a2a-f960-4536-aa35-4a9a7c98ad48" title="Logo">
<h3>FileBrowser Quantum</h3>
A modern web-based file manager
The best free self-hosted web-based file manager.
<br/><br/>
<img width="800" src="https://github.com/user-attachments/assets/c35a8455-c32d-47f5-853d-f285e160b022" title="Main Screenshot">
<img width="800" src="https://github.com/user-attachments/assets/95af22fa-7760-4f7b-9c20-51fdcfe8f4ea" title="Main Screenshot">
</div>
> [!WARNING]
> There is no stable version -- planned 2025.
> There is no stable version -- planned for 2025.
FileBrowser Quantum is a fork of the file browser open-source project with the following changes:
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
1. ✅ Indexes files (See [indexing Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.)
- Real-time search results as you type
- Search supports file/folder sizes and many file type filters.
- Enhanced interactive results that show file/folder sizes.
2. ✅ Multiple sources support
1. ✅ Multiple sources support
2. ✅ Login support for OIDC, password, and proxy.
3. ✅ Revamped UI
- Additional compact view mode as well as refreshed view mode styles.
- Many graphical and user experience improvements.
- right-click context menu
4. ✅ Revamped and simplified configuration via `config.yaml` config file.
5. ✅ 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.
6. ✅ Developer API support
- Ability to create long-live API Tokens.
- Helpful Swagger page available at `/swagger` endpoint.
4. ✅ Simplified configuration via `config.yaml` config file.
5. ✅ Ultra-efficient [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) and real-time updates
- Real-time search results as you type.
- Real-time monitoring and updates in the UI.
- Search supports file and folder sizes, along with various filters.
6. ✅ Better listing browsing
- More file type previews, such as **office** and **video** file previews
- Instantly switches view modes and sort order without reloading data.
- Folder sizes are displayed.
- Navigating remembers the last scroll position.
7. ✅ Developer API support
- Ability to create long-lived API Tokens.
- A helpful Swagger page is available at `/swagger` endpoint for API enabled users.
Notable features that this fork *does not* have (removed):
@@ -47,29 +45,28 @@ Notable features that this fork *does not* have (removed):
## About
FileBrowser Quantum differs significantly from the original.
There are hundreds of thousands of lines changed and they are generally
no longer compatible with each other. This has been intentional -- the
focus of this fork is on a few key principles:
- Simplicity and improved user experience
- Improving performance and faster feedback when making changes.
- Minimize external dependencies and standard library usage.
- Of course -- adding much-needed features.
FileBrowser Quantum differs significantly from the original version. Many of these changes required a significant overhaul. Creating a fork was a necessary process to make the program better. There have been many growing pains, but a stable release is planned and coming soon.
This version is called "Quantum" because it packs tons of advanced features in a tiny executable file. Unlike the majority of alternative options, FileBrowser Quantum is simple to install and easy to configure.
The goal for this repo is to become the best open-source self-hosted file browsing application that exists -- **all for free**.
This repo will always be free and open-source.
For more, see the [Q&A Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A)
## Look
The UI has a simple three-component navigation system :
The UI has a simple three-component navigation system:
1. (Left) The slide-out action panel button
1. (Left) Multi-action button with slide-out panel.
2. (Middle) The powerful search bar.
3. (Right) The view change toggle.
All other functions are moved either into the action menu or popup menus.
All other functions are moved either into the action menu or pop-up menus.
If the action does not depend on context, it will exist in the slide-out
action panel. If the action is available based on context, it will show up as
a popup menu.
a pop-up menu.
<p align="center">
<img width="1000" src="https://github.com/user-attachments/assets/aa32b05c-f917-47bb-b07f-857edc5e47f7" title="Search GIF">
@@ -114,32 +111,31 @@ webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
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 |
Docker image size | 200 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 256 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 | ✅ | ✅ |
Custom job support | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ |
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Single sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Single sign-on support | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
2FA sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Mobile App | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
tags support | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
sharable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Event-based notifications | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Metrics | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
file space quotas | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
Branding support | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
activity log | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
@@ -150,3 +146,6 @@ Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
Share collections of files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert documents | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert videos | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
Can convert photos | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
+30 -4
View File
@@ -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/backend/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA=${REVISION}'" \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM node:slim AS nbuild
@@ -18,14 +18,40 @@ COPY ./frontend/ ./
RUN npm run build-docker
FROM alpine:latest
RUN apk add --no-cache curl xz && \
ARCH=$(uname -m) && \
case "$ARCH" in \
x86_64) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" ;; \
aarch64) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz" ;; \
armv7l) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-armhf-static.tar.xz" ;; \
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
esac && \
mkdir -p /tmp/ffmpeg && \
curl -L "$URL" | tar -xJ -C /tmp/ffmpeg && \
mv /tmp/ffmpeg/ffmpeg-*/ffmpeg /usr/local/bin/ && \
mv /tmp/ffmpeg/ffmpeg-*/ffprobe /usr/local/bin/ && \
chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe && \
rm -rf /tmp/ffmpeg && \
apk del curl xz && \
rm -rf /var/cache/apk/*
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_NO_EMBEDED="true"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap
WORKDIR /home/filebrowser
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
WORKDIR /home/filebrowser
COPY --from=base --chown=filebrowser:1000 /app/filebrowser* ./
COPY --from=base --chown=filebrowser:1000 /app/config.yaml ./
COPY --from=base --chown=filebrowser:1000 /app/config.media.yaml ./config.yaml
COPY --from=nbuild --chown=filebrowser:1000 /app/dist/ ./http/dist/
## sanity checks
RUN [ "filebrowser", "version" ]
RUN [ "ffmpeg", "-version" ]
RUN [ "ffprobe", "-version" ]
USER root
# exposing default port for auto discovery.
EXPOSE 80
+1 -1
View File
@@ -1,6 +1,6 @@
FROM gtstef/playwright-base
WORKDIR /app
COPY [ "./_docker/src/regular/", "./" ]
COPY [ "./_docker/src/general/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
+1
View File
@@ -7,4 +7,5 @@ WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
@@ -10,9 +10,9 @@ import { defineConfig, devices } from "@playwright/test";
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./global-setup",
timeout: 6000,
testDir: "./tests",
globalSetup: "./tests-playwright/global-setup",
timeout: 5000,
testDir: "./tests-playwright/general",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -28,7 +28,7 @@ export default defineConfig({
actionTimeout: 5000,
storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1",
baseURL: "http://127.0.0.1/",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
+4 -2
View File
@@ -1,12 +1,11 @@
server:
port: 80
baseURL: "/"
baseURL: "/files/"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
auth:
signup: false
methods:
noauth: true
frontend:
@@ -16,3 +15,6 @@ frontend:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
userDefaults:
permissions:
realtime: true
@@ -10,9 +10,9 @@ import { defineConfig, devices } from "@playwright/test";
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
//globalSetup: "./global-setup",
timeout: 6000,
testDir: "./tests-proxy",
globalSetup: "./tests-playwright/noauth-setup",
timeout: 5000,
testDir: "./tests-playwright/noauth",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -25,6 +25,7 @@ export default defineConfig({
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
storageState: "noauth.json",
actionTimeout: 5000,
//storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
+6 -3
View File
@@ -5,6 +5,10 @@ server:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
config:
defaultEnabled: true
createUserDir: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
@@ -12,8 +16,8 @@ frontend:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
auth:
signup: false
methods:
proxy:
enabled: true
@@ -23,10 +27,9 @@ auth:
userDefaults:
darkMode: true
disableSettings: false
scope: "."
singleClick: false
permissions:
admin: false
modify: false
modify: true
share: false
api: false
+1 -1
View File
@@ -10,7 +10,7 @@ server {
location / {
proxy_set_header X-Username $uuid;
add_header X-Username $uuid;
proxy_pass http://localhost:8080;
proxy_pass http://filebrowser:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -11,8 +11,8 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
//globalSetup: "./global-setup",
timeout: 6000,
testDir: "./tests-proxy",
timeout: 3000,
testDir: "./tests-playwright/proxy",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -25,7 +25,7 @@ export default defineConfig({
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 5000,
actionTimeout: 3000,
//storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1",
+12 -2
View File
@@ -6,7 +6,7 @@ builds:
# Build configuration for darwin and linux
- id: default
ldflags: &ldflags
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA={{ .ShortCommit }}
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/common/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA={{ .ShortCommit }}
main: main.go
binary: filebrowser
goos:
@@ -35,7 +35,7 @@ builds:
post:
- upx {{ .Path }} # Compress the binary with UPX
# Build configuration for windows without arm & upx
# Build configuration for macos without upx
- id: macos
ldflags: *ldflags
main: main.go
@@ -46,6 +46,16 @@ builds:
- amd64
- arm64
# Build configuration for freebsd without arm & upx
- id: freeBSD
ldflags: *ldflags
main: main.go
binary: filebrowser
goos:
- freebsd
goarch:
- amd64
archives:
- name_template: >
{{- if eq .Os "windows" -}}
File renamed without changes.
@@ -31,9 +31,10 @@ type FileCache struct {
// NewFileCache creates a new FileCache
func NewFileCache(dir string) (*FileCache, error) {
if err := os.MkdirAll(dir, 0700); err != nil {
if err := os.MkdirAll(filepath.Join(dir, "thumbnails", "video"), 0700); err != nil {
return nil, fmt.Errorf("can't make directory %s: %v", dir, err)
}
return &FileCache{dir: dir}, nil
}
File renamed without changes.
@@ -7,7 +7,9 @@ import (
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func Test_GetRealPath(t *testing.T) {
@@ -64,7 +66,7 @@ func Test_GetRealPath(t *testing.T) {
},
},
}
idx := Index{
idx := indexing.Index{
Source: settings.Source{
Path: trimPrefix,
},
@@ -84,33 +86,33 @@ func Test_GetRealPath(t *testing.T) {
func TestSortItems(t *testing.T) {
tests := []struct {
name string
input FileInfo
expected FileInfo
input iteminfo.FileInfo
expected iteminfo.FileInfo
}{
{
name: "Numeric and Lexicographical Sorting",
input: FileInfo{
Folders: []ItemInfo{
input: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "10.txt"},
{Name: "2.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "File2.txt"},
{Name: "File10.txt"},
{Name: "File1"},
{Name: "banana"},
},
},
expected: FileInfo{
Folders: []ItemInfo{
expected: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "2.txt"},
{Name: "10.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "banana"},
{Name: "File1"},
{Name: "File10.txt"},
@@ -120,25 +122,25 @@ func TestSortItems(t *testing.T) {
},
{
name: "Only Lexicographical Sorting",
input: FileInfo{
Folders: []ItemInfo{
input: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "dog.txt"},
{Name: "Cat.txt"},
{Name: "apple"},
},
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "Zebra"},
{Name: "apple"},
{Name: "cat"},
},
},
expected: FileInfo{
Folders: []ItemInfo{
expected: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "apple"},
{Name: "Cat.txt"},
{Name: "dog.txt"},
},
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "apple"},
{Name: "cat"},
{Name: "Zebra"},
@@ -151,7 +153,7 @@ func TestSortItems(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
test.input.SortItems()
getNames := func(items []ItemInfo) []string {
getNames := func(items []iteminfo.ItemInfo) []string {
names := []string{}
for _, folder := range items {
names = append(names, folder.Name)
+304
View File
@@ -0,0 +1,304 @@
package files
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"fmt"
"hash"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error) {
response := iteminfo.ExtendedFileInfo{}
if opts.Source == "" {
opts.Source = settings.Config.Server.DefaultSource.Name
}
index := indexing.GetIndex(opts.Source)
if index == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
}
realPath, isDir, err := index.GetRealPath(opts.Path)
if err != nil {
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 {
// 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 response, err
}
info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
if !exists {
return response, fmt.Errorf("could not get metadata for path: %v", opts.Path)
}
if opts.Content && strings.HasPrefix(info.Type, "text") {
// Check file size
if info.Size > 50*1024*1024 { // 50 megabytes in bytes
logger.Debug(fmt.Sprintf("Reading large text file contents: "+info.Path, info.Name))
}
content, err := getContent(realPath)
if err != nil {
return response, err
}
response.Content = content
}
response.FileInfo = *info
response.RealPath = realPath
response.Source = opts.Source
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
response.OnlyOfficeId = generateOfficeId(realPath)
}
if strings.HasPrefix(info.Type, "video") {
parentInfo, exists := index.GetReducedMetadata(filepath.Dir(info.Path), true)
if exists {
response.DetectSubtitles(parentInfo)
}
}
return response, nil
}
func generateOfficeId(realPath string) string {
key, ok := cache.OnlyOffice.Get(realPath).(string)
if !ok {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
documentKey := utils.HashSHA256(realPath + timestamp)
cache.OnlyOffice.Set(realPath, documentKey)
return documentKey
}
return key
}
// Checksum checksums a given File for a given User, using a specific
// algorithm. The checksums data is saved on File object.
func GetChecksum(fullPath, algo string) (map[string]string, error) {
subs := map[string]string{}
reader, err := os.Open(fullPath)
if err != nil {
return subs, err
}
defer reader.Close()
hashFuncs := map[string]hash.Hash{
"md5": md5.New(),
"sha1": sha1.New(),
"sha256": sha256.New(),
"sha512": sha512.New(),
}
h, ok := hashFuncs[algo]
if !ok {
return subs, errors.ErrInvalidOption
}
_, err = io.Copy(h, reader)
if err != nil {
return subs, err
}
subs[algo] = hex.EncodeToString(h.Sum(nil))
return subs, nil
}
func DeleteFiles(source, absPath string, absDirPath string) error {
err := os.RemoveAll(absPath)
if err != nil {
return err
}
index := indexing.GetIndex(source)
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
}
refreshConfig := iteminfo.FileOptions{Path: index.MakeIndexPath(absDirPath), IsDir: true}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return err
}
return nil
}
func MoveResource(sourceIndex, destIndex, realsrc, realdst string) error {
err := fileutils.MoveFile(realsrc, realdst)
if err != nil {
return err
}
idxSrc := indexing.GetIndex(sourceIndex)
if idxSrc == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
idxDst := indexing.GetIndex(destIndex)
if idxDst == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshSourceDir := idxSrc.MakeIndexPath(filepath.Dir(realsrc))
refreshDestDir := idxDst.MakeIndexPath(filepath.Dir(realdst))
// refresh info for source and dest
err = idxSrc.RefreshFileInfo(iteminfo.FileOptions{
Path: refreshSourceDir,
IsDir: true,
})
if err != nil {
return fmt.Errorf("could not refresh index for source: %v", err)
}
if refreshSourceDir == refreshDestDir {
return nil
}
refreshConfig := iteminfo.FileOptions{Path: refreshDestDir, IsDir: true}
err = idxDst.RefreshFileInfo(refreshConfig)
if err != nil {
return fmt.Errorf("could not refresh index for dest: %v", err)
}
return nil
}
func CopyResource(sourceIndex, destIndex, realsrc, realdst string) error {
err := fileutils.CopyFile(realsrc, realdst)
if err != nil {
return err
}
idxSrc := indexing.GetIndex(sourceIndex)
if idxSrc == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
idxDst := indexing.GetIndex(destIndex)
if idxDst == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshSourceDir := idxSrc.MakeIndexPath(filepath.Dir(realsrc))
refreshDestDir := idxDst.MakeIndexPath(filepath.Dir(realdst))
index := indexing.GetIndex(sourceIndex)
if index == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshConfig := iteminfo.FileOptions{Path: refreshSourceDir, IsDir: true}
// refresh info for source and dest
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return fmt.Errorf("could not refresh index for source: %v", err)
}
refreshConfig.Path = refreshDestDir
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return errors.ErrEmptyKey
}
return nil
}
func WriteDirectory(opts iteminfo.FileOptions) error {
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
realPath, _, _ := idx.GetRealPath(opts.Path)
// Ensure the parent directories exist
err := os.MkdirAll(realPath, 0775)
if err != nil {
return err
}
err = idx.RefreshFileInfo(opts)
if err != nil {
return errors.ErrEmptyKey
}
return nil
}
func WriteFile(opts iteminfo.FileOptions, in io.Reader) error {
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
dst, _, _ := idx.GetRealPath(opts.Path)
parentDir := filepath.Dir(dst)
// Create the directory and all necessary parents
err := os.MkdirAll(parentDir, 0775)
if err != nil {
return err
}
// Open the file for writing (create if it doesn't exist, truncate if it does)
file, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775)
if err != nil {
return err
}
defer file.Close()
// Copy the contents from the reader to the file
_, err = io.Copy(file, in)
if err != nil {
return err
}
opts.Path = idx.MakeIndexPath(parentDir)
opts.IsDir = true
return idx.RefreshFileInfo(opts)
}
// addContent reads and sets content based on the file type.
func getContent(realPath string) (string, error) {
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
}
stringContent := string(content)
if !utf8.ValidString(stringContent) {
return "", nil
}
if stringContent == "" {
return "empty-file-x6OlSil", nil
}
return stringContent, nil
}
func IsNamedPipe(mode os.FileMode) bool {
return mode&os.ModeNamedPipe != 0
}
func IsSymlink(mode os.FileMode) bool {
return mode&os.ModeSymlink != 0
}
func Exists(path string) bool {
_, err := os.Stat(path)
if err == nil {
return true
}
if os.IsNotExist(err) {
return false
}
return false
}
File renamed without changes.
+64
View File
@@ -0,0 +1,64 @@
package files
import (
"fmt"
"os"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// MakeUserDir makes the user directory according to settings.
func MakeUserDir(fullPath string) error {
if err := os.MkdirAll(fullPath, os.ModePerm); err != nil {
return err
}
return nil
}
func MakeUserDirs(u *users.User, disableScopeChange bool) error {
cleanedUserName := users.CleanUsername(u.Username)
if cleanedUserName == "" || cleanedUserName == "-" || cleanedUserName == "." {
return fmt.Errorf("create user: invalid user for home dir creation: [%s]", u.Username)
}
for i, scope := range u.Scopes {
source, ok := settings.Config.Server.SourceMap[scope.Name]
if !ok {
return fmt.Errorf("create user: source not found: %s", scope.Name)
}
// create directory and append user name
if filepath.Base(scope.Scope) != cleanedUserName && source.Config.CreateUserDir && !disableScopeChange {
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
parentDir := filepath.Join(source.Path, scope.Scope)
// validate that scope path exists
if !Exists(parentDir) {
return fmt.Errorf("create user: scope path does not exist: %s", scope.Scope)
}
scope.Scope = filepath.Join(scope.Scope, cleanedUserName)
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("create user: failed to create user home dir: %s", err)
}
} else if filepath.Base(scope.Scope) == cleanedUserName && source.Config.CreateUserDir {
// create directory exactly as specified
fullPath := filepath.Join(source.Path, scope.Scope)
parentDir := filepath.Dir(fullPath)
if !Exists(parentDir) {
return fmt.Errorf("create user: scope folder does not exist: %s", parentDir)
}
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("create user: failed to create user home dir: %s", err)
}
} else {
// just assigning scope to path provided, so just check that it exists
path := filepath.Join(source.Path, scope.Scope)
if !Exists(path) {
return fmt.Errorf("create user: scope folder does not exist: %s", path)
}
}
u.Scopes[i] = scope
}
return nil
}
File renamed without changes.
File renamed without changes.
@@ -7,7 +7,8 @@ import (
"path"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
// MoveFile moves a file from src to dst.
@@ -175,3 +176,20 @@ func CommonPrefix(sep byte, paths ...string) string {
return string(c)
}
func ClearCacheDir() {
cacheDir := settings.Config.Server.CacheDir
entries, err := os.ReadDir(cacheDir)
if err != nil {
logger.Error(fmt.Sprintf("failed clear cache dir: %v", err))
}
for _, entry := range entries {
path := filepath.Join(cacheDir, entry.Name())
err = os.RemoveAll(path)
if err != nil {
logger.Error(fmt.Sprintf("failed clear cache dir: %v", err))
}
}
}
File renamed without changes.
+18 -1
View File
@@ -2,8 +2,14 @@ package auth
import (
"net/http"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
var (
revokedApiKeyList map[string]bool
revokeMu sync.Mutex
)
// Auther is the authentication interface.
@@ -13,3 +19,14 @@ type Auther interface {
// LoginPage indicates if this auther needs a login page.
LoginPage() bool
}
func IsRevokedApiKey(key string) bool {
_, exists := revokedApiKeyList[key]
return exists
}
func RevokeAPIKey(key string) {
revokeMu.Lock()
delete(revokedApiKeyList, key)
revokeMu.Unlock()
}
+11 -14
View File
@@ -8,11 +8,10 @@ import (
"os/exec"
"strings"
"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/users"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
type hookCred struct {
@@ -163,13 +162,11 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
SingleClick: a.Settings.UserDefaults.SingleClick,
ShowHidden: a.Settings.UserDefaults.ShowHidden,
},
Username: a.Cred.Username,
Perm: a.Settings.UserDefaults.Perm,
Username: a.Cred.Username,
Permissions: a.Settings.UserDefaults.Permissions,
}
u = a.GetUser(d)
files.MakeUserDirs(u)
err = a.Users.Save(u, false)
if err != nil {
return nil, err
@@ -184,7 +181,7 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
if len(a.Fields.Values) > 1 {
u = a.GetUser(u)
// update user with provided fields
err := a.Users.Update(u, true)
err := a.Users.Update(u, u.Permissions.Admin)
if err != nil {
return nil, err
}
@@ -196,11 +193,11 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
// GetUser returns a User filled with hook values or provided defaults
func (a *HookAuth) GetUser(d *users.User) *users.User {
// adds all permissions when user is admin
isAdmin := d.Perm.Admin
isAdmin := d.Permissions.Admin
perms := users.Permissions{
Admin: isAdmin,
Modify: isAdmin || d.Perm.Modify,
Share: isAdmin || d.Perm.Share,
Modify: isAdmin || d.Permissions.Modify,
Share: isAdmin || d.Permissions.Share,
}
user := users.User{
NonAdminEditable: users.NonAdminEditable{
@@ -213,7 +210,7 @@ func (a *HookAuth) GetUser(d *users.User) *users.User {
ID: d.ID,
Username: d.Username,
Scopes: d.Scopes,
Perm: perms,
Permissions: perms,
LockPassword: true,
}
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
type jsonCred struct {
+1 -1
View File
@@ -3,7 +3,7 @@ package auth
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// MethodNoAuth is used to identify no auth.
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"net/http"
"os"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// MethodProxyAuth is used to identify no auth.
+1 -1
View File
@@ -1,7 +1,7 @@
package auth
import (
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// StorageBackend is a storage backend for auth storage.
+13 -13
View File
@@ -5,8 +5,8 @@
? github.com/gtsteffaniak/filebrowser/backend/auth [no test files]
? github.com/gtsteffaniak/filebrowser/backend/cmd [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/diskcache 0.005s
? github.com/gtsteffaniak/filebrowser/backend/errors [no test files]
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache 0.005s
? github.com/gtsteffaniak/filebrowser/backend/common/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
@@ -22,7 +22,7 @@ ok github.com/gtsteffaniak/filebrowser/backend/diskcache 0.005s
2025/01/04 14:04:56 Next scan in 1m0s
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/backend/files
pkg: github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
BenchmarkFillIndex-8 2025/01/04 14:04:57 Initializing index and assessing file system complexity
2025/01/04 14:04:57 Starting full scan
@@ -44,23 +44,23 @@ BenchmarkSearchAllIndexes-8 2025/01/04 14:04:59 Initializing index
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/backend/files 5.094s
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files 5.094s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/fileutils 0.002s
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils 0.002s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/http 0.184s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/img 0.123s
ok github.com/gtsteffaniak/filebrowser/backend/preview/img 0.123s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/runner 0.004s
PASS
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]
ok github.com/gtsteffaniak/filebrowser/backend/common/settings 0.005s
? github.com/gtsteffaniak/filebrowser/backend/database/share [no test files]
? github.com/gtsteffaniak/filebrowser/backend/database/storage [no test files]
? github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt [no test files]
? github.com/gtsteffaniak/filebrowser/backend/swagger/docs [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/users 0.003s
ok github.com/gtsteffaniak/filebrowser/backend/database/users 0.003s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/utils 0.002s
? github.com/gtsteffaniak/filebrowser/backend/version [no test files]
ok github.com/gtsteffaniak/filebrowser/backend/common/utils 0.002s
? github.com/gtsteffaniak/filebrowser/backend/common/version [no test files]
+127
View File
@@ -0,0 +1,127 @@
package cmd
import (
"flag"
"fmt"
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
var (
configPath string
)
// return bool to indicate if the program should continue running
func runCLI() bool {
// Global flags
var help bool
// Override the default usage output to use generalUsage()
flag.Usage = generalUsage
flag.StringVar(&configPath, "c", "", "Path to the config file, default: config.yaml")
flag.BoolVar(&help, "h", false, "Get help about commands")
if configPath == "" {
configPath = os.Getenv("FILEBROWSER_CONFIG")
}
if configPath == "" {
configPath = "config.yaml"
}
// Parse global flags (before subcommands)
flag.Parse() // print generalUsage on error
// Show help if requested
if help {
generalUsage()
return false
}
// Create a new FlagSet for the 'set' subcommand
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
var user, scope, dbConfig string
var asAdmin bool
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", "config.yaml", "Path to the config file, default: config.yaml")
// Parse subcommand flags only if a subcommand is specified
if len(os.Args) > 1 {
switch os.Args[1] {
case "set":
err := setCmd.Parse(os.Args[2:])
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
userInfo := strings.Split(user, ",")
if len(userInfo) < 2 {
fmt.Printf("not enough info to create user: \"set -u username,password\", only provided %v\n", userInfo)
setCmd.PrintDefaults()
os.Exit(1)
}
username := userInfo[0]
password := userInfo[1]
ok := getStore(dbConfig)
if !ok {
logger.Fatal("could not load db info")
}
user, err := store.Users.Get(username)
if err != nil {
newUser := users.User{
Username: username,
NonAdminEditable: users.NonAdminEditable{
Password: password,
},
}
for _, source := range settings.Config.Server.SourceMap {
if source.Config.DefaultEnabled {
newUser.Scopes = append(newUser.Scopes, users.SourceScope{
Name: source.Name,
Scope: source.Config.DefaultUserScope,
})
}
}
// Create the user logic
if asAdmin {
logger.Info(fmt.Sprintf("Creating user as admin: %s\n", username))
} else {
logger.Info(fmt.Sprintf("Creating non-admin user: %s\n", username))
}
err = storage.CreateUser(newUser, asAdmin)
if err != nil {
logger.Error(fmt.Sprintf("could not create user: %v", err))
}
return false
}
user.Password = password
if asAdmin {
user.Permissions.Admin = true
}
err = store.Users.Save(user, true)
if err != nil {
logger.Error(fmt.Sprintf("could not update user: %v", err))
}
fmt.Printf("successfully updated user: %s\n", username)
return false
case "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 false
}
}
return true
}
+21
View File
@@ -0,0 +1,21 @@
package cmd
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
// healthcheck attempt to save test file against configured url
func validateOfficeIntegration() {
if settings.Config.Integrations.OnlyOffice.Url != "" {
// get url health
// get request against the url
_, err := http.NewRequest("GET", settings.Config.Integrations.OnlyOffice.Url, nil)
if err != nil {
logger.Warning("Could not create request to only office Url, make sure its valid and running")
return
}
}
}
+29 -181
View File
@@ -2,79 +2,35 @@ package cmd
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"github.com/gtsteffaniak/filebrowser/backend/diskcache"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
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/indexing"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
"github.com/swaggo/swag"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/version"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
)
func getStore(configFile string) (*storage.Storage, bool) {
var store *storage.Storage
func getStore(configFile string) bool {
// Use the config file (global flag)
settings.Initialize(configFile)
store, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
s, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
if err != nil {
logger.Fatal(fmt.Sprintf("could not load db info: %v", err))
}
// update source info for users if names/sources/paths might have changed
usersList, err2 := store.Users.Gets()
if err2 != nil {
logger.Fatal(fmt.Sprintf("could not load users: %v", err2))
}
// this function adds scopes as needed on startup
for _, user := range usersList {
updateUser := false
newScopes := []users.SourceScope{}
for _, source := range settings.Config.Server.SourceMap {
scopePath := ""
if !user.Perm.Admin {
scopePath = source.Config.DefaultUserScope
}
scope, err := settings.GetScopeFromSourcePath(user.Scopes, source.Path)
if err != nil {
if user.Perm.Admin || source.Config.DefaultEnabled {
newScopes = append(newScopes, users.SourceScope{Scope: scopePath, Name: source.Path}) // backend name is path
updateUser = true
}
} else {
newScopes = append(newScopes, users.SourceScope{Scope: scope, Name: source.Path}) // backend name is path
}
}
user.Scopes = newScopes
// maintain backwards compatibility, update user scope from scopes
if len(user.Scopes) == 0 {
user.Scopes = []users.SourceScope{
{
Scope: user.Scope,
Name: settings.Config.Server.DefaultSource.Path, // backend name is path
},
}
updateUser = true
}
if !updateUser {
continue
}
err := store.Users.Save(user, false)
if err != nil {
logger.Error(fmt.Sprintf("could not update user: %v", err))
}
}
return store, hasDB
store = s
return hasDB
}
func generalUsage() {
@@ -91,122 +47,20 @@ commands:
}
func StartFilebrowser() {
// Global flags
var configPath string
var help bool
// Override the default usage output to use generalUsage()
flag.Usage = generalUsage
flag.StringVar(&configPath, "c", "", "Path to the config file, default: config.yaml")
flag.BoolVar(&help, "h", false, "Get help about commands")
if configPath == "" {
configPath = os.Getenv("FILEBROWSER_CONFIG")
}
if configPath == "" {
configPath = "config.yaml"
}
// Parse global flags (before subcommands)
flag.Parse() // print generalUsage on error
// Show help if requested
if help {
generalUsage()
keepGoing := runCLI()
if !keepGoing {
return
}
// Create a new FlagSet for the 'set' subcommand
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
var user, scope, dbConfig string
var asAdmin bool
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", "config.yaml", "Path to the config file, default: config.yaml")
// Create context and channels for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{}) // Signals server has stopped
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
// Parse subcommand flags only if a subcommand is specified
if len(os.Args) > 1 {
switch os.Args[1] {
case "set":
err := setCmd.Parse(os.Args[2:])
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
userInfo := strings.Split(user, ",")
if len(userInfo) < 2 {
fmt.Printf("not enough info to create user: \"set -u username,password\", only provided %v\n", userInfo)
setCmd.PrintDefaults()
os.Exit(1)
}
username := userInfo[0]
password := userInfo[1]
store, ok := getStore(dbConfig)
if !ok {
logger.Fatal("could not load db info")
}
user, err := store.Users.Get(username)
if err != nil {
newUser := users.User{
Username: username,
NonAdminEditable: users.NonAdminEditable{
Password: password,
},
}
for _, source := range settings.Config.Server.SourceMap {
if source.Config.DefaultEnabled {
newUser.Scopes = append(newUser.Scopes, users.SourceScope{
Name: source.Name,
Scope: source.Config.DefaultUserScope,
})
}
}
// Create the user logic
if asAdmin {
logger.Info(fmt.Sprintf("Creating user as admin: %s\n", username))
} else {
logger.Info(fmt.Sprintf("Creating non-admin user: %s\n", username))
}
err = storage.CreateUser(newUser, asAdmin)
if err != nil {
logger.Error(fmt.Sprintf("could not create user: %v", err))
}
return
}
user.Password = password
if asAdmin {
user.Perm.Admin = true
}
err = store.Users.Save(user, true)
if err != nil {
logger.Error(fmt.Sprintf("could not update user: %v", err))
}
fmt.Printf("successfully updated user: %s\n", username)
return
case "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)
done := make(chan struct{}) // Signals server has stopped
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
dbExists := getStore(configPath)
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
@@ -229,8 +83,10 @@ Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
logger.Fatal("No sources configured, exiting...")
}
for _, source := range settings.Config.Server.SourceMap {
go files.Initialize(source)
go indexing.Initialize(source, false)
}
validateUserInfo()
validateOfficeIntegration()
// Start the rootCMD in a goroutine
go func() {
if err := rootCMD(ctx, store, &serverConfig, shutdownComplete); err != nil {
@@ -246,6 +102,7 @@ Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
case <-done:
logger.Info("Server stopped unexpectedly. Shutting down...")
}
fileutils.ClearCacheDir()
<-shutdownComplete // Ensure we don't exit prematurely
// Wait for the server to stop
@@ -256,23 +113,14 @@ func rootCMD(ctx context.Context, store *storage.Storage, serverConfig *settings
if serverConfig.NumImageProcessors < 1 {
logger.Fatal("Image resize workers count could not be < 1")
}
imgSvc := img.New(serverConfig.NumImageProcessors)
cacheDir := settings.Config.Server.CacheDir
var fileCache diskcache.Interface
// Use file cache if cacheDir is specified
if cacheDir != "" {
var err error
fileCache, err = diskcache.NewFileCache(cacheDir)
if err != nil {
logger.Fatal(fmt.Sprintf("failed to create file cache: %v", err))
}
} else {
// No-op cache if no cacheDir is specified
fileCache = diskcache.NewNoOp()
numWorkers := settings.Config.Server.NumImageProcessors
ffpmpegPath := settings.Config.Integrations.Media.FfmpegPath
// setup disk cache
err := preview.Start(numWorkers, ffpmpegPath, cacheDir)
if err != nil {
logger.Fatal(fmt.Sprintf("Error starting preview service: %v", err))
}
fbhttp.StartHttp(ctx, imgSvc, store, fileCache, shutdownComplete)
fbhttp.StartHttp(ctx, store, shutdownComplete)
return nil
}
+187
View File
@@ -0,0 +1,187 @@
package cmd
import (
"fmt"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
var createBackup = []bool{}
func validateUserInfo() {
// update source info for users if names/sources/paths might have changed
usersList, err := store.Users.Gets()
if err != nil {
logger.Fatal(fmt.Sprintf("could not load users: %v", err))
}
for _, user := range usersList {
updateUser := false
if user.Username == "publicUser" {
settings.ApplyUserDefaults(user)
updateUser = true
}
if updateUserScopes(user) {
updateUser = true
}
if updatePermissions(user) {
updateUser = true
}
if updatePreviewSettings(user) {
updateUser = true
}
if updateLoginType(user) {
updateUser = true
}
if updateUser {
if len(createBackup) == 1 {
logger.Warning("Incompatible user settings detected, creating backup of database before converting.")
err = fileutils.CopyFile(settings.Config.Server.Database, fmt.Sprintf("%s.bak", settings.Config.Server.Database))
if err != nil {
logger.Fatal(fmt.Sprintf("Unable to create automatic backup of database due to error: %v", err))
}
}
err := store.Users.Save(user, false)
if err != nil {
logger.Error(fmt.Sprintf("could not update user: %v", err))
}
}
}
}
func updateUserScopes(user *users.User) bool {
updateUser := false
finalScopes := []users.SourceScope{}
seenNames := make(map[string]bool)
// Step 1: Start by including all existing scopes, updating Name if source.Path matches
for _, existingScope := range user.Scopes {
if existingScope.Scope != "/" {
existingScope.Scope = strings.TrimSuffix(existingScope.Scope, "/")
}
if existingScope.Scope == "" {
updateUser = true
continue
}
if !seenNames[existingScope.Name] {
finalScopes = append(finalScopes, existingScope)
seenNames[existingScope.Name] = true
}
}
// Step 2: Add missing scopes from SourceMap if admin or DefaultEnabled
for _, source := range settings.Config.Server.SourceMap {
if seenNames[source.Path] {
continue
}
// Check if scope exists already via path
scopePath, _ := settings.GetScopeFromSourcePath(user.Scopes, source.Path)
if scopePath == "" {
// must be new scope for user
// If user has no access, skip unless default-enabled
if !source.Config.DefaultEnabled {
continue
}
// Determine scope path
if scopePath == "" {
scopePath = source.Config.DefaultUserScope
}
if scopePath == "" {
scopePath = "/"
}
if scopePath != "/" {
scopePath = strings.TrimSuffix(scopePath, "/")
}
}
// Add new scope
finalScopes = append(finalScopes, users.SourceScope{
Scope: scopePath,
Name: source.Path,
})
seenNames[source.Path] = true
updateUser = true
}
// Step 3: If no scopes, apply default
if len(finalScopes) == 0 {
defaultScope := users.SourceScope{
Scope: settings.Config.Server.DefaultSource.Config.DefaultUserScope,
Name: settings.Config.Server.DefaultSource.Path,
}
finalScopes = append(finalScopes, defaultScope)
updateUser = true
}
// Step 4: Assign only if different
if !scopesEqual(user.Scopes, finalScopes) {
user.Scopes = finalScopes
updateUser = true
}
return updateUser
}
// scopesEqual does a deep comparison to avoid unnecessary writes
func scopesEqual(a, b []users.SourceScope) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].Scope != b[i].Scope || a[i].Name != b[i].Name {
return false
}
}
return true
}
// func to convert legacy user with perm key to permissions
func updatePermissions(user *users.User) bool {
updateUser := false
// if any keys are true, set the permissions to true
if user.Perm.Api {
user.Permissions.Api = true
user.Perm.Api = false
updateUser = true
}
if user.Perm.Admin {
user.Permissions.Admin = true
user.Perm.Admin = false
updateUser = true
}
if user.Perm.Modify {
user.Permissions.Modify = true
user.Perm.Modify = false
updateUser = true
}
if user.Perm.Share {
user.Permissions.Share = true
user.Perm.Share = false
updateUser = true
}
if updateUser {
createBackup = append(createBackup, true)
}
return updateUser
}
func updateLoginType(user *users.User) bool {
if user.LoginMethod == "" {
user.LoginMethod = users.LoginMethodPassword
return true
}
return false
}
func updatePreviewSettings(user *users.User) bool {
// if user hasn't been updated yet
if user.LoginMethod == "" {
user.Preview.Image = true
user.Preview.PopUp = true
return true
}
return false
}
+2 -1
View File
@@ -8,8 +8,9 @@ import (
var (
DiskUsage = NewCache(30*time.Second, 24*time.Hour)
RealPath = NewCache(48*time.Hour, 72*time.Hour)
SearchResults = NewCache(15*time.Second, time.Hour)
SearchResults = NewCache(15*time.Second, 1*time.Hour)
OnlyOffice = NewCache(48*time.Hour, 1*time.Hour)
JwtCache = NewCache(1*time.Hour, 72*time.Hour)
)
func NewCache(expires time.Duration, cleanup time.Duration) *KeyCache {
File renamed without changes.
File renamed without changes.
@@ -96,7 +96,8 @@ func Log(level string, msg string, prefix, api bool, color string) {
}
func Api(msg string, statusCode int) {
if statusCode >= 300 && statusCode < 500 {
// redirects are not warnings anymore
if statusCode > 302 && statusCode < 500 {
Log(levels.WARNING, msg, false, true, YELLOW)
} else if statusCode >= 500 {
Log(levels.ERROR, msg, false, true, RED)
@@ -134,7 +135,7 @@ func Error(msg string) {
if len(loggers) > 0 {
Log(levels.ERROR, msg, true, false, RED)
} else {
log.Println("[ERROR] ", msg)
log.Println("[ERROR]", msg)
}
}
+49
View File
@@ -0,0 +1,49 @@
package settings
type Auth struct {
TokenExpirationHours int `json:"tokenExpirationHours"` // the number of hours until the token expires. Default is 2 hours.
Methods LoginMethods `json:"methods"`
Key string `json:"key"` // the key used to sign the JWT tokens. If not set, a random key will be generated.
AdminUsername string `json:"adminUsername"` // the username of the admin user. If not set, the default is "admin".
AdminPassword string `json:"adminPassword"` // the password of the admin user. If not set, the default is "admin".
AuthMethods []string `json:"-"`
}
type LoginMethods struct {
ProxyAuth ProxyAuthConfig `json:"proxy" validate:"omitempty"`
NoAuth bool `json:"noauth"` // if set to true, overrides all other auth methods and disables authentication
PasswordAuth PasswordAuthConfig `json:"password" validate:"omitempty"`
OidcAuth OidcConfig `json:"oidc" validate:"omitempty"`
}
type PasswordAuthConfig struct {
Enabled bool `json:"enabled"`
MinLength int `json:"minLength" validate:"omitempty,min=5"`
Signup bool `json:"signup" validate:"omitempty"` // currently not used by filebrowser
Recaptcha Recaptcha `json:"recaptcha" validate:"omitempty"` // recaptcha config, only used if signup is enabled
}
type ProxyAuthConfig struct {
Enabled bool `json:"enabled"`
CreateUser bool `json:"createUser"` // create user if not exists
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
}
type Recaptcha struct {
Host string `json:"host" validate:"required"`
Key string `json:"key" validate:"required"`
Secret string `json:"secret" validate:"required"`
}
// OpenID OAuth2.0
type OidcConfig struct {
Enabled bool `json:"enabled"` // whether to enable OIDC authentication
ClientID string `json:"clientId" validate:"required"` // client id of the OIDC application
ClientSecret string `json:"clientSecret" validate:"required"` // client secret of the OIDC application
AuthorizationUrl string `json:"authorizationUrl" validate:"required"` // authorization URL of the OIDC provider
TokenUrl string `json:"tokenUrl" validate:"required"` // token URL of the OIDC provider
UserInfoUrl string `json:"userInfoUrl" validate:"required"` // user info URL of the OIDC provider
Scopes string `json:"scopes" validate:"required"` // space separated list of scopes to request from the OIDC provider
UserIdentifier string `json:"userIdentifier"` // optional: which attribute should be used as the username? options: email, username, name, phone_number, sub
JwksUrl string `json:"jwksUrl"` // currently not used by filebrowser
}
@@ -8,11 +8,11 @@ import (
"slices"
"strings"
yaml "github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/backend/version"
"github.com/go-playground/validator/v10"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
var Config Settings
@@ -22,10 +22,20 @@ func Initialize(configFile string) {
if err != nil {
logger.Fatal(err.Error())
}
err = ValidateConfig(Config)
if err != nil {
errmsg := "the provided config file failed validation. "
errmsg += "If you are seeing this on a config that worked previeously, "
errmsg += "this is because v0.6.8 requires a fully validated config to run. "
errmsg += "Please review your config for typos and invalid keys which are no longer supported. "
errmsg += "visit https://github.com/gtsteffaniak/filebrowser/wiki/Full-Config-Example for more information."
logger.Error(errmsg)
logger.Fatal(err.Error())
}
setupLogging()
setupAuth()
setupSources()
setupBaseURL()
setupUrls()
setupFrontend()
}
@@ -61,7 +71,10 @@ func setupSources() {
} else {
for k, source := range Config.Server.Sources {
realPath := getRealPath(source.Path)
name := utils.GetLastComponent(realPath)
name := filepath.Base(realPath)
if name == "\\" {
name = strings.Split(realPath, ":")[0]
}
source.Path = realPath // use absolute path
if source.Name == "" {
_, ok := Config.Server.SourceMap[source.Path]
@@ -74,6 +87,9 @@ func setupSources() {
Config.Server.DefaultSource = source
}
}
if source.Config.DefaultUserScope == "" {
source.Config.DefaultUserScope = "/"
}
Config.Server.SourceMap[source.Path] = source
Config.Server.NameToSource[source.Name] = source
}
@@ -83,20 +99,20 @@ func setupSources() {
defaultScopes := []users.SourceScope{}
allSourceNames := []string{}
first := true
potentialDefaultSource := Config.Server.DefaultSource
for _, sourcePathOnly := range Config.Server.Sources {
realPath := getRealPath(sourcePathOnly.Path)
source, ok := Config.Server.SourceMap[realPath]
if ok && !slices.Contains(allSourceNames, source.Name) {
if first {
source.Config.DefaultEnabled = true
Config.Server.SourceMap[source.Path] = source
Config.Server.NameToSource[source.Name] = source
Config.Server.DefaultSource = source
potentialDefaultSource = source
}
first = false
sourceList = append(sourceList, source)
if source.Config.DefaultEnabled {
Config.Server.DefaultSource = source
if Config.Server.DefaultSource.Path == "" {
Config.Server.DefaultSource = source
}
defaultScopes = append(defaultScopes, users.SourceScope{
Name: source.Path,
Scope: source.Config.DefaultUserScope,
@@ -107,30 +123,36 @@ func setupSources() {
logger.Warning(fmt.Sprintf("source %v is not configured correctly, skipping", sourcePathOnly.Path))
}
}
if Config.Server.DefaultSource.Path == "" {
Config.Server.DefaultSource = potentialDefaultSource
}
Config.UserDefaults.DefaultScopes = defaultScopes
Config.Server.Sources = sourceList
}
func setupBaseURL() {
func setupUrls() {
baseurl := strings.Trim(Config.Server.BaseURL, "/")
if baseurl == "" {
Config.Server.BaseURL = "/"
} else {
Config.Server.BaseURL = "/" + baseurl + "/"
}
Config.Server.InternalUrl = strings.Trim(Config.Server.InternalUrl, "/")
Config.Server.ExternalUrl = strings.Trim(Config.Server.ExternalUrl, "/")
Config.Integrations.OnlyOffice.Url = strings.Trim(Config.Integrations.OnlyOffice.Url, "/")
Config.Integrations.OnlyOffice.InternalUrl = strings.Trim(Config.Integrations.OnlyOffice.InternalUrl, "/")
}
func setupAuth() {
if Config.Auth.Method != "" {
logger.Warning("The `auth.method` setting is deprecated and will be removed in a future version. Please use `auth.methods` instead.")
}
Config.UserDefaults.Perm = Config.UserDefaults.Permissions
if Config.Auth.Methods.PasswordAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "Password")
}
if Config.Auth.Methods.ProxyAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "Proxy")
}
if Config.Auth.Methods.OidcAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "OIDC")
}
if Config.Auth.Methods.NoAuth {
logger.Warning("Configured with no authentication, this is not recommended.")
Config.Auth.AuthMethods = []string{"Disabled"}
@@ -164,58 +186,84 @@ func setupLogging() {
}
func loadConfigWithDefaults(configFile string) error {
Config = setDefaults()
// Open and read the YAML file
yamlFile, err := os.Open(configFile)
if err != nil {
return err
}
defer yamlFile.Close()
stat, err := yamlFile.Stat()
if err != nil {
return err
}
yamlData := make([]byte, stat.Size())
_, err = yamlFile.Read(yamlData)
if err != nil && configFile != "config.yaml" {
return fmt.Errorf("could not load specified config file: " + err.Error())
return fmt.Errorf("could not load specified config file: %v", err.Error())
}
if err != nil {
logger.Warning(fmt.Sprintf("Could not load config file '%v', using default settings: %v", configFile, err))
}
Config = setDefaults()
err = yaml.Unmarshal(yamlData, &Config)
err = yaml.NewDecoder(strings.NewReader(string(yamlData)), yaml.DisallowUnknownField()).Decode(&Config)
if err != nil {
return fmt.Errorf("error unmarshaling YAML data: %v", err)
}
loadEnvConfig()
return nil
}
func ValidateConfig(config Settings) error {
validate := validator.New()
err := validate.Struct(Config)
if err != nil {
return fmt.Errorf("could not validate config: %v", err)
}
return nil
}
func loadEnvConfig() {
adminPassword, ok := os.LookupEnv("FILEBROWSER_ADMIN_PASSWORD")
if ok {
logger.Info("Using admin password from FILEBROWSER_ADMIN_PASSWORD environment variable")
Config.Auth.AdminPassword = adminPassword
}
officeSecret, ok := os.LookupEnv("FILEBROWSER_ONLYOFFICE_SECRET")
if ok {
logger.Info("Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable")
Config.Integrations.OnlyOffice.Secret = officeSecret
}
ffmpegPath, ok := os.LookupEnv("FILEBROWSER_FFMPEG_PATH")
if ok {
Config.Integrations.Media.FfmpegPath = ffmpegPath
}
}
func setDefaults() Settings {
return Settings{
Server: Server{
EnableThumbnails: true,
ResizePreview: false,
EnableExec: false,
Port: 80,
NumImageProcessors: 4,
BaseURL: "",
Database: "database.db",
SourceMap: map[string]Source{},
NameToSource: map[string]Source{},
MaxArchiveSizeGB: 50,
CacheDir: "tmp",
},
Auth: Auth{
AdminUsername: "admin",
AdminPassword: "admin",
TokenExpirationHours: 2,
Signup: false,
Recaptcha: Recaptcha{
Host: "",
},
Methods: LoginMethods{
PasswordAuth: PasswordAuthConfig{
Enabled: true,
MinLength: 5,
Signup: false,
},
},
},
@@ -223,7 +271,7 @@ func setDefaults() Settings {
Name: "FileBrowser Quantum",
},
UserDefaults: UserDefaults{
DisableOnlyOfficeExt: ".txt .csv .html",
DisableOnlyOfficeExt: ".txt .csv .html .pdf",
StickySidebar: true,
LockPassword: false,
ShowHidden: false,
@@ -232,6 +280,7 @@ func setDefaults() Settings {
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
Permissions: users.Permissions{
Modify: false,
Share: false,
@@ -248,9 +297,19 @@ func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, er
}
newScopes := []users.SourceScope{}
for _, scope := range scopes {
// first check if its already a path name and keep it
source, ok := Config.Server.SourceMap[scope.Name]
if ok {
if scope.Scope == "" {
scope.Scope = source.Config.DefaultUserScope
}
if !strings.HasPrefix(scope.Scope, "/") {
scope.Scope = "/" + scope.Scope
}
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
}
newScopes = append(newScopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
@@ -261,7 +320,17 @@ func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, er
// check if its the name of a source and convert it to a path
source, ok = Config.Server.NameToSource[scope.Name]
if !ok {
return newScopes, fmt.Errorf("invalid scope for source %v", scope.Name)
// source might no longer be configured
continue
}
if scope.Scope == "" {
scope.Scope = source.Config.DefaultUserScope
}
if !strings.HasPrefix(scope.Scope, "/") {
scope.Scope = "/" + scope.Scope
}
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
}
newScopes = append(newScopes, users.SourceScope{
Name: source.Path, // backend name is path
@@ -272,7 +341,7 @@ func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, er
}
func ConvertToFrontendScopes(scopes []users.SourceScope) []users.SourceScope {
newScopes := make([]users.SourceScope, 0, len(scopes)) // Preserve original order
newScopes := []users.SourceScope{}
for _, scope := range scopes {
if source, ok := Config.Server.SourceMap[scope.Name]; ok {
// Replace scope.Name with source.Path while keeping the same Scope value
@@ -317,3 +386,15 @@ func GetScopeFromSourcePath(scopes []users.SourceScope, sourcePath string) (stri
}
return "", fmt.Errorf("scope not found for source %v", sourcePath)
}
// assumes backend style scopes
func GetSources(u *users.User) []string {
sources := []string{}
for _, scope := range u.Scopes {
source, ok := Config.Server.SourceMap[scope.Name]
if ok {
sources = append(sources, source.Name)
}
}
return sources
}
+101
View File
@@ -0,0 +1,101 @@
package settings
import (
"os"
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestInitialize(t *testing.T) {
type args struct {
configFile string
}
tests := []struct {
name string
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Initialize(tt.args.configFile)
})
}
}
func Test_setDefaults(t *testing.T) {
tests := []struct {
name string
want Settings
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := setDefaults(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("setDefaults() = %v, want %v", got, tt.want)
}
})
}
}
func TestConfigLoadChanged(t *testing.T) {
defaultConfig := setDefaults()
err := loadConfigWithDefaults("./validConfig.yaml")
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
// Use go-cmp to compare the two structs
if diff := cmp.Diff(defaultConfig, Config); diff == "" {
t.Errorf("No change when there should have been (-want +got):\n%s", diff)
}
}
func TestConfigLoadEnvVars(t *testing.T) {
defaultConfig := setDefaults()
expectedKey := "MYKEY"
// mock environment variables
os.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", expectedKey)
err := loadConfigWithDefaults("./validConfig.yaml")
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
if Config.Integrations.OnlyOffice.Secret != expectedKey {
t.Errorf("Expected OnlyOffice.Secret to be '%v', got '%s'", expectedKey, Config.Integrations.OnlyOffice.Secret)
}
// Use go-cmp to compare the two structs
if diff := cmp.Diff(defaultConfig, Config); diff == "" {
t.Errorf("No change when there should have been (-want +got):\n%s", diff)
}
}
func TestConfigLoadSpecificValues(t *testing.T) {
defaultConfig := setDefaults()
err := loadConfigWithDefaults("./validConfig.yaml")
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
testCases := []struct {
fieldName string
globalVal interface{}
newVal interface{}
}{
{"Server.Database", Config.Server.Database, defaultConfig.Server.Database},
}
for _, tc := range testCases {
if tc.globalVal == tc.newVal {
t.Errorf("Differences should have been found:\nConfig.%s: %v \nSetConfig: %v \n", tc.fieldName, tc.globalVal, tc.newVal)
}
}
}
func TestInvalidConfig(t *testing.T) {
configFile := "./invalidConfig.yaml"
err := loadConfigWithDefaults(configFile)
if err == nil {
t.Fatalf("expected error loading config file %s, got nil", configFile)
}
}
@@ -1,9 +1,10 @@
//go:generate go run ./tools/yaml.go -input=common/settings/settings.go -output=config.generated.yaml
package settings
import (
"crypto/rand"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
const DefaultUsersHomeBasePath = "/users"
@@ -44,16 +45,22 @@ func ApplyUserDefaults(u *users.User) {
u.Locale = Config.UserDefaults.Locale
u.ViewMode = Config.UserDefaults.ViewMode
u.SingleClick = Config.UserDefaults.SingleClick
u.Perm = Config.UserDefaults.Perm
u.Permissions = Config.UserDefaults.Permissions
u.ShowHidden = Config.UserDefaults.ShowHidden
u.DateFormat = Config.UserDefaults.DateFormat
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
u.ThemeColor = Config.UserDefaults.ThemeColor
u.GallerySize = Config.UserDefaults.GallerySize
u.QuickDownload = Config.UserDefaults.QuickDownload
u.LockPassword = Config.UserDefaults.LockPassword
if len(u.Scopes) == 0 {
u.Scopes = []users.SourceScope{
{
Scope: Config.Server.DefaultSource.Config.DefaultUserScope,
Name: Config.Server.DefaultSource.Path,
},
for _, source := range Config.Server.Sources {
if source.Config.DefaultEnabled {
u.Scopes = append(u.Scopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: source.Config.DefaultUserScope,
})
}
}
}
}
@@ -1,7 +1,7 @@
package settings
import (
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
// StorageBackend is a settings storage backend.
@@ -28,9 +28,6 @@ func (s *Storage) Get() (*Settings, error) {
if err != nil {
return nil, err
}
if set.Server.UserHomeBasePath == "" {
set.Server.UserHomeBasePath = DefaultUsersHomeBasePath
}
return set, nil
}
+129
View File
@@ -0,0 +1,129 @@
package settings
import (
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
type AllowedMethods string
const (
ProxyAuth AllowedMethods = "proxyAuth"
NoAuth AllowedMethods = "noAuth"
PasswordAuth AllowedMethods = "passwordAuth"
)
type Settings struct {
Server Server `json:"server"`
Auth Auth `json:"auth"`
Frontend Frontend `json:"frontend"`
UserDefaults UserDefaults `json:"userDefaults"`
Integrations Integrations `json:"integrations"`
}
type Server struct {
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs
Socket string `json:"socket"` // socket to listen on
TLSKey string `json:"tlsKey"` // path to TLS key
TLSCert string `json:"tlsCert"` // path to TLS cert
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
Port int `json:"port"` // port to listen on
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
Logging []LogConfig `json:"logging"`
Database string `json:"database"` // path to the database file
Sources []Source `json:"sources" validate:"required,dive"`
ExternalUrl string `json:"externalUrl"` // used by share links if set
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the url that an integration service will use to communicate with filebrowser
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
// not exposed to config
SourceMap map[string]Source `json:"-" validate:"omitempty"` // uses realpath as key
NameToSource map[string]Source `json:"-" validate:"omitempty"` // uses name as key
DefaultSource Source `json:"-" validate:"omitempty"`
}
type Integrations struct {
OnlyOffice OnlyOffice `json:"office" validate:"omitempty"`
Media Media `json:"media" validate:"omitempty"`
}
// onlyoffice secret is stored in the local.json file
// docker exec <containerID> /var/www/onlyoffice/documentserver/npm/json -f /etc/onlyoffice/documentserver/local.json 'services.CoAuthoring.secret.session.string'
type OnlyOffice struct {
Url string `json:"url" validate:"required"` // The URL to the OnlyOffice Document Server, needs to be accessible to the user.
InternalUrl string `json:"internalUrl"` // An optional internal address that the filebrowser server can use to communicate with the OnlyOffice Document Server, could be useful to bypass proxy.
Secret string `json:"secret" validate:"required"`
}
type Media struct {
FfmpegPath string `json:"ffmpegPath"` // path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
}
type LogConfig struct {
Levels string `json:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
ApiLevels string `json:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
Output string `json:"output"` // output location. (eg. "stdout" or "path/to/file.log")
NoColors bool `json:"noColors"` // disable colors in the output
Json bool `json:"json"` // output in json format, currently not supported
}
type Source struct {
Path string `json:"path" validate:"required"` // file system path. (Can be relative)
Name string `json:"name"` // display name
Config SourceConfig `json:"config"`
}
type SourceConfig struct {
IndexingInterval uint32 `json:"indexingInterval"` // optional manual overide interval in seconds to re-index the source
Disabled bool `json:"disabled"` // disable the indexing of this source
MaxWatchers int `json:"maxWatchers"` // number of concurrent watchers to use for this source, currently not supported
NeverWatch []string `json:"neverWatchPaths"` // paths to never watch, relative to the source path (eg. "/folder/file.txt")
IgnoreHidden bool `json:"ignoreHidden"` // ignore hidden files and folders.
IgnoreZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // ignore folders with 0 size
Exclude IndexFilter `json:"exclude"` // exclude files and folders from indexing, if include is not set
Include IndexFilter `json:"include"` // include files and folders from indexing, if exclude is not set
DefaultUserScope string `json:"defaultUserScope"` // default "/" should match folders under path
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user
}
type IndexFilter struct {
Files []string `json:"files"` // array of file names to include/exclude
Folders []string `json:"folders"` // array of folder names to include/exclude
FileEndsWith []string `json:"fileEndsWith"` // array of file names to include/exclude (eg "a.jpg")
}
type Frontend struct {
Name string `json:"name"` // display name
DisableDefaultLinks bool `json:"disableDefaultLinks"` // disable default links in the sidebar
DisableUsedPercentage bool `json:"disableUsedPercentage"` // disable used percentage for the sources in the sidebar
ExternalLinks []ExternalLink `json:"externalLinks"`
}
type ExternalLink struct {
Text string `json:"text" validate:"required"` // the text to display on the link
Title string `json:"title"` // the title to display on hover
Url string `json:"url" validate:"required"` // the url to link to
}
// UserDefaults is a type that holds the default values
// for some fields on User.
type UserDefaults struct {
PreviewOptions users.PreviewOptions `json:"preview"`
StickySidebar bool `json:"stickySidebar"`
DarkMode bool `json:"darkMode"`
LockPassword bool `json:"lockPassword"`
DisableSettings bool `json:"disableSettings,omitempty"`
Locale string `json:"locale"`
ViewMode string `json:"viewMode"`
GallerySize int `json:"gallerySize"`
SingleClick bool `json:"singleClick"`
Permissions users.Permissions `json:"permissions"`
ShowHidden bool `json:"showHidden"`
DateFormat bool `json:"dateFormat"`
ThemeColor string `json:"themeColor"`
QuickDownload bool `json:"quickDownload"`
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"`
DefaultScopes []users.SourceScope `json:"-"`
}
+35
View File
@@ -0,0 +1,35 @@
server:
numImageProcessors: 4
socket: ""
tlsKey: ""
tlsCert: ""
disablePreviews: false
disablePreviewResize: false
port: 80
baseURL: "/"
database: "mydb.db"
sources:
- path: "."
integrations:
office:
url: "http://localhost:9052"
secret: 6H1H7GzUk2alRkNxAPav0W02g8JS5oUd
auth:
methods:
password:
enabled: true
frontend:
name: ""
disableUsedPercentage: true
userDefaults:
locale: ""
viewMode: ""
darkMode: true
disableSettings: false
singleClick: true
permissions:
admin: true
modify: true
share: true
api: true
dateFormat: false
@@ -6,11 +6,15 @@ import (
"encoding/hex"
"fmt"
math "math/rand"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func CheckErr(source string, err error) {
@@ -19,13 +23,13 @@ func CheckErr(source string, err error) {
}
}
func GenerateKey() []byte {
func GenerateKey() string {
b := make([]byte, 64)
_, err := rand.Read(b)
if err != nil {
return nil
return ""
}
return b
return string(b)
}
// CapitalizeFirst returns the input string with the first letter capitalized.
@@ -104,3 +108,39 @@ func GetLastComponent(path string) string {
}
return path[lastSlash+1:]
}
func JoinPathAsUnix(parts ...string) string {
joinedPath := filepath.Join(parts...)
if runtime.GOOS == "windows" {
joinedPath = strings.ReplaceAll(joinedPath, "\\", "/")
}
return joinedPath
}
// ResolveSymlinks resolves symlinks in the given path and returns
// the final resolved path, whether it's a directory (considering bundle logic), and any error.
func ResolveSymlinks(path string) (string, bool, error) {
for {
// 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: %s, %v", path, err)
}
// Check if the path is a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Read the symlink target
target, err := os.Readlink(path)
if err != nil {
return path, false, fmt.Errorf("could not read symlink: %s, %v", path, err)
}
// Resolve the symlink's target relative to its directory
path = filepath.Join(filepath.Dir(path), target)
} else {
// Not a symlink, check with bundle-aware directory logic
isDir := iteminfo.IsDirectory(info)
return path, isDir, nil
}
}
}
File renamed without changes.
@@ -1,17 +1,19 @@
package files
package utils
import (
"time"
"math/rand"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func CreateMockData(numDirs, numFilesPerDir int) FileInfo {
dir := FileInfo{}
func CreateMockData(numDirs, numFilesPerDir int) iteminfo.FileInfo {
dir := iteminfo.FileInfo{}
dir.Path = "/here/is/your/mock/dir"
for i := 0; i < numDirs; i++ {
newFile := ItemInfo{
Name: "file-" + getRandomTerm() + getRandomExtension(),
newFile := iteminfo.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",
@@ -20,8 +22,8 @@ func CreateMockData(numDirs, numFilesPerDir int) FileInfo {
}
// Simulating files and directories with FileInfo
for j := 0; j < numFilesPerDir; j++ {
newFile := ItemInfo{
Name: "file-" + getRandomTerm() + getRandomExtension(),
newFile := iteminfo.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",
@@ -31,16 +33,16 @@ func CreateMockData(numDirs, numFilesPerDir int) FileInfo {
return dir
}
func generateRandomPath(levels int) string {
func GenerateRandomPath(levels int) string {
rand.New(rand.NewSource(time.Now().UnixNano()))
dirName := "srv"
for i := 0; i < levels; i++ {
dirName += "/" + getRandomTerm()
dirName += "/" + GetRandomTerm()
}
return dirName
}
func getRandomTerm() string {
func GetRandomTerm() string {
wordbank := []string{
"hi", "test", "other", "name",
"cool", "things", "more", "items",
@@ -51,7 +53,7 @@ func getRandomTerm() string {
return wordbank[index]
}
func getRandomExtension() string {
func GetRandomExtension() string {
wordbank := []string{
".txt", ".mp3", ".mov", ".doc",
".mp4", ".bak", ".zip", ".jpg",
@@ -61,11 +63,11 @@ func getRandomExtension() string {
return wordbank[index]
}
func generateRandomSearchTerms(numTerms int) []string {
func GenerateRandomSearchTerms(numTerms int) []string {
// Generate random search terms
searchTerms := make([]string, numTerms)
for i := 0; i < numTerms; i++ {
searchTerms[i] = getRandomTerm()
searchTerms[i] = GetRandomTerm()
}
return searchTerms
}
File renamed without changes.
+11
View File
@@ -0,0 +1,11 @@
server:
logging:
- levels: "info|warning|error"
sources:
- path: "/srv"
userDefaults:
preview:
image: true
popup: true
video: true
highQuality: true
+7 -4
View File
@@ -2,15 +2,18 @@ server:
port: 80
baseURL: "/"
logging:
- levels: "info|debug|warning|error"
- levels: "info|warning|error"
sources:
- path: "/srv"
auth:
signup: false
userDefaults:
preview:
image: true
popup: true
video: false
office: false
highQuality: false
darkMode: true
disableSettings: false
scope: "."
singleClick: false
permissions:
admin: false
File renamed without changes.
@@ -3,7 +3,7 @@ package share
import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
// StorageBackend is the interface to implement for a share storage.
@@ -90,8 +90,8 @@ func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
}
// Gets wraps a StorageBackend.Gets
func (s *Storage) Gets(path, source string, id uint) ([]*Link, error) {
links, err := s.back.Gets(path, source, id)
func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
links, err := s.back.Gets(sourcePath, source, id)
if err != nil {
return nil, err
@@ -3,7 +3,7 @@ package bolt
import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
type authBackend struct {
@@ -4,9 +4,9 @@ import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// NewStorage creates a storage.Storage based on Bolt DB.
@@ -2,7 +2,7 @@ package bolt
import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
type settingsBackend struct {
@@ -7,9 +7,9 @@ import (
storm "github.com/asdine/storm/v3"
"github.com/asdine/storm/v3/q"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
)
type shareBackend struct {
@@ -48,19 +48,14 @@ func (s shareBackend) GetByHash(hash string) (*share.Link, error) {
func (s shareBackend) GetPermanent(path, source string, id uint) (*share.Link, error) {
var v share.Link
var legacy []*share.Link
// todo remove legacy and return notfound errors
// TODO remove legacy and return notfound errors
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", source), q.Eq("Expire", 0), q.Eq("UserID", id)).First(&v)
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", ""), q.Eq("UserID", id)).Find(&legacy)
return &v, nil
}
func (s shareBackend) Gets(path, source string, id uint) ([]*share.Link, error) {
func (s shareBackend) Gets(path, sourcePath string, id uint) ([]*share.Link, error) {
var v []*share.Link
var legacy []*share.Link
// todo remove legacy and return notfound errors
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", source), q.Eq("UserID", id)).Find(&v)
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", ""), q.Eq("UserID", id)).Find(&legacy)
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", sourcePath), q.Eq("UserID", id)).Find(&v)
filteredList := []*share.Link{}
var err error
// through and filter out expired share
@@ -74,20 +69,6 @@ func (s shareBackend) Gets(path, source string, id uint) ([]*share.Link, error)
filteredList = append(filteredList, v[i])
}
}
// automatically delete and clear expired shares
// todo remove after some time.
for i := range legacy {
if legacy[i].Expire < time.Now().Unix() && legacy[i].Expire != 0 {
err = s.Delete(legacy[i].PasswordHash)
if err != nil {
logger.Error(fmt.Sprintf("expired share could not be deleted: %v", err.Error()))
}
} else {
filteredList = append(filteredList, legacy[i])
}
}
return filteredList, err
}
@@ -4,13 +4,17 @@ import (
"fmt"
"reflect"
"slices"
"strings"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
type usersBackend struct {
@@ -65,11 +69,19 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
return err
}
fields, err = parseFields(user, fields)
fields, err = parseFields(user, fields, actorIsAdmin)
if err != nil {
return err
}
if !slices.Contains(fields, "Password") {
user.Password = existingUser.Password
} else {
if existingUser.LockPassword {
return fmt.Errorf("password cannot be changed when lock password is enabled")
}
}
if !actorIsAdmin {
err := checkRestrictedFields(existingUser, fields)
if err != nil {
@@ -82,42 +94,54 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
}
// converting scopes to map of paths intead of names (names can change)
if slices.Contains(fields, "scopes") || slices.Contains(fields, "Scopes") {
if slices.Contains(fields, "Scopes") {
adjustedScopes, err := settings.ConvertToBackendScopes(user.Scopes)
if err != nil {
return err
}
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user, true)
if err != nil {
return err
}
}
// Use reflection to access struct fields
userFields := reflect.ValueOf(user).Elem() // Get struct value
for _, field := range fields {
correctedField := utils.CapitalizeFirst(field)
// Get the corresponding field using reflection
fieldValue := userFields.FieldByName(correctedField)
fieldValue := userFields.FieldByName(field)
if !fieldValue.IsValid() {
return fmt.Errorf("invalid field: %s", correctedField)
return fmt.Errorf("invalid field: %s", field)
}
// Ensure the field is settable
if !fieldValue.CanSet() {
return fmt.Errorf("cannot set value of field: %s", correctedField)
return fmt.Errorf("cannot set value of field: %s", field)
}
// Get the value to be stored
val := fieldValue.Interface()
// Update the database
if err := st.db.UpdateField(user, correctedField, val); err != nil {
return fmt.Errorf("failed to update user field: %s, error: %v", correctedField, err)
if err := st.db.UpdateField(existingUser, field, val); err != nil {
return fmt.Errorf("failed to update user field: %s, error: %v", field, err)
}
}
// last revoke api keys if needed.
if existingUser.Permissions.Api && !user.Permissions.Api && slices.Contains(fields, "Permissions") {
for _, key := range existingUser.ApiKeys {
auth.RevokeAPIKey(key.Key) // add to blacklist
}
}
return nil
}
func (st usersBackend) Save(user *users.User, changePass bool) error {
if settings.Config.Auth.Methods.PasswordAuth.Enabled && changePass {
if user.LoginMethod == "" {
user.LoginMethod = users.LoginMethodPassword
}
logger.Debug(fmt.Sprintf("Saving user [%s] changepass: %v", user.Username, changePass))
if user.LoginMethod == users.LoginMethodPassword && changePass {
err := checkPassword(user.Password)
if err != nil {
return err
@@ -135,7 +159,10 @@ func (st usersBackend) Save(user *users.User, changePass bool) error {
return err
}
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user, false)
if err != nil {
logger.Error(err.Error())
}
err = st.db.Save(user)
if err == storm.ErrAlreadyExists {
return errors.ErrExist
@@ -158,25 +185,28 @@ func (st usersBackend) DeleteByUsername(username string) error {
// Define a function to check for restricted fields
func checkRestrictedFields(existingUser *users.User, fields []string) error {
// Check if 'password' field is locked
if existingUser.LockPassword && slices.Contains(fields, "password") {
return fmt.Errorf("password is locked")
}
// Use reflection to get the field names of NonAdminEditable
editableFields := reflect.ValueOf(existingUser.NonAdminEditable)
if editableFields.Kind() == reflect.Struct {
for i := 0; i < editableFields.NumField(); i++ {
fieldName := editableFields.Type().Field(i).Name
if slices.Contains(fields, fieldName) {
return fmt.Errorf("non-admins cannot modify field: %s", fieldName)
}
// Get a list of allowed fields from NonAdminEditable
allowed := getNonAdminEditableFieldNames()
for _, field := range fields {
if !slices.Contains(allowed, field) {
return fmt.Errorf("non-admins cannot modify field: %s", field)
}
}
// No restricted fields found, return nil (no error)
return nil
}
func parseFields(user *users.User, fields []string) ([]string, error) {
// Helper to return list of field names from NonAdminEditable struct
func getNonAdminEditableFieldNames() []string {
var names []string
t := reflect.TypeOf(users.NonAdminEditable{})
for i := 0; i < t.NumField(); i++ {
names = append(names, t.Field(i).Name)
}
return names
}
func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string, error) {
// If `Which` is not specified, default to updating all fields
if len(fields) == 0 || fields[0] == "all" {
fields = []string{}
@@ -189,23 +219,38 @@ func parseFields(user *users.User, fields []string) ([]string, error) {
// Dynamically populate fields to update
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if (field.Name == "password" && user.Password != "") || field.Name != "password" {
// which=all can't update password
if strings.ToLower(field.Name) != "password" && strings.ToLower(field.Name) != "id" && strings.ToLower(field.Name) != "username" {
fields = append(fields, field.Name)
}
}
} else {
if slices.Contains(fields, "password") && user.Password != "" {
if settings.Config.Auth.Methods.PasswordAuth.Enabled {
pass, err := users.HashPwd(user.Password)
if err != nil {
return fields, err
}
user.Password = pass
}
newfields := []string{}
for _, field := range fields {
capitalField := utils.CapitalizeFirst(field)
if capitalField == "Scopes" {
if !actorIsAdmin {
continue
}
}
if capitalField == "Password" {
if user.LoginMethod != users.LoginMethodPassword {
return nil, fmt.Errorf("password cannot be changed when login method is not password")
}
err := checkPassword(user.Password)
if err != nil {
return nil, fmt.Errorf("password does not meet complexity requirements")
}
value, err := users.HashPwd(user.Password)
if err != nil {
logger.Error(err.Error())
}
user.Password = value
}
newfields = append(newfields, capitalField)
}
return fields, nil
return newfields, nil
}
func checkPassword(password string) error {
@@ -3,7 +3,7 @@ package bolt
import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
func get(db *storm.DB, name string, to interface{}) error {
@@ -7,14 +7,13 @@ import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"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/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// Storage is a storage powered by a Backend which makes the necessary
@@ -88,7 +87,7 @@ func quickSetup(store *Storage) {
settings.ApplyUserDefaults(user)
user.Username = settings.Config.Auth.AdminUsername
user.Password = settings.Config.Auth.AdminPassword
user.Perm.Admin = true
user.Permissions.Admin = true
user.Scopes = []users.SourceScope{}
for _, val := range settings.Config.Server.Sources {
user.Scopes = append(user.Scopes, users.SourceScope{
@@ -97,7 +96,7 @@ func quickSetup(store *Storage) {
})
}
user.LockPassword = false
user.Perm = settings.AdminPerms()
user.Permissions = settings.AdminPerms()
logger.Debug(fmt.Sprintf("Creating user as admin: %v %v", user.Username, user.Password))
err = store.Users.Save(user, true)
utils.CheckErr("store.Users.Save", err)
@@ -110,17 +109,12 @@ func CreateUser(userInfo users.User, asAdmin bool) error {
if userInfo.Username == "" || userInfo.Password == "" {
return errors.ErrInvalidRequestParams
}
logger.Debug(fmt.Sprintf("Creating user: %v %v\n", userInfo.Username, userInfo.Scopes))
logger.Debug(fmt.Sprintf("Creating user: %v %v", userInfo.Username, userInfo.Scopes))
settings.ApplyUserDefaults(newUser)
if asAdmin {
userInfo.Perm = settings.AdminPerms()
}
if len(userInfo.Scopes) == 0 {
userInfo.Scopes = settings.Config.UserDefaults.DefaultScopes
userInfo.Permissions = settings.AdminPerms()
}
// create new home directories
files.MakeUserDirs(newUser)
err := store.Users.Save(newUser, true)
if err != nil {
return err
File renamed without changes.
@@ -4,7 +4,7 @@ import (
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
// StorageBackend is the interface to implement for a users storage.
@@ -87,7 +87,7 @@ func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
user.ApiKeys = make(map[string]AuthToken)
}
user.ApiKeys[name] = key
err = s.Update(user, false, "ApiKeys")
err = s.Update(user, true, "ApiKeys")
if err != nil {
return err
}
@@ -105,7 +105,7 @@ func (s *Storage) DeleteApiKey(userID uint, name string) error {
user.ApiKeys = make(map[string]AuthToken)
}
delete(user.ApiKeys, name)
err = s.Update(user, false, "ApiKeys")
err = s.Update(user, true, "ApiKeys")
if err != nil {
return err
}
File renamed without changes.
+104
View File
@@ -0,0 +1,104 @@
package users
import (
"strings"
jwt "github.com/golang-jwt/jwt/v4"
)
const ()
type LoginMethod string
const (
LoginMethodPassword LoginMethod = "password"
LoginMethodProxy LoginMethod = "proxy"
LoginMethodOidc LoginMethod = "oidc"
)
type AuthToken struct {
Key string `json:"key"`
Name string `json:"name"`
Created int64 `json:"createdAt"`
Expires int64 `json:"expiresAt"`
BelongsTo uint `json:"belongsTo"`
Permissions Permissions `json:"Permissions"`
jwt.RegisteredClaims `json:"-"`
}
type Permissions struct {
Api bool `json:"api"`
Admin bool `json:"admin"`
Modify bool `json:"modify"`
Share bool `json:"share"`
Realtime bool `json:"realtime"`
}
// SortingSettings represents the sorting settings.
type Sorting struct {
By string `json:"by"`
Asc bool `json:"asc"`
}
type PreviewOptions struct {
HighQuality bool `json:"highQuality"`
Image bool `json:"image"`
Video bool `json:"video"`
Office bool `json:"office"`
PopUp bool `json:"popup"`
}
// User describes a user.
type User struct {
NonAdminEditable
DisableSettings bool `json:"disableSettings"`
ID uint `storm:"id,increment" json:"id"`
Username string `storm:"unique" json:"username"`
Scopes []SourceScope `json:"scopes"`
Scope string `json:"scope,omitempty"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"`
LoginMethod LoginMethod `json:"loginMethod"`
// legacy for migration purposes... og filebrowser has perm attribute
Perm Permissions `json:"perm,omitzero"`
}
type SourceScope struct {
Name string `json:"name"`
Scope string `json:"scope"`
}
type NonAdminEditable struct {
Preview PreviewOptions `json:"preview"`
StickySidebar bool `json:"stickySidebar"`
DarkMode bool `json:"darkMode"`
Password string `json:"password,omitempty"`
Locale string `json:"locale"`
ViewMode string `json:"viewMode"`
SingleClick bool `json:"singleClick"`
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"`
DateFormat bool `json:"dateFormat"`
GallerySize int `json:"gallerySize"`
ThemeColor string `json:"themeColor"`
QuickDownload bool `json:"quickDownload"`
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"`
}
var PublicUser = User{
NonAdminEditable: NonAdminEditable{
Password: "publicUser", // temp user not registered
ViewMode: "normal",
},
Username: "publicUser", // temp user not registered
LockPassword: true,
Permissions: Permissions{},
}
func CleanUsername(s string) string {
// Remove any trailing space to avoid ending on -
s = strings.Trim(s, " ")
s = strings.Replace(s, "..", "", -1)
return s
}
+140
View File
@@ -0,0 +1,140 @@
package events
import (
"sync"
)
type EventMessage struct {
EventType string `json:"eventType"`
Message string `json:"message"`
}
type sourceEvent struct {
source string
event EventMessage
}
type userEvent struct {
event EventMessage
users []string
}
var (
BroadcastChan = make(chan EventMessage, 100)
userEventChan = make(chan userEvent, 100)
debounceInputChan = make(chan EventMessage, 100)
sourceUpdateChan = make(chan sourceEvent, 100)
userClientsMu sync.RWMutex
userClients = make(map[string][]chan EventMessage)
sourceClientsMu sync.RWMutex
sourceClients = make(map[string]map[chan EventMessage]struct{})
)
func init() {
go handleUserEvents()
go handleSourceUpdates() // Add this
}
func handleUserEvents() {
for ue := range userEventChan {
for _, user := range ue.users {
userClientsMu.RLock()
conns := userClients[user]
userClientsMu.RUnlock()
for _, ch := range conns {
select {
case ch <- ue.event:
default:
}
}
}
}
}
func Register(username string, sources []string) chan EventMessage {
ch := make(chan EventMessage, 10)
userClientsMu.Lock()
userClients[username] = append(userClients[username], ch)
userClientsMu.Unlock()
sourceClientsMu.Lock()
for _, source := range sources {
if sourceClients[source] == nil {
sourceClients[source] = make(map[chan EventMessage]struct{})
}
sourceClients[source][ch] = struct{}{}
}
sourceClientsMu.Unlock()
return ch
}
func Unregister(username string, ch chan EventMessage) {
userClientsMu.Lock()
conns := userClients[username]
for i, c := range conns {
if c == ch {
userClients[username] = append(conns[:i], conns[i+1:]...)
break
}
}
if len(userClients[username]) == 0 {
delete(userClients, username)
}
userClientsMu.Unlock()
sourceClientsMu.Lock()
for source, clients := range sourceClients {
delete(clients, ch)
if len(clients) == 0 {
delete(sourceClients, source)
}
}
sourceClientsMu.Unlock()
close(ch)
}
func SendToUsers(eventType, message string, users []string) {
userEventChan <- userEvent{
event: EventMessage{EventType: eventType, Message: message},
users: users,
}
}
func SendSourceUpdate(source string, message string) {
sourceUpdateChan <- sourceEvent{
source: source,
event: EventMessage{
EventType: "sourceUpdate",
Message: message,
},
}
}
func DebouncedBroadcast(eventType, message string) {
debounceInputChan <- EventMessage{
EventType: eventType,
Message: message,
}
}
func handleSourceUpdates() {
for update := range sourceUpdateChan {
sourceClientsMu.RLock()
clients := sourceClients[update.source]
sourceClientsMu.RUnlock()
for ch := range clients {
select {
case ch <- update.event:
default:
// Optional: log dropped message
}
}
}
}
-280
View File
@@ -1,280 +0,0 @@
package files
import (
"mime"
"path/filepath"
"regexp"
"strconv"
"strings"
)
var typeRegexp = regexp.MustCompile(`type:(\S+)`)
var AllFiletypeOptions = []string{
"image",
"audio",
"archive",
"video",
"doc",
"text",
}
// Document file extensions
var documentTypes = []string{
// 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
}
var onlyOfficeSupported = []string{
".doc", ".docm", ".docx", ".dot", ".dotm", ".dotx", ".epub",
".fb2", ".fodt", ".htm", ".html", ".mht", ".mhtml", ".odt",
".ott", ".rtf", ".stw", ".sxw", ".txt", ".wps", ".wpt", ".xml",
".csv", ".et", ".ett", ".fods", ".ods", ".ots", ".sxc", ".xls",
".xlsb", ".xlsm", ".xlsx", ".xlt", ".xltm", ".xltx", ".dps",
".dpt", ".fodp", ".odp", ".otp", ".pot", ".potm", ".potx",
".pps", ".ppsm", ".ppsx", ".ppt", ".pptm", ".pptx", ".sxi",
".djvu", ".docxf", ".oform", ".oxps", ".pdf", ".xps",
}
// 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",
".zip",
".tar",
".gz",
".xz",
".bz2",
".tgz", // tar.gz
".tbz2", // tar.bz2
".lzma",
".lz4",
".zstd",
}
type SearchOptions struct {
Conditions map[string]bool
LargerThan int
SmallerThan int
Terms []string
}
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"),
},
Terms: []string{},
}
// removes the options from the value
value = strings.Replace(value, "case:exact", "", -1)
value = strings.TrimSpace(value)
types := typeRegexp.FindAllStringSubmatch(value, -1)
for _, filterType := range types {
if len(filterType) == 1 {
continue
}
filter := filterType[1]
switch filter {
case "image":
opts.Conditions["image"] = true
case "audio", "music":
opts.Conditions["audio"] = true
case "video":
opts.Conditions["video"] = true
case "doc":
opts.Conditions["doc"] = true
case "archive":
opts.Conditions["archive"] = true
case "folder":
opts.Conditions["dir"] = true
case "file":
opts.Conditions["dir"] = false
}
if len(filter) < 8 {
continue
}
if strings.HasPrefix(filter, "largerThan=") {
opts.Conditions["larger"] = true
size := strings.TrimPrefix(filter, "largerThan=")
opts.LargerThan = updateSize(size)
}
if strings.HasPrefix(filter, "smallerThan=") {
opts.Conditions["smaller"] = true
size := strings.TrimPrefix(filter, "smallerThan=")
opts.SmallerThan = updateSize(size)
}
}
if len(types) > 0 {
// Remove the fields from the search value
value = typeRegexp.ReplaceAllString(value, "")
}
if value == "" {
return opts
}
// if the value starts with " and finishes what that character, we will
// only search for that term
if value[0] == '"' && value[len(value)-1] == '"' {
unique := strings.TrimPrefix(value, "\"")
unique = strings.TrimSuffix(unique, "\"")
opts.Terms = []string{unique}
return opts
}
value = strings.TrimSpace(value)
opts.Terms = strings.Split(value, "|")
return opts
}
func toInt(str string) int {
val, err := strconv.Atoi(str)
if err != nil {
return 0
}
return val
}
func updateSize(given string) int {
size := toInt(given)
if size == 0 {
return 100
} else {
return size
}
}
func IsMatchingType(extension string, matchType string) bool {
mimetype := mime.TypeByExtension(extension)
if strings.HasPrefix(mimetype, matchType) {
return true
}
switch matchType {
case "doc":
return isDoc(extension)
case "text":
return isText(extension)
case "archive":
return isArchive(extension)
}
return false
}
func isText(extension string) bool {
for _, typefile := range textTypes {
if extension == typefile {
return true
}
}
return false
}
func isDoc(extension string) bool {
for _, typefile := range documentTypes {
if extension == typefile {
return true
}
}
return false
}
func isArchive(extension string) bool {
for _, typefile := range compressedFile {
if extension == typefile {
return true
}
}
return false
}
func isOnlyOffice(name string) bool {
extention := filepath.Ext(name)
for _, typefile := range onlyOfficeSupported {
if extention == typefile {
return true
}
}
return false
}
-460
View File
@@ -1,460 +0,0 @@
package files
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"fmt"
"hash"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
type ItemInfo struct {
Name string `json:"name"` // name of the file
Size int64 `json:"size"` // length in bytes for regular files
ModTime time.Time `json:"modified"` // modification time
Type string `json:"type"` // type of the file, either "directory" or a file mimetype
Hidden bool `json:"hidden"` // whether the file is hidden
}
// FileInfo describes a file.
// reduced item is non-recursive reduced "Items", used to pass flat items array
type FileInfo struct {
ItemInfo
Files []ItemInfo `json:"files"` // files in the directory
Folders []ItemInfo `json:"folders"` // folders in the directory
Path string `json:"path"` // path scoped to the associated index
}
// 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"` // text content of a file, if requested
Subtitles []string `json:"subtitles,omitempty"` // subtitles for video files
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
Token string `json:"token,omitempty"` // token for the file -- used for sharing
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source"` // associated index source for the file
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
Content bool
}
func (f FileOptions) Components() (string, string) {
return filepath.Dir(f.Path), filepath.Base(f.Path)
}
func FileInfoFaster(opts FileOptions) (ExtendedFileInfo, error) {
response := ExtendedFileInfo{}
if opts.Source == "" {
opts.Source = settings.Config.Server.DefaultSource.Name
}
index := GetIndex(opts.Source)
if index == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
}
realPath, isDir, err := index.GetRealPath(opts.Path)
if err != nil {
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 {
// 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 response, err
}
info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
if !exists {
return response, fmt.Errorf("could not get metadata for path: %v", opts.Path)
}
if opts.Content {
content, err := getContent(realPath)
if err != nil {
return response, err
}
response.Content = content
}
response.FileInfo = *info
response.RealPath = realPath
response.Source = opts.Source
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && isOnlyOffice(info.Name) {
response.OnlyOfficeId = generateOfficeId(realPath)
}
if strings.HasPrefix(info.Type, "video") {
response.detectSubtitles(realPath)
}
return response, nil
}
func generateOfficeId(realPath string) string {
key, ok := cache.OnlyOffice.Get(realPath).(string)
if !ok {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
documentKey := utils.HashSHA256(realPath + timestamp)
cache.OnlyOffice.Set(realPath, documentKey)
return documentKey
}
return key
}
// Checksum checksums a given File for a given User, using a specific
// algorithm. The checksums data is saved on File object.
func GetChecksum(fullPath, algo string) (map[string]string, error) {
subs := map[string]string{}
reader, err := os.Open(fullPath)
if err != nil {
return subs, err
}
defer reader.Close()
hashFuncs := map[string]hash.Hash{
"md5": md5.New(),
"sha1": sha1.New(),
"sha256": sha256.New(),
"sha512": sha512.New(),
}
h, ok := hashFuncs[algo]
if !ok {
return subs, errors.ErrInvalidOption
}
_, err = io.Copy(h, reader)
if err != nil {
return subs, err
}
subs[algo] = hex.EncodeToString(h.Sum(nil))
return subs, nil
}
func DeleteFiles(source, absPath string, absDirPath string) error {
err := os.RemoveAll(absPath)
if err != nil {
return err
}
index := GetIndex(source)
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
}
refreshConfig := FileOptions{Path: index.MakeIndexPath(absDirPath), IsDir: true}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return err
}
return nil
}
func MoveResource(sourceIndex, destIndex, realsrc, realdst string) error {
err := fileutils.MoveFile(realsrc, realdst)
if err != nil {
return err
}
idxSrc := GetIndex(sourceIndex)
if idxSrc == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
idxDst := GetIndex(destIndex)
if idxDst == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshSourceDir := idxSrc.MakeIndexPath(filepath.Dir(realsrc))
refreshDestDir := idxDst.MakeIndexPath(filepath.Dir(realdst))
// refresh info for source and dest
err = idxSrc.RefreshFileInfo(FileOptions{
Path: refreshSourceDir,
IsDir: true,
})
if err != nil {
return fmt.Errorf("could not refresh index for source: %v", err)
}
if refreshSourceDir == refreshDestDir {
return nil
}
refreshConfig := FileOptions{Path: refreshDestDir, IsDir: true}
err = idxDst.RefreshFileInfo(refreshConfig)
if err != nil {
return fmt.Errorf("could not refresh index for dest: %v", err)
}
return nil
}
func CopyResource(sourceIndex, destIndex, realsrc, realdst string) error {
err := fileutils.CopyFile(realsrc, realdst)
if err != nil {
return err
}
idxSrc := GetIndex(sourceIndex)
if idxSrc == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
idxDst := GetIndex(destIndex)
if idxDst == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshSourceDir := idxSrc.MakeIndexPath(filepath.Dir(realsrc))
refreshDestDir := idxDst.MakeIndexPath(filepath.Dir(realdst))
index := GetIndex(sourceIndex)
if index == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshConfig := FileOptions{Path: refreshSourceDir, IsDir: true}
// refresh info for source and dest
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return fmt.Errorf("could not refresh index for source: %v", err)
}
refreshConfig.Path = refreshDestDir
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return errors.ErrEmptyKey
}
return nil
}
func WriteDirectory(opts FileOptions) error {
idx := GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
realPath, _, _ := idx.GetRealPath(opts.Path)
// Ensure the parent directories exist
err := os.MkdirAll(realPath, 0775)
if err != nil {
return err
}
err = idx.RefreshFileInfo(opts)
if err != nil {
return errors.ErrEmptyKey
}
return nil
}
func WriteFile(opts FileOptions, in io.Reader) error {
idx := GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
dst, _, _ := idx.GetRealPath(opts.Path)
parentDir := filepath.Dir(dst)
// Create the directory and all necessary parents
err := os.MkdirAll(parentDir, 0775)
if err != nil {
return err
}
// Open the file for writing (create if it doesn't exist, truncate if it does)
file, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775)
if err != nil {
return err
}
defer file.Close()
// Copy the contents from the reader to the file
_, err = io.Copy(file, in)
if err != nil {
return err
}
opts.Path = idx.MakeIndexPath(parentDir)
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 using os.Lstat to handle symlinks
info, err := os.Lstat(path)
if err != nil {
return path, false, fmt.Errorf("could not stat path: %s, %v", path, err)
}
// Check if the path is a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Read the symlink target
target, err := os.Readlink(path)
if err != nil {
return path, false, fmt.Errorf("could not read symlink: %s, %v", path, err)
}
// Resolve the symlink's target relative to its directory
// This ensures the resolved path is absolute and correctly calculated
path = filepath.Join(filepath.Dir(path), target)
} else {
// Not a symlink, so return the resolved path and whether it's a directory
isDir := info.IsDir()
return path, isDir, nil
}
}
}
// addContent reads and sets content based on the file type.
func getContent(realPath string) (string, error) {
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
}
stringContent := string(content)
if !utf8.ValidString(stringContent) {
return "", nil
}
if stringContent == "" {
return "empty-file-x6OlSil", nil
}
return stringContent, nil
}
// DetectType detects the MIME type of a file and updates the ItemInfo struct.
func (i *ItemInfo) DetectType(realPath string, saveContent bool) {
name := i.Name
ext := filepath.Ext(name)
// Attempt MIME detection by file extension
if ext == ".md" {
i.Type = "text/markdown"
return
}
i.Type = strings.Split(mime.TypeByExtension(ext), ";")[0]
if i.Type == "" {
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]
}
}
}
// TODO add subtitles back
// detectSubtitles detects subtitles for video files.
func (i *ExtendedFileInfo) detectSubtitles(path string) {
if !strings.HasPrefix(i.Type, "video") {
logger.Debug("subtitles are not supported for this file : " + path)
return
}
idx := GetIndex(i.Source)
if idx == nil {
return
}
parentInfo, exists := idx.GetReducedMetadata(filepath.Dir(i.Path), true)
if !exists {
return
}
base := strings.Split(i.Name, ".")[0]
for _, f := range parentInfo.Files {
baseName := strings.Split(f.Name, ".")[0]
if baseName != base {
continue
}
for _, subtitleExt := range []string{".vtt", ".srt", ".lrc", ".sbv", ".ass", ".ssa", ".sub", ".smi"} {
if strings.HasSuffix(f.Name, subtitleExt) {
fullPathBase := strings.Split(i.Path, ".")[0]
i.Subtitles = append(i.Subtitles, fullPathBase+subtitleExt)
}
}
}
}
func IsNamedPipe(mode os.FileMode) bool {
return mode&os.ModeNamedPipe != 0
}
func IsSymlink(mode os.FileMode) bool {
return mode&os.ModeSymlink != 0
}
func Exists(path string) bool {
_, err := os.Stat(path)
if err == nil {
return true
}
if os.IsNotExist(err) {
return false
}
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)
})
}
-127
View File
@@ -1,127 +0,0 @@
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,
40 * time.Minute, // [3] element is 40 minutes, reset anchor for full scan
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", 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 {
logger.Debug(fmt.Sprintf("Files changed during indexing [%v], adjusting schedule.", idx.Name))
// 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(fmt.Sprintf("Starting quick scan for [%v]", idx.Source.Name))
} else {
logger.Debug(fmt.Sprintf("Starting full scan for [%v]", idx.Source.Name))
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 : [%v] complexity=%v directories=%v files=%v", idx.Source.Name, idx.assessment, idx.NumDirs, idx.NumFiles))
} else {
logger.Debug(fmt.Sprintf("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Source.Name, 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]: %v seconds", idx.Source.Name, idx.indexingTime))
} else {
logger.Debug(fmt.Sprintf("Time spent indexing [%v]: %v seconds", idx.Source.Name, idx.indexingTime))
}
}
func (idx *Index) setupIndexingScanners() {
go idx.newScanner("/")
}
-84
View File
@@ -1,84 +0,0 @@
package files
import (
"fmt"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
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 file or directory in the index.
func (idx *Index) GetReducedMetadata(target string, isDir bool) (*FileInfo, bool) {
idx.mu.Lock()
defer idx.mu.Unlock()
checkDir := idx.MakeIndexPath(target)
if !isDir {
checkDir = idx.MakeIndexPath(filepath.Dir(target))
}
if checkDir == "" {
checkDir = "/"
}
dir, exists := idx.Directories[checkDir]
if !exists {
return nil, false
}
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
}
// raw directory info retrieval -- does not work on files, only returns a directory
func (idx *Index) GetMetadataInfo(target string, isDir bool) (*FileInfo, bool) {
idx.mu.RLock()
defer idx.mu.RUnlock()
checkDir := idx.MakeIndexPath(target)
if !isDir {
checkDir = idx.MakeIndexPath(filepath.Dir(target))
}
if checkDir == "" {
checkDir = "/"
}
dir, exists := idx.Directories[checkDir]
return dir, exists
}
func (idx *Index) RemoveDirectory(path string) {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.NumDeleted++
delete(idx.Directories, path)
}
func GetIndex(name string) *Index {
indexesMutex.Lock()
defer indexesMutex.Unlock()
index, ok := indexes[name]
if !ok {
logger.Error(fmt.Sprintf("index %s not found", name))
}
return index
}
-67
View File
@@ -1,67 +0,0 @@
package files
import (
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
var (
invalidFilenameChars = regexp.MustCompile(`[^0-9A-Za-z@_\-.]`)
dashes = regexp.MustCompile(`[\-]+`)
)
// todo fix this!!
// MakeUserDir makes the user directory according to settings.
func (idx *Index) MakeUserDir(username string, scope string) error {
if idx.Config.CreateUserDir {
username = cleanUsername(username)
if username == "" || username == "-" || username == "." {
logger.Error(fmt.Sprintf("create user: invalid user for home dir creation: [%s]", username))
}
}
userScope := path.Join("/", scope)
fullPath := filepath.Join(idx.Path, userScope)
logger.Debug(fmt.Sprintf("creating user home dir: [%s]", fullPath))
if err := os.MkdirAll(fullPath, os.ModePerm); err != nil {
return err
}
return nil
}
func MakeUserDirs(u *users.User) {
for _, scope := range u.Scopes {
source := settings.Config.Server.NameToSource[scope.Name]
if source.Config.CreateUserDir {
idx := GetIndex(source.Name)
if idx == nil {
logger.Error(fmt.Sprintf("create user: failed to find source index for user home dir creation: %s", source.Name))
continue
}
err := idx.MakeUserDir(u.Username, scope.Scope)
if err != nil {
logger.Error(fmt.Sprintf("create user: failed to create user home dir: %s", err))
}
}
}
}
func cleanUsername(s string) string {
// Remove any trailing space to avoid ending on -
s = strings.Trim(s, " ")
s = strings.Replace(s, "..", "", -1)
// Replace all characters which not in the list `0-9A-Za-z@_\-.` with a dash
s = invalidFilenameChars.ReplaceAllString(s, "-")
// Remove any multiple dashes caused by replacements above
s = dashes.ReplaceAllString(s, "-")
return s
}
+216 -18
View File
@@ -1,49 +1,247 @@
module github.com/gtsteffaniak/filebrowser/backend
go 1.23
toolchain go1.23.1
go 1.24.0
require (
github.com/asdine/storm/v3 v3.2.1
github.com/disintegration/imaging v1.6.2
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/goccy/go-yaml v1.15.23
github.com/golang-jwt/jwt/v4 v4.5.1
github.com/google/go-cmp v0.6.0
github.com/go-playground/validator/v10 v10.26.0
github.com/goccy/go-yaml v1.17.1
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/afero v1.11.0
github.com/spf13/afero v1.14.0
github.com/stretchr/testify v1.10.0
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.33.0
golang.org/x/image v0.24.0
golang.org/x/sys v0.30.0
golang.org/x/crypto v0.38.0
golang.org/x/image v0.27.0
golang.org/x/sys v0.33.0
gopkg.in/yaml.v3 v3.0.1
)
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect
github.com/Antonboom/errname v1.0.0 // indirect
github.com/Antonboom/nilnil v1.0.1 // indirect
github.com/Antonboom/testifylint v1.5.2 // indirect
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
github.com/Crocmagnon/fatcontext v0.7.1 // indirect
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.5 // indirect
github.com/alexkohler/prealloc v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.1.2 // indirect
github.com/ashanbrown/forbidigo v1.6.0 // indirect
github.com/ashanbrown/makezero v1.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.5.0 // indirect
github.com/breml/bidichk v0.3.2 // indirect
github.com/breml/errchkjson v0.4.0 // indirect
github.com/butuzov/ireturn v0.3.1 // indirect
github.com/butuzov/mirror v1.3.0 // indirect
github.com/catenacyber/perfsprint v0.8.2 // indirect
github.com/ccojocar/zxcvbn-go v1.0.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.10 // indirect
github.com/chavacava/garif v0.1.0 // indirect
github.com/ckaznocha/intrange v0.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // 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/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.5 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/ghostiam/protogetter v0.3.9 // indirect
github.com/go-critic/go-critic v0.12.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect
github.com/go-toolsmith/astequal v1.2.0 // indirect
github.com/go-toolsmith/astfmt v1.1.0 // indirect
github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/geo v0.0.0-20250505201543-5b58c72585db // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.0 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint v1.64.8 // indirect
github.com/golangci/misspell v0.6.0 // indirect
github.com/golangci/plugin-module-register v0.1.1 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect
github.com/gordonklaus/ineffassign v0.1.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.5.0 // indirect
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
github.com/gostaticanalysis/nilerr v0.1.1 // indirect
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jgautheron/goconst v1.7.1 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
github.com/jjti/go-spancheck v0.6.4 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kulti/thelper v0.6.3 // indirect
github.com/kunwardeep/paralleltest v1.0.10 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.2 // indirect
github.com/ldez/gomoddirectives v0.6.1 // indirect
github.com/ldez/grignotin v0.9.0 // indirect
github.com/ldez/tagliatelle v0.7.1 // indirect
github.com/ldez/usetesting v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx v1.2.31 // indirect
github.com/lestrrat-go/jwx/v3 v3.0.1 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/macabu/inamedparam v0.1.3 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/maratori/testableexamples v1.0.0 // indirect
github.com/maratori/testpackage v1.1.1 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.7.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.19.1 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/polyfloyd/go-errorlint v1.7.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/raeperd/recvcheck v0.2.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/ryancurrah/gomodguard v1.3.5 // indirect
github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect
github.com/securego/gosec/v2 v2.22.2 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sivchari/tenv v1.12.1 // indirect
github.com/sonatard/noctx v0.1.0 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/tdakkota/asciicheck v0.4.1 // indirect
github.com/tetafro/godot v1.5.0 // indirect
github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3 // indirect
github.com/timonwong/loggercheck v0.10.1 // indirect
github.com/tomarrell/wrapcheck/v2 v2.10.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/uudashr/gocognit v1.2.0 // indirect
github.com/uudashr/iface v1.3.1 // indirect
github.com/xen0n/gosmopolitan v1.2.2 // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.13.0 // indirect
go-simpler.org/sloglint v0.9.0 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/tools v0.30.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.33.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.6.1 // indirect
mvdan.cc/gofumpt v0.7.0 // indirect
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
tool (
github.com/golangci/golangci-lint/cmd/golangci-lint
github.com/swaggo/swag/cmd/swag
)
+977 -36
View File
File diff suppressed because it is too large. Load diff
+10 -9
View File
@@ -7,7 +7,8 @@ import (
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// createApiKeyHandler creates an API key for the user.
@@ -30,7 +31,7 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
durationStr := r.URL.Query().Get("days")
permissionsStr := r.URL.Query().Get("permissions")
if !d.user.Perm.Api {
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to create api keys")
}
@@ -45,10 +46,10 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
// Parse permissions from the query parameter
permissions := users.Permissions{
Api: strings.Contains(permissionsStr, "api") && d.user.Perm.Api,
Admin: strings.Contains(permissionsStr, "admin") && d.user.Perm.Admin,
Modify: strings.Contains(permissionsStr, "modify") && d.user.Perm.Modify,
Share: strings.Contains(permissionsStr, "share") && d.user.Perm.Share,
Api: strings.Contains(permissionsStr, "api") && d.user.Permissions.Api,
Admin: strings.Contains(permissionsStr, "admin") && d.user.Permissions.Admin,
Modify: strings.Contains(permissionsStr, "modify") && d.user.Permissions.Modify,
Share: strings.Contains(permissionsStr, "share") && d.user.Permissions.Share,
}
// Convert the duration string to an int64
@@ -87,7 +88,7 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// @Router /api/deleteApiKey [delete]
func deleteApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
name := r.URL.Query().Get("name")
if !d.user.Perm.Api {
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to delete api keys")
}
@@ -101,7 +102,7 @@ func deleteApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusNotFound, err
}
revokeAPIKey(keyInfo.Key) // add to blacklist
auth.RevokeAPIKey(keyInfo.Key) // add to blacklist
response := HttpResponse{
Message: "successfully deleted api key from user",
}
@@ -129,7 +130,7 @@ type AuthTokenMin struct {
// @Router /api/listApiKeys [get]
func listApiKeysHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
key := r.URL.Query().Get("name")
if !d.user.Perm.Api {
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api keys")
}
+428 -29
View File
@@ -4,27 +4,25 @@ import (
"encoding/json"
libError "errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v4/request"
"github.com/lestrrat-go/jwx/v3/jwk"
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"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 (
revokedApiKeyList map[string]bool
revokeMu sync.Mutex
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// first checks for cookie
@@ -72,6 +70,50 @@ func extractToken(r *http.Request) (string, error) {
return "", request.ErrNoTokenInRequest
}
func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*users.User, error) {
var err error
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(proxyUser)
if err != nil {
if err.Error() != "the resource does not exist" {
return nil, err
}
if config.Auth.Methods.ProxyAuth.CreateUser {
hashpass, err := users.HashPwd(proxyUser)
if err != nil {
return nil, err
}
err = storage.CreateUser(users.User{
LoginMethod: users.LoginMethodProxy,
Username: proxyUser,
NonAdminEditable: users.NonAdminEditable{
Password: hashpass, // hashed password that can't actually be used
},
}, false)
if err != nil {
return nil, err
}
data.user, err = store.Users.Get(proxyUser)
if err != nil {
return nil, err
}
} else {
return nil, fmt.Errorf("proxy authentication failed - no user found")
}
}
if data.user.LoginMethod != users.LoginMethodProxy {
logger.Warning(fmt.Sprintf("user %s is not allowed to login with proxy authentication, bypassing and updating login method", data.user.Username))
data.user.LoginMethod = users.LoginMethodProxy
// Perform the user update
err := store.Users.Update(data.user, true, "LoginMethod")
if err != nil {
logger.Debug(err.Error())
}
//return nil, fmt.Errorf("user %s is not allowed to login with proxy authentication", proxyUser)
}
return data.user, nil
}
// loginHandler handles user authentication via password.
// @Summary User login
// @Description Authenticate a user with a username and password.
@@ -83,6 +125,19 @@ func extractToken(r *http.Request) (string, error) {
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/login [post]
func loginHandler(w http.ResponseWriter, r *http.Request) {
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
user, err := setupProxyUser(r, &requestContext{}, proxyUser)
if err != nil {
http.Error(w, err.Error(), 403)
return
}
status, err := printToken(w, r, user) // Pass the data object
if err != nil {
http.Error(w, http.StatusText(status), status)
}
return
}
if !config.Auth.Methods.PasswordAuth.Enabled {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
@@ -94,12 +149,26 @@ func loginHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
// Authenticate the user based on the request
user, err := auther.Auth(r, store.Users)
if err != nil {
logger.Debug(err.Error())
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
if user.LoginMethod != users.LoginMethodPassword {
logger.Warning(fmt.Sprintf("user %s is not allowed to login with password authentication, bypassing and updating login method", user.Username))
user.LoginMethod = users.LoginMethodPassword
// Perform the user update
err = store.Users.Update(user, true, "LoginMethod")
if err != nil {
logger.Debug(err.Error())
}
//http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
//return
}
status, err := printToken(w, r, user) // Pass the data object
if err != nil {
http.Error(w, http.StatusText(status), status)
@@ -125,7 +194,7 @@ type signupBody struct {
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/signup [post]
func signupHandler(w http.ResponseWriter, r *http.Request) {
if !settings.Config.Auth.Signup {
if !settings.Config.Auth.Methods.PasswordAuth.Signup {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
@@ -151,8 +220,6 @@ func signupHandler(w http.ResponseWriter, r *http.Request) {
user.Username = info.Username
user.Password = info.Password
files.MakeUserDirs(user)
err = store.Users.Save(user, true)
if err == errors.ErrExist {
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
@@ -179,8 +246,7 @@ func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
}
func printToken(w http.ResponseWriter, _ *http.Request, user *users.User) (int, error) {
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Perm)
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Permissions)
if err != nil {
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, err
@@ -194,17 +260,6 @@ func printToken(w http.ResponseWriter, _ *http.Request, user *users.User) (int,
return 0, nil
}
func isRevokedApiKey(key string) bool {
_, exists := revokedApiKeyList[key]
return exists
}
func revokeAPIKey(key string) {
revokeMu.Lock()
delete(revokedApiKeyList, key)
revokeMu.Unlock()
}
func makeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions) (users.AuthToken, error) {
_, ok := user.ApiKeys[name]
if ok {
@@ -225,7 +280,7 @@ func makeSignedTokenAPI(user *users.User, name string, duration time.Duration, p
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim)
tokenString, err := token.SignedString(config.Auth.Key)
tokenString, err := token.SignedString([]byte(config.Auth.Key))
if err != nil {
return claim, err
}
@@ -267,3 +322,347 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
}
return 200, nil
}
// userInfo struct to hold user claims from either UserInfo or ID token
type userInfo struct {
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Sub string `json:"sub"`
Phone string `json:"phone_number"`
}
// getKeySet fetches the JWKS from the given URL, using the provided cache.
func getKeySet(jwksUrl string) (jwk.Set, error) {
// Check cache first
value := cache.JwtCache.Get(jwksUrl)
jwks, ok := value.(jwk.Set)
if ok {
return jwks, nil
}
logger.Debug("JWKS not found in cache for, fetching...")
// Fetch the JWKS from the URL
resp, err := http.Get(jwksUrl)
if err != nil {
return nil, fmt.Errorf("failed to fetch JWKS from %s: %v", jwksUrl, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to fetch JWKS from %s: received status code %d with body: %s", jwksUrl, resp.StatusCode, string(bodyBytes))
}
// Decode the JWKS using jwk.ParseReader
jwks, err = jwk.ParseReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to decode JWKS: %v", err)
}
// Store in cache. Assuming your cache handles expiration (e.g., 1 hour).
cache.JwtCache.Set(jwksUrl, jwks) // Store the jwk.Set
return jwks, nil // Return the jwk.Set after type assertion
}
// oidcCallbackHandler handles the OIDC callback after the user authenticates with the provider.
// It exchanges the authorization code for tokens, then attempts to verify the ID token using JWKS
// if configured, falling back to the UserInfo endpoint if necessary.
func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
code := r.URL.Query().Get("code")
// state := r.URL.Query().Get("state") // You might want to validate the state parameter for CSRF protection
origin := r.Header.Get("Origin")
if origin == "" {
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
}
redirectURI := fmt.Sprintf("%s/api/auth/oidc/callback", origin)
// Step 1: Exchange the code for tokens
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("code", code)
data.Set("client_id", config.Auth.Methods.OidcAuth.ClientID)
data.Set("client_secret", config.Auth.Methods.OidcAuth.ClientSecret)
data.Set("redirect_uri", redirectURI)
req, _ := http.NewRequest("POST", config.Auth.Methods.OidcAuth.TokenUrl, strings.NewReader(data.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{} // Consider using a client with a timeout
resp, err := client.Do(req)
if err != nil {
logger.Debug(fmt.Sprintf("failed to send token request: %v", err))
return 500, fmt.Errorf("token request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
logger.Debug(fmt.Sprintf("failed to fetch token: %v %v", resp.StatusCode, string(body)))
return 500, fmt.Errorf("failed to fetch token: %v %v", resp.StatusCode, string(body))
}
// Decode the token response, expecting access_token and potentially id_token
var tokenResp struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"` // OIDC providers include id_token here
}
if err = json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
logger.Debug(fmt.Sprintf("failed to parse token response: %v", err))
return 500, fmt.Errorf("failed to parse token response: %v", err)
}
var userdata userInfo // This will hold the user claims, using the original struct
useUserInfoEndpoint := true // Flag to determine if we need to call the UserInfo endpoint
// Step 2: Process ID Token if available and JWKS URL is configured
if tokenResp.IDToken != "" && config.Auth.Methods.OidcAuth.JwksUrl != "" {
logger.Debug(fmt.Sprintf("ID token received `%v` attempting to verify using JWKS URL", tokenResp.IDToken))
// getKeySet now returns jwk.Set (interface)
var jwks jwk.Set
jwks, err = getKeySet(config.Auth.Methods.OidcAuth.JwksUrl)
if err != nil {
// Log the error but don't fail yet, try UserInfo endpoint as a fallback
logger.Warning("Failed to fetch or decode JWKS: %v. Falling back to UserInfo endpoint. Ensure your OidcAuth.JwksUrl is correct.")
} else {
var token *jwt.Token
// Parse the ID token. We need to provide a key function to `jwt.Parse`
// that looks up the key in the JWKS based on the token's `kid`.
token, err = jwt.Parse(tokenResp.IDToken, func(token *jwt.Token) (interface{}, error) {
switch alg := token.Method.Alg(); alg {
case "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA":
// accepted
default:
logger.Debug(fmt.Sprintf("unsupported signing method recieved `%v`", alg))
return nil, fmt.Errorf("unsupported signing method recieved: %v", alg)
}
// Find the key in the JWKS that matches the token's kid (Key ID)
keyID, ok := token.Header["kid"].(string)
if !ok {
logger.Debug("kid header not found in ID token")
return nil, fmt.Errorf("kid header not found in ID token")
}
// Use the jwk.Set interface to find the key
// LookupKeyID returns a slice of jwk.Key and a boolean indicating if any keys were found.
key, found := jwks.LookupKeyID(keyID) // Called on the jwk.Set interface
if !found {
logger.Debug(fmt.Sprintf("key with kid %s not found in JWKS", keyID))
return nil, fmt.Errorf("key with kid %s not found in JWKS", keyID)
}
// Return the public key from the first matching JWK
// In a real scenario, you might need more sophisticated key selection logic.
// The jwk.Key interface has a PublicKey() method to get the public key.
publicKey, publicKeyErr := key.PublicKey()
if publicKeyErr != nil {
logger.Debug(fmt.Sprintf("failed to get public key from JWK: %v", publicKeyErr))
return nil, fmt.Errorf("failed to get public key from JWK: %v", err)
}
return publicKey, nil
})
if err != nil {
// Log the verification error but fall back to UserInfo endpoint
logger.Error(fmt.Sprintf("failed to parse or verify ID token: %v. Falling back to UserInfo endpoint.", err))
} else if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
logger.Debug("ID token verified successfully, claims extracted.")
// --- IMPORTANT OIDC VALIDATION ---
// In a production application, you MUST perform additional validations on the ID token claims:
// - Issuer (iss): Must match the expected issuer URL.
// - Audience (aud): Must contain your client ID.
// - Expiration Time (exp): Token must not be expired (jwt.Parse handles this if clock skew is accounted for).
// - Issued At Time (iat): Token must not be issued in the future.
// - Nonce (nonce): If a nonce was sent in the authorization request, it must match the 'nonce' claim in the ID token.
// - Authorized Party (azp): If 'aud' has multiple values, 'azp' must be present and equal to your client ID.
// - at_hash and c_hash: If the access token or authorization code were issued, validate their hashes if present in the ID token.
// A dedicated OIDC library handles these validations correctly and securely.
// This simplified example only verifies the signature and basic validity.
// --- END IMPORTANT OIDC VALIDATION ---
// Populate the userInfo struct from the ID token claims (map)
if name, ok := claims["name"].(string); ok {
userdata.Name = name
}
if preferredUsername, ok := claims["preferred_username"].(string); ok {
userdata.PreferredUsername = preferredUsername
}
if email, ok := claims["email"].(string); ok {
userdata.Email = email
}
if sub, ok := claims["sub"].(string); ok {
userdata.Sub = sub
}
if phone, ok := claims["phone_number"].(string); ok {
userdata.Phone = phone
}
useUserInfoEndpoint = false // Successfully processed ID token, no need for UserInfo endpoint
} else {
// ID token signature verification failed or token is invalid for other reasons
logger.Error("id token signature verification failed or token is invalid. Falling back to UserInfo endpoint.")
}
}
}
// Step 3: Use the access token to fetch user info from the UserInfo URL if needed
// This step is skipped if the ID token was successfully processed and verified.
if useUserInfoEndpoint {
userInfoURL := config.Auth.Methods.OidcAuth.UserInfoUrl
req, _ = http.NewRequest("GET", userInfoURL, nil)
req.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
resp, err = client.Do(req)
if err != nil {
return 500, fmt.Errorf("failed to fetch user info: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return 500, fmt.Errorf("failed to fetch user info: received status code %d with body: %s", resp.StatusCode, string(bodyBytes))
}
// Decode the UserInfo response directly into the original userdata struct
if err = json.NewDecoder(resp.Body).Decode(&userdata); err != nil {
return 500, fmt.Errorf("failed to parse user info: %v", err)
}
}
// Now userdata contains claims from either the verified ID token or the UserInfo endpoint.
// Proceed with logging in the user based on the extracted claims.
return loginWithOidcUser(w, r, userdata)
}
// oidcLoginHandler redirects the user to the OIDC provider's authorization endpoint.
// This function remains largely the same, but includes the 'fb_redirect' parameter
// to redirect the user back to the original page after successful login.
func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Auth.Methods.OidcAuth.Enabled {
origin := r.Header.Get("Origin")
if origin == "" {
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
}
// Generate a nonce for CSRF protection (optional but recommended)
// You should store this nonce and validate it in the callback handler.
nonce := utils.InsecureRandomIdentifier(16)
// For state parameter, you might include the nonce and the original redirect URL
state := fmt.Sprintf("%s:%s", nonce, r.URL.Query().Get("fb_redirect")) // Example state parameter structure
redirectURI := fmt.Sprintf("%s/api/auth/oidc/callback", origin)
// Construct the authorization URL
authURL := fmt.Sprintf("%s?client_id=%s&response_type=code&scope=%s&redirect_uri=%s&redirect_uris=%s&state=%s",
config.Auth.Methods.OidcAuth.AuthorizationUrl,
url.QueryEscape(config.Auth.Methods.OidcAuth.ClientID),
url.QueryEscape(config.Auth.Methods.OidcAuth.Scopes),
url.QueryEscape(redirectURI),
url.QueryEscape(redirectURI),
url.QueryEscape(state), // Use the state parameter
)
// Redirect the user to the OIDC provider
http.Redirect(w, r, authURL, http.StatusFound)
return 0, nil // Indicate that the request has been handled by the redirect
}
// OIDC authentication is not enabled
return http.StatusForbidden, fmt.Errorf("oidc authentication is not enabled")
}
// loginWithOidcUser extracts the username from the user claims (userInfo)
// based on the configured UserIdentifier and logs the user into the application.
// It creates a new user if one doesn't exist.
func loginWithOidcUser(w http.ResponseWriter, r *http.Request, userInfo userInfo) (int, error) {
username := userInfo.PreferredUsername
switch config.Auth.Methods.OidcAuth.UserIdentifier {
case "email":
username = userInfo.Email
case "username":
username = userInfo.PreferredUsername
}
// Retrieve the user from the store and store it in the context
user, err := store.Users.Get(username)
if err != nil {
if err.Error() != "the resource does not exist" {
return http.StatusInternalServerError, err
}
hashpass := ""
hashpass, err = users.HashPwd(username) // hashed password that can't actually be used, gets double hashed
if err != nil {
return http.StatusInternalServerError, err
}
err = storage.CreateUser(users.User{
LoginMethod: users.LoginMethodOidc,
Username: username,
NonAdminEditable: users.NonAdminEditable{
Password: hashpass, // hashed password that can't actually be used
},
}, false)
if err != nil {
return http.StatusInternalServerError, err
}
user, err = store.Users.Get(username)
if err != nil {
return http.StatusInternalServerError, err
}
}
if user.LoginMethod != users.LoginMethodOidc {
logger.Warning(fmt.Sprintf("user %s is not allowed to login with oidc authentication, bypassing and updating login method", user.Username))
user.LoginMethod = users.LoginMethodOidc
err = store.Users.Update(user, true, "LoginMethod")
if err != nil {
logger.Debug(err.Error())
}
//return http.StatusForbidden, fmt.Errorf("user %s is not allowed to login with OIDC", username)
}
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Permissions)
if err != nil {
// Handle potential errors during token generation
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, fmt.Errorf("token name conflict: %v", err)
}
return http.StatusInternalServerError, fmt.Errorf("failed to generate authentication token for user %s: %v", username, err)
}
// Set the authentication token as an HTTP cookie
cookie := &http.Cookie{
Name: "auth", // The name of your auth cookie
Value: signed.Key, // The generated token value
Domain: strings.Split(r.Host, ":")[0], // Set domain to the host without port
Path: "/", // Make the cookie available to the whole site
// Secure: true, // Recommended: Set to true in production with HTTPS
// HttpOnly: true, // Recommended: Set to true to prevent client-side script access
Expires: time.Now().Add(time.Hour * time.Duration(config.Auth.TokenExpirationHours)), // Set cookie expiration
}
http.SetCookie(w, cookie)
// Set the authenticated user in the request context or response writer
// This allows subsequent handlers to access the current user.
setUserInResponseWriter(w, user)
// Redirect the user to the page they were trying to access before login,
// or to the root ("/") if no specific redirect was requested.
// The 'fb_redirect' parameter is extracted from the 'state' parameter for security.
state := r.URL.Query().Get("state")
fbRedirect := "/" // Default redirect path
if state != "" {
// Assuming state is in the format "nonce:fb_redirect"
parts := strings.SplitN(state, ":", 2)
if len(parts) == 2 {
// TODO: Validate the nonce part against the stored nonce for CSRF protection
// For this example, we'll just extract the redirect part
extractedRedirect := parts[1]
if extractedRedirect != "" {
fbRedirect = extractedRedirect
}
}
}
http.Redirect(w, r, fbRedirect, http.StatusFound)
// Return 0 to indicate that the response has been handled by the redirect
return 0, nil
}
+80
View File
@@ -0,0 +1,80 @@
package http
import (
"fmt"
"io"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/events"
)
type messenger struct {
flusher http.Flusher
writer io.Writer
}
// expects message with double quotes around string
func (msgr messenger) sendEvent(eventType, message string) error {
_, err := fmt.Fprintf(msgr.writer, "data: {\"eventType\":\"%s\",\"message\":%s}\n\n", eventType, message)
if err != nil {
return err
}
msgr.flusher.Flush()
return nil
}
func sseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Realtime {
return http.StatusForbidden, fmt.Errorf("realtime is disabled for this user")
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
sessionId := r.URL.Query().Get("sessionId")
username := d.user.Username
f, ok := w.(http.Flusher)
if !ok {
logger.Debug(fmt.Sprintf("error: ResponseWriter does not support Flusher. User: %s, SessionId: %s", username, sessionId))
return http.StatusInternalServerError, fmt.Errorf("streaming not supported")
}
msgr := messenger{flusher: f, writer: w}
clientGone := r.Context().Done()
// Initial ack
if err := msgr.sendEvent("acknowledge", "\"connection established\""); err != nil {
return http.StatusInternalServerError, fmt.Errorf("error sending message: %v, user: %s, SessionId: %s", err, username, sessionId)
}
// Register this client with the events system
sendChan := events.Register(username, settings.GetSources(d.user))
defer events.Unregister(username, sendChan)
for {
select {
case <-d.ctx.Done():
_ = msgr.sendEvent("notification", "\"the server is shutting down\"")
return http.StatusOK, nil
case <-clientGone:
logger.Debug(fmt.Sprintf("client disconnected. user: %s, SessionId: %s", username, sessionId))
return http.StatusOK, nil
case msg := <-events.BroadcastChan:
if err := msgr.sendEvent(msg.EventType, msg.Message); err != nil {
return http.StatusInternalServerError, fmt.Errorf("error sending broadcast: %v, user: %s", err, username)
}
case msg := <-sendChan:
if err := msgr.sendEvent(msg.EventType, msg.Message); err != nil {
return http.StatusInternalServerError, fmt.Errorf("error sending targeted message: %v, user: %s", err, username)
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
package http
import (
"fmt"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
func getJobsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sources := settings.GetSources(d.user)
reducedIndexes := map[string]indexing.ReducedIndex{}
for _, source := range sources {
reducedIndex, err := indexing.GetIndexInfo(source)
if err != nil {
logger.Debug(fmt.Sprintf("error getting index info: %v", err))
continue
}
reducedIndexes[source] = reducedIndex
}
return renderJSON(w, r, reducedIndexes)
}
@@ -11,12 +11,11 @@ import (
"text/template"
"time"
"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
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
// http-swagger middleware
)
// Embed the files in the frontend/dist directory
@@ -38,20 +37,14 @@ func (d dirFS) Open(name string) (fs.File, error) {
}
var (
store *storage.Storage
config *settings.Settings
fileCache FileCache
imgSvc ImgService
assetFs fs.FS
store *storage.Storage
config *settings.Settings
assetFs fs.FS
)
func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage, cache FileCache, shutdownComplete chan struct{}) {
func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete chan struct{}) {
store = storage
fileCache = cache
imgSvc = Service
config = &settings.Config
var err error
if embeddedFS {
@@ -86,6 +79,8 @@ func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage
api.HandleFunc("GET /auth/token", withUser(createApiKeyHandler))
api.HandleFunc("DELETE /auth/token", withUser(deleteApiKeyHandler))
api.HandleFunc("GET /auth/tokens", withUser(listApiKeysHandler))
api.HandleFunc("GET /auth/oidc/callback", wrapHandler(oidcCallbackHandler))
api.HandleFunc("GET /auth/oidc/login", wrapHandler(oidcLoginHandler))
// Resources routes
api.HandleFunc("GET /resources", withUser(resourceGetHandler))
@@ -93,7 +88,6 @@ func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage
api.HandleFunc("POST /resources", withUser(resourcePostHandler))
api.HandleFunc("PUT /resources", withUser(resourcePutHandler))
api.HandleFunc("PATCH /resources", withUser(resourcePatchHandler))
api.HandleFunc("GET /usage", withUser(diskUsage))
api.HandleFunc("GET /raw", withUser(rawHandler))
api.HandleFunc("GET /preview", withUser(previewHandler))
if version.Version == "testing" || version.Version == "untracked" {
@@ -103,7 +97,7 @@ func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage
// Share routes
api.HandleFunc("GET /shares", withPermShare(shareListHandler))
api.HandleFunc("GET /share", withPermShare(shareGetsHandler))
api.HandleFunc("GET /share", withPermShare(shareGetHandler))
api.HandleFunc("POST /share", withPermShare(sharePostHandler))
api.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
@@ -111,13 +105,23 @@ func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage
api.HandleFunc("GET /public/publicUser", publicUserGetHandler)
api.HandleFunc("GET /public/dl", withHashFile(publicRawHandler))
api.HandleFunc("GET /public/share", withHashFile(publicShareHandler))
api.HandleFunc("GET /public/preview", withHashFile(publicPreviewHandler))
// Settings routes
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
api.HandleFunc("PUT /settings", withAdmin(settingsPutHandler))
// Events routes
api.HandleFunc("GET /events", withUser(func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
d.ctx = ctx // Pass the parent context to ensure proper shutdown
return sseHandler(w, r, d)
}))
// Job routes
api.HandleFunc("GET /jobs/{action}/{target}", withUser(getJobsHandler))
api.HandleFunc("GET /onlyoffice/config", withUser(onlyofficeClientConfigGetHandler))
api.HandleFunc("POST /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
api.HandleFunc("GET /onlyoffice/getToken", withUser(onlyofficeGetTokenHandler))
api.HandleFunc("GET /search", withUser(searchHandler))
apiPath := config.Server.BaseURL + "api"
@@ -131,14 +135,7 @@ func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage
router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler)
// Swagger
router.Handle(fmt.Sprintf("%vswagger/", config.Server.BaseURL),
httpSwagger.Handler(
httpSwagger.URL(config.Server.BaseURL+"swagger/doc.json"), //The url pointing to API definition
httpSwagger.DeepLinking(true),
httpSwagger.DocExpansion("none"),
httpSwagger.DomID("swagger-ui"),
),
)
router.Handle(fmt.Sprintf("%vswagger/", config.Server.BaseURL), withUser(swaggerHandler))
var scheme string
port := ""
+50 -41
View File
@@ -2,6 +2,7 @@ package http
import (
"compress/gzip"
"context"
"encoding/json"
"fmt"
"net/http"
@@ -9,19 +10,22 @@ import (
"time"
jwt "github.com/golang-jwt/jwt/v4"
"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/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
type requestContext struct {
user *users.User
raw interface{}
path string
token string
share *share.Link
user *users.User
raw interface{}
fileInfo iteminfo.ExtendedFileInfo
path string
token string
share *share.Link
ctx context.Context
}
type HttpResponse struct {
@@ -56,22 +60,29 @@ func withHashFileHelper(fn handleFunc) handleFunc {
return status, fmt.Errorf("could not authenticate share request")
}
}
data.path = link.Path + "/" + path
data.path = strings.TrimSuffix(link.Path, "/") + "/" + strings.TrimPrefix(path, "/")
if path == "" || path == "/" {
data.path = link.Path
}
source, ok := config.Server.SourceMap[link.Source]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
// Get file information with options
file, err := FileInfoFasterFunc(files.FileOptions{
file, err := FileInfoFasterFunc(iteminfo.FileOptions{
Path: data.path,
Source: link.Source,
Source: source.Name,
Modify: false,
Expand: true,
})
file.Token = link.Token
if err != nil {
logger.Error(fmt.Sprintf("error fetching file info for share. hash=%v path=%v error=%v", hash, data.path, err))
return errToStatus(err), fmt.Errorf("error fetching share from server")
}
// Set the file info in the `data` object
data.raw = file
// Call the next handler with the data
return fn(w, r, data)
}
@@ -81,7 +92,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
func withAdminHelper(fn handleFunc) handleFunc {
return withUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
// Ensure the user has admin permissions
if !data.user.Perm.Admin {
if !data.user.Permissions.Admin {
return http.StatusForbidden, nil
}
return fn(w, r, data)
@@ -103,40 +114,22 @@ func withUserHelper(fn handleFunc) handleFunc {
}
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
var err error
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(proxyUser)
user, err := setupProxyUser(r, data, proxyUser)
if err != nil {
if err.Error() != "the resource does not exist" {
return http.StatusInternalServerError, err
}
if config.Auth.Methods.ProxyAuth.CreateUser {
newUser := &users.User{Username: proxyUser}
settings.ApplyUserDefaults(newUser)
err := store.Users.Save(newUser, false)
if err != nil {
return http.StatusInternalServerError, err
}
data.user, err = store.Users.Get(proxyUser)
if err != nil {
return http.StatusInternalServerError, err
}
} else {
return http.StatusUnauthorized, fmt.Errorf("proxy authentication failed - no user found")
}
return http.StatusForbidden, err
}
data.user = user
setUserInResponseWriter(w, data.user)
return fn(w, r, data)
}
keyFunc := func(token *jwt.Token) (interface{}, error) {
return config.Auth.Key, nil
return []byte(config.Auth.Key), nil
}
tokenString, err := extractToken(r)
if err != nil {
return http.StatusUnauthorized, err
}
data.token = tokenString
var tk users.AuthToken
token, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
if err != nil {
@@ -145,7 +138,7 @@ func withUserHelper(fn handleFunc) handleFunc {
if !token.Valid {
return http.StatusUnauthorized, fmt.Errorf("invalid token")
}
if isRevokedApiKey(tk.Key) || tk.Expires < time.Now().Unix() {
if auth.IsRevokedApiKey(tk.Key) || tk.Expires < time.Now().Unix() {
return http.StatusUnauthorized, fmt.Errorf("token expired or revoked")
}
// Check if the token is about to expire and send a header to renew it
@@ -170,7 +163,7 @@ func withUserHelper(fn handleFunc) handleFunc {
func withSelfOrAdminHelper(fn handleFunc) handleFunc {
return withUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
// Check if the current user is the same as the requested user or if they are an admin
if !data.user.Perm.Admin {
if !data.user.Permissions.Admin {
return http.StatusForbidden, nil
}
// Call the actual handler function with the updated context
@@ -220,7 +213,7 @@ func wrapHandler(fn handleFunc) http.HandlerFunc {
func withPermShareHelper(fn handleFunc) handleFunc {
return withUserHelper(func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Perm.Share {
if !d.user.Permissions.Share {
return http.StatusForbidden, nil
}
return fn(w, r, d)
@@ -358,3 +351,19 @@ func acceptsGzip(r *http.Request) bool {
ae := r.Header.Get("Accept-Encoding")
return ae != "" && strings.Contains(ae, "gzip")
}
func (w *ResponseWriterWrapper) Flush() {
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
func getScheme(r *http.Request) string {
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
return proto
}
if r.TLS != nil {
return "https"
}
return "http"
}
+29 -25
View File
@@ -8,15 +8,13 @@ import (
"time"
storm "github.com/asdine/storm/v3"
"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"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func setupTestEnv(t *testing.T) {
@@ -35,10 +33,14 @@ func setupTestEnv(t *testing.T) {
Share: shareStore,
Settings: settingsStore,
}
fileCache = diskcache.NewNoOp() // mocked
imgSvc = img.New(1) // mocked
config = &settings.Config // mocked
mockFileInfoFaster(t) // Mock FileInfoFasterFunc for this test
config = &settings.Config // mocked
config.Server.SourceMap = map[string]settings.Source{
"/srv": settings.Source{
Path: "/srv",
Name: "srv",
},
}
mockFileInfoFaster(t) // Mock FileInfoFasterFunc for this test
}
func mockFileInfoFaster(t *testing.T) {
@@ -48,11 +50,11 @@ func mockFileInfoFaster(t *testing.T) {
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{
FileInfoFasterFunc = func(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error) {
return iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
Path: opts.Path,
ItemInfo: files.ItemInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "mocked_file",
Size: 12345,
},
@@ -65,14 +67,14 @@ func TestWithAdminHelper(t *testing.T) {
setupTestEnv(t)
// Mock a user who has admin permissions
adminUser := &users.User{
ID: 1,
Username: "admin",
Perm: users.Permissions{Admin: true}, // Ensure the user is an admin
ID: 1,
Username: "admin",
Permissions: users.Permissions{Admin: true}, // Ensure the user is an admin
}
nonAdminUser := &users.User{
ID: 2,
Username: "non-admin",
Perm: users.Permissions{Admin: false}, // Non-admin user
ID: 2,
Username: "non-admin",
Permissions: users.Permissions{Admin: false}, // Non-admin user
}
// Save the users to the mock database
if err := store.Users.Save(adminUser, true); err != nil {
@@ -155,7 +157,8 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
{
name: "Public share, no auth required",
share: &share.Link{
Hash: "public_hash",
Hash: "public_hash",
Source: "/srv",
},
expectedStatusCode: 0, // zero means 200 on helpers
},
@@ -176,6 +179,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
UserID: 1,
PasswordHash: passwordBcrypt,
Token: "123",
Source: "/srv",
},
token: "123",
expectedStatusCode: 0, // zero means 200 on helpers
@@ -209,7 +213,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
handler := withHashFileHelper(publicShareHandler)
if err := store.Settings.Save(&settings.Settings{
Auth: settings.Auth{
Key: []byte("key"),
Key: "key",
},
}); err != nil {
t.Fatalf("failed to save settings: %v", err)
+51 -21
View File
@@ -7,15 +7,17 @@ import (
"io"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
const (
@@ -45,6 +47,8 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
}
// get path from url
pathParts := strings.Split(url, "/api/raw?files=")
origPathParts := strings.Split(encodedUrl, "/api/raw?files=")
encodedPath := origPathParts[len(origPathParts)-1]
sourceFile := pathParts[len(pathParts)-1]
sourceSplit := strings.Split(sourceFile, "::")
if len(sourceSplit) != 2 {
@@ -54,7 +58,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
path := sourceSplit[1]
urlFirst := pathParts[0]
if settings.Config.Server.InternalUrl != "" {
urlFirst = strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
urlFirst = settings.Config.Server.InternalUrl
replacement := strings.Split(url, "/api/raw")[0]
url = strings.Replace(url, replacement, settings.Config.Server.InternalUrl, 1)
}
@@ -62,29 +66,26 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(userscope, path),
Modify: d.user.Perm.Modify,
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Modify: d.user.Permissions.Modify,
Source: source,
Expand: false,
})
if err != nil {
return errToStatus(err), err
}
id, err := getOnlyOfficeId(source, fileInfo.Path)
if err != nil {
return http.StatusNotFound, err
}
split := strings.Split(fileInfo.Name, ".")
fileType := split[len(split)-1]
theme := "light"
if d.user.DarkMode {
theme = "dark"
}
callbackURL := fmt.Sprintf("%v/api/onlyoffice/callback?path=%v&auth=%v", urlFirst, path, d.token)
callbackURL := fmt.Sprintf("%v/api/onlyoffice/callback?path=%v&auth=%v", urlFirst, encodedPath, d.token)
clientConfig := map[string]interface{}{
"document": map[string]interface{}{
"fileType": fileType,
@@ -92,7 +93,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
"title": fileInfo.Name,
"url": url + "&auth=" + d.token,
"permissions": map[string]interface{}{
"edit": d.user.Perm.Modify,
"edit": d.user.Permissions.Modify,
"download": true,
"print": true,
},
@@ -128,7 +129,6 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
if err != nil {
return http.StatusInternalServerError, err
}
var data OnlyOfficeCallback
err = json.Unmarshal(body, &data)
if err != nil {
@@ -136,12 +136,16 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
}
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
pathParts := strings.Split(encodedPath, "::")
if len(pathParts) < 2 {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
source := pathParts[0]
if source == "" {
source = settings.Config.Server.DefaultSource.Name
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
path, err := url.QueryUnescape(pathParts[1])
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
@@ -158,7 +162,7 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
data.Status == onlyOfficeStatusForceSaveWhileDocumentStillOpen {
if !d.user.Perm.Modify {
if !d.user.Permissions.Modify {
return http.StatusForbidden, nil
}
@@ -168,7 +172,7 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
}
defer doc.Body.Close()
fileOpts := files.FileOptions{
fileOpts := iteminfo.FileOptions{
Path: path,
Source: source,
}
@@ -185,7 +189,7 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
}
func getOnlyOfficeId(source, path string) (string, error) {
idx := files.GetIndex(source)
idx := indexing.GetIndex(source)
if idx == nil {
return "", fmt.Errorf("source not found")
}
@@ -200,7 +204,7 @@ func getOnlyOfficeId(source, path string) (string, error) {
}
func deleteOfficeId(source, path string) {
idx := files.GetIndex(source)
idx := indexing.GetIndex(source)
if idx == nil {
logger.Error(fmt.Sprintf("deleteOfficeId: failed to find source index for user home dir creation: %s", source))
return
@@ -208,3 +212,29 @@ func deleteOfficeId(source, path string) {
realpath, _, _ := idx.GetRealPath(path)
cache.OnlyOffice.Delete(realpath)
}
func onlyofficeGetTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// get config from body
body, err := io.ReadAll(r.Body)
if err != nil {
return http.StatusInternalServerError, err
}
defer r.Body.Close()
var payload map[string]interface{}
// marshall to struct
err = json.Unmarshal(body, &payload)
if err != nil {
return http.StatusInternalServerError, err
}
if settings.Config.Integrations.OnlyOffice.Secret != "" {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(payload))
ss, err := token.SignedString([]byte(settings.Config.Integrations.OnlyOffice.Secret))
if err != nil {
return 500, errors.New("could not generate a new jwt")
}
return renderJSON(w, r, map[string]string{"token": ss})
}
return 400, fmt.Errorf("bad request")
}
+57 -103
View File
@@ -4,24 +4,18 @@ import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/img"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/preview"
)
type ImgService interface {
FormatFromExtension(ext string) (img.Format, error)
Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...img.Option) error
}
type FileCache interface {
Store(ctx context.Context, key string, value []byte) error
Load(ctx context.Context, key string) ([]byte, bool, error)
@@ -42,17 +36,18 @@ type FileCache interface {
// @Failure 404 {object} map[string]string "File not found"
// @Failure 415 {object} map[string]string "Unsupported file type for preview"
// @Failure 500 {object} map[string]string "Internal server error"
// @Failure 501 {object} map[string]string "Preview generation not implemented"
// @Router /api/preview [get]
func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Server.DisablePreviews {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
}
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
}
previewSize := r.URL.Query().Get("size")
if previewSize != "small" {
previewSize = "large"
}
if path == "" {
return http.StatusBadRequest, fmt.Errorf("invalid request path")
}
@@ -60,101 +55,21 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(userscope, path),
Modify: d.user.Perm.Modify,
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
})
if err != nil {
return errToStatus(err), err
}
if fileInfo.Type == "directory" {
return http.StatusBadRequest, fmt.Errorf("can't create preview for directory")
}
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) ||
(previewSize == "small" && !config.Server.EnableThumbnails) {
return rawFileHandler(w, r, fileInfo)
}
format, err := imgSvc.FormatFromExtension(filepath.Ext(fileInfo.Name))
// Unsupported extensions directly return the raw data
if err == img.ErrUnsupportedFormat || format == img.FormatGif {
return rawFileHandler(w, r, fileInfo)
}
if err != nil {
return errToStatus(err), err
}
cacheKey := previewCacheKey(fileInfo.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, fileInfo, previewSize)
if err != nil {
return errToStatus(err), err
}
}
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, fileInfo.RealPath, fileInfo.ModTime, bytes.NewReader(resizedImage))
return 0, nil
d.fileInfo = fileInfo
return previewHelperFunc(w, r, d)
}
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
}
defer fd.Close()
var (
width int
height int
options []img.Option
)
switch {
case previewSize == "large":
width = 1080
height = 1080
options = append(options, img.WithMode(img.ResizeModeFit), img.WithQuality(img.QualityMedium))
case previewSize == "small":
width = 256
height = 256
options = append(options, img.WithMode(img.ResizeModeFill), img.WithQuality(img.QualityLow), img.WithFormat(img.FormatJpeg))
default:
return nil, img.ErrUnsupportedFormat
}
buf := &bytes.Buffer{}
if err := imgSvc.Resize(context.Background(), fd, width, height, buf, options...); err != nil {
return nil, err
}
go func() {
cacheKey := previewCacheKey(file.RealPath, previewSize, file.FileInfo.ModTime)
if err := fileCache.Store(context.Background(), cacheKey, buf.Bytes()); err != nil {
logger.Error(fmt.Sprintf("failed to cache resized image: %v", err))
}
}()
return buf.Bytes(), nil
}
// Generates a cache key for the preview image
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.ExtendedFileInfo) (int, error) {
idx := files.GetIndex(file.Source)
func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.ExtendedFileInfo) (int, error) {
idx := indexing.GetIndex(file.Source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source not found: %s", file.Source)
}
@@ -171,3 +86,42 @@ func rawFileHandler(w http.ResponseWriter, r *http.Request, file files.ExtendedF
http.ServeContent(w, r, file.Name, file.ModTime, fd)
return 0, nil
}
func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
previewSize := r.URL.Query().Get("size")
if previewSize != "small" {
previewSize = "large"
}
if d.fileInfo.Type == "directory" {
return http.StatusBadRequest, fmt.Errorf("can't create preview for directory")
}
setContentDisposition(w, r, d.fileInfo.Name)
isImage := strings.HasPrefix(d.fileInfo.Type, "image")
if config.Server.DisableResize && isImage {
return rawFileHandler(w, r, d.fileInfo)
}
if !preview.AvailablePreview(d.fileInfo) {
if isImage {
return rawFileHandler(w, r, d.fileInfo)
}
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", d.fileInfo.Type)
}
pathUrl := fmt.Sprintf("/api/raw?files=%s::%s", d.fileInfo.Source, d.fileInfo.Path)
pathUrl = pathUrl + "&auth=" + d.token
if settings.Config.Server.InternalUrl != "" {
pathUrl = config.Server.InternalUrl + pathUrl
} else {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
pathUrl = scheme + "://" + r.Host + pathUrl
}
previewImg, err := preview.GetPreviewForFile(d.fileInfo, previewSize, pathUrl)
if err != nil {
return http.StatusInternalServerError, err
}
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, d.fileInfo.RealPath, d.fileInfo.ModTime, bytes.NewReader(previewImg))
return 0, nil
}
+73 -17
View File
@@ -7,8 +7,12 @@ import (
"net/url"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
)
@@ -19,7 +23,7 @@ import (
// @Tags Resources
// @Accept json
// @Produce json
// @Param files query string true "a list of files in the following format 'source::filename' and separated by '||' with additional items in the list. (required)"
// @Param files query string true "a list of files in the following format 'filename' and separated by '||' with additional items in the list. (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' and 'tar.gz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
@@ -29,30 +33,37 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /public/dl [get]
func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
filePrefix := ""
file, ok := d.raw.(files.ExtendedFileInfo)
if ok {
filePrefix = file.Source + "::"
}
encodedFiles := file.Path + r.URL.Query().Get("files")
encodedFiles := r.URL.Query().Get("files")
// Decode the URL-encoded path
files, err := url.QueryUnescape(encodedFiles)
f, err := url.QueryUnescape(encodedFiles)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
fileList := strings.Split(files, "||")
for i, f := range fileList {
fileList[i] = filePrefix + f
fileInfo, ok := d.raw.(iteminfo.ExtendedFileInfo)
if !ok {
logger.Error("public share handler: failed to assert type files.FileInfo")
return http.StatusInternalServerError, fmt.Errorf("failed to assert type files.FileInfo")
}
return rawFilesHandler(w, r, d, fileList)
fileList := []string{}
for _, file := range strings.Split(f, "||") {
fileList = append(fileList, fileInfo.Source+"::"+fileInfo.Path+file)
}
var status int
status, err = rawFilesHandler(w, r, d, fileList)
if err != nil {
logger.Error(fmt.Sprintf("public share handler: error processing filelist: %v", err))
return status, fmt.Errorf("error processing filelist: %v", f)
}
return status, nil
}
func publicShareHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
file, ok := d.raw.(files.ExtendedFileInfo)
file, ok := d.raw.(iteminfo.ExtendedFileInfo)
if !ok {
return http.StatusInternalServerError, fmt.Errorf("failed to assert type files.FileInfo")
return http.StatusInternalServerError, fmt.Errorf("failed to assert type iteminfo.FileInfo")
}
file.Path = strings.TrimPrefix(file.Path, file.Source)
file.Path = strings.TrimPrefix(file.Path, d.share.Path)
return renderJSON(w, r, file)
}
@@ -82,3 +93,48 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
// publicPreviewHandler handles the preview request for images from shares.
// @Summary Get image preview
// @Description Returns a preview image based on the requested path and size.
// @Tags Resources
// @Accept json
// @Produce json
// @Param hash query string true "source hash"
// @Param path query string true "File path of the image to preview"
// @Param size query string false "Preview size ('small' or 'large'). Default is based on server config."
// @Success 200 {file} file "Preview image content"
// @Failure 202 {object} map[string]string "Download permissions required"
// @Failure 400 {object} map[string]string "Invalid request path"
// @Failure 404 {object} map[string]string "File not found"
// @Failure 415 {object} map[string]string "Unsupported file type for preview"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/preview [get]
func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Server.DisablePreviews {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
}
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
}
if path == "" {
return http.StatusBadRequest, fmt.Errorf("invalid request path")
}
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(d.share.Path, path),
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
})
if err != nil {
logger.Debug(fmt.Sprintf("public preview handler: error getting file info: %v", err))
return 400, fmt.Errorf("file not found")
}
if fileInfo.Type == "directory" {
return http.StatusBadRequest, fmt.Errorf("can't create preview for directory")
}
d.fileInfo = fileInfo
return previewHelperFunc(w, r, d)
}
+100 -72
View File
@@ -13,14 +13,15 @@ import (
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
if r.URL.Query().Get("inline") == "true" {
w.Header().Set("Content-Disposition", "inline")
w.Header().Set("Content-Disposition", "inline; filename*=utf-8''"+url.PathEscape(fileName))
} else {
// As per RFC6266 section 4.3
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(fileName))
@@ -43,11 +44,6 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
// @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) {
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)
@@ -55,9 +51,6 @@ func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
fileList := strings.Split(files, "||")
for i, f := range fileList {
fileList[i] = filepath.Join(filePrefix, f)
}
return rawFilesHandler(w, r, d, fileList)
}
@@ -68,12 +61,16 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
}
source := splitFile[0]
path = splitFile[1]
userScope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if d.share == nil && err != nil {
return fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
var err error
userScope := "/"
if d.user.Username != "publicUser" {
userScope, err = settings.GetScopeFromSourceName(d.user.Scopes, source)
if d.share == nil && err != nil {
return fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
}
}
idx := files.GetIndex(source)
idx := indexing.GetIndex(source)
if idx == nil {
return fmt.Errorf("source %s is not available", source)
}
@@ -192,12 +189,15 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
firstFileSource := splitFile[0]
firstFilePath := splitFile[1]
fileName := filepath.Base(firstFilePath)
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, firstFileSource)
if err != nil {
return http.StatusForbidden, err
var err error
userscope := "/"
if d.user.Username != "publicUser" {
userscope, err = settings.GetScopeFromSourceName(d.user.Scopes, firstFileSource)
if err != nil {
return http.StatusForbidden, err
}
}
idx := files.GetIndex(firstFileSource)
idx := indexing.GetIndex(firstFileSource)
if idx == nil {
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", firstFileSource)
}
@@ -205,13 +205,11 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
if err != nil {
return http.StatusInternalServerError, err
}
// Compute estimated download size
estimatedSize, err := computeArchiveSize(fileList, d)
if err != nil {
return http.StatusInternalServerError, err
}
// ** Single file download with Content-Length **
if len(fileList) == 1 && !isDir {
fd, err2 := os.Open(realPath)
@@ -231,18 +229,23 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
w.Header().Set("Cache-Control", "private")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
// if larger than 100 megabytes, log it
if estimatedSize > 100*1024/1024 {
logger.Debug(fmt.Sprintf("User %v is downloading large (%d MB) file: %v", d.user.Username, estimatedSize/1024/1024, fileName))
}
// Stream file to response
_, err2 = io.Copy(w, fd)
if err2 != nil {
return http.StatusInternalServerError, err2
sizeInMB := estimatedSize / 1024 / 1024
// if larger than 500 MB, log it
if sizeInMB > 500 {
logger.Debug(fmt.Sprintf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName))
}
// serve content allows for range requests.
// video scrubbing, etc.
http.ServeContent(w, r, fileName, fileInfo.ModTime(), fd)
return 200, nil
}
if config.Server.MaxArchiveSizeGB > 0 {
maxSize := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
if estimatedSize > maxSize {
return http.StatusRequestEntityTooLarge, fmt.Errorf("pre-archive combined size of files exceeds maximum limit of %d GB", config.Server.MaxArchiveSizeGB)
}
}
// ** Archive (ZIP/TAR.GZ) handling **
algo := r.URL.Query().Get("algo")
var extension string
@@ -255,7 +258,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
return http.StatusInternalServerError, errors.New("format not implemented")
}
baseDirName := filepath.Base(filepath.Dir(realPath))
baseDirName := filepath.Base(filepath.Dir(firstFilePath))
if baseDirName == "" || baseDirName == "/" {
baseDirName = "download"
}
@@ -264,23 +267,50 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
fileName = url.PathEscape(baseDirName + extension)
// Set headers for archive
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+fileName)
w.Header().Set("Content-Length", fmt.Sprintf("%d", estimatedSize))
// if larger than 100 megabytes, log it
if estimatedSize > 100*1024/1024 {
logger.Debug(fmt.Sprintf("User %v is downloading large (%d MB) file: %v", d.user.Username, estimatedSize/1024/1024, fileName))
}
// Create archive and stream it
archiveData := filepath.Join(config.Server.CacheDir, utils.InsecureRandomIdentifier(10))
if extension == ".zip" {
err = createZip(w, d, fileList...)
archiveData = archiveData + ".zip"
err = createZip(d, archiveData, fileList...)
} else {
err = createTarGz(w, d, fileList...)
archiveData = archiveData + ".tar.gz"
err = createTarGz(d, archiveData, fileList...)
}
if err != nil {
return http.StatusInternalServerError, err
}
// stream archive to response
fd, err := os.Open(archiveData)
if err != nil {
return http.StatusInternalServerError, err
}
defer fd.Close()
// Get file size
fileInfo, err := fd.Stat()
if err != nil {
os.Remove(archiveData) // Remove the file if stat fails
return http.StatusInternalServerError, err
}
sizeInMB := fileInfo.Size() / 1024 / 1024
if sizeInMB > 500 {
logger.Debug(fmt.Sprintf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName))
}
// Set headers AFTER computing actual archive size
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+fileName)
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
w.Header().Set("Content-Type", "application/octet-stream")
// Stream the file
_, err = io.Copy(w, fd)
os.Remove(archiveData) // Remove the file after streaming
if err != nil {
logger.Error(fmt.Sprintf("Failed to copy archive data to response: %v", err))
return http.StatusInternalServerError, err
}
return 0, nil
}
@@ -293,19 +323,24 @@ func computeArchiveSize(fileList []string, d *requestContext) (int64, error) {
}
source := splitFile[0]
path := splitFile[1]
idx := files.GetIndex(source)
idx := indexing.GetIndex(source)
if idx == nil {
return 0, fmt.Errorf("source %s is not available", source)
}
userScope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if d.share == nil && err != nil {
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
var err error
userScope := "/"
if d.user.Username != "publicUser" {
userScope, err = settings.GetScopeFromSourceName(d.user.Scopes, source)
if d.share == nil && err != nil {
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
}
}
_, isDir, err := idx.GetRealPath(userScope, path)
realPath, isDir, err := idx.GetRealPath(userScope, path)
if err != nil {
return http.StatusInternalServerError, err
}
info, ok := idx.GetReducedMetadata(path, isDir)
indexPath := idx.MakeIndexPath(realPath)
info, ok := idx.GetReducedMetadata(indexPath, isDir)
if !ok {
return 0, fmt.Errorf("failed to get metadata info for %s", path)
}
@@ -314,52 +349,45 @@ func computeArchiveSize(fileList []string, d *requestContext) (int64, error) {
return estimatedSize, nil
}
func createZip(w io.Writer, d *requestContext, filenames ...string) error {
zipWriter := zip.NewWriter(w)
defer zipWriter.Close()
// Stream the response to avoid high memory usage
if f, ok := w.(http.Flusher); ok {
f.Flush()
func createZip(d *requestContext, tmpDirPath string, filenames ...string) error {
file, err := os.Create(tmpDirPath)
if err != nil {
return err
}
defer file.Close()
zipWriter := zip.NewWriter(file)
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 err
}
// Flush after adding each file
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
return nil
}
func createTarGz(w io.Writer, d *requestContext, filenames ...string) error {
gzWriter := gzip.NewWriter(w)
defer gzWriter.Close()
func createTarGz(d *requestContext, tmpDirPath string, filenames ...string) error {
file, err := os.Create(tmpDirPath)
if err != nil {
return err
}
defer file.Close()
gzWriter := gzip.NewWriter(file)
defer gzWriter.Close()
tarWriter := tar.NewWriter(gzWriter)
defer tarWriter.Close()
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// Check if we have exactly one directory
//flatten := len(filenames) == 1
for _, fname := range filenames {
err := addFile(fname, d, tarWriter, nil, false)
if err != nil {
logger.Error(fmt.Sprintf("Failed to add %s to TAR.GZ: %v", fname, err))
return err
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
return nil
+51 -106
View File
@@ -11,13 +11,14 @@ import (
"strconv"
"strings"
"github.com/shirou/gopsutil/v3/disk"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"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/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/preview"
)
// resourceGetHandler retrieves information about a resource.
@@ -31,7 +32,7 @@ import (
// @Param source query string false "Name for the desired source, default is used if not provided"
// @Param content query string false "Include file content if true"
// @Param checksum query string false "Optional checksum validation"
// @Success 200 {object} files.FileInfo "Resource metadata"
// @Success 200 {object} iteminfo.FileInfo "Resource metadata"
// @Failure 404 {object} map[string]string "Resource not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [get]
@@ -50,9 +51,10 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(userscope, path),
Modify: d.user.Perm.Modify,
scopePath := utils.JoinPathAsUnix(userscope, path)
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: scopePath,
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
Content: r.URL.Query().Get("content") == "true",
@@ -60,11 +62,17 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
if err != nil {
return errToStatus(err), err
}
if userscope != "/" {
fileInfo.Path = strings.TrimPrefix(fileInfo.Path, userscope)
}
if fileInfo.Path == "" {
fileInfo.Path = "/"
}
if fileInfo.Type == "directory" {
return renderJSON(w, r, fileInfo)
}
if algo := r.URL.Query().Get("checksum"); algo != "" {
idx := files.GetIndex(source)
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
@@ -114,10 +122,10 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(userscope, path),
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Perm.Modify,
Modify: d.user.Permissions.Modify,
Expand: false,
})
if err != nil {
@@ -125,7 +133,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
}
// delete thumbnails
delThumbs(r.Context(), fileCache, fileInfo)
preview.DelThumbs(r.Context(), fileInfo)
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath))
if err != nil {
@@ -141,9 +149,8 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
// @Tags Resources
// @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 path query string true "Destination path where to place the files inside the destination source, a directory must end in / to create a directory"
// @Param source query string false "Name for the desired filebrowser destination source name, default is used if not provided"
// @Param override query bool false "Override existing file if true"
// @Success 200 "Resource created successfully"
// @Failure 403 {object} map[string]string "Forbidden"
@@ -152,29 +159,22 @@ 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) {
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
}
if !d.user.Perm.Modify {
if !d.user.Permissions.Modify {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
fileOpts := files.FileOptions{
Path: filepath.Join(userscope, path),
fileOpts := iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Perm.Modify,
Modify: d.user.Permissions.Modify,
Expand: false,
}
// Directories creation on POST.
@@ -193,11 +193,11 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
// Permission for overwriting the file
if !d.user.Perm.Modify {
if !d.user.Permissions.Modify {
return http.StatusForbidden, nil
}
delThumbs(r.Context(), fileCache, fileInfo)
preview.DelThumbs(r.Context(), fileInfo)
}
err = files.WriteFile(fileOpts, r.Body)
if err != nil {
@@ -213,7 +213,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "Path to the resource"
// @Param path query string true "Destination path where to place the files inside the destination source"
// @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"
@@ -227,7 +227,7 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
if source == "" {
source = config.Server.DefaultSource.Name
}
if !d.user.Perm.Modify {
if !d.user.Permissions.Modify {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
encodedPath := r.URL.Query().Get("path")
@@ -245,10 +245,10 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
if err != nil {
return http.StatusForbidden, err
}
fileOpts := files.FileOptions{
Path: filepath.Join(userscope, path),
fileOpts := iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Perm.Modify,
Modify: d.user.Permissions.Modify,
Expand: false,
}
err = files.WriteFile(fileOpts, r.Body)
@@ -261,8 +261,7 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// @Tags Resources
// @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 from query string true "Path from resource in <source_name>::<index_path> format"
// @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"
@@ -274,9 +273,8 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [patch]
func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// TODO source := r.URL.Query().Get("source")
action := r.URL.Query().Get("action")
if !d.user.Perm.Modify {
if !d.user.Permissions.Modify {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
@@ -319,7 +317,7 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
return http.StatusForbidden, err
}
idx := files.GetIndex(dstIndex)
idx := indexing.GetIndex(dstIndex)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", dstIndex)
}
@@ -331,7 +329,7 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
}
realDest := parentDir + "/" + filepath.Base(dst)
idx2 := files.GetIndex(srcIndex)
idx2 := indexing.GetIndex(srcIndex)
if idx2 == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", srcIndex)
}
@@ -346,10 +344,10 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
realDest = addVersionSuffix(realDest)
}
// Permission for overwriting the file
if overwrite && !d.user.Perm.Modify {
if overwrite && !d.user.Permissions.Modify {
return http.StatusForbidden, fmt.Errorf("forbidden: user does not have permission to overwrite file")
}
err = patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir, srcIndex, dstIndex)
err = patchAction(r.Context(), action, realSrc, realDest, d, isSrcDir, srcIndex, dstIndex)
if err != nil {
logger.Debug(fmt.Sprintf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err))
}
@@ -372,27 +370,19 @@ func addVersionSuffix(source string) string {
return source
}
func delThumbs(ctx context.Context, fileCache FileCache, file files.ExtendedFileInfo) {
err := fileCache.Delete(ctx, previewCacheKey(file.RealPath, "small", file.FileInfo.ModTime))
if err != nil {
logger.Debug(fmt.Sprintf("Could not delete small thumbnail: %v", err))
}
}
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, fileCache FileCache, isSrcDir bool, srcIndex, destIndex string) error {
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, isSrcDir bool, srcIndex, destIndex string) error {
switch action {
case "copy":
err := files.CopyResource(srcIndex, destIndex, src, dst)
return err
case "rename", "move":
idx := files.GetIndex(srcIndex)
idx := indexing.GetIndex(srcIndex)
srcPath := idx.MakeIndexPath(src)
fmt.Println("srcPath", srcPath)
fileInfo, err := files.FileInfoFaster(files.FileOptions{
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: srcPath,
Source: srcIndex,
IsDir: isSrcDir,
Modify: d.user.Perm.Modify,
Modify: d.user.Permissions.Modify,
Expand: false,
ReadHeader: false,
})
@@ -402,58 +392,13 @@ func patchAction(ctx context.Context, action, src, dst string, d *requestContext
}
// delete thumbnails
delThumbs(ctx, fileCache, fileInfo)
preview.DelThumbs(ctx, fileInfo)
return files.MoveResource(srcIndex, destIndex, src, dst)
default:
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
}
}
type DiskUsageResponse struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
}
// diskUsage returns the disk usage information for a given directory.
// @Summary Get disk usage
// @Description Returns the total and used disk space for a specified directory.
// @Tags Resources
// @Accept json
// @Produce json
// @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"
// @Router /api/usage [get]
func diskUsage(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sourceName := r.URL.Query().Get("source")
if sourceName == "" {
sourceName = config.Server.DefaultSource.Name
}
value, ok := cache.DiskUsage.Get(sourceName).(DiskUsageResponse)
if ok {
return renderJSON(w, r, &value)
}
source, ok := config.Server.NameToSource[sourceName]
if !ok {
return 403, fmt.Errorf("source '%s' either does not exist or user does not have permission to it", sourceName)
}
_, err := settings.GetScopeFromSourceName(d.user.Scopes, sourceName)
if err != nil {
return 403, fmt.Errorf("user doesn't have access to '%s', contact your administrator for access", sourceName)
}
usage, err := disk.UsageWithContext(r.Context(), source.Path)
if err != nil {
return errToStatus(err), err
}
latestUsage := DiskUsageResponse{
Total: usage.Total,
Used: usage.Used,
}
cache.DiskUsage.Set(sourceName, latestUsage)
return renderJSON(w, r, &latestUsage)
}
func inspectIndex(w http.ResponseWriter, r *http.Request) {
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
@@ -463,7 +408,7 @@ func inspectIndex(w http.ResponseWriter, r *http.Request) {
// Decode the URL-encoded path
path, _ := url.QueryUnescape(encodedPath)
isNotDir := r.URL.Query().Get("isDir") == "false" // default to isDir true
index := files.GetIndex(source)
index := indexing.GetIndex(source)
if index == nil {
http.Error(w, "source not found", http.StatusNotFound)
return
@@ -480,6 +425,6 @@ func mockData(w http.ResponseWriter, r *http.Request) {
if err != nil || err2 != nil {
return
}
mockDir := files.CreateMockData(NumDirs, numFiles)
mockDir := utils.CreateMockData(NumDirs, numFiles)
renderJSON(w, r, mockDir) // nolint:errcheck
}
+12 -4
View File
@@ -7,8 +7,8 @@ import (
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
// searchHandler handles search requests for files based on the provided query.
@@ -52,7 +52,7 @@ 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} indexing.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) {
@@ -69,7 +69,7 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
searchScope := strings.TrimPrefix(unencodedScope, ".")
// Retrieve the User-Agent and X-Auth headers from the request
sessionId := r.Header.Get("SessionId")
index := files.GetIndex(source)
index := indexing.GetIndex(source)
if index == nil {
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", source)
}
@@ -80,6 +80,14 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScope))
// Perform the search using the provided query and user scope
response := index.Search(query, combinedPath, sessionId)
for i := range response {
// Remove the user scope from the path
response[i].Path = strings.TrimPrefix(response[i].Path, userscope)
if response[i].Path == "" {
response[i].Path = "/"
}
}
// trim user scope from each result path
// Set the Content-Type header to application/json
w.Header().Set("Content-Type", "application/json")
return renderJSON(w, r, response)
+2 -39
View File
@@ -1,28 +1,17 @@
package http
import (
"encoding/json"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
type settingsData struct {
Signup bool `json:"signup"`
CreateUserDir bool `json:"createUserDir"`
UserHomeBasePath string `json:"userHomeBasePath"`
Defaults settings.UserDefaults `json:"defaults"`
Frontend settings.Frontend `json:"frontend"`
}
// settingsGetHandler retrieves the current system settings.
// @Summary Get system settings
// @Description Returns the current configuration settings for signup, user directories, rules, frontend.
// @Tags Settings
// @Accept json
// @Produce json
// @Param property query string false "Property to retrieve"
// @Success 200 {object} settingsData "System settings data"
// @Param property query string false "Property to retrieve: `userDefaults`, `frontend`, `auth`, `server`, `sources`"
// @Success 200 {object} settings.Settings "System settings data"
// @Router /api/settings [get]
func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
property := r.URL.Query().Get("property")
@@ -45,29 +34,3 @@ func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
}
return renderJSON(w, r, config)
}
// settingsPutHandler updates the system settings.
// @Summary Update system settings
// @Description Updates the system configuration settings for signup, user directories, rules, frontend.
// @Tags Settings
// @Accept json
// @Produce json
// @Param body body settingsData true "Settings data to update"
// @Success 200 "Settings updated successfully"
// @Failure 400 {object} map[string]string "Bad request - failed to decode body"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/settings [put]
func settingsPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
req := &settingsData{}
err := json.NewDecoder(r.Body).Decode(req)
if err != nil {
return http.StatusBadRequest, err
}
config.Server.UserHomeBasePath = req.UserHomeBasePath
config.UserDefaults = req.Defaults
config.Frontend = req.Frontend
config.Auth.Signup = req.Signup
err = store.Settings.Save(config)
return errToStatus(err), err
}
+30 -13
View File
@@ -6,15 +6,16 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
)
// shareListHandler returns a list of all share links.
@@ -31,7 +32,7 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
s []*share.Link
err error
)
if d.user.Perm.Admin {
if d.user.Permissions.Admin {
s, err = store.Share.All()
} else {
s, err = store.Share.FindByUserID(d.user.ID)
@@ -39,7 +40,6 @@ func shareListHandler(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
}
@@ -64,13 +64,23 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// @Success 200 {array} share.Link "List of share links for the specified path"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/share [get]
func shareGetsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
}
s, err := store.Share.Gets(path, source, d.user.ID)
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
sourcePath, ok := config.Server.NameToSource[source]
if !ok {
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", source)
}
s, err := store.Share.Gets(path, sourcePath.Path, d.user.ID)
if err == errors.ErrNotExist {
return renderJSON(w, r, []*share.Link{})
}
@@ -173,15 +183,22 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
token = base64.URLEncoding.EncodeToString(tokenBuffer)
stringHash = string(hash)
}
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
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)
}
sourceName := r.URL.Query().Get("source")
if sourceName == "" {
sourceName = config.Server.DefaultSource.Name
}
source := config.Server.NameToSource[sourceName]
s = &share.Link{
Path: path,
Hash: secure_hash,
Source: source,
Source: source.Path, // path instead to persist accoss name change
Expire: expire,
UserID: d.user.ID,
PasswordHash: stringHash,
Loaded 100 of 252 files, more files were not shown because too many files have changed in this diff. Show more