Compare commits

...
20 Commits
Author SHA1 Message Date
Graham Steffaniak c168599c91 beta/v0.4.1 release (#307) 2025-01-25 18:31:40 -06:00
Graham Steffaniak a30bfcf7d0 Beta/v0.4.0 release (#297) 2025-01-21 08:02:43 -06:00
Graham Steffaniak b273f44cac Update .goreleaser.yaml (#295) 2025-01-20 21:05:12 -05:00
Graham Steffaniak f9a31f08c3 updated go-releaser (#294) 2025-01-20 17:31:38 -06:00
Graham Steffaniak 09493392db Fix user creation issue (#291) 2025-01-13 19:30:51 -06:00
Graham Steffaniak 7e68db9858 Update .goreleaser.yaml 2025-01-12 20:12:27 -05:00
Graham Steffaniak 34f6bea461 Update main.yaml 2025-01-12 18:55:13 -06:00
Graham Steffaniak 1e35473998 beta v0.3.7 release (#286) 2025-01-12 18:50:22 -06:00
Graham bd4d108b58 updated workflow 2025-01-12 18:30:40 -06:00
Graham Steffaniak 288ea8f59b Update .goreleaser.yaml (#281) 2025-01-08 20:12:53 -06:00
Graham Steffaniak f18c388c88 updated tag match 2025-01-08 19:29:43 -06:00
Graham Steffaniak e922a92aa6 Merge branch 'main' of github.com:gtsteffaniak/filebrowser 2025-01-08 19:22:45 -06:00
Graham Steffaniak 237769fa9e udpated workflows 2025-01-08 19:22:02 -06:00
Graham Steffaniak 9a33e7b379 Update README.md
updated readme
2025-01-08 19:08:17 -06:00
Graham Steffaniak cd3e111e39 Beta/v0.3.6 release (#279) 2025-01-08 19:02:57 -06:00
Graham Steffaniak 9cc30f25e4 fix user creation condition (#275) 2025-01-06 14:57:31 -06:00
Graham Steffaniak 7ab400eb7f updated root 2025-01-06 09:59:34 -06:00
Graham Steffaniak 4a4eb7ce31 Windows compatibility (#271) 2025-01-06 08:43:37 -06:00
Graham Steffaniak f864790175 Updated feature matrix 2025-01-05 13:07:16 -06:00
Graham Steffaniak 6494ca1991 v0.3.5 (#269) 2025-01-05 13:05:33 -06:00
175 changed files with 3688 additions and 3468 deletions

No files matched your search

+27 -2
View File
@@ -6,7 +6,32 @@ on:
- "main"
jobs:
test_frontend:
name: Test Playwright
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go build -o filebrowser .
- name: Build
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.playwright
push: false
push_latest_to_registry:
needs: [ test_frontend ]
name: Push latest
runs-on: ubuntu-latest
steps:
@@ -14,9 +39,9 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Find latest tag
- name: Find latest beta tag
run: |
echo "LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`)" >> $GITHUB_ENV
echo "LATEST_TAG=$(git describe --tags $(git rev-list --tags="*-beta*" --max-count=1))" >> $GITHUB_ENV
echo "latest tag is $LATEST_TAG"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
+28 -19
View File
@@ -1,28 +1,37 @@
name: pr-request
name: PR Request
on:
pull_request:
branches:
- "main"
- "v[0-9]+.[0-9]+.[0-9]+"
- "dev/v[0-9]+.[0-9]+.[0-9]+"
- "beta/v[0-9]+.[0-9]+.[0-9]+"
- "stable/v[0-9]+.[0-9]+.[0-9]+"
jobs:
#test_frontend:
# name: Push release
# runs-on: ubuntu-latest
# steps:
# - name: Checkout
# uses: actions/checkout@v4
# - name: Set up QEMU
# uses: docker/setup-qemu-action@v3.0.0
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@v3.0.0
# - name: Build
# uses: docker/build-push-action@v6
# with:
# context: .
# file: ./Dockerfile.playwright
# push: false
test_frontend:
name: Test Playwright
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go build -o filebrowser .
- name: Build
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.playwright
push: false
push_pr_to_registry:
name: Push PR
runs-on: ubuntu-latest
+3 -3
View File
@@ -1,9 +1,9 @@
name: dev tests
name: regular tests
on:
push:
branches:
- "*"
- "**"
jobs:
test-backend:
@@ -21,7 +21,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23.3'
go-version: 'stable'
- uses: golangci/golangci-lint-action@v5
with:
version: v1.60
+107
View File
@@ -0,0 +1,107 @@
name: beta release
on:
push:
branches:
- "beta/v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
test_frontend:
name: Test Playwright
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go build -o filebrowser .
- name: Build
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.playwright
push: false
create_release_tag:
needs: [ test_frontend ]
name: Create Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
- name: Extract branch name
shell: bash
run: |
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
id: extract_branch
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ steps.extract_branch.outputs.branch_name }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
prerelease: false # change this to false when stable gets released
make_latest: true # change this to false when stable gets released
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.tag_name }}
push_release_to_registry:
needs: [ test_frontend ]
name: Push release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: gtstef/filebrowser
- name: Strip v from version number
id: modify-json
run: |
JSON="${{ steps.meta.outputs.tags }}"
# Use jq to remove 'v' from the version field
JSON=$(echo "$JSON" | sed 's/filebrowser:beta\/v/filebrowser:beta_v/')
echo "cleaned_tag=$JSON" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
build-args: |
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
file: ./Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+2 -2
View File
@@ -3,7 +3,7 @@ name: dev release
on:
push:
branches:
- "dev_v[0-9]+.[0-9]+.[0-9]+"
- "dev/v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
@@ -34,7 +34,7 @@ jobs:
run: |
JSON="${{ steps.meta.outputs.tags }}"
# Use jq to remove 'v' from the version field
JSON=$(echo "$JSON" | sed 's/filebrowser:v/filebrowser:/')
JSON=$(echo "$JSON" | sed 's/filebrowser:dev\/v/filebrowser:dev_v/')
echo "cleaned_tag=$JSON" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
@@ -1,15 +1,40 @@
name: version release
name: stable release
on:
push:
branches:
- "v[0-9]+.[0-9]+.[0-9]+"
- "stable/v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
create_release:
test_frontend:
name: Test Playwright
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go build -o filebrowser .
- name: Build
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.playwright
push: false
create_release_tag:
needs: [ test_frontend ]
name: Create Release
runs-on: ubuntu-latest
steps:
@@ -19,23 +44,28 @@ jobs:
token: ${{ secrets.PAT }}
- name: Extract branch name
shell: bash
run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT
run: |
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-stable
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
id: extract_branch
- uses: actions/setup-go@v5
with:
go-version: 1.22.2
go-version: 'stable'
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ steps.extract_branch.outputs.branch }}
target_commitish: ${{ steps.extract_branch.outputs.branch_name }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_branch.outputs.branch }}
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
prerelease: false
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.branch }}
name: ${{ steps.extract_branch.outputs.tag_name }}
push_release_to_registry:
needs: [ test_frontend ]
name: Push release
runs-on: ubuntu-latest
steps:
@@ -60,7 +90,7 @@ jobs:
run: |
JSON="${{ steps.meta.outputs.tags }}"
# Use jq to remove 'v' from the version field
JSON=$(echo "$JSON" | sed 's/filebrowser:v/filebrowser:/')
JSON=$(echo "$JSON" | sed 's/filebrowser:stable\/v/filebrowser:/')
echo "cleaned_tag=$JSON" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
+2 -2
View File
@@ -1,9 +1,9 @@
name: tag
name: tag update
on:
push:
tags:
- "v*"
- "v[0-9]+.[0-9]+.[0-9]+*"
permissions:
contents: write
+8 -2
View File
@@ -1,19 +1,25 @@
*.db
*.bak
*.log
*.mjs
_old
rice-box.go
.idea/
/filebrowser
/filebrowser.exe
/backend/backend
/backend/filebrowser
/backend/filebrowser.exe
/backend/backend.exe
/frontend/dist
/frontend/pkg
/frontend/test-results
/frontend/loginAuth.json
/frontend/package-lock.json
/backend/vendor
/backend/*.cov
/backend/test_config.yaml
/backend/srv
/backend/http/dist
/backend/http/embed/*
.DS_Store
node_modules
+96 -1
View File
@@ -2,12 +2,107 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v0.4.1-beta
**New Features**
- right-click actions are available on search. https://github.com/gtsteffaniak/filebrowser/issues/273
**Notes**
- delete prompt now lists all items that will be affected by delete
- Debug and logger output tweaks.
**Bugfixes**:
- calculating checksums errors.
- copy/move issues for some circumstances.
- The previous position wasn't returned when closing a preview window https://github.com/gtsteffaniak/filebrowser/issues/298
- fixed sources configuration mapping error (advanced `server.sources` config)
## v0.4.0-beta
**New Features**
- Better logging https://github.com/gtsteffaniak/filebrowser/issues/288
- highly configurable
- api logs include user
- onlyOffice support for editing only office files (inspired from https://github.com/filebrowser/filebrowser/pull/2954)
**Notes**
- Breadcrumbs will only show on file listing (not on previews or editors)
- Config file is now optional. It will run with default settings without one and throw a `[WARN ]` message.
- Added more descriptions to swagger API
## v0.3.7-beta
**Notes**:
- Adding windows builds back to automated process... will replace manually if they throw malicious defender warnings.
- Adding playwright tests to all pr's against dev/beta/release branches.
- These playwright tests should help keep release more reliably stable.
**Bugfixes**:
- closing with the default bar issue.
- tar.gz archive creation issue
## v0.3.6-beta
**New Features**
- Adds "externalUrl" server config https://github.com/gtsteffaniak/filebrowser/issues/272
**Notes**:
- All views modes to show header bar for sorting.
- other small style changes
**Bugfixes**:
- select and info bug after sorting https://github.com/gtsteffaniak/filebrowser/issues/277
- downloading from shares with public user
- Ctrl and Shift key modifiers work on listing views as expected.
- copy/move file/folder error and show errors https://github.com/gtsteffaniak/filebrowser/issues/278
- file move/copy context fix.
## v0.3.5
**New Features**
- More indexing configuration options possible. However consider waiting on using this feature, because I will soon have a full onboarding experience in the UI to manage sources instead.
- added config file options "sources" in the server config.
- can enable/disable indexing a specified list of directories/files
- can enable/disable indexing hidden files
- prepped for multiple sources (not supported yet!)
- Theme and Branding support (see updates to [configuration wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration) on how to use)
- Automatically expire shares https://github.com/gtsteffaniak/filebrowser/issues/208
**Notes**:
- MacOS application files (ending in ".app") were previously treated as folders, now they are treated as a single file.
- No longer indexes "$RECYCLE.BIN" or "System Volume Information" directories.
- Icon styling tweaked so all icons have a background.
- Updated Login page styling.
- Settings profile menu has been simplified, password changes happen in user management.
- Improved windows compatibility and built on windows platform to fix false windows defender warning.
- If no "root" location is provided in the server config, the default is the **current directory** (rather than `/srv` like before)
**Bugfixes**:
- Fixed setting share expiration time would not work due to type conversion error.
- More safari fixes related to text-selection.
- Sort by name value sorting ignores the extension, only sorts by name https://github.com/gtsteffaniak/filebrowser/issues/230
- Fixed manual language selection issue.
- Fixed exact date time issue.
New login page:
<img width="300" alt="image" src="https://github.com/user-attachments/assets/d3ed359e-a969-4f6a-9f72-94d2b68aba49" />
Example branding in sidebar:
<img width="500" alt="image2" src="https://github.com/user-attachments/assets/d8ee14ca-4495-4106-9d26-631a5937e134" />
Example user settings page:
<img width="500" alt="image3" src="https://github.com/user-attachments/assets/79757a11-669e-4597-bd3d-e41efd667a1e" />
## v0.3.4
**Bugfixes**:
- Safari right-click actions.
- Some small image viewer behavior
- Progressive webapp "install to homescreen" fix.
- Progressive webapp "install to homescreen" fix.
## v0.3.3
+2 -2
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/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/version.CommitSHA=${REVISION}'" \
-X 'github.com/gtsteffaniak/filebrowser/backend/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM node:slim AS nbuild
+5 -18
View File
@@ -1,19 +1,6 @@
FROM golang:1.23-alpine AS base
WORKDIR /app
COPY ./backend ./
RUN go build -ldflags="-w -s" -o filebrowser .
FROM node:slim
WORKDIR /app
COPY ./frontend/package.json ./
RUN npm i --maxsockets 1
RUN npx playwright install --with-deps firefox
COPY [ "backend/config.yaml", "./" ]
COPY ./frontend/ ./frontend
FROM gtstef/playwright-base
WORKDIR /app/frontend
RUN npm run build-docker
WORKDIR /app
COPY --from=base /app/filebrowser* ./
RUN cp -R frontend/tests/ srv
ENV FILEBROWSER_NO_EMBEDED="true"
RUN ./filebrowser & sleep 2 && cd frontend && npx playwright test
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
RUN ./filebrowser -c filebrowser-playwright.yaml & sleep 2 && cd ../frontend && npx playwright test
+4
View File
@@ -0,0 +1,4 @@
FROM node:22-slim
WORKDIR /app/frontend
RUN npm i @playwright/test
RUN npx playwright install --with-deps firefox
+41 -133
View File
@@ -6,56 +6,51 @@
</p>
<h3 align="center">FileBrowser Quantum - A modern web-based file manager</h3>
<p align="center">
<img width="800" src="https://github.com/user-attachments/assets/e4a47229-66f8-4838-9575-dd2413596688" title="Main Screenshot">
<img width="800" src="https://github.com/user-attachments/assets/b16acd67-0292-437a-a06c-bc83f95758e6" title="Main Screenshot">
</p>
> [!Note]
> Starting with v0.3.3, configuration file mapping is different to support non-root user. Now, the default config file name is `config.yaml` and in docker the path is `/home/filebrowser/config.yaml` and `/home/filebrowser/<database_file>`. Please read the usage below to properly update your config to point the new config location. (open an issue for any help needed)
> [!WARNING]
> - There is no stable version yet. Always check release notes for bug fixes on functionality that may have been changed. If you notice any unexpected behavior -- please open an issue to have it fixed soon.
> There is no stable version yet.
> (planned for later this year after these are complete: multiple sources support, initial onboarding page, official automated docs website)
FileBrowser Quantum is a fork of the file browser opensource project with the following changes:
FileBrowser Quantum is a fork of the file browser open-source project with the following changes:
1. [x] Indexes files efficiently. (See [indexing readme](./docs/indexing.md) for more info.)
- Real-time search results as you type!
1. ✅ Indexes files efficiently. (See [indexing Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.)
- Real-time search results as you type
- Search supports file/folder sizes and many file type filters.
- Enhanced interactive results that show file/folder sizes.
2. [x] Revamped and simplified GUI navbar and sidebar menu.
- Additional compact view mode as well as refreshed view mode
styles.
2. ✅ Revamped and simplified GUI navbar and sidebar menu.
- Additional compact view mode as well as refreshed view mode styles.
- Many graphical and user experience improvements.
- right-click context menu
3. [x] Revamped and simplified configuration via `config.yaml` config file.
4. [x] Better listing browsing
- Switching view modes is instant
- Folder sizes are shown as well
- Changing Sort order is instant
- The entire directory is loaded in 1/3 the time
5. [x] Developer API support
3. ✅ Revamped and simplified configuration via `config.yaml` config file.
4. ✅ Better listing browsing
- Instantly Switches view modes and sort order without reloading data.
- Folder sizes are displayed
- Navigating remembers the scroll position, navigating back keeps the last scroll position.
5. ✅ Developer API support
- Ability to create long-live API Tokens.
- Helpful Swagger page available at `/swagger` endpoint.
Notable features that this fork *does not* have (removed):
- jobs/runners are not supported yet (planned).
- shell commands are completely removed and will not be returned.
- Themes and branding are not fully supported yet (planned).
- ❌ jobs/runners are not supported yet (planned).
- ❌ per-user rules are not supported yet (planned).
- ❌ pagination for directory items for extremely large directories.
- ❌ shell commands are completely removed and will not be returned.
- see feature matrix below for more.
- pagination for directory items, so large directories with more than 100,000 items may be slow to load or not load at all.
## About
FileBrowser Quantum provides a file-managing interface within a specified directory
and can be used to upload, delete, preview, rename, and edit your files.
It allows the creation of multiple users and each user can have its
directory.
It allows the creation of multiple users and each user can have its directory.
This repository is a fork of the original [filebrowser](https://github.com/filebrowser/filebrowser)
with a collection of changes that make this program work better in terms of
aesthetics and performance. Improved search, simplified UI
(without removing features) and more secure and up-to-date
build are just a few examples.
This repository is a fork of the original [filebrowser](https://github.com/filebrowser/filebrowser)
with a collection of changes that make this program work better in terms of
aesthetics and performance. Improved search, simplified UI
(without removing features) and more secure and up-to-date
build are just a few examples.
FileBrowser Quantum differs significantly from the original.
There are hundreds of thousands of lines changed and they are generally
@@ -66,7 +61,7 @@ focus of this fork is on a few key principles:
- Minimize external dependencies and standard library usage.
- Of course -- adding much-needed features.
For more questions, see the [Q&A Readme](./docs/questions.md)
For more, see the [Q&A Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A)
## Look
@@ -84,117 +79,32 @@ a popup menu.
<p align="center">
<img width="800" src="https://github.com/user-attachments/assets/2be7a6c5-0f95-4d9f-bc05-484ee71246d8" title="Search GIF">
<img width="800" src="https://github.com/user-attachments/assets/f55a6f1f-b930-4399-98b5-94da6e90527a" title="Navigation GIF">
<img width="800" src="https://github.com/user-attachments/assets/75226dc4-9802-46f0-9e3c-e4403d3275da" title="Main Screenshot">
<img width="800" src="https://github.com/user-attachments/assets/93b019de-d38f-4aaa-bde3-3ba4e99ecd25" title="Main Screenshot">
</p>
## Install
## Install and Configuration
Using docker:
1. docker run (no persistent db):
```
docker run -it -v /path/to/folder:/srv -v $(pwd)/config.yaml:/home/filebrowser/config.yaml -p 80:80 gtstef/filebrowser
```
or optionally, as non-root filebrowser user:
```
docker run -u filebrowser -it -v $(pwd)/config.yaml:/home/filebrowser/config.yaml -v /path/to/folder:/srv -p 80:80 gtstef/filebrowser
```
1. docker compose:
- with local storage
```
services:
filebrowser:
volumes:
- '/path/to/folder:/srv' # required (for now not configurable)
# optional if you want db to persist - configure a path under "database" dir in config file.
- './database:/home/filebrowser/database'
- './config.yaml:/home/filebrowser/config.yaml'
ports:
- '80:80'
image: gtstef/filebrowser
# optionally run as non-root filebrowser user
#user: filebrowser
restart: always
```
- with network share
```
services:
filebrowser:
volumes:
- 'storage:/srv' # required (for now not configurable)
# optional if you want db to persist - configure a path under "database" dir in config file.
- './database:/home/filebrowser/database'
- './config.yaml:/home/filebrowser/config.yaml'
ports:
- '80:80'
image: gtstef/filebrowser
restart: always
volumes:
storage:
driver_opts:
type: cifs
o: "username=admin,password=password,rw" # enter valid info here
device: "//192.168.1.100/share/" # enter valid info here
```
Not using docker (not recommended), download your binary from releases and run with your custom config file:
```
./filebrowser -c <config.yaml or other /path/to/config.yaml>
```
See the [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration)
## Command Line Usage
There are very few commands available. There are 3 actions done via the command line:
1. Running the program, as shown in the install step. The only argument used is the config file if you choose to override the default "config.yaml"
2. Checking the version info via `./filebrowser version`
3. Updating the DB, which currently only supports adding users via `./filebrowser set -u username,password [-a] [-s "example/scope"]`
See the [CLI Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/CLI)
## API Usage
API tokens can be created to perform actions, access file information, and update user settings just like what can be done from the UI. You can create API tokens from the settings page via "API Management" section. This section will only show up if the user has "API" permissions, which can be granted by editing the user in user management.
Regardless of whether a user has API permissions, anyone can visit the swagger page which is found at `/swagger`. This swagger page uses a short-live token (2-hour exp) that the UI uses, but allows for quick access to all the API's and their described usage and requirements:
![image](https://github.com/user-attachments/assets/12abd1f6-21d3-4437-98ed-9b0da6cf2c73)
When using the API outside of swagger, you will need to set the API token as a bearer token authentication type. This means the authorization header will look like `Authorization: Bearer <token>`. For example in Postman:
Successful Request:
<p align="center"><img width="500" alt="image" src="https://github.com/user-attachments/assets/4f18fa8a-8d87-4f40-9dc7-3d4407769b59"></p>
Failed Request
<p align="center"><img width="500" alt="image" src="https://github.com/user-attachments/assets/4da0deae-f93d-4d94-83b1-68806afb343a"></p>
See the [API Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/API)
## Configuration
All configuration is now done via a single configuration file:
`config.yaml`, here is an example of minimal [configuration
file](./backend/config.yaml).
Configuration is done via the `config.yaml`, see the [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration) for available configuration options and other help.
View the [Configuration Help Page](./docs/configuration.md) for available
configuration options and other help.
## Office File Support
See [Office Support Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Office-Support#adding-open-office-integration-for-docker) on how to enable office file editing.
## Migration from the original filebrowser
I would recommend that you start fresh without reusing the database. However,
If you want to migrate your existing database to FileBrowser Quantum,
visit the [migration
readme](./docs/migration.md)
See the [Migration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Migration)
## Comparison Chart
@@ -208,7 +118,7 @@ Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
ftp 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 |
@@ -235,18 +145,16 @@ Event-based notifications | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Metrics | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
file space quotas | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
office file support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
Branding support | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
Branding support | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
activity log | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Comments support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
collaboration on same file | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
trash support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Starred/pinned files | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
Content preview icons | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ |
Plugins support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
## Roadmap
see [Roadmap Page](./docs/roadmap.md)
Share collections of files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅
+32 -15
View File
@@ -6,36 +6,53 @@ builds:
# Build configuration for darwin and linux
- id: default
ldflags: &ldflags
- -s -w -X github.com/gtsteffaniak/filebrowser/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/version.CommitSHA={{ .ShortCommit }}
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA={{ .ShortCommit }}
main: main.go
binary: filebrowser
goos:
- darwin
- linux
goarch:
- amd64
- arm
- arm64
goarm:
- 6
- 7
- "6"
- "7"
hooks:
post:
post:
- upx {{ .Path }} # Compress the binary with UPX
# Build configuration for windows without arm
# - id: windows
# ldflags: *ldflags
# main: main.go
# binary: filebrowser
# goos:
# - windows
# goarch:
# - amd64
# - arm64
- id: windows
ldflags: *ldflags
main: main.go
binary: filebrowser
goos:
- windows
goarch:
- amd64
hooks:
post:
- upx {{ .Path }} # Compress the binary with UPX
# Build configuration for windows without arm & upx
- id: macos
ldflags: *ldflags
main: main.go
binary: filebrowser
goos:
- darwin
goarch:
- amd64
- arm64
archives:
- name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
- name_template: >
{{- if eq .Os "windows" -}}
{{.ProjectName}}
{{- else -}}
{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{.ProjectName}}
{{- end -}}
format: binary
checksum:
+2 -2
View File
@@ -3,13 +3,13 @@ package auth
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
@@ -164,7 +164,7 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
return nil, fmt.Errorf("user: failed to mkdir user home dir: [%s]", userHome)
}
u.Scope = userHome
log.Printf("user: %s, home dir: [%s].", u.Username, userHome)
logger.Debug(fmt.Sprintf("user: %s, home dir: [%s].", u.Username, userHome))
err = a.Users.Save(u)
if err != nil {
+2 -3
View File
@@ -7,7 +7,7 @@ import (
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
@@ -24,7 +24,6 @@ type JSONAuth struct {
// Auth authenticates the user via a json in content body.
func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
config := &settings.Config
var cred jsonCred
if r.Body == nil {
@@ -47,7 +46,7 @@ func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User,
return nil, os.ErrPermission
}
}
u, err := userStore.Get(config.Server.Root, cred.Username)
u, err := userStore.Get(files.RootPaths["default"], cred.Username)
if err != nil || !users.CheckPwd(cred.Password, u.Password) {
return nil, os.ErrPermission
}
+2 -2
View File
@@ -3,7 +3,7 @@ package auth
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
@@ -15,7 +15,7 @@ type NoAuth struct{}
// Auth uses authenticates user 1.
func (a NoAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
return usr.Get(settings.Config.Server.Root, uint(1))
return usr.Get(files.RootPaths["default"], uint(1))
}
// LoginPage tells that no auth doesn't require a login page.
+2 -3
View File
@@ -4,9 +4,8 @@ import (
"net/http"
"os"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
@@ -21,7 +20,7 @@ type ProxyAuth struct {
// Auth authenticates the user via an HTTP header.
func (a ProxyAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
username := r.Header.Get(a.Header)
user, err := usr.Get(settings.Config.Server.Root, username)
user, err := usr.Get(files.RootPaths["default"], username)
if err == errors.ErrNotExist {
return nil, os.ErrPermission
}
+51 -28
View File
@@ -1,43 +1,66 @@
== Running benchmark ==
/usr/local/go/bin/go
? github.com/gtsteffaniak/filebrowser [no test files]
? github.com/gtsteffaniak/filebrowser/auth [no test files]
? github.com/gtsteffaniak/filebrowser/cmd [no test files]
? github.com/gtsteffaniak/filebrowser/backend [no test files]
? github.com/gtsteffaniak/filebrowser/backend/auth [no test files]
? github.com/gtsteffaniak/filebrowser/backend/cmd [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/diskcache 0.004s
? github.com/gtsteffaniak/filebrowser/errors [no test files]
2024/10/07 12:46:34 could not update unknown type: unknown
ok github.com/gtsteffaniak/filebrowser/backend/diskcache 0.005s
? github.com/gtsteffaniak/filebrowser/backend/errors [no test files]
/home/graham/git/filebrowser /home/graham/git/filebrowser
/home/graham/git/filebrowser/files/file.go /home/graham/git/filebrowser
/home/graham/git/filebrowser/mnt/doesnt/exist /home/graham/git/filebrowser
2025/01/04 14:04:55 Initializing index and assessing file system complexity
2025/01/04 14:04:55 Starting full scan
2025/01/04 14:04:55 Index assessment : complexity=simple directories=0 files=0
2025/01/04 14:04:55 Time Spent Indexing : 0 seconds
2025/01/04 14:04:55 Next scan in 1m0s
2025/01/04 14:04:56 Initializing index and assessing file system complexity
2025/01/04 14:04:56 Starting full scan
2025/01/04 14:04:56 Index assessment : complexity=simple directories=0 files=0
2025/01/04 14:04:56 Time Spent Indexing : 0 seconds
2025/01/04 14:04:56 Next scan in 1m0s
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/files
pkg: github.com/gtsteffaniak/filebrowser/backend/files
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
BenchmarkFillIndex-8 10 3847878 ns/op 758424 B/op 5567 allocs/op
BenchmarkSearchAllIndexes-8 10 780431 ns/op 173444 B/op 2014 allocs/op
BenchmarkFillIndex-8 2025/01/04 14:04:57 Initializing index and assessing file system complexity
2025/01/04 14:04:57 Starting full scan
2025/01/04 14:04:57 Index assessment : complexity=simple directories=0 files=0
2025/01/04 14:04:57 Time Spent Indexing : 0 seconds
2025/01/04 14:04:57 Next scan in 1m0s
10 3515090 ns/op 34273 B/op 451 allocs/op
BenchmarkCheckIndexExclude-8 10 156.2 ns/op 0 B/op 0 allocs/op
BenchmarkCheckIndexConditionsInclude-8 10 98.00 ns/op 0 B/op 0 allocs/op
2025/01/04 14:04:58 Initializing index and assessing file system complexity
2025/01/04 14:04:58 Starting full scan
2025/01/04 14:04:58 Index assessment : complexity=simple directories=0 files=0
2025/01/04 14:04:58 Time Spent Indexing : 0 seconds
2025/01/04 14:04:58 Next scan in 1m0s
BenchmarkSearchAllIndexes-8 2025/01/04 14:04:59 Initializing index and assessing file system complexity
2025/01/04 14:04:59 Starting full scan
2025/01/04 14:04:59 Index assessment : complexity=simple directories=0 files=0
2025/01/04 14:04:59 Time Spent Indexing : 0 seconds
2025/01/04 14:04:59 Next scan in 1m0s
10 766822 ns/op 34230 B/op 900 allocs/op
PASS
ok github.com/gtsteffaniak/filebrowser/files 0.073s
ok github.com/gtsteffaniak/filebrowser/backend/files 5.094s
PASS
ok github.com/gtsteffaniak/filebrowser/fileutils 0.003s
2024/10/07 12:46:34 h: 401 <nil>
2024/10/07 12:46:34 h: 401 <nil>
2024/10/07 12:46:34 h: 401 <nil>
2024/10/07 12:46:34 h: 401 <nil>
2024/10/07 12:46:34 h: 401 <nil>
2024/10/07 12:46:34 h: 401 <nil>
ok github.com/gtsteffaniak/filebrowser/backend/fileutils 0.002s
PASS
ok github.com/gtsteffaniak/filebrowser/http 0.080s
ok github.com/gtsteffaniak/filebrowser/backend/http 0.184s
PASS
ok github.com/gtsteffaniak/filebrowser/img 0.137s
ok github.com/gtsteffaniak/filebrowser/backend/img 0.123s
PASS
ok github.com/gtsteffaniak/filebrowser/rules 0.002s
ok github.com/gtsteffaniak/filebrowser/backend/runner 0.004s
PASS
ok github.com/gtsteffaniak/filebrowser/runner 0.003s
ok github.com/gtsteffaniak/filebrowser/backend/settings 0.005s
? github.com/gtsteffaniak/filebrowser/backend/share [no test files]
? github.com/gtsteffaniak/filebrowser/backend/storage [no test files]
? github.com/gtsteffaniak/filebrowser/backend/storage/bolt [no test files]
? github.com/gtsteffaniak/filebrowser/backend/swagger/docs [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/settings 0.004s
? github.com/gtsteffaniak/filebrowser/share [no test files]
? github.com/gtsteffaniak/filebrowser/storage [no test files]
? github.com/gtsteffaniak/filebrowser/storage/bolt [no test files]
ok github.com/gtsteffaniak/filebrowser/backend/users 0.003s
PASS
ok github.com/gtsteffaniak/filebrowser/users 0.002s
? github.com/gtsteffaniak/filebrowser/utils [no test files]
? github.com/gtsteffaniak/filebrowser/version [no test files]
ok github.com/gtsteffaniak/filebrowser/backend/utils 0.002s
? github.com/gtsteffaniak/filebrowser/backend/version [no test files]
+12 -5
View File
@@ -1,4 +1,4 @@
package utils
package cache
import (
"sync"
@@ -6,12 +6,13 @@ import (
)
var (
DiskUsageCache = newCache(30*time.Second, 24*time.Hour)
RealPathCache = newCache(48*time.Hour, 72*time.Hour)
SearchResultsCache = newCache(15*time.Second, time.Hour)
DiskUsage = NewCache(30*time.Second, 24*time.Hour)
RealPath = NewCache(48*time.Hour, 72*time.Hour)
SearchResults = NewCache(15*time.Second, time.Hour)
OnlyOffice = NewCache(48*time.Hour, 1*time.Hour)
)
func newCache(expires time.Duration, cleanup time.Duration) *KeyCache {
func NewCache(expires time.Duration, cleanup time.Duration) *KeyCache {
newCache := KeyCache{
data: make(map[string]cachedValue),
expiresAfter: expires, // default
@@ -40,6 +41,12 @@ func (c *KeyCache) Set(key string, value interface{}) {
}
}
func (c *KeyCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, key)
}
func (c *KeyCache) SetWithExp(key string, value interface{}, exp time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
+39 -27
View File
@@ -3,7 +3,6 @@ package cmd
import (
"flag"
"fmt"
"log"
"os"
"strings"
@@ -11,6 +10,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/files"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/img"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
@@ -25,20 +25,22 @@ func getStore(config string) (*storage.Storage, bool) {
settings.Initialize(config)
store, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
if err != nil {
log.Fatal("could not load db info: ", err)
logger.Fatal(fmt.Sprintf("could not load db info: %v", err))
}
return store, hasDB
}
func generalUsage() {
fmt.Printf(`usage: ./html-web-crawler <command> [options] --urls <urls>
commands:
collect Collect data from URLs
crawl Crawl URLs and collect data
install Install chrome browser for javascript enabled scraping.
Note: Consider instead to install via native package manager,
then set "CHROME_EXECUTABLE" in the environment
` + "\n")
fmt.Printf(`usage: ./filebrowser <command> [options]
commands:
-h Print help
-c Print the default config file
version Print version information
set -u Username and password for the new user
set -a Create user as admin
set -s Specify a user scope
set -h Print this help message
`)
}
func StartFilebrowser() {
@@ -89,9 +91,9 @@ func StartFilebrowser() {
getStore(dbConfig)
// Create the user logic
if asAdmin {
log.Printf("Creating user as admin: %s\n", username)
logger.Info(fmt.Sprintf("Creating user as admin: %s\n", username))
} else {
log.Printf("Creating user: %s\n", username)
logger.Info(fmt.Sprintf("Creating non-admin user: %s\n", username))
}
newUser := users.User{
Username: username,
@@ -102,14 +104,15 @@ func StartFilebrowser() {
}
err = storage.CreateUser(newUser, asAdmin)
if err != nil {
log.Fatal("Could not create user: ", err)
logger.Fatal(fmt.Sprintf("could not create user: %v", err))
}
return
case "version":
fmt.Println("FileBrowser Quantum - A modern web-based file manager")
fmt.Printf("Version : %v\n", version.Version)
fmt.Printf("Commit : %v\n", version.CommitSHA)
fmt.Printf("Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v\n", version.Version)
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
Version : %v
Commit : %v
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
`, version.Version, version.CommitSHA, version.Version)
return
}
}
@@ -118,26 +121,35 @@ func StartFilebrowser() {
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
}
log.Printf("Initializing FileBrowser Quantum (%v)\n", version.Version)
log.Printf("Using Config file : %v", configPath)
log.Println("Embeded frontend :", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true")
log.Println(database)
log.Println("Sources :", settings.Config.Server.Root)
sources := []string{}
for _, v := range settings.Config.Server.Sources {
sources = append(sources, v.Name+": "+v.Path)
}
logger.Info(fmt.Sprintf("Initializing FileBrowser Quantum (%v)", version.Version))
logger.Info(fmt.Sprintf("Using Config file : %v", configPath))
logger.Debug(fmt.Sprintf("Embeded frontend : %v", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true"))
logger.Info(database)
logger.Info(fmt.Sprintf("Sources : %v", sources))
serverConfig := settings.Config.Server
swagInfo := docs.SwaggerInfo
swagInfo.BasePath = serverConfig.BaseURL
swag.Register(docs.SwaggerInfo.InstanceName(), swagInfo)
// initialize indexing and schedule indexing ever n minutes (default 5)
go files.InitializeIndex(serverConfig.Indexing)
sourceConfigs := settings.Config.Server.Sources
if len(sourceConfigs) == 0 {
logger.Fatal("No sources configured, exiting...")
}
for _, source := range sourceConfigs {
go files.Initialize(source)
}
if err := rootCMD(store, &serverConfig); err != nil {
log.Fatal("Error starting filebrowser:", err)
logger.Fatal(fmt.Sprintf("Error starting filebrowser: %v", err))
}
}
func rootCMD(store *storage.Storage, serverConfig *settings.Server) error {
if serverConfig.NumImageProcessors < 1 {
log.Fatal("Image resize workers count could not be < 1")
logger.Fatal("Image resize workers count could not be < 1")
}
imgSvc := img.New(serverConfig.NumImageProcessors)
@@ -149,7 +161,7 @@ func rootCMD(store *storage.Storage, serverConfig *settings.Server) error {
var err error
fileCache, err = diskcache.NewFileCache(cacheDir)
if err != nil {
log.Fatalf("failed to create file cache: %v", err)
logger.Fatal(fmt.Sprintf("failed to create file cache: %v", err))
}
} else {
// No-op cache if no cacheDir is specified
-68
View File
@@ -1,68 +0,0 @@
package cmd
import (
"strconv"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
rulesCmd.AddCommand(rulesRmCommand)
rulesRmCommand.Flags().Uint("index", 0, "index of rule to remove")
_ = rulesRmCommand.MarkFlagRequired("index")
}
var rulesRmCommand = &cobra.Command{
Use: "rm <index> [index_end]",
Short: "Remove a global rule or user rule",
Long: `Remove a global rule or user rule. The provided index
is the same that's printed when you run 'rules ls'. Note
that after each removal/addition, the index of the
commands change. So be careful when removing them after each
other.
You can also specify an optional parameter (index_end) so
you can remove all commands from 'index' to 'index_end',
including 'index_end'.`,
Args: func(cmd *cobra.Command, args []string) error {
if err := cobra.RangeArgs(1, 2)(cmd, args); err != nil { //nolint:gomnd
return err
}
for _, arg := range args {
if _, err := strconv.Atoi(arg); err != nil {
return err
}
}
return nil
},
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
i, err := strconv.Atoi(args[0])
utils.CheckErr("strconv.Atoi", err)
f := i
if len(args) == 2 { //nolint:gomnd
f, err = strconv.Atoi(args[1])
utils.CheckErr("strconv.Atoi", err)
}
user := func(u *users.User) {
u.Rules = append(u.Rules[:i], u.Rules[f+1:]...)
err := store.Users.Save(u)
utils.CheckErr("store.Users.Save", err)
}
global := func(s *settings.Settings) {
s.Rules = append(s.Rules[:i], s.Rules[f+1:]...)
err := store.Settings.Save(s)
utils.CheckErr("store.Settings.Save", err)
}
runRules(store, cmd, user, global)
}),
}
-86
View File
@@ -1,86 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
rulesCmd.PersistentFlags().StringP("username", "u", "", "username of user to which the rules apply")
rulesCmd.PersistentFlags().UintP("id", "i", 0, "id of user to which the rules apply")
}
var rulesCmd = &cobra.Command{
Use: "rules",
Short: "Rules management utility",
Long: `On each subcommand you'll have available at least two flags:
"username" and "id". You must either set only one of them
or none. If you set one of them, the command will apply to
an user, otherwise it will be applied to the global set or
rules.`,
Args: cobra.NoArgs,
}
func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User), globalFn func(*settings.Settings)) {
id := getUserIdentifier(cmd.Flags())
if id != nil {
user, err := st.Users.Get("", id)
utils.CheckErr("st.Users.Get", err)
if usersFn != nil {
usersFn(user)
}
printRules(user.Rules, id)
return
}
s, err := st.Settings.Get()
utils.CheckErr("st.Settings.Get", err)
if globalFn != nil {
globalFn(s)
}
printRules(s.Rules, id)
}
func getUserIdentifier(flags *pflag.FlagSet) interface{} {
id := mustGetUint(flags, "id")
username := mustGetString(flags, "username")
if id != 0 {
return id
} else if username != "" {
return username
}
return nil
}
func printRules(rulez []users.Rule, id interface{}) {
for id, rule := range rulez {
fmt.Printf("(%d) ", id)
if rule.Regex {
if rule.Allow {
fmt.Printf("Allow Regex: \t%s\n", rule.Regexp.Raw)
} else {
fmt.Printf("Disallow Regex: \t%s\n", rule.Regexp.Raw)
}
} else {
if rule.Allow {
fmt.Printf("Allow Path: \t%s\n", rule.Path)
} else {
fmt.Printf("Disallow Path: \t%s\n", rule.Path)
}
}
}
}
-59
View File
@@ -1,59 +0,0 @@
package cmd
import (
"regexp"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
rulesCmd.AddCommand(rulesAddCmd)
rulesAddCmd.Flags().BoolP("allow", "a", false, "indicates this is an allow rule")
rulesAddCmd.Flags().BoolP("regex", "r", false, "indicates this is a regex rule")
}
var rulesAddCmd = &cobra.Command{
Use: "add <path|expression>",
Short: "Add a global rule or user rule",
Long: `Add a global rule or user rule.`,
Args: cobra.ExactArgs(1),
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
allow := mustGetBool(cmd.Flags(), "allow")
regex := mustGetBool(cmd.Flags(), "regex")
exp := args[0]
if regex {
regexp.MustCompile(exp)
}
rule := users.Rule{
Allow: allow,
Regex: regex,
}
if regex {
rule.Regexp = &users.Regexp{Raw: exp}
} else {
rule.Path = exp
}
user := func(u *users.User) {
u.Rules = append(u.Rules, rule)
err := store.Users.Save(u)
utils.CheckErr("store.Users.Save", err)
}
global := func(s *settings.Settings) {
s.Rules = append(s.Rules, rule)
err := store.Settings.Save(s)
utils.CheckErr("store.Settings.Save", err)
}
runRules(store, cmd, user, global)
}),
}
-20
View File
@@ -1,20 +0,0 @@
package cmd
import (
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/spf13/cobra"
)
func init() {
rulesCmd.AddCommand(rulesLsCommand)
}
var rulesLsCommand = &cobra.Command{
Use: "ls",
Short: "List global rules or user specific rules",
Long: `List global rules or user specific rules.`,
Args: cobra.NoArgs,
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
runRules(store, cmd, nil, nil)
}),
}
-54
View File
@@ -1,54 +0,0 @@
package cmd
import (
"fmt"
"os"
"strconv"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
var usersCmd = &cobra.Command{
Use: "users",
Short: "Users management utility",
Long: `Users management utility.`,
Args: cobra.NoArgs,
}
func printUsers(usrs []*users.User) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) //nolint:gomnd
fmt.Fprintln(w, "ID\tUsername\tScope\tLocale\tV. Mode\tS.Click\tAdmin\tExecute\tCreate\tRename\tModify\tDelete\tShare\tDownload\tPwd Lock")
for _, u := range usrs {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t\n",
u.ID,
u.Username,
u.Scope,
u.Locale,
u.ViewMode,
u.SingleClick,
u.Perm.Admin,
u.Perm.Execute,
u.Perm.Create,
u.Perm.Rename,
u.Perm.Modify,
u.Perm.Delete,
u.Perm.Share,
u.Perm.Download,
u.LockPassword,
)
}
w.Flush()
}
func parseUsernameOrID(arg string) (username string, id uint) {
id64, err := strconv.ParseUint(arg, 10, 64)
if err != nil {
return arg, 0
}
return "", uint(id64)
}
-42
View File
@@ -1,42 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
usersCmd.AddCommand(usersAddCmd)
}
var usersAddCmd = &cobra.Command{
Use: "add <username> <password>",
Short: "Create a new user",
Long: `Create a new user and add it to the database.`,
Args: cobra.ExactArgs(2), //nolint:gomnd
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
user := &users.User{
Username: args[0],
Password: args[1],
LockPassword: mustGetBool(cmd.Flags(), "lockPassword"),
}
servSettings, err := store.Settings.GetServer()
utils.CheckErr("store.Settings.GetServer()", err)
// since getUserDefaults() polluted s.Defaults.Scope
// which makes the Scope not the one saved in the db
// we need the right s.Defaults.Scope here
s2, err := store.Settings.Get()
utils.CheckErr("store.Settings.Get()", err)
userHome, err := s2.MakeUserDir(user.Username, user.Scope, servSettings.Root)
utils.CheckErr("s2.MakeUserDir", err)
user.Scope = userHome
err = store.Users.Save(user)
utils.CheckErr("store.Users.Save", err)
printUsers([]*users.User{user})
}),
}
-26
View File
@@ -1,26 +0,0 @@
package cmd
import (
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/spf13/cobra"
)
func init() {
usersCmd.AddCommand(usersExportCmd)
}
var usersExportCmd = &cobra.Command{
Use: "export <path>",
Short: "Export all users to a file.",
Long: `Export all users to a json or yaml file. Please indicate the
path to the file where you want to write the users.`,
Args: jsonYamlArg,
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
list, err := store.Users.Gets("")
utils.CheckErr("store.Users.Gets", err)
err = marshal(args[0], list)
utils.CheckErr("marshal", err)
}),
}
-53
View File
@@ -1,53 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
usersCmd.AddCommand(usersFindCmd)
usersCmd.AddCommand(usersLsCmd)
}
var usersFindCmd = &cobra.Command{
Use: "find <id|username>",
Short: "Find a user by username or id",
Long: `Find a user by username or id. If no flag is set, all users will be printed.`,
Args: cobra.ExactArgs(1),
Run: findUsers,
}
var usersLsCmd = &cobra.Command{
Use: "ls",
Short: "List all users.",
Args: cobra.NoArgs,
Run: findUsers,
}
var findUsers = cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
var (
list []*users.User
user *users.User
err error
)
if len(args) == 1 {
username, id := parseUsernameOrID(args[0])
if username != "" {
user, err = store.Users.Get("", username)
} else {
user, err = store.Users.Get("", id)
}
list = []*users.User{user}
} else {
list, err = store.Users.Gets("")
}
utils.CheckErr("findUsers", err)
printUsers(list)
})
-88
View File
@@ -1,88 +0,0 @@
package cmd
import (
"errors"
"fmt"
"os"
"strconv"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
usersCmd.AddCommand(usersImportCmd)
usersImportCmd.Flags().Bool("overwrite", false, "overwrite users with the same id/username combo")
usersImportCmd.Flags().Bool("replace", false, "replace the entire user base")
}
var usersImportCmd = &cobra.Command{
Use: "import <path>",
Short: "Import users from a file",
Long: `Import users from a file. The path must be for a json or yaml
file. You can use this command to import new users to your
installation. For that, just don't place their ID on the files
list or set it to 0.`,
Args: jsonYamlArg,
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
fd, err := os.Open(args[0])
utils.CheckErr("os.Open", err)
defer fd.Close()
list := []*users.User{}
err = unmarshal(args[0], &list)
utils.CheckErr("unmarshal", err)
if mustGetBool(cmd.Flags(), "replace") {
oldUsers, err := store.Users.Gets("")
utils.CheckErr("store.Users.Gets", err)
err = marshal("users.backup.json", list)
utils.CheckErr("marshal users.backup.json", err)
for _, user := range oldUsers {
err = store.Users.Delete(user.ID)
utils.CheckErr("store.Users.Delete", err)
}
}
overwrite := mustGetBool(cmd.Flags(), "overwrite")
for _, user := range list {
onDB, err := store.Users.Get("", user.ID)
// User exists in DB.
if err == nil {
if !overwrite {
newErr := errors.New("user " + strconv.Itoa(int(user.ID)) + " is already registered")
utils.CheckErr("", newErr)
}
// If the usernames mismatch, check if there is another one in the DB
// with the new username. If there is, print an error and cancel the
// operation
if user.Username != onDB.Username {
if conflictuous, err := store.Users.Get("", user.Username); err == nil { //nolint:govet
newErr := usernameConflictError(user.Username, conflictuous.ID, user.ID)
utils.CheckErr("usernameConflictError", newErr)
}
}
} else {
// If it doesn't exist, set the ID to 0 to automatically get a new
// one that make sense in this DB.
user.ID = 0
}
err = store.Users.Save(user)
utils.CheckErr("store.Users.Save", err)
}
}),
}
func usernameConflictError(username string, originalID, newID uint) error {
return fmt.Errorf(`can't import user with ID %d and username "%s" because the username is already registred with the user %d`,
newID, username, originalID)
}
-33
View File
@@ -1,33 +0,0 @@
package cmd
import (
"log"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/spf13/cobra"
)
func init() {
usersCmd.AddCommand(usersRmCmd)
}
var usersRmCmd = &cobra.Command{
Use: "rm <id|username>",
Short: "Delete a user by username or id",
Long: `Delete a user by username or id`,
Args: cobra.ExactArgs(1),
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
username, id := parseUsernameOrID(args[0])
var err error
if username != "" {
err = store.Users.Delete(username)
} else {
err = store.Users.Delete(id)
}
utils.CheckErr("usersRmCmd", err)
log.Println("user deleted successfully")
}),
}
-40
View File
@@ -1,40 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func init() {
usersCmd.AddCommand(usersUpdateCmd)
}
var usersUpdateCmd = &cobra.Command{
Use: "update <id|username>",
Short: "Updates an existing user",
Long: `Updates an existing user. Set the flags for the
options you want to change.`,
Args: cobra.ExactArgs(1),
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
username, id := parseUsernameOrID(args[0])
var (
err error
user *users.User
)
if id != 0 {
user, err = store.Users.Get("", id)
} else {
user, err = store.Users.Get("", username)
}
utils.CheckErr("store.Users.Get", err)
err = store.Users.Update(user)
utils.CheckErr("store.Users.Update", err)
printUsers([]*users.User{user})
}),
}
-88
View File
@@ -1,88 +0,0 @@
package cmd
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"github.com/goccy/go-yaml"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
func mustGetString(flags *pflag.FlagSet, flag string) string {
s, err := flags.GetString(flag)
utils.CheckErr("mustGetString", err)
return s
}
func mustGetBool(flags *pflag.FlagSet, flag string) bool {
b, err := flags.GetBool(flag)
utils.CheckErr("mustGetBool", err)
return b
}
func mustGetUint(flags *pflag.FlagSet, flag string) uint {
b, err := flags.GetUint(flag)
utils.CheckErr("mustGetUint", err)
return b
}
type cobraFunc func(cmd *cobra.Command, args []string)
type pythonFunc func(cmd *cobra.Command, args []string, store *storage.Storage)
func marshal(filename string, data interface{}) error {
fd, err := os.Create(filename)
utils.CheckErr("os.Create", err)
defer fd.Close()
switch ext := filepath.Ext(filename); ext {
case ".json":
encoder := json.NewEncoder(fd)
encoder.SetIndent("", " ")
return encoder.Encode(data)
case ".yml", ".yaml": //nolint:goconst
_, err := yaml.Marshal(fd)
return err
default:
return errors.New("invalid format: " + ext)
}
}
func unmarshal(filename string, data interface{}) error {
fd, err := os.Open(filename)
utils.CheckErr("os.Open", err)
defer fd.Close()
switch ext := filepath.Ext(filename); ext {
case ".json":
return json.NewDecoder(fd).Decode(data)
case ".yml", ".yaml":
return yaml.NewDecoder(fd).Decode(data)
default:
return errors.New("invalid format: " + ext)
}
}
func jsonYamlArg(cmd *cobra.Command, args []string) error {
if err := cobra.ExactArgs(1)(cmd, args); err != nil {
return err
}
switch ext := filepath.Ext(args[0]); ext {
case ".json", ".yml", ".yaml":
return nil
default:
return errors.New("invalid format: " + ext)
}
}
func cobraCmd(fn pythonFunc) cobraFunc {
return func(cmd *cobra.Command, args []string) {
}
}
+28
View File
@@ -0,0 +1,28 @@
server:
port: 80
baseURL: "/"
root: "../frontend/tests/playwright-files"
auth:
method: password
signup: false
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
userDefaults:
darkMode: true
disableSettings: false
scope: "."
hideDotfiles: true
singleClick: false
permissions:
admin: false
create: false
rename: false
modify: false
delete: false
share: false
download: false
+22
View File
@@ -2,6 +2,7 @@ package files
import (
"mime"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -52,6 +53,17 @@ var documentTypes = []string{
".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
@@ -256,3 +268,13 @@ func isArchive(extension string) bool {
}
return false
}
func isOnlyOffice(name string) bool {
extention := filepath.Ext(name)
for _, typefile := range onlyOfficeSupported {
if extention == typefile {
return true
}
}
return false
}
+69 -75
View File
@@ -21,6 +21,7 @@ import (
"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/settings"
@@ -34,34 +35,38 @@ var (
)
type ItemInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModTime time.Time `json:"modified"`
Type string `json:"type"`
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
}
// 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"`
Folders []ItemInfo `json:"folders"`
Path string `json:"path"`
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"`
Subtitles []string `json:"subtitles,omitempty"`
Checksums map[string]string `json:"checksums,omitempty"`
Token string `json:"token,omitempty"`
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
@@ -75,9 +80,15 @@ func (f FileOptions) Components() (string, string) {
}
func FileInfoFaster(opts FileOptions) (ExtendedFileInfo, error) {
index := GetIndex(rootPath)
opts.Path = index.makeIndexPath(opts.Path)
response := ExtendedFileInfo{}
if opts.Source == "" {
opts.Source = "default"
}
index := GetIndex(opts.Source)
if index == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
}
opts.Path = index.makeIndexPath(opts.Path)
// Lock access for the specific path
pathMutex := getMutex(opts.Path)
pathMutex.Lock()
@@ -86,7 +97,7 @@ func FileInfoFaster(opts FileOptions) (ExtendedFileInfo, error) {
return response, os.ErrPermission
}
_, isDir, err := GetRealPath(opts.Path)
realPath, isDir, err := index.GetRealPath(opts.Path)
if err != nil {
return response, err
}
@@ -120,16 +131,31 @@ func FileInfoFaster(opts FileOptions) (ExtendedFileInfo, error) {
return response, err
}
if opts.Content {
content, err := getContent(opts.Path)
content, err := getContent("default", opts.Path)
if err != nil {
return response, err
}
response.Content = content
}
response.FileInfo = info
response.RealPath = realPath
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && isOnlyOffice(info.Name) {
response.OnlyOfficeId = generateOfficeId(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) {
@@ -160,49 +186,13 @@ func GetChecksum(fullPath, algo string) (map[string]string, error) {
return subs, nil
}
// RealPath gets the real path for the file, resolving symlinks if supported.
func (i *FileInfo) RealPath() string {
realPath, _, _ := GetRealPath(rootPath, i.Path)
realPath, err := filepath.EvalSymlinks(realPath)
if err == nil {
return realPath
}
return i.Path
}
func GetRealPath(relativePath ...string) (string, bool, error) {
combined := []string{settings.Config.Server.Root}
for _, path := range relativePath {
combined = append(combined, strings.TrimPrefix(path, settings.Config.Server.Root))
}
joinedPath := filepath.Join(combined...)
isDir, _ := utils.RealPathCache.Get(joinedPath + ":isdir").(bool)
cached, ok := utils.RealPathCache.Get(joinedPath).(string)
if ok && cached != "" {
return cached, isDir, nil
}
// Convert relative path to absolute path
absolutePath, err := filepath.Abs(joinedPath)
if err != nil {
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", combined, err)
}
// Resolve symlinks and get the real path
realPath, isDir, err := resolveSymlinks(absolutePath)
if err == nil {
utils.RealPathCache.Set(joinedPath, realPath)
utils.RealPathCache.Set(joinedPath+":isdir", isDir)
}
return realPath, isDir, err
}
func DeleteFiles(absPath string, opts FileOptions) error {
func DeleteFiles(source, absPath string, dirPath string) error {
err := os.RemoveAll(absPath)
if err != nil {
return err
}
index := GetIndex(rootPath)
refreshConfig := FileOptions{Path: filepath.Dir(opts.Path), IsDir: true}
index := GetIndex(source)
refreshConfig := FileOptions{Path: dirPath, IsDir: true}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return err
@@ -210,19 +200,19 @@ func DeleteFiles(absPath string, opts FileOptions) error {
return nil
}
func MoveResource(realsrc, realdst string, isSrcDir bool) error {
func MoveResource(source, realsrc, realdst string, isSrcDir bool) error {
err := fileutils.MoveFile(realsrc, realdst)
if err != nil {
return err
}
index := GetIndex(rootPath)
index := GetIndex(source)
// refresh info for source and dest
err = index.RefreshFileInfo(FileOptions{
Path: realsrc,
IsDir: isSrcDir,
})
if err != nil {
return errors.ErrEmptyKey
return fmt.Errorf("could not refresh index for source: %v", err)
}
refreshConfig := FileOptions{Path: realdst, IsDir: true}
if !isSrcDir {
@@ -230,17 +220,17 @@ func MoveResource(realsrc, realdst string, isSrcDir bool) error {
}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return errors.ErrEmptyKey
return fmt.Errorf("could not refresh index for dest: %v", err)
}
return nil
}
func CopyResource(realsrc, realdst string, isSrcDir bool) error {
func CopyResource(source, realsrc, realdst string, isSrcDir bool) error {
err := fileutils.CopyFile(realsrc, realdst)
if err != nil {
return err
}
index := GetIndex(rootPath)
index := GetIndex(source)
refreshConfig := FileOptions{Path: realdst, IsDir: true}
if !isSrcDir {
refreshConfig.Path = filepath.Dir(realdst)
@@ -253,14 +243,14 @@ func CopyResource(realsrc, realdst string, isSrcDir bool) error {
}
func WriteDirectory(opts FileOptions) error {
realPath, _, _ := GetRealPath(rootPath, opts.Path)
idx := GetIndex(opts.Source)
realPath, _, _ := idx.GetRealPath(opts.Path)
// Ensure the parent directories exist
err := os.MkdirAll(realPath, 0775)
if err != nil {
return err
}
index := GetIndex(rootPath)
err = index.RefreshFileInfo(opts)
err = idx.RefreshFileInfo(opts)
if err != nil {
return errors.ErrEmptyKey
}
@@ -268,7 +258,8 @@ func WriteDirectory(opts FileOptions) error {
}
func WriteFile(opts FileOptions, in io.Reader) error {
dst, _, _ := GetRealPath(rootPath, opts.Path)
idx := GetIndex(opts.Source)
dst, _, _ := idx.GetRealPath(opts.Path)
parentDir := filepath.Dir(dst)
// Create the directory and all necessary parents
err := os.MkdirAll(parentDir, 0775)
@@ -290,8 +281,7 @@ func WriteFile(opts FileOptions, in io.Reader) error {
}
opts.Path = parentDir
opts.IsDir = true
index := GetIndex(rootPath)
return index.RefreshFileInfo(opts)
return idx.RefreshFileInfo(opts)
}
// resolveSymlinks resolves symlinks in the given path
@@ -323,8 +313,9 @@ func resolveSymlinks(path string) (string, bool, error) {
}
// addContent reads and sets content based on the file type.
func getContent(path string) (string, error) {
realPath, _, err := GetRealPath(rootPath, path)
func getContent(source, path string) (string, error) {
idx := GetIndex(source)
realPath, _, err := idx.GetRealPath(path)
if err != nil {
return "", err
}
@@ -344,7 +335,7 @@ func getContent(path string) (string, error) {
}
// DetectType detects the MIME type of a file and updates the ItemInfo struct.
func (i *ItemInfo) DetectType(path string, saveContent bool) {
func (i *ItemInfo) DetectType(realPath string, saveContent bool) {
name := i.Name
ext := filepath.Ext(name)
@@ -354,9 +345,8 @@ func (i *ItemInfo) DetectType(path string, saveContent bool) {
i.Type = extendedMimeTypeCheck(ext)
}
if i.Type == "blob" {
realpath, _, _ := GetRealPath(path)
// Read only the first 512 bytes for efficient MIME detection
file, err := os.Open(realpath)
file, err := os.Open(realPath)
if err != nil {
} else {
@@ -441,9 +431,11 @@ func Exists(path string) bool {
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(info.Folders[i].Name)
numJ, errJ := strconv.Atoi(info.Folders[j].Name)
numI, errI := strconv.Atoi(nameWithoutExt)
numJ, errJ := strconv.Atoi(nameWithoutExt2)
if errI == nil && errJ == nil {
return numI < numJ
}
@@ -451,9 +443,11 @@ func (info *FileInfo) SortItems() {
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(info.Files[i].Name)
numJ, errJ := strconv.Atoi(info.Files[j].Name)
numI, errI := strconv.Atoi(nameWithoutExt)
numJ, errJ := strconv.Atoi(nameWithoutExt2)
if errI == nil && errJ == nil {
return numI < numJ
}
+25 -17
View File
@@ -6,6 +6,8 @@ import (
"reflect"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
func Test_GetRealPath(t *testing.T) {
@@ -13,7 +15,7 @@ func Test_GetRealPath(t *testing.T) {
if err != nil {
return
}
trimPrefix := filepath.Dir(filepath.Dir(cwd)) + "/"
trimPrefix := filepath.Dir(filepath.Dir(cwd))
tests := []struct {
name string
paths []string
@@ -31,20 +33,20 @@ func Test_GetRealPath(t *testing.T) {
path string
isDir bool
}{
path: "backend/files",
path: "",
isDir: true,
},
},
{
name: "current directory",
paths: []string{
"./file.go",
"./files/file.go",
},
want: struct {
path string
isDir bool
}{
path: "backend/files/file.go",
path: "/files/file.go",
isDir: false,
},
},
@@ -62,9 +64,15 @@ func Test_GetRealPath(t *testing.T) {
},
},
}
idx := Index{
Source: settings.Source{
Path: trimPrefix,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
realPath, isDir, _ := GetRealPath(tt.paths...)
realPath, isDir, _ := idx.GetRealPath(tt.paths...)
adjustedRealPath := strings.TrimPrefix(realPath, trimPrefix)
if tt.want.path != adjustedRealPath || tt.want.isDir != isDir {
t.Errorf("expected %v:%v but got: %v:%v", tt.want.path, tt.want.isDir, adjustedRealPath, isDir)
@@ -83,30 +91,30 @@ func TestSortItems(t *testing.T) {
name: "Numeric and Lexicographical Sorting",
input: FileInfo{
Folders: []ItemInfo{
{Name: "10"},
{Name: "2"},
{Name: "10.txt"},
{Name: "2.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []ItemInfo{
{Name: "File2"},
{Name: "File10"},
{Name: "File2.txt"},
{Name: "File10.txt"},
{Name: "File1"},
{Name: "banana"},
},
},
expected: FileInfo{
Folders: []ItemInfo{
{Name: "2"},
{Name: "10"},
{Name: "2.txt"},
{Name: "10.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []ItemInfo{
{Name: "banana"},
{Name: "File1"},
{Name: "File10"},
{Name: "File2"},
{Name: "File10.txt"},
{Name: "File2.txt"},
},
},
},
@@ -114,8 +122,8 @@ func TestSortItems(t *testing.T) {
name: "Only Lexicographical Sorting",
input: FileInfo{
Folders: []ItemInfo{
{Name: "dog"},
{Name: "Cat"},
{Name: "dog.txt"},
{Name: "Cat.txt"},
{Name: "apple"},
},
Files: []ItemInfo{
@@ -127,8 +135,8 @@ func TestSortItems(t *testing.T) {
expected: FileInfo{
Folders: []ItemInfo{
{Name: "apple"},
{Name: "Cat"},
{Name: "dog"},
{Name: "Cat.txt"},
{Name: "dog.txt"},
},
Files: []ItemInfo{
{Name: "apple"},
+133 -49
View File
@@ -2,19 +2,21 @@ package files
import (
"fmt"
"log"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
type Index struct {
Root string
settings.Source
Directories map[string]*FileInfo
NumDirs uint64
NumFiles uint64
@@ -30,32 +32,42 @@ type Index struct {
}
var (
rootPath string = "/srv"
indexes []*Index
indexes map[string]*Index
indexesMutex sync.RWMutex
RootPaths map[string]string
)
func InitializeIndex(enabled bool) {
if enabled {
func Initialize(source settings.Source) {
indexesMutex.RLock()
newIndex := Index{
Source: source,
Directories: make(map[string]*FileInfo),
}
if RootPaths == nil {
RootPaths = make(map[string]string)
}
RootPaths[source.Name] = source.Path
indexes = make(map[string]*Index)
indexes[newIndex.Source.Name] = &newIndex
indexesMutex.RUnlock()
if !newIndex.Source.Config.Disabled {
time.Sleep(time.Second)
if settings.Config.Server.Root != "" {
rootPath = settings.Config.Server.Root
}
si := GetIndex(rootPath)
log.Println("Initializing index and assessing file system complexity")
si.RunIndexing("/", false)
go si.setupIndexingScanners()
logger.Info("Initializing index and assessing file system complexity")
newIndex.RunIndexing("/", false)
go newIndex.setupIndexingScanners()
} else {
logger.Debug("Indexing disabled for source: " + newIndex.Source.Name)
}
}
// Define a function to recursively index files and directories
func (si *Index) indexDirectory(adjustedPath string, quick, recursive bool) error {
realPath := strings.TrimRight(si.Root, "/") + adjustedPath
func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) error {
realPath := strings.TrimRight(idx.Source.Path, "/") + adjustedPath
// Open the directory
dir, err := os.Open(realPath)
if err != nil {
si.RemoveDirectory(adjustedPath) // Remove, must have been deleted
idx.RemoveDirectory(adjustedPath) // Remove, must have been deleted
return err
}
defer dir.Close()
@@ -69,32 +81,32 @@ func (si *Index) indexDirectory(adjustedPath string, quick, recursive bool) erro
combinedPath = "/"
}
// get whats currently in cache
si.mu.RLock()
idx.mu.RLock()
cacheDirItems := []ItemInfo{}
modChange := true // default to true
cachedDir, exists := si.Directories[adjustedPath]
cachedDir, exists := idx.Directories[adjustedPath]
if exists && quick {
modChange = dirInfo.ModTime() != cachedDir.ModTime
cacheDirItems = cachedDir.Folders
}
si.mu.RUnlock()
idx.mu.RUnlock()
// If the directory has not been modified since the last index, skip expensive readdir
// recursively check cached dirs for mod time changes as well
if !modChange && recursive {
for _, item := range cacheDirItems {
err = si.indexDirectory(combinedPath+item.Name, quick, true)
err = idx.indexDirectory(combinedPath+item.Name, quick, true)
if err != nil {
fmt.Printf("error indexing directory %v : %v", combinedPath+item.Name, err)
logger.Error(fmt.Sprintf("error indexing directory %v : %v", combinedPath+item.Name, err))
}
}
return nil
}
if quick {
si.mu.Lock()
si.FilesChangedDuringIndexing = true
si.mu.Unlock()
idx.mu.Lock()
idx.FilesChangedDuringIndexing = true
idx.mu.Unlock()
}
// Read directory contents
@@ -109,36 +121,56 @@ func (si *Index) indexDirectory(adjustedPath string, quick, recursive bool) erro
// Process each file and directory in the current directory
for _, file := range files {
isDir := file.IsDir()
fullCombined := combinedPath + file.Name()
if idx.shouldSkip(isDir, isHidden(file, ""), fullCombined) {
continue
}
itemInfo := &ItemInfo{
Name: file.Name(),
ModTime: file.ModTime(),
}
if file.IsDir() {
// fix for .app files on macos which are technically directories, but we don't want to treat them as such
if isDir && strings.HasSuffix(file.Name(), ".app") {
isDir = false
}
if isDir {
// skip non-indexable dirs.
if file.Name() == "$RECYCLE.BIN" || file.Name() == "System Volume Information" {
continue
}
dirPath := combinedPath + file.Name()
if recursive {
// Recursively index the subdirectory
err = si.indexDirectory(dirPath, quick, recursive)
err = idx.indexDirectory(dirPath, quick, recursive)
if err != nil {
log.Printf("Failed to index directory %s: %v", dirPath, err)
logger.Error(fmt.Sprintf("Failed to index directory %s: %v", dirPath, err))
continue
}
}
realDirInfo, exists := si.GetMetadataInfo(dirPath, true)
realDirInfo, exists := idx.GetMetadataInfo(dirPath, true)
if exists {
itemInfo.Size = realDirInfo.Size
}
totalSize += itemInfo.Size
itemInfo.Type = "directory"
dirInfos = append(dirInfos, *itemInfo)
si.NumDirs++
idx.NumDirs++
} else {
itemInfo.DetectType(combinedPath+file.Name(), false)
itemInfo.DetectType(fullCombined, false)
itemInfo.Size = file.Size()
fileInfos = append(fileInfos, *itemInfo)
totalSize += itemInfo.Size
si.NumFiles++
idx.NumFiles++
}
}
if totalSize == 0 && idx.Source.Config.IgnoreZeroSizeFolders {
return nil
}
// Create FileInfo for the current directory
dirFileInfo := &FileInfo{
Path: adjustedPath,
@@ -155,22 +187,23 @@ func (si *Index) indexDirectory(adjustedPath string, quick, recursive bool) erro
dirFileInfo.SortItems()
// Update the current directory metadata in the index
si.UpdateMetadata(dirFileInfo)
idx.UpdateMetadata(dirFileInfo)
return nil
}
func (si *Index) makeIndexPath(subPath string) string {
func (idx *Index) makeIndexPath(subPath string) string {
if strings.HasPrefix(subPath, "./") {
subPath = strings.TrimPrefix(subPath, ".")
}
if si.Root == subPath || subPath == "." {
if idx.Source.Path == subPath || subPath == "." {
return "/"
}
// clean path
subPath = strings.TrimSuffix(subPath, "/")
// remove index prefix
adjustedPath := strings.TrimPrefix(subPath, si.Root)
adjustedPath := strings.TrimPrefix(subPath, idx.Source.Path)
adjustedPath = strings.ReplaceAll(adjustedPath, "\\", "/")
// remove trailing slash
adjustedPath = strings.TrimSuffix(adjustedPath, "/")
if !strings.HasPrefix(adjustedPath, "/") {
@@ -179,43 +212,64 @@ func (si *Index) makeIndexPath(subPath string) string {
return adjustedPath
}
func (si *Index) recursiveUpdateDirSizes(childInfo *FileInfo, previousSize int64) {
func (idx *Index) recursiveUpdateDirSizes(childInfo *FileInfo, previousSize int64) {
parentDir := utils.GetParentDirectoryPath(childInfo.Path)
parentInfo, exists := si.GetMetadataInfo(parentDir, true)
parentInfo, exists := idx.GetMetadataInfo(parentDir, true)
if !exists || parentDir == "" {
return
}
newSize := parentInfo.Size - previousSize + childInfo.Size
parentInfo.Size += newSize
si.UpdateMetadata(parentInfo)
si.recursiveUpdateDirSizes(parentInfo, newSize)
idx.UpdateMetadata(parentInfo)
idx.recursiveUpdateDirSizes(parentInfo, newSize)
}
func (si *Index) RefreshFileInfo(opts FileOptions) error {
func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
combined := append([]string{idx.Source.Path}, relativePath...)
joinedPath := filepath.Join(combined...)
isDir, _ := cache.RealPath.Get(joinedPath + ":isdir").(bool)
cached, ok := cache.RealPath.Get(joinedPath).(string)
if ok && cached != "" {
return cached, isDir, nil
}
// Convert relative path to absolute path
absolutePath, err := filepath.Abs(joinedPath)
if err != nil {
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err)
}
// Resolve symlinks and get the real path
realPath, isDir, err := resolveSymlinks(absolutePath)
if err == nil {
cache.RealPath.Set(joinedPath, realPath)
cache.RealPath.Set(joinedPath+":isdir", isDir)
}
return realPath, isDir, err
}
func (idx *Index) RefreshFileInfo(opts FileOptions) error {
refreshOptions := FileOptions{
Path: opts.Path,
IsDir: opts.IsDir,
}
if !refreshOptions.IsDir {
refreshOptions.Path = si.makeIndexPath(filepath.Dir(refreshOptions.Path))
refreshOptions.Path = idx.makeIndexPath(filepath.Dir(refreshOptions.Path))
refreshOptions.IsDir = true
} else {
refreshOptions.Path = si.makeIndexPath(refreshOptions.Path)
refreshOptions.Path = idx.makeIndexPath(refreshOptions.Path)
}
err := si.indexDirectory(refreshOptions.Path, false, false)
err := idx.indexDirectory(refreshOptions.Path, false, false)
if err != nil {
return fmt.Errorf("file/folder does not exist to refresh data: %s", refreshOptions.Path)
}
file, exists := si.GetMetadataInfo(refreshOptions.Path, true)
file, exists := idx.GetMetadataInfo(refreshOptions.Path, true)
if !exists {
return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path)
}
current, firstExisted := si.GetMetadataInfo(refreshOptions.Path, true)
current, firstExisted := idx.GetMetadataInfo(refreshOptions.Path, true)
refreshParentInfo := firstExisted && current.Size != file.Size
//utils.PrintStructFields(*file)
result := si.UpdateMetadata(file)
result := idx.UpdateMetadata(file)
if !result {
return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path)
}
@@ -223,7 +277,37 @@ func (si *Index) RefreshFileInfo(opts FileOptions) error {
return nil
}
if refreshParentInfo {
si.recursiveUpdateDirSizes(file, current.Size)
idx.recursiveUpdateDirSizes(file, current.Size)
}
return nil
}
func isHidden(file os.FileInfo, realpath string) bool {
return file.Name()[0] == '.'
}
func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined string) bool {
// check inclusions first
if isDir && len(idx.Source.Config.Include.Folders) > 0 {
if !slices.Contains(idx.Source.Config.Include.Folders, fullCombined) {
return true
}
}
if !isDir && len(idx.Source.Config.Include.Files) > 0 {
if !slices.Contains(idx.Source.Config.Include.Files, fullCombined) {
return true
}
}
// check exclusions
if isDir && slices.Contains(idx.Source.Config.Exclude.Folders, fullCombined) {
return true
}
if !isDir && slices.Contains(idx.Source.Config.Exclude.Files, fullCombined) {
return true
}
if idx.Source.Config.IgnoreHidden && isHidden {
return true
}
return false
}
+61 -52
View File
@@ -1,10 +1,10 @@
package files
import (
"log"
"fmt"
"time"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
// schedule in minutes
@@ -19,99 +19,108 @@ var scanSchedule = []time.Duration{
4 * time.Hour, // 4 hours for quick scan & 20 hours for a full scan
}
func (si *Index) newScanner(origin string) {
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[si.currentSchedule] + si.SmartModifier
if si.assessment == "simple" {
sleepTime = scanSchedule[si.currentSchedule] - si.SmartModifier
sleepTime := scanSchedule[idx.currentSchedule] + idx.SmartModifier
if idx.assessment == "simple" {
sleepTime = scanSchedule[idx.currentSchedule] - idx.SmartModifier
}
if settings.Config.Server.IndexingInterval > 0 {
sleepTime = time.Duration(settings.Config.Server.IndexingInterval) * time.Minute
if idx.Source.Config.IndexingInterval > 0 {
sleepTime = time.Duration(idx.Source.Config.IndexingInterval) * time.Minute
}
// Log and sleep before indexing
log.Printf("Next scan in %v\n", sleepTime)
logger.Debug(fmt.Sprintf("Next scan in %v", sleepTime))
time.Sleep(sleepTime)
si.scannerMu.Lock()
idx.scannerMu.Lock()
if fullScanCounter == 5 {
si.RunIndexing(origin, false) // Full scan
idx.RunIndexing(origin, false) // Full scan
fullScanCounter = 0
} else {
si.RunIndexing(origin, true) // Quick scan
idx.RunIndexing(origin, true) // Quick scan
}
si.scannerMu.Unlock()
idx.scannerMu.Unlock()
// Adjust schedule based on file changes
if si.FilesChangedDuringIndexing {
if idx.FilesChangedDuringIndexing {
// Move to at least the full-scan anchor or reduce interval
if si.currentSchedule > fullScanAnchor {
si.currentSchedule = fullScanAnchor
} else if si.currentSchedule > 0 {
si.currentSchedule--
if idx.currentSchedule > fullScanAnchor {
idx.currentSchedule = fullScanAnchor
} else if idx.currentSchedule > 0 {
idx.currentSchedule--
}
} else {
// Increment toward the longest interval if no changes
if si.currentSchedule < len(scanSchedule)-1 {
si.currentSchedule++
if idx.currentSchedule < len(scanSchedule)-1 {
idx.currentSchedule++
}
}
if si.assessment == "simple" && si.currentSchedule > 3 {
si.currentSchedule = 3
if idx.assessment == "simple" && idx.currentSchedule > 3 {
idx.currentSchedule = 3
}
// Ensure `currentSchedule` stays within bounds
if si.currentSchedule < 0 {
si.currentSchedule = 0
} else if si.currentSchedule >= len(scanSchedule) {
si.currentSchedule = len(scanSchedule) - 1
if idx.currentSchedule < 0 {
idx.currentSchedule = 0
} else if idx.currentSchedule >= len(scanSchedule) {
idx.currentSchedule = len(scanSchedule) - 1
}
}
}
func (si *Index) RunIndexing(origin string, quick bool) {
prevNumDirs := si.NumDirs
prevNumFiles := si.NumFiles
func (idx *Index) RunIndexing(origin string, quick bool) {
prevNumDirs := idx.NumDirs
prevNumFiles := idx.NumFiles
if quick {
log.Println("Starting quick scan")
logger.Debug("Starting quick scan")
} else {
log.Println("Starting full scan")
si.NumDirs = 0
si.NumFiles = 0
logger.Debug("Starting full scan")
idx.NumDirs = 0
idx.NumFiles = 0
}
startTime := time.Now()
si.FilesChangedDuringIndexing = false
idx.FilesChangedDuringIndexing = false
// Perform the indexing operation
err := si.indexDirectory("/", quick, true)
err := idx.indexDirectory("/", quick, true)
if err != nil {
log.Printf("Error during indexing: %v", err)
logger.Error(fmt.Sprintf("Error during indexing: %v", err))
}
firstRun := idx.LastIndexed == time.Time{}
// Update the LastIndexed time
si.LastIndexed = time.Now()
si.indexingTime = int(time.Since(startTime).Seconds())
idx.LastIndexed = time.Now()
idx.indexingTime = int(time.Since(startTime).Seconds())
if !quick {
// update smart indexing
if si.indexingTime < 3 || si.NumDirs < 10000 {
si.assessment = "simple"
si.SmartModifier = 4 * time.Minute
} else if si.indexingTime > 120 || si.NumDirs > 500000 {
si.assessment = "complex"
modifier := si.indexingTime / 10 // seconds
si.SmartModifier = time.Duration(modifier) * time.Minute
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 {
si.assessment = "normal"
idx.assessment = "normal"
}
log.Printf("Index assessment : complexity=%v directories=%v files=%v \n", si.assessment, si.NumDirs, si.NumFiles)
if si.NumDirs != prevNumDirs || si.NumFiles != prevNumFiles {
si.FilesChangedDuringIndexing = true
if firstRun {
logger.Info(fmt.Sprintf("Index assessment : complexity=%v directories=%v files=%v", idx.assessment, idx.NumDirs, idx.NumFiles))
} else {
logger.Debug(fmt.Sprintf("Index assessment : complexity=%v directories=%v files=%v", idx.assessment, idx.NumDirs, idx.NumFiles))
}
if idx.NumDirs != prevNumDirs || idx.NumFiles != prevNumFiles {
idx.FilesChangedDuringIndexing = true
}
}
log.Printf("Time Spent Indexing : %v seconds\n", si.indexingTime)
if firstRun {
logger.Info(fmt.Sprintf("Time spent indexing : %v seconds", idx.indexingTime))
} else {
logger.Debug(fmt.Sprintf("Time spent indexing : %v seconds", idx.indexingTime))
}
}
func (si *Index) setupIndexingScanners() {
go si.newScanner("/")
func (idx *Index) setupIndexingScanners() {
go idx.newScanner("/")
}
+129 -8
View File
@@ -11,16 +11,19 @@ import (
)
func BenchmarkFillIndex(b *testing.B) {
InitializeIndex(false)
si := GetIndex(settings.Config.Server.Root)
Initialize(settings.Source{
Name: "test",
Path: "/srv",
})
idx := GetIndex("test")
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
si.createMockData(50, 3) // 1000 dirs, 3 files per dir
idx.createMockData(50, 3) // 1000 dirs, 3 files per dir
}
}
func (si *Index) createMockData(numDirs, numFilesPerDir int) {
func (idx *Index) createMockData(numDirs, numFilesPerDir int) {
for i := 0; i < numDirs; i++ {
dirPath := generateRandomPath(rand.Intn(3) + 1)
files := []ItemInfo{} // Slice of FileInfo
@@ -40,7 +43,7 @@ func (si *Index) createMockData(numDirs, numFilesPerDir int) {
Files: files,
}
si.UpdateMetadata(dirInfo)
idx.UpdateMetadata(dirInfo)
}
}
@@ -78,6 +81,7 @@ func TestMakeIndexPath(t *testing.T) {
subPath string
expected string
}{
// Linux
{"Root path returns slash", "/", "/"},
{"Dot-prefixed returns slash", ".", "/"},
{"Double-dot prefix ignored", "./", "/"},
@@ -91,11 +95,128 @@ func TestMakeIndexPath(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
si := &Index{Root: "/"}
result := si.makeIndexPath(tt.subPath)
idx := &Index{Source: settings.Source{Path: "/"}}
result := idx.makeIndexPath(tt.subPath)
if result != tt.expected {
t.Errorf("makeIndexPath(%q) = %q; want %q", tt.name, result, tt.expected)
t.Errorf("makeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
}
})
}
tests = []struct {
name string
subPath string
expected string
}{
// Windows
{"Mixed slash", "/first\\second", "/first/second"},
{"Windows slash", "\\first\\second", "/first/second"},
{"Windows full path", "C:\\Users\\testfolder\\nestedfolder", "/testfolder/nestedfolder"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := &Index{Source: settings.Source{Path: "C:\\Users"}}
result := idx.makeIndexPath(tt.subPath)
if result != tt.expected {
t.Errorf("makeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
}
})
}
}
func TestMakeIndexPathRoot(t *testing.T) {
tests := []struct {
name string
subPath string
expected string
}{
// Linux
{"Root path returns slash", "/rootpath", "/"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := &Index{Source: settings.Source{Path: "/rootpath", Name: "default"}}
result := idx.makeIndexPath(tt.subPath)
if result != tt.expected {
t.Errorf("makeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
}
})
}
}
func BenchmarkCheckIndexExclude(b *testing.B) {
tests := []struct {
isDir bool
isHidden bool
fullPath string
}{
{false, false, "/test/.test"},
{true, false, "/test/.test"},
{true, true, "/test/.test"},
{false, false, "/test/filepath"},
{false, true, "/test/filepath"},
{true, true, "/test/filepath"},
}
b.ResetTimer()
b.ReportAllocs()
idx := Index{
Source: settings.Source{
Name: "files",
Config: settings.IndexConfig{
IgnoreHidden: true,
Exclude: settings.IndexFilter{
Files: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
Folders: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
FileEndsWith: []string{".zip", ".tar", ".jpeg"},
},
},
},
}
for i := 0; i < b.N; i++ {
for _, v := range tests {
idx.shouldSkip(v.isDir, v.isHidden, v.fullPath)
}
}
}
func BenchmarkCheckIndexConditionsInclude(b *testing.B) {
tests := []struct {
isDir bool
isHidden bool
fullPath string
}{
{false, false, "/test/.test"},
{true, false, "/test/.test"},
{true, true, "/test/.test"},
{false, false, "/test/filepath"},
{false, true, "/test/filepath"},
{true, true, "/test/filepath"},
}
b.ResetTimer()
b.ReportAllocs()
idx2 := Index{
Source: settings.Source{
Name: "files",
Config: settings.IndexConfig{
IgnoreHidden: true,
Include: settings.IndexFilter{
Files: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
Folders: []string{"test", "filepath", ".test", ".filepath", "test", "filepath", ".test", ".filepath"},
FileEndsWith: []string{".zip", ".tar", ".jpeg"},
},
},
},
}
for i := 0; i < b.N; i++ {
for _, v := range tests {
idx2.shouldSkip(v.isDir, v.isHidden, v.fullPath)
}
}
}
+17 -16
View File
@@ -6,6 +6,7 @@ import (
"strings"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
@@ -20,21 +21,21 @@ type SearchResult struct {
Size int64 `json:"size"`
}
func (si *Index) Search(search string, scope string, sourceSession string) []SearchResult {
func (idx *Index) Search(search string, scope string, sourceSession string) []SearchResult {
// Remove slashes
scope = si.makeIndexPath(scope)
runningHash := utils.GenerateRandomHash(4)
scope = idx.makeIndexPath(scope)
runningHash := utils.InsecureRandomIdentifier(4)
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
searchOptions := ParseSearch(search)
results := make(map[string]SearchResult, 0)
count := 0
var directories []string
cachedDirs, ok := utils.SearchResultsCache.Get(si.Root + scope).([]string)
cachedDirs, ok := cache.SearchResults.Get(idx.Source.Path + scope).([]string)
if ok {
directories = cachedDirs
} else {
directories = si.getDirsInScope(scope)
utils.SearchResultsCache.Set(si.Root+scope, directories)
directories = idx.getDirsInScope(scope)
cache.SearchResults.Set(idx.Source.Path+scope, directories)
}
for _, searchTerm := range searchOptions.Terms {
if searchTerm == "" {
@@ -43,12 +44,12 @@ func (si *Index) Search(search string, scope string, sourceSession string) []Sea
if count > maxSearchResults {
break
}
si.mu.Lock()
idx.mu.Lock()
for _, dirName := range directories {
scopedPath := strings.TrimPrefix(strings.TrimPrefix(dirName, scope), "/") + "/"
si.mu.Unlock()
dir, found := si.GetReducedMetadata(dirName, true)
si.mu.Lock()
idx.mu.Unlock()
dir, found := idx.GetReducedMetadata(dirName, true)
idx.mu.Lock()
if !found {
continue
}
@@ -74,7 +75,7 @@ func (si *Index) Search(search string, scope string, sourceSession string) []Sea
}
value, found := sessionInProgress.Load(sourceSession)
if !found || value != runningHash {
si.mu.Unlock()
idx.mu.Unlock()
return []SearchResult{}
}
if count > maxSearchResults {
@@ -87,7 +88,7 @@ func (si *Index) Search(search string, scope string, sourceSession string) []Sea
}
}
}
si.mu.Unlock()
idx.mu.Unlock()
}
// Sort keys based on the number of elements in the path after splitting by "/"
@@ -169,11 +170,11 @@ func (fi ItemInfo) containsSearchTerm(searchTerm string, options SearchOptions)
return true
}
func (si *Index) getDirsInScope(scope string) []string {
func (idx *Index) getDirsInScope(scope string) []string {
newList := []string{}
si.mu.Lock()
defer si.mu.Unlock()
for k := range si.Directories {
idx.mu.Lock()
defer idx.mu.Unlock()
for k := range idx.Directories {
if strings.HasPrefix(k, scope) || scope == "" {
newList = append(newList, k)
}
+9 -8
View File
@@ -4,14 +4,15 @@ import (
"reflect"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/stretchr/testify/assert"
)
func BenchmarkSearchAllIndexes(b *testing.B) {
InitializeIndex(false)
si := GetIndex(rootPath)
Initialize(settings.Source{Name: "test", Path: "/srv"})
idx := GetIndex("test")
si.createMockData(50, 3) // 50 dirs, 3 files per dir
idx.createMockData(50, 3) // 50 dirs, 3 files per dir
// Generate 100 random search terms
searchTerms := generateRandomSearchTerms(100)
@@ -21,7 +22,7 @@ func BenchmarkSearchAllIndexes(b *testing.B) {
for i := 0; i < b.N; i++ {
// Execute the SearchAllIndexes function
for _, term := range searchTerms {
si.Search(term, "/", "test")
idx.Search(term, "/", "test")
}
}
}
@@ -74,14 +75,14 @@ func TestParseSearch(t *testing.T) {
}
func TestSearchWhileIndexing(t *testing.T) {
InitializeIndex(false)
si := GetIndex(rootPath)
Initialize(settings.Source{Name: "test", Path: "/srv"})
idx := GetIndex("test")
searchTerms := generateRandomSearchTerms(10)
for i := 0; i < 5; i++ {
go si.createMockData(100, 100) // Creating mock data concurrently
go idx.createMockData(100, 100) // Creating mock data concurrently
for _, term := range searchTerms {
go si.Search(term, "/", "test") // Search concurrently
go idx.Search(term, "/", "test") // Search concurrently
}
}
}
+28 -42
View File
@@ -2,27 +2,25 @@ package files
import (
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
func (si *Index) UpdateMetadata(info *FileInfo) bool {
si.mu.Lock()
defer si.mu.Unlock()
si.Directories[info.Path] = info
func (idx *Index) UpdateMetadata(info *FileInfo) bool {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.Directories[info.Path] = info
return true
}
// GetMetadataInfo retrieves the FileInfo from the specified directory in the index.
func (si *Index) GetReducedMetadata(target string, isDir bool) (*FileInfo, bool) {
si.mu.Lock()
defer si.mu.Unlock()
checkDir := si.makeIndexPath(target)
func (idx *Index) GetReducedMetadata(target string, isDir bool) (*FileInfo, bool) {
idx.mu.Lock()
defer idx.mu.Unlock()
checkDir := idx.makeIndexPath(target)
if !isDir {
checkDir = si.makeIndexPath(filepath.Dir(target))
checkDir = idx.makeIndexPath(filepath.Dir(target))
}
dir, exists := si.Directories[checkDir]
dir, exists := idx.Directories[checkDir]
if !exists {
return nil, false
}
@@ -48,42 +46,30 @@ func (si *Index) GetReducedMetadata(target string, isDir bool) (*FileInfo, bool)
}
// GetMetadataInfo retrieves the FileInfo from the specified directory in the index.
func (si *Index) GetMetadataInfo(target string, isDir bool) (*FileInfo, bool) {
si.mu.RLock()
defer si.mu.RUnlock()
checkDir := si.makeIndexPath(target)
func (idx *Index) GetMetadataInfo(target string, isDir bool) (*FileInfo, bool) {
idx.mu.RLock()
defer idx.mu.RUnlock()
checkDir := idx.makeIndexPath(target)
if !isDir {
checkDir = si.makeIndexPath(filepath.Dir(target))
checkDir = idx.makeIndexPath(filepath.Dir(target))
}
dir, exists := si.Directories[checkDir]
dir, exists := idx.Directories[checkDir]
return dir, exists
}
func (si *Index) RemoveDirectory(path string) {
si.mu.Lock()
defer si.mu.Unlock()
si.NumDeleted++
delete(si.Directories, path)
func (idx *Index) RemoveDirectory(path string) {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.NumDeleted++
delete(idx.Directories, path)
}
func GetIndex(root string) *Index {
for _, index := range indexes {
if index.Root == root {
return index
}
}
if settings.Config.Server.Root != "" {
rootPath = settings.Config.Server.Root
}
newIndex := &Index{
Root: rootPath,
Directories: map[string]*FileInfo{},
NumDirs: 0,
NumFiles: 0,
}
newIndex.Directories["/"] = &FileInfo{}
func GetIndex(name string) *Index {
indexesMutex.Lock()
indexes = append(indexes, newIndex)
indexesMutex.Unlock()
return newIndex
defer indexesMutex.Unlock()
index, ok := indexes[name]
if !ok {
return nil
}
return index
}
+5 -1
View File
@@ -3,6 +3,7 @@ package files
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/stretchr/testify/assert"
)
@@ -207,7 +208,10 @@ func TestRemoveDirectory(t *testing.T) {
func init() {
testIndex = Index{
Root: "/",
Source: settings.Source{
Path: "/",
Name: "test",
},
NumFiles: 10,
NumDirs: 5,
Directories: map[string]*FileInfo{
+63 -3
View File
@@ -1,6 +1,7 @@
package fileutils
import (
"fmt"
"io"
"os"
"path"
@@ -11,24 +12,46 @@ import (
// By default, the rename system call is used. If src and dst point to different volumes,
// the file copy is used as a fallback.
func MoveFile(src, dst string) error {
fmt.Println("moving", src, dst)
if os.Rename(src, dst) == nil {
return nil
}
fmt.Println("copyfile instead", src, dst)
// fallback
err := CopyFile(src, dst)
if err != nil {
fmt.Println("ok it errored too", err)
_ = os.Remove(dst)
return err
}
fmt.Println("removing", src)
if err := os.Remove(src); err != nil {
return err
}
return nil
}
// CopyFile copies a file from source to dest and returns
// an error if any.
// CopyFile copies a file or directory from source to dest and returns an error if any.
func CopyFile(source, dest string) error {
// Check if the source exists and whether it's a file or directory.
info, err := os.Stat(source)
if err != nil {
return err
}
if info.IsDir() {
// If the source is a directory, copy it recursively.
return copyDirectory(source, dest)
}
// If the source is a file, copy the file.
return copySingleFile(source, dest)
}
// copySingleFile handles copying a single file.
func copySingleFile(source, dest string) error {
// Open the source file.
src, err := os.Open(source)
if err != nil {
@@ -36,7 +59,7 @@ func CopyFile(source, dest string) error {
}
defer src.Close()
// Makes the directory needed to create the dst file.
// Create the destination directory if needed.
err = os.MkdirAll(filepath.Dir(dest), 0775) //nolint:gomnd
if err != nil {
return err
@@ -68,6 +91,43 @@ func CopyFile(source, dest string) error {
return nil
}
// copyDirectory handles copying directories recursively.
func copyDirectory(source, dest string) error {
// Create the destination directory.
err := os.MkdirAll(dest, 0775) //nolint:gomnd
if err != nil {
return err
}
// Read the contents of the source directory.
entries, err := os.ReadDir(source)
if err != nil {
return err
}
// Iterate over each entry in the directory.
for _, entry := range entries {
srcPath := filepath.Join(source, entry.Name())
destPath := filepath.Join(dest, entry.Name())
if entry.IsDir() {
// Recursively copy subdirectories.
err = copyDirectory(srcPath, destPath)
if err != nil {
return err
}
} else {
// Copy files.
err = copySingleFile(srcPath, destPath)
if err != nil {
return err
}
}
}
return nil
}
// CommonPrefix returns the common directory path of provided files.
func CommonPrefix(sep byte, paths ...string) string {
// Handle special cases.
+6 -10
View File
@@ -7,18 +7,15 @@ require (
github.com/disintegration/imaging v1.6.2
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568
github.com/gabriel-vasile/mimetype v1.4.7
github.com/goccy/go-yaml v1.15.7
github.com/goccy/go-yaml v1.15.15
github.com/golang-jwt/jwt/v4 v4.5.1
github.com/google/go-cmp v0.6.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/afero v1.11.0
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.9.0
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.30.0
golang.org/x/crypto v0.32.0
golang.org/x/image v0.23.0
golang.org/x/text v0.21.0
)
@@ -36,17 +33,16 @@ require (
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/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.etcd.io/bbolt v1.3.11 // indirect
golang.org/x/net v0.32.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/tools v0.28.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/tools v0.29.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+12 -22
View File
@@ -6,7 +6,6 @@ github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwv
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
@@ -30,8 +29,6 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
@@ -49,8 +46,8 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/goccy/go-yaml v1.15.7 h1:L7XuKpd/A66X4w/dlk08lVfiIADdy79a1AzRoIefC98=
github.com/goccy/go-yaml v1.15.7/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.15.15 h1:5turdzAlutS2Q7/QR/9R99Z1K0J00qDb4T0pHJcZ5ew=
github.com/goccy/go-yaml v1.15.15/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
@@ -67,8 +64,6 @@ github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@@ -80,23 +75,18 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
@@ -116,8 +106,8 @@ go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY=
golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
@@ -135,8 +125,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
@@ -155,8 +145,8 @@ golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -170,8 +160,8 @@ golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
+15 -13
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
libError "errors"
"fmt"
"log"
"net/http"
"net/url"
"os"
@@ -17,6 +16,8 @@ import (
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/users"
@@ -45,9 +46,18 @@ func extractToken(r *http.Request) (string, error) {
}
}
auth := r.URL.Query().Get("auth")
if auth != "" {
hasToken = true
if strings.Count(auth, ".") == 2 {
return auth, nil
}
}
// Check for Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader != "" {
hasToken = true
// Split the header to get "Bearer {token}"
parts := strings.Split(authHeader, " ")
@@ -57,14 +67,6 @@ func extractToken(r *http.Request) (string, error) {
}
}
auth := r.URL.Query().Get("auth")
if auth != "" {
hasToken = true
if strings.Count(auth, ".") == 2 {
return auth, nil
}
}
if hasToken {
return "", fmt.Errorf("invalid token provided")
}
@@ -126,14 +128,14 @@ func signupHandler(w http.ResponseWriter, r *http.Request) {
user.Username = info.Username
user.Password = info.Password
userHome, err := config.MakeUserDir(user.Username, user.Scope, config.Server.Root)
userHome, err := config.MakeUserDir(user.Username, user.Scope, files.RootPaths["default"])
if err != nil {
log.Printf("create user: failed to mkdir user home dir: [%s]", userHome)
logger.Error(fmt.Sprintf("create user: failed to mkdir user home dir: [%s]", userHome))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
user.Scope = userHome
log.Printf("new user: %s, home dir: [%s].", user.Username, userHome)
logger.Debug(fmt.Sprintf("new user: %s, home dir: [%s].", user.Username, userHome))
err = store.Users.Save(&user)
if err == errors.ErrExist {
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
@@ -150,7 +152,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.GenerateRandomHash(4), time.Hour*2, user.Perm)
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, user.Perm)
if err != nil {
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, err
+36 -27
View File
@@ -4,7 +4,6 @@ import (
"compress/gzip"
"encoding/json"
"fmt"
"log"
"net/http"
"path/filepath"
"strings"
@@ -12,15 +11,16 @@ import (
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/runner"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
)
type requestContext struct {
user *users.User
*runner.Runner
raw interface{}
raw interface{}
token string
}
type HttpResponse struct {
@@ -94,10 +94,10 @@ func withAdminHelper(fn handleFunc) handleFunc {
// Middleware to retrieve and authenticate user
func withUserHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
if settings.Config.Auth.Method == "noauth" {
if config.Auth.Method == "noauth" {
var err error
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(config.Server.Root, "admin")
data.user, err = store.Users.Get(files.RootPaths["default"], "admin")
if err != nil {
return http.StatusInternalServerError, err
}
@@ -110,6 +110,7 @@ func withUserHelper(fn handleFunc) handleFunc {
if err != nil {
return http.StatusUnauthorized, err
}
data.token = tokenString
var tk users.AuthToken
token, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
@@ -126,11 +127,15 @@ func withUserHelper(fn handleFunc) handleFunc {
if tk.Expires < time.Now().Add(time.Hour).Unix() {
w.Header().Add("X-Renew-Token", "true")
}
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(config.Server.Root, tk.BelongsTo)
data.user, err = store.Users.Get(files.RootPaths["default"], tk.BelongsTo)
if err != nil {
return http.StatusInternalServerError, err
}
setUserInResponseWriter(w, data.user)
// Call the handler function, passing in the context
return fn(w, r, data)
}
@@ -172,14 +177,14 @@ func wrapHandler(fn handleFunc) http.HandlerFunc {
// Marshal the error response to JSON
errorBytes, marshalErr := json.Marshal(response)
if marshalErr != nil {
log.Printf("Error marshalling error response: %v", marshalErr)
logger.Error(fmt.Sprintf("Error marshalling error response: %v", marshalErr))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Write the JSON error response
if _, writeErr := w.Write(errorBytes); writeErr != nil {
log.Printf("Error writing error response: %v", writeErr)
logger.Error(fmt.Sprintf("Error writing error response: %v", writeErr))
}
return
}
@@ -233,6 +238,7 @@ type ResponseWriterWrapper struct {
StatusCode int
wroteHeader bool
PayloadSize int
User string
}
// WriteHeader captures the status code and ensures it's only written once
@@ -255,6 +261,16 @@ func (w *ResponseWriterWrapper) Write(b []byte) (int, error) {
return w.ResponseWriter.Write(b)
}
// Helper function to set the user in the ResponseWriterWrapper
func setUserInResponseWriter(w http.ResponseWriter, user *users.User) {
// Wrap the response writer to set the user field
if wrappedWriter, ok := w.(*ResponseWriterWrapper); ok {
if user != nil {
wrappedWriter.User = user.Username
}
}
}
// LoggingMiddleware logs each request and its status code.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -267,30 +283,23 @@ func LoggingMiddleware(next http.Handler) http.Handler {
// Call the next handler.
next.ServeHTTP(wrappedWriter, r)
// Determine the color based on the status code.
color := "\033[32m" // Default green color
if wrappedWriter.StatusCode >= 300 && wrappedWriter.StatusCode < 500 {
color = "\033[33m" // Yellow for client errors (4xx)
} else if wrappedWriter.StatusCode >= 500 {
color = "\033[31m" // Red for server errors (5xx)
}
// Capture the full URL path including the query parameters.
fullURL := r.URL.Path
if r.URL.RawQuery != "" {
fullURL += "?" + r.URL.RawQuery
}
// Log the request, status code, and response size.
log.Printf("%s%-7s | %3d | %-15s | %-12s | \"%s\"%s",
color,
r.Method,
wrappedWriter.StatusCode, // Captured status code
r.RemoteAddr,
time.Since(start).String(),
fullURL,
"\033[0m", // Reset color
)
truncUser := wrappedWriter.User
if len(truncUser) > 12 {
truncUser = truncUser[:10] + ".."
}
logger.Api(
fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"",
r.Method,
wrappedWriter.StatusCode, // Captured status code
r.RemoteAddr,
truncUser,
time.Since(start).String(),
fullURL), wrappedWriter.StatusCode)
})
}
+1 -1
View File
@@ -105,7 +105,7 @@ func TestWithAdminHelper(t *testing.T) {
data := &requestContext{
user: tc.user,
}
token, err := makeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.GenerateRandomHash(4), time.Hour*2, tc.user.Perm)
token, err := makeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, tc.user.Perm)
if err != nil {
t.Fatalf("Error making token for request: %v", err)
}
+209
View File
@@ -0,0 +1,209 @@
package http
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
const (
onlyOfficeStatusDocumentClosedWithChanges = 2
onlyOfficeStatusDocumentClosedWithNoChanges = 4
onlyOfficeStatusForceSaveWhileDocumentStillOpen = 6
)
type OnlyOfficeCallback struct {
ChangesURL string `json:"changesurl,omitempty"`
Key string `json:"key,omitempty"`
Status int `json:"status,omitempty"`
URL string `json:"url,omitempty"`
Users []string `json:"users,omitempty"`
UserData string `json:"userdata,omitempty"`
}
func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if settings.Config.Integrations.OnlyOffice.Url == "" {
return http.StatusInternalServerError, errors.New("only-office integration must be configured in settings")
}
if !d.user.Perm.Modify {
return http.StatusForbidden, nil
}
encodedUrl := r.URL.Query().Get("url")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
}
// Decode the URL-encoded path
url, err := url.QueryUnescape(encodedUrl)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// get path from url
pathParts := strings.Split(url, "/api/raw?files=/")
path := pathParts[len(pathParts)-1]
urlFirst := pathParts[0]
if 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)
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Source: source,
Expand: false,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
})
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"
}
clientConfig := map[string]interface{}{
"document": map[string]interface{}{
"fileType": fileType,
"key": id,
"title": fileInfo.Name,
"url": url + "&auth=" + d.token,
"permissions": map[string]interface{}{
"edit": d.user.Perm.Modify,
"download": d.user.Perm.Download,
"print": d.user.Perm.Download,
},
},
"editorConfig": map[string]interface{}{
"callbackUrl": fmt.Sprintf("%v/api/onlyoffice/callback?path=%v&auth=%v", urlFirst, path, d.token),
"user": map[string]interface{}{
"id": strconv.FormatUint(uint64(d.user.ID), 10),
"name": d.user.Username,
},
"customization": map[string]interface{}{
"autosave": true,
"forcesave": true,
"uiTheme": theme,
},
"lang": d.user.Locale,
"mode": "edit",
},
}
if settings.Config.Integrations.OnlyOffice.Secret != "" {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(clientConfig))
signature, err := token.SignedString([]byte(settings.Config.Integrations.OnlyOffice.Secret))
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to sign JWT")
}
clientConfig["token"] = signature
}
return renderJSON(w, r, clientConfig)
}
func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return http.StatusInternalServerError, err
}
var data OnlyOfficeCallback
err = json.Unmarshal(body, &data)
if err != nil {
return http.StatusInternalServerError, err
}
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
data.Status == onlyOfficeStatusDocumentClosedWithNoChanges {
// Refer to only-office documentation
// - https://api.onlyoffice.com/editors/coedit
// - https://api.onlyoffice.com/editors/callback
//
// When the document is fully closed by all editors,
// then the document key should no longer be re-used.
deleteOfficeId(source, path)
}
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
data.Status == onlyOfficeStatusForceSaveWhileDocumentStillOpen {
if !d.user.Perm.Modify {
return http.StatusForbidden, nil
}
doc, err := http.Get(data.URL)
if err != nil {
return http.StatusInternalServerError, err
}
defer doc.Body.Close()
err = d.Runner.RunHook(func() error {
fileOpts := files.FileOptions{
Path: path,
Source: source,
}
writeErr := files.WriteFile(fileOpts, doc.Body)
if writeErr != nil {
return writeErr
}
return nil
}, "save", path, "", d.user)
if err != nil {
return http.StatusInternalServerError, err
}
}
resp := map[string]int{
"error": 0,
}
return renderJSON(w, r, resp)
}
func getOnlyOfficeId(source, path string) (string, error) {
idx := files.GetIndex(source)
realpath, _, _ := idx.GetRealPath(path)
// error is intentionally ignored in order treat errors
// the same as a cache-miss
cachedDocumentKey, ok := cache.OnlyOffice.Get(realpath).(string)
if ok {
return cachedDocumentKey, nil
}
return "", fmt.Errorf("document key not found")
}
func deleteOfficeId(source, path string) {
idx := files.GetIndex(source)
realpath, _, _ := idx.GetRealPath(path)
cache.OnlyOffice.Delete(realpath)
}
+15 -12
View File
@@ -9,9 +9,11 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/img"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
type ImgService interface {
@@ -42,17 +44,18 @@ type FileCache interface {
// @Router /api/preview [get]
func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
previewSize := r.URL.Query().Get("size")
if previewSize != "small" {
previewSize = "large"
}
if path == "" {
return http.StatusBadRequest, fmt.Errorf("invalid request path")
}
response, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Source: source,
Expand: true,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
@@ -88,26 +91,25 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
if err != nil {
return errToStatus(err), err
}
cacheKey := previewCacheKey(fileInfo, previewSize)
cacheKey := previewCacheKey(response.RealPath, previewSize, fileInfo.ModTime)
resizedImage, ok, err := fileCache.Load(r.Context(), cacheKey)
if err != nil {
return errToStatus(err), err
}
if !ok {
resizedImage, err = createPreview(imgSvc, fileCache, fileInfo, previewSize)
resizedImage, err = createPreview(imgSvc, fileCache, response, previewSize)
if err != nil {
return errToStatus(err), err
}
}
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, fileInfo.RealPath(), fileInfo.ModTime, bytes.NewReader(resizedImage))
http.ServeContent(w, r, response.RealPath, fileInfo.ModTime, bytes.NewReader(resizedImage))
return 0, nil
}
func createPreview(imgSvc ImgService, fileCache FileCache, file *files.FileInfo, previewSize string) ([]byte, error) {
fd, err := os.Open(file.RealPath())
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
}
@@ -138,9 +140,9 @@ func createPreview(imgSvc ImgService, fileCache FileCache, file *files.FileInfo,
}
go func() {
cacheKey := previewCacheKey(file, previewSize)
cacheKey := previewCacheKey(file.RealPath, previewSize, file.FileInfo.ModTime)
if err := fileCache.Store(context.Background(), cacheKey, buf.Bytes()); err != nil {
fmt.Printf("failed to cache resized image: %v", err)
logger.Error(fmt.Sprintf("failed to cache resized image: %v", err))
}
}()
@@ -148,12 +150,13 @@ func createPreview(imgSvc ImgService, fileCache FileCache, file *files.FileInfo,
}
// Generates a cache key for the preview image
func previewCacheKey(f *files.FileInfo, previewSize string) string {
return fmt.Sprintf("%x%x%x", f.RealPath(), f.ModTime.Unix(), previewSize)
func previewCacheKey(realPath, previewSize string, modTime time.Time) string {
return fmt.Sprintf("%x%x%x", realPath, modTime.Unix(), previewSize)
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file *files.FileInfo) (int, error) {
realPath, _, _ := files.GetRealPath(file.Path)
idx := files.GetIndex("default")
realPath, _, _ := idx.GetRealPath(file.Path)
fd, err := os.Open(realPath)
if err != nil {
return http.StatusInternalServerError, err
+2 -20
View File
@@ -7,7 +7,6 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
@@ -16,9 +15,9 @@ import (
func publicShareHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
file, ok := d.raw.(files.ExtendedFileInfo)
if !ok {
return http.StatusInternalServerError, fmt.Errorf("failed to assert type *files.FileInfo")
return http.StatusInternalServerError, fmt.Errorf("failed to assert type files.FileInfo")
}
file.Path = strings.TrimPrefix(file.Path, settings.Config.Server.Root)
file.Path = strings.TrimPrefix(file.Path, files.RootPaths["default"])
return renderJSON(w, r, file)
}
@@ -29,23 +28,6 @@ func publicUserGetHandler(w http.ResponseWriter, r *http.Request) {
if err != nil {
http.Error(w, http.StatusText(status), status)
}
}
func publicDlHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
file, ok := d.raw.(files.ExtendedFileInfo)
if !ok {
return http.StatusInternalServerError, fmt.Errorf("failed to assert type files.FileInfo")
}
if d.user == nil {
return http.StatusUnauthorized, fmt.Errorf("failed to get user")
}
if file.Type == "directory" {
return rawFilesHandler(w, r, d, []string{file.Path})
}
return rawFileHandler(w, r, file.FileInfo)
}
// health godoc
+55 -17
View File
@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
@@ -15,6 +14,7 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
@@ -45,17 +45,27 @@ func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
if !d.user.Perm.Download {
return http.StatusAccepted, nil
}
filePrefix := ""
file, ok := d.raw.(files.ExtendedFileInfo)
if ok {
filePrefix = file.Path
}
encodedFiles := r.URL.Query().Get("files")
// Decode the URL-encoded path
files, err := url.QueryUnescape(encodedFiles)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
return rawFilesHandler(w, r, d, strings.Split(files, ","))
fileList := strings.Split(files, ",")
for i, f := range fileList {
fileList[i] = filepath.Join(filePrefix, f)
}
return rawFilesHandler(w, r, d, fileList)
}
func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *zip.Writer) error {
realPath, _, _ := files.GetRealPath(d.user.Scope, path)
func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *zip.Writer, flatten bool) error {
idx := files.GetIndex("default")
realPath, _, _ := idx.GetRealPath(d.user.Scope, path)
if !d.user.Check(realPath) {
return nil
}
@@ -64,16 +74,36 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
return err
}
// Get the base name of the top-level folder or file
baseName := filepath.Base(realPath)
if info.IsDir() {
// Walk through directory contents
return filepath.Walk(realPath, func(filePath string, fileInfo os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(filepath.Dir(realPath), filePath) // Relative path including base dir
// Calculate the relative path
relPath, err := filepath.Rel(realPath, filePath) // Use realPath directly
if err != nil {
return err
}
// Normalize for tar: convert \ to /
relPath = filepath.ToSlash(relPath)
// Skip adding `.` (current directory)
if relPath == "." {
return nil
}
// Prepend base folder name unless flatten is true
if !flatten {
relPath = filepath.Join(baseName, relPath)
relPath = filepath.ToSlash(relPath) // Ensure normalized separators
}
if fileInfo.IsDir() {
if tarWriter != nil {
header := &tar.Header{
@@ -94,8 +124,8 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
})
}
// Add a single file if it's not a directory
return addSingleFile(realPath, filepath.Base(realPath), zipWriter, tarWriter)
// For a single file, use the base name as the archive path
return addSingleFile(realPath, baseName, zipWriter, tarWriter)
}
func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWriter *tar.Writer) error {
@@ -111,11 +141,12 @@ func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWrite
}
if tarWriter != nil {
header, err := tar.FileInfoHeader(info, realPath)
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = archivePath
// Ensure correct relative path
header.Name = filepath.ToSlash(archivePath)
if err = tarWriter.WriteHeader(header); err != nil {
return err
}
@@ -143,7 +174,8 @@ func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWrite
func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext, fileList []string) (int, error) {
filePath := fileList[0]
fileName := filepath.Base(filePath)
realPath, isDir, err := files.GetRealPath(d.user.Scope, filePath)
idx := files.GetIndex("default")
realPath, isDir, err := idx.GetRealPath(d.user.Scope, filePath)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -179,12 +211,14 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
default:
return http.StatusInternalServerError, errors.New("format not implemented")
}
baseDirName := filepath.Base(filepath.Dir(realPath))
if baseDirName == "" || baseDirName == "/" {
baseDirName = "download"
}
downloadFileName := url.PathEscape(baseDirName + "." + algo)
if len(fileList) == 1 && isDir {
baseDirName = filepath.Base(realPath)
}
downloadFileName := url.PathEscape(baseDirName + extension)
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+downloadFileName)
// Create the archive and stream it directly to the response
if extension == ".zip" {
@@ -204,10 +238,12 @@ func createZip(w io.Writer, d *requestContext, filenames ...string) error {
zipWriter := zip.NewWriter(w)
defer zipWriter.Close()
// Check if we have exactly one directory
//flatten := len(filenames) == 1
for _, fname := range filenames {
err := addFile(fname, d, nil, zipWriter)
err := addFile(fname, d, nil, zipWriter, false)
if err != nil {
log.Printf("Failed to add %s to ZIP: %v", fname, err)
logger.Error(fmt.Sprintf("Failed to add %s to ZIP: %v", fname, err))
}
}
@@ -221,10 +257,12 @@ func createTarGz(w io.Writer, d *requestContext, filenames ...string) error {
tarWriter := tar.NewWriter(gzWriter)
defer tarWriter.Close()
// Check if we have exactly one directory
//flatten := len(filenames) == 1
for _, fname := range filenames {
err := addFile(fname, d, tarWriter, nil)
err := addFile(fname, d, tarWriter, nil, false)
if err != nil {
log.Printf("Failed to add %s to TAR.GZ: %v", fname, err)
logger.Error(fmt.Sprintf("Failed to add %s to TAR.GZ: %v", fname, err))
}
}
+76 -61
View File
@@ -13,9 +13,10 @@ import (
"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/utils"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
// resourceGetHandler retrieves information about a resource.
@@ -25,6 +26,7 @@ import (
// @Accept json
// @Produce json
// @Param path query string true "Path to the resource"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Param source query string false "Name for the desired source, default is used if not provided"
// @Param content query string false "Include file content if true"
// @Param checksum query string false "Optional checksum validation"
@@ -33,8 +35,11 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [get]
func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// TODO source := r.URL.Query().Get("source")
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
@@ -43,6 +48,7 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Source: source,
Expand: true,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
@@ -55,7 +61,9 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return renderJSON(w, r, fileInfo)
}
if algo := r.URL.Query().Get("checksum"); algo != "" {
checksums, err := files.GetChecksum(fileInfo.Path, algo)
idx := files.GetIndex(source)
realPath, _, _ := idx.GetRealPath(d.user.Scope, path)
checksums, err := files.GetChecksum(realPath, algo)
if err == errors.ErrInvalidOption {
return http.StatusBadRequest, nil
} else if err != nil {
@@ -74,6 +82,7 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// @Accept json
// @Produce json
// @Param path query string true "Path to the resource"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Param source query string false "Name for the desired source, default is used if not provided"
// @Success 200 "Resource deleted successfully"
// @Failure 403 {object} map[string]string "Forbidden"
@@ -83,6 +92,10 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// TODO source := r.URL.Query().Get("source")
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
@@ -91,13 +104,9 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
if path == "/" || !d.user.Perm.Delete {
return http.StatusForbidden, nil
}
realPath, isDir, err := files.GetRealPath(d.user.Scope, path)
if err != nil {
return http.StatusNotFound, err
}
fileOpts := files.FileOptions{
Path: realPath,
IsDir: isDir,
Path: filepath.Join(d.user.Scope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: config.Server.TypeDetectionByHeader,
@@ -109,12 +118,9 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
}
// delete thumbnails
err = delThumbs(r.Context(), fileCache, fileInfo.FileInfo)
if err != nil {
return errToStatus(err), err
}
delThumbs(r.Context(), fileCache, fileInfo)
err = files.DeleteFiles(realPath, fileOpts)
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath))
if err != nil {
return errToStatus(err), err
}
@@ -129,6 +135,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
// @Accept json
// @Produce json
// @Param path query string true "Path to the resource"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Param source query string false "Name for the desired source, default is used if not provided"
// @Param override query bool false "Override existing file if true"
// @Success 200 "Resource created successfully"
@@ -138,8 +145,11 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [post]
func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// TODO source := r.URL.Query().Get("source")
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
@@ -150,6 +160,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
fileOpts := files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
Checker: d.user,
@@ -173,10 +184,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusForbidden, nil
}
err = delThumbs(r.Context(), fileCache, fileInfo.FileInfo)
if err != nil {
return errToStatus(err), err
}
delThumbs(r.Context(), fileCache, fileInfo)
}
err = files.WriteFile(fileOpts, r.Body)
if err != nil {
@@ -193,6 +201,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// @Accept json
// @Produce json
// @Param path query string true "Path to the resource"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Param source query string false "Name for the desired source, default is used if not provided"
// @Success 200 "Resource updated successfully"
// @Failure 403 {object} map[string]string "Forbidden"
@@ -201,9 +210,13 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [put]
func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// TODO source := r.URL.Query().Get("source")
// TODO source := r.URL.Query().Get("source")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
}
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
@@ -218,13 +231,9 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return http.StatusMethodNotAllowed, nil
}
realPath, isDir, err := files.GetRealPath(d.user.Scope, path)
if err != nil {
return http.StatusNotFound, err
}
fileOpts := files.FileOptions{
Path: realPath,
IsDir: isDir,
Path: filepath.Join(d.user.Scope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: config.Server.TypeDetectionByHeader,
@@ -241,6 +250,7 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// @Accept json
// @Produce json
// @Param from query string true "Path from resource"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Param destination query string true "Destination path for the resource"
// @Param action query string true "Action to perform (copy, rename)"
// @Param overwrite query bool false "Overwrite if destination exists"
@@ -254,6 +264,10 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
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")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
}
encodedFrom := r.URL.Query().Get("from")
// Decode the URL-encoded path
src, err := url.QueryUnescape(encodedFrom)
@@ -266,19 +280,20 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
return errToStatus(err), err
}
if !d.user.Check(src) || !d.user.Check(dst) {
return http.StatusForbidden, nil
return http.StatusForbidden, fmt.Errorf("forbidden: user rules deny access to source or destination")
}
if dst == "/" || src == "/" {
return http.StatusForbidden, nil
return http.StatusForbidden, fmt.Errorf("forbidden: source or destination is attempting to modify root")
}
idx := files.GetIndex(source)
// check target dir exists
parentDir, _, err := files.GetRealPath(d.user.Scope, filepath.Dir(dst))
parentDir, _, err := idx.GetRealPath(d.user.Scope, filepath.Dir(dst))
if err != nil {
return http.StatusNotFound, err
}
realDest := parentDir + "/" + filepath.Base(dst)
realSrc, isSrcDir, err := files.GetRealPath(d.user.Scope, src)
realSrc, isSrcDir, err := idx.GetRealPath(d.user.Scope, src)
if err != nil {
return http.StatusNotFound, err
}
@@ -289,12 +304,14 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
}
// Permission for overwriting the file
if overwrite && !d.user.Perm.Modify {
return http.StatusForbidden, nil
return http.StatusForbidden, fmt.Errorf("forbidden: user does not have permission to overwrite file")
}
err = d.RunHook(func() error {
return patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir)
return patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir, source)
}, action, realSrc, realDest, d.user)
if err != nil {
logger.Debug(fmt.Sprintf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err))
}
return errToStatus(err), err
}
@@ -314,28 +331,28 @@ func addVersionSuffix(source string) string {
return source
}
func delThumbs(ctx context.Context, fileCache FileCache, file *files.FileInfo) error {
if err := fileCache.Delete(ctx, previewCacheKey(file, "small")); err != nil {
return err
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))
}
return nil
}
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, fileCache FileCache, isSrcDir bool) error {
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, fileCache FileCache, isSrcDir bool, index string) error {
switch action {
case "copy":
if !d.user.Perm.Create {
return errors.ErrPermissionDenied
}
err := files.CopyResource(src, dst, isSrcDir)
err := files.CopyResource(index, src, dst, isSrcDir)
return err
case "rename", "move":
if !d.user.Perm.Rename {
return errors.ErrPermissionDenied
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: src,
Source: index,
IsDir: isSrcDir,
Modify: d.user.Perm.Modify,
Expand: false,
@@ -347,11 +364,8 @@ func patchAction(ctx context.Context, action, src, dst string, d *requestContext
}
// delete thumbnails
err = delThumbs(ctx, fileCache, fileInfo.FileInfo)
if err != nil {
return err
}
return files.MoveResource(src, dst, isSrcDir)
delThumbs(ctx, fileCache, fileInfo)
return files.MoveResource(index, src, dst, isSrcDir)
default:
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
}
@@ -368,7 +382,7 @@ type DiskUsageResponse struct {
// @Tags Resources
// @Accept json
// @Produce json
// @Param source query string false "Name for the desired source, default is used if not provided"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Success 200 {object} DiskUsageResponse "Disk usage details"
// @Failure 404 {object} map[string]string "Directory not found"
// @Failure 500 {object} map[string]string "Internal server error"
@@ -376,22 +390,19 @@ type DiskUsageResponse struct {
func diskUsage(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
source := r.URL.Query().Get("source")
if source == "" {
source = "/"
source = "default"
}
value, ok := utils.DiskUsageCache.Get(source).(DiskUsageResponse)
value, ok := cache.DiskUsage.Get(source).(DiskUsageResponse)
if ok {
return renderJSON(w, r, &value)
}
fPath, isDir, err := files.GetRealPath(d.user.Scope, source)
if err != nil {
return errToStatus(err), err
rootPath, ok := files.RootPaths[source]
if !ok {
return 400, fmt.Errorf("bad source path provided: %v", source)
}
if !isDir {
return http.StatusNotFound, fmt.Errorf("not a directory: %s", source)
}
usage, err := disk.UsageWithContext(r.Context(), fPath)
usage, err := disk.UsageWithContext(r.Context(), rootPath)
if err != nil {
return errToStatus(err), err
}
@@ -399,17 +410,21 @@ func diskUsage(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
Total: usage.Total,
Used: usage.Used,
}
utils.DiskUsageCache.Set(source, latestUsage)
cache.DiskUsage.Set(source, 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")
if source == "" {
source = "default"
}
// Decode the URL-encoded path
path, _ := url.QueryUnescape(encodedPath)
isDir := r.URL.Query().Get("isDir") == "true"
index := files.GetIndex(config.Server.Root)
info, _ := index.GetReducedMetadata(path, isDir)
isNotDir := r.URL.Query().Get("isDir") == "false" // default to isDir true
index := files.GetIndex(source)
info, _ := index.GetReducedMetadata(path, !isNotDir)
renderJSON(w, r, info) // nolint:errcheck
}
+12 -9
View File
@@ -5,11 +5,11 @@ import (
"embed"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"text/template"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/version"
@@ -56,7 +56,7 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
// Embedded mode: Serve files from the embedded assets
assetFs, err = fs.Sub(assets, "embed")
if err != nil {
log.Fatal("Could not embed frontend. Does dist exist?")
logger.Fatal("Could not embed frontend. Does dist exist?")
}
} else {
assetFs = dirFS{Dir: http.Dir("http/dist")}
@@ -107,13 +107,16 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
// Public routes
api.HandleFunc("GET /public/publicUser", publicUserGetHandler)
api.HandleFunc("GET /public/dl", withHashFile(publicDlHandler))
api.HandleFunc("GET /public/dl", withHashFile(rawHandler))
api.HandleFunc("GET /public/share", withHashFile(publicShareHandler))
// Settings routes
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
api.HandleFunc("PUT /settings", withAdmin(settingsPutHandler))
api.HandleFunc("GET /onlyoffice/config", withUser(onlyofficeClientConfigGetHandler))
api.HandleFunc("POST /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
api.HandleFunc("GET /search", withUser(searchHandler))
apiPath := config.Server.BaseURL + "api"
router.Handle(apiPath+"/", http.StripPrefix(apiPath, api))
@@ -143,7 +146,7 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
// Load the TLS certificate and key
cer, err := tls.LoadX509KeyPair(config.Server.TLSCert, config.Server.TLSKey)
if err != nil {
log.Fatalf("could not load certificate: %v", err)
logger.Fatal(fmt.Sprintf("could not load certificate: %v", err))
}
// Create a custom TLS listener
@@ -158,17 +161,17 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
// Listen on TCP and wrap with TLS
listener, err := tls.Listen("tcp", fmt.Sprintf(":%v", config.Server.Port), tlsConfig)
if err != nil {
log.Fatalf("could not start TLS server: %v", err)
logger.Fatal(fmt.Sprintf("could not start TLS server: %v", err))
}
if config.Server.Port != 443 {
port = fmt.Sprintf(":%d", config.Server.Port)
}
// Build the full URL with host and port
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
log.Printf("Running at : %s", fullURL)
logger.Info(fmt.Sprintf("Running at : %s", fullURL))
err = http.Serve(listener, muxWithMiddleware(router))
if err != nil {
log.Fatalf("could not start server: %v", err)
logger.Fatal(fmt.Sprintf("could not start server: %v", err))
}
} else {
// Set HTTP scheme and the default port for HTTP
@@ -178,10 +181,10 @@ func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
}
// Build the full URL with host and port
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
log.Printf("Running at : %s", fullURL)
logger.Info(fmt.Sprintf("Running at : %s", fullURL))
err := http.ListenAndServe(fmt.Sprintf(":%v", config.Server.Port), muxWithMiddleware(router))
if err != nil {
log.Fatalf("could not start server: %v", err)
logger.Fatal(fmt.Sprintf("could not start server: %v", err))
}
}
}
+5 -2
View File
@@ -5,7 +5,6 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
// searchHandler handles search requests for files based on the provided query.
@@ -54,11 +53,15 @@ import (
// @Router /api/search [get]
func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
query := r.URL.Query().Get("query")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
}
searchScope := strings.TrimPrefix(r.URL.Query().Get("scope"), ".")
searchScope = strings.TrimPrefix(searchScope, "/")
// Retrieve the User-Agent and X-Auth headers from the request
sessionId := r.Header.Get("SessionId")
index := files.GetIndex(settings.Config.Server.Root)
index := files.GetIndex(source)
userScope := strings.TrimPrefix(d.user.Scope, ".")
combinedScope := strings.TrimPrefix(userScope+"/"+searchScope, "/")
+1
View File
@@ -68,6 +68,7 @@ func shareGetsHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if err == errors.ErrNotExist {
return renderJSON(w, r, []*share.Link{})
}
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error getting share info from server")
}
+6 -3
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
@@ -12,6 +11,7 @@ import (
"text/template"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/version"
)
@@ -44,7 +44,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
data := map[string]interface{}{
"Name": config.Frontend.Name,
"DisableExternal": config.Frontend.DisableExternal,
"DisableExternal": config.Frontend.DisableDefaultLinks,
"DisableUsedPercentage": config.Frontend.DisableUsedPercentage,
"darkMode": settings.Config.UserDefaults.DarkMode,
"Color": config.Frontend.Color,
@@ -62,6 +62,9 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
"ResizePreview": config.Server.ResizePreview,
"EnableExec": config.Server.EnableExec,
"ReCaptchaHost": config.Auth.Recaptcha.Host,
"ExternalLinks": config.Frontend.ExternalLinks,
"ExternalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"OnlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
}
if config.Frontend.Files != "" {
@@ -69,7 +72,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
_, err := os.Stat(fPath) //nolint:govet
if err != nil && !os.IsNotExist(err) {
log.Printf("couldn't load custom styles: %v", err)
logger.Error(fmt.Sprintf("couldn't load custom styles: %v", err))
}
if err == nil {
+18 -13
View File
@@ -55,26 +55,29 @@ func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
if givenUserIdString == "self" {
givenUserId = d.user.ID
} else if givenUserIdString == "" {
if !d.user.Perm.Admin {
return http.StatusForbidden, nil
}
users, err := store.Users.Gets(config.Server.Root)
userList, err := store.Users.Gets(files.RootPaths["default"])
if err != nil {
return http.StatusInternalServerError, err
}
for _, u := range users {
selfUserList := []*users.User{}
for _, u := range userList {
u.Password = ""
}
for _, u := range users {
u.ApiKeys = nil
if u.ID == d.user.ID {
selfUserList = append(selfUserList, u)
}
}
sort.Slice(users, func(i, j int) bool {
return users[i].ID < users[j].ID
sort.Slice(userList, func(i, j int) bool {
return userList[i].ID < userList[j].ID
})
return renderJSON(w, r, users)
if !d.user.Perm.Admin {
userList = selfUserList
}
return renderJSON(w, r, userList)
} else {
num, _ := strconv.ParseUint(givenUserIdString, 10, 32)
givenUserId = uint(num)
@@ -85,7 +88,7 @@ func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
}
// Fetch the user details
u, err := store.Users.Get(config.Server.Root, givenUserId)
u, err := store.Users.Get(files.RootPaths["default"], givenUserId)
if err == errors.ErrNotExist {
return http.StatusNotFound, err
}
@@ -156,7 +159,8 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
// Validate the user's scope
_, _, err := files.GetRealPath(config.Server.Root, d.user.Scope)
idx := files.GetIndex("default")
_, _, err := idx.GetRealPath(d.user.Scope)
if err != nil {
return http.StatusBadRequest, err
}
@@ -214,7 +218,8 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
}
// Validate the user's scope
_, _, err := files.GetRealPath(config.Server.Root, d.user.Scope)
idx := files.GetIndex("default")
_, _, err := idx.GetRealPath(d.user.Scope)
if err != nil {
return http.StatusBadRequest, err
}
+1
View File
@@ -0,0 +1 @@
../../frontend/tests/playwright-files/myfolder/testdata/
+141
View File
@@ -0,0 +1,141 @@
package logger
import (
"fmt"
"io"
"log"
"os"
"slices"
"strings"
)
// Logger wraps the standard log.Logger with log level functionality
type Logger struct {
logger *log.Logger
levels []LogLevel
apiLevels []LogLevel
stdout bool
disabled bool
debugEnabled bool
disabledAPI bool
colors bool
}
var stdOutLoggerExists bool
// NewLogger creates a new Logger instance with separate file and stdout loggers
func NewLogger(filepath string, levels, apiLevels []LogLevel, noColors bool) (*Logger, error) {
var fileWriter io.Writer = io.Discard
stdout := filepath == ""
// Configure file logging
if !stdout {
file, err := os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return nil, fmt.Errorf("failed to open log file: %v", err)
}
fileWriter = file
}
var flags int
if slices.Contains(levels, DEBUG) {
flags |= log.Lshortfile
}
logger := log.New(os.Stdout, "", flags)
if filepath != "" {
logger = log.New(fileWriter, "", flags)
}
if stdout {
stdOutLoggerExists = true
}
return &Logger{
logger: logger,
levels: levels,
apiLevels: apiLevels,
disabled: slices.Contains(levels, DISABLED),
debugEnabled: slices.Contains(levels, DEBUG),
disabledAPI: slices.Contains(apiLevels, DISABLED),
colors: !noColors,
stdout: stdout,
}, nil
}
// SetupLogger configures the logger with file and stdout options and their respective log levels
func SetupLogger(output, levels, apiLevels string, noColors bool) error {
upperLevels := []LogLevel{}
for _, level := range SplitByMultiple(levels) {
if level == "" {
break
}
upperLevel := strings.ToUpper(level)
if upperLevel == "WARNING" || upperLevel == "WARN" {
upperLevel = "WARN "
}
if upperLevel == "INFO" {
upperLevel = "INFO "
}
// Convert level strings to LogLevel
level, ok := stringToLevel[upperLevel]
if !ok {
loggers = []*Logger{}
return fmt.Errorf("invalid file log level: %s", upperLevel)
}
upperLevels = append(upperLevels, level)
}
if len(upperLevels) == 0 {
upperLevels = []LogLevel{INFO, ERROR, WARNING}
}
upperApiLevels := []LogLevel{}
for _, level := range SplitByMultiple(apiLevels) {
if level == "" {
break
}
upperLevel := strings.ToUpper(level)
if upperLevel == "WARNING" || upperLevel == "WARN" {
upperLevel = "WARN "
}
if upperLevel == "INFO" {
upperLevel = "INFO "
}
// Convert level strings to LogLevel
level, ok := stringToLevel[strings.ToUpper(upperLevel)]
if !ok {
return fmt.Errorf("invalid api log level: %s", upperLevel)
}
upperApiLevels = append(upperApiLevels, level)
}
if len(upperApiLevels) == 0 {
upperApiLevels = []LogLevel{INFO, ERROR, WARNING}
}
if slices.Contains(upperLevels, DISABLED) && slices.Contains(upperApiLevels, DISABLED) {
// both disabled, not creating a logger
loggers = []*Logger{}
return nil
}
outputStdout := strings.ToUpper(output)
if outputStdout == "STDOUT" {
output = ""
}
if output == "" && stdOutLoggerExists {
// stdout logger already exists... don't create another
return fmt.Errorf("stdout logger already exists, could not set config levels=[%v] apiLevels=[%v] noColors=[%v]", levels, apiLevels, noColors)
}
// Create the logger
logger, err := NewLogger(output, upperLevels, upperApiLevels, noColors)
if err != nil {
loggers = []*Logger{}
return err
}
loggers = append(loggers, logger)
return nil
}
func SplitByMultiple(str string) []string {
delimiters := []rune{'|', ',', ' '}
return strings.FieldsFunc(str, func(r rune) bool {
for _, d := range delimiters {
if r == d {
return true
}
}
return false
})
}
+146
View File
@@ -0,0 +1,146 @@
package logger
import (
"fmt"
"log"
"slices"
"time"
)
type LogLevel int
const (
DISABLED LogLevel = 0
ERROR LogLevel = 1
FATAL LogLevel = 1
WARNING LogLevel = 2
INFO LogLevel = 3
DEBUG LogLevel = 4
API LogLevel = 10
// COLORS
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
GRAY = "\033[37m"
)
var (
loggers []*Logger
)
type levelConsts struct {
INFO string
FATAL string
ERROR string
WARNING string
DEBUG string
API string
DISABLED string
}
var levels = levelConsts{
INFO: "INFO ", // with consistent space padding
FATAL: "FATAL",
ERROR: "ERROR",
WARNING: "WARN ", // with consistent space padding
DEBUG: "DEBUG",
DISABLED: "DISABLED",
API: "API",
}
// stringToLevel maps string representation to LogLevel
var stringToLevel = map[string]LogLevel{
"DEBUG": DEBUG,
"INFO ": INFO, // with consistent space padding
"ERROR": ERROR,
"DISABLED": DISABLED,
"WARN ": WARNING, // with consistent space padding
"FATAL": FATAL,
"API": API,
}
// Log prints a log message if its level is greater than or equal to the logger's levels
func Log(level string, msg string, prefix, api bool, color string) {
LEVEL := stringToLevel[level]
for _, logger := range loggers {
if api {
if logger.disabledAPI || !slices.Contains(logger.apiLevels, LEVEL) {
continue
}
} else {
if logger.disabled || !slices.Contains(logger.levels, LEVEL) {
continue
}
}
if logger.stdout && level == "FATAL" {
continue
}
writeOut := msg
formattedTime := time.Now().Format("2006/01/02 15:04:05")
if logger.colors && color != "" {
formattedTime = formattedTime + color
}
if prefix || logger.debugEnabled {
logger.logger.SetPrefix(fmt.Sprintf("%s [%s] ", formattedTime, level))
} else {
logger.logger.SetPrefix(formattedTime + " ")
}
if logger.colors && color != "" {
writeOut = writeOut + "\033[0m"
}
err := logger.logger.Output(3, writeOut) // 3 skips this function for correct file:line
if err != nil {
log.Printf("failed to log message '%v' with error `%v`", msg, err)
}
}
}
func Api(msg string, statusCode int) {
if statusCode >= 300 && statusCode < 500 {
Log(levels.WARNING, msg, false, true, YELLOW)
} else if statusCode >= 500 {
Log(levels.ERROR, msg, false, true, RED)
} else {
Log(levels.INFO, msg, false, true, GREEN)
}
}
// Helper methods for specific log levels
func Debug(msg string) {
if len(loggers) > 0 {
Log(levels.DEBUG, msg, true, false, GRAY)
} else {
log.Println("[DEBUG]: " + msg)
}
}
func Info(msg string) {
if len(loggers) > 0 {
Log(levels.INFO, msg, false, false, "")
} else {
log.Println(msg)
}
}
func Warning(msg string) {
if len(loggers) > 0 {
Log(levels.WARNING, msg, true, false, YELLOW)
} else {
log.Println("[WARN ]: " + msg)
}
}
func Error(msg string) {
if len(loggers) > 0 {
Log(levels.ERROR, msg, true, false, RED)
} else {
log.Println("[ERROR] : ", msg)
}
}
func Fatal(msg string) {
if len(loggers) > 0 {
Log(levels.FATAL, msg, true, false, RED)
}
log.Fatal("[FATAL] : ", msg)
}
-17
View File
@@ -1,17 +0,0 @@
#!/bin/sh
## TEST file used by docker testing containers
checkExit() {
if [ "$?" -ne 0 ];then
exit 1
fi
}
if command -v go &> /dev/null
then
printf "\n == Running tests == \n"
go test -race -parallel -v ./...
checkExit
else
echo "ERROR: unable to perform tests"
exit 1
fi
+3 -2
View File
@@ -20,8 +20,9 @@ type Runner struct {
// RunHook runs the hooks for the before and after event.
func (r *Runner) RunHook(fn func() error, evt, path, dst string, user *users.User) error {
path, _, _ = files.GetRealPath(user.Scope, path)
dst, _, _ = files.GetRealPath(user.Scope, dst)
idx := files.GetIndex("default")
path, _, _ = idx.GetRealPath(user.Scope, path)
dst, _, _ = idx.GetRealPath(user.Scope, dst)
if r.Enabled {
if val, ok := r.Commands["before_"+evt]; ok {
-14
View File
@@ -1,14 +0,0 @@
#!/usr/bin/env bash
set -e
if ! [ -x "$(command -v standard-version)" ]; then
echo "standard-version is not installed. please run 'npm i -g standard-version'"
exit 1
fi
standard-version --dry-run --skip
read -p "Continue (y/n)? " -n 1 -r
echo ;
if [[ $REPLY =~ ^[Yy]$ ]]; then
standard-version -s ;
fi
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -e
if ! [ -x "$(command -v commitlint)" ]; then
echo "commitlint is not installed. please run 'npm i -g commitlint'"
exit 1
fi
for commit_hash in $(git log --pretty=format:%H origin/master..HEAD); do
commitlint -f ${commit_hash}~1 -t ${commit_hash}
done
+75 -21
View File
@@ -1,63 +1,115 @@
package settings
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/version"
)
var Config Settings
func Initialize(configFile string) {
yamlData := loadConfigFile(configFile)
Config = setDefaults()
err := yaml.Unmarshal(yamlData, &Config)
yamlData, err := loadConfigFile(configFile)
if err != nil && configFile != "config.yaml" {
logger.Fatal("Could not load specified config file: " + err.Error())
}
if err != nil {
log.Fatalf("Error unmarshaling YAML data: %v", err)
logger.Warning(fmt.Sprintf("Could not load config file '%v', using default settings: %v", configFile, err))
}
Config = setDefaults()
err = yaml.Unmarshal(yamlData, &Config)
if err != nil {
logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err))
}
Config.UserDefaults.Perm = Config.UserDefaults.Permissions
// Convert relative path to absolute path
realRoot, err := filepath.Abs(Config.Server.Root)
if err != nil {
log.Fatalf("Error getting root path: %v", err)
if len(Config.Server.Sources) > 0 {
// TODO allow multipe sources not named default
for _, source := range Config.Server.Sources {
realPath, err2 := filepath.Abs(source.Path)
if err2 != nil {
logger.Fatal(fmt.Sprintf("Error getting source path: %v", err2))
}
source.Path = realPath
source.Name = "default"
Config.Server.Sources = []Source{source} // temporary set only one source
}
} else {
realPath, err2 := filepath.Abs(Config.Server.Root)
if err2 != nil {
logger.Fatal(fmt.Sprintf("Error getting source path: %v", err2))
}
Config.Server.Sources = []Source{
{
Name: "default",
Path: realPath,
},
}
}
_, err = os.Stat(realRoot)
if err != nil {
log.Fatalf("ERROR: Configured Root Path does not exist! %v", err)
}
Config.Server.Root = realRoot
baseurl := strings.Trim(Config.Server.BaseURL, "/")
if baseurl == "" {
Config.Server.BaseURL = "/"
} else {
Config.Server.BaseURL = "/" + baseurl + "/"
}
if !Config.Frontend.DisableDefaultLinks {
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
Text: fmt.Sprintf("(%v)", version.Version),
Title: version.CommitSHA,
Url: "https://github.com/gtsteffaniak/filebrowser/releases/",
})
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
Text: "Help",
Url: "https://github.com/gtsteffaniak/filebrowser/wiki",
})
}
if len(Config.Server.Logging) == 0 {
Config.Server.Logging = []LogConfig{
{
Output: "stdout",
},
}
}
for _, logConfig := range Config.Server.Logging {
err = logger.SetupLogger(
logConfig.Output,
logConfig.Levels,
logConfig.ApiLevels,
logConfig.NoColors,
)
if err != nil {
log.Println("[ERROR] Failed to set up logger:", err)
}
}
}
func loadConfigFile(configFile string) []byte {
func loadConfigFile(configFile string) ([]byte, error) {
// Open and read the YAML file
yamlFile, err := os.Open(configFile)
if err != nil {
log.Println(err)
os.Exit(1)
return nil, err
}
defer yamlFile.Close()
stat, err := yamlFile.Stat()
if err != nil {
log.Fatalf("error getting file information: %s", err.Error())
return nil, err
}
yamlData := make([]byte, stat.Size())
_, err = yamlFile.Read(yamlData)
if err != nil {
log.Fatalf("Error reading YAML data: %v", err)
return nil, err
}
return yamlData
return yamlData, nil
}
func setDefaults() Settings {
@@ -70,9 +122,7 @@ func setDefaults() Settings {
NumImageProcessors: 4,
BaseURL: "",
Database: "database.db",
Log: "stdout",
Root: "/srv",
Indexing: true,
Root: ".",
},
Auth: Auth{
TokenExpirationTime: "2h",
@@ -84,6 +134,9 @@ func setDefaults() Settings {
Host: "",
},
},
Frontend: Frontend{
Name: "FileBrowser Quantum",
},
UserDefaults: UserDefaults{
StickySidebar: true,
Scope: ".",
@@ -93,6 +146,7 @@ func setDefaults() Settings {
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
Permissions: users.Permissions{
Create: false,
Rename: false,
+1 -1
View File
@@ -35,7 +35,7 @@ func Test_loadConfigFile(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := loadConfigFile(tt.args.configFile); !reflect.DeepEqual(got, tt.want) {
if got, _ := loadConfigFile(tt.args.configFile); !reflect.DeepEqual(got, tt.want) {
t.Errorf("loadConfigFile() = %v, want %v", got, tt.want)
}
})
+3 -2
View File
@@ -3,12 +3,13 @@ package settings
import (
"errors"
"fmt"
"log"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
var (
@@ -22,7 +23,7 @@ func (s *Settings) MakeUserDir(username, userScope, serverRoot string) (string,
if userScope == "" && s.Server.CreateUserDir {
username = cleanUsername(username)
if username == "" || username == "-" || username == "." {
log.Printf("create user: invalid user for home dir creation: [%s]", username)
logger.Error(fmt.Sprintf("create user: invalid user for home dir creation: [%s]", username))
return "", errors.New("invalid user for home dir creation")
}
userScope = path.Join(s.Server.UserHomeBasePath, username)
+7 -7
View File
@@ -1,22 +1,23 @@
package settings
import (
"log"
"fmt"
"testing"
"github.com/goccy/go-yaml"
"github.com/google/go-cmp/cmp"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
func TestConfigLoadChanged(t *testing.T) {
yamlData := loadConfigFile("./testingConfig.yaml")
yamlData, _ := loadConfigFile("./testingConfig.yaml")
// Marshal the YAML data to a more human-readable format
newConfig := setDefaults()
Config := setDefaults()
err := yaml.Unmarshal(yamlData, &newConfig)
if err != nil {
log.Fatalf("Error unmarshaling YAML data: %v", err)
logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err))
}
// Use go-cmp to compare the two structs
if diff := cmp.Diff(newConfig, Config); diff == "" {
@@ -25,14 +26,14 @@ func TestConfigLoadChanged(t *testing.T) {
}
func TestConfigLoadSpecificValues(t *testing.T) {
yamlData := loadConfigFile("./testingConfig.yaml")
yamlData, _ := loadConfigFile("./testingConfig.yaml")
// Marshal the YAML data to a more human-readable format
newConfig := setDefaults()
Config := setDefaults()
err := yaml.Unmarshal(yamlData, &newConfig)
if err != nil {
log.Fatalf("Error unmarshaling YAML data: %v", err)
logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err))
}
testCases := []struct {
fieldName string
@@ -41,14 +42,13 @@ func TestConfigLoadSpecificValues(t *testing.T) {
}{
{"Auth.Method", Config.Auth.Method, newConfig.Auth.Method},
{"Auth.Method", Config.Auth.Method, newConfig.Auth.Method},
{"Frontend.disableExternal", Config.Frontend.DisableExternal, newConfig.Frontend.DisableExternal},
{"UserDefaults.HideDotfiles", Config.UserDefaults.HideDotfiles, newConfig.UserDefaults.HideDotfiles},
{"Server.Database", Config.Server.Database, newConfig.Server.Database},
}
for _, tc := range testCases {
if tc.globalVal == tc.newVal {
t.Errorf("Differences should have been found:\n\tConfig.%s: %v \n\tSetConfig: %v \n", tc.fieldName, tc.globalVal, tc.newVal)
t.Errorf("Differences should have been found:\nConfig.%s: %v \nSetConfig: %v \n", tc.fieldName, tc.globalVal, tc.newVal)
}
}
}
+76 -24
View File
@@ -13,6 +13,7 @@ type Settings struct {
Frontend Frontend `json:"frontend"`
Users []UserDefaults `json:"users,omitempty"`
UserDefaults UserDefaults `json:"userDefaults"`
Integrations Integrations `json:"integrations"`
}
type Auth struct {
@@ -35,33 +36,83 @@ type Recaptcha struct {
}
type Server struct {
IndexingInterval uint32 `json:"indexingInterval"`
NumImageProcessors int `json:"numImageProcessors"`
Socket string `json:"socket"`
TLSKey string `json:"tlsKey"`
TLSCert string `json:"tlsCert"`
EnableThumbnails bool `json:"enableThumbnails"`
ResizePreview bool `json:"resizePreview"`
EnableExec bool `json:"enableExec"`
TypeDetectionByHeader bool `json:"typeDetectionByHeader"`
AuthHook string `json:"authHook"`
Port int `json:"port"`
BaseURL string `json:"baseURL"`
Address string `json:"address"`
Log string `json:"log"`
Database string `json:"database"`
Root string `json:"root"`
UserHomeBasePath string `json:"userHomeBasePath"`
CreateUserDir bool `json:"createUserDir"`
Indexing bool `json:"indexing"`
NumImageProcessors int `json:"numImageProcessors"`
Socket string `json:"socket"`
TLSKey string `json:"tlsKey"`
TLSCert string `json:"tlsCert"`
EnableThumbnails bool `json:"enableThumbnails"`
ResizePreview bool `json:"resizePreview"`
EnableExec bool `json:"enableExec"`
TypeDetectionByHeader bool `json:"typeDetectionByHeader"`
AuthHook string `json:"authHook"`
Port int `json:"port"`
BaseURL string `json:"baseURL"`
Address string `json:"address"`
Logging []LogConfig `json:"logging"`
Database string `json:"database"`
Root string `json:"root"`
UserHomeBasePath string `json:"userHomeBasePath"`
CreateUserDir bool `json:"createUserDir"`
Sources []Source `json:"sources"`
ExternalUrl string `json:"externalUrl"`
InternalUrl string `json:"internalUrl"` // used by integrations
}
type Integrations struct {
OnlyOffice OnlyOffice `json:"office"`
}
// onlyoffice secret is stored in the local.json file
// docker exec <containerID> /var/www/onlyoffice/documentserver/npm/json -f /etc/onlyoffice/documentserver/local.json 'services.CoAuthoring.secret.session.string'
type OnlyOffice struct {
Url string `json:"url"`
Secret string `json:"secret"`
}
type LogConfig struct {
Levels string `json:"levels"`
ApiLevels string `json:"apiLevels"`
Output string `json:"output"`
NoColors bool `json:"noColors"`
Json bool `json:"json"`
}
type Source struct {
Path string `json:"path"`
Name string `json:"name"`
Config IndexConfig `json:"config"`
}
type IndexConfig struct {
IndexingInterval uint32 `json:"indexingInterval"`
Disabled bool `json:"disabled"`
MaxWatchers int `json:"maxWatchers"`
NeverWatch []string `json:"neverWatchPaths"`
IgnoreHidden bool `json:"ignoreHidden"`
IgnoreZeroSizeFolders bool `json:"ignoreZeroSizeFolders"`
Exclude IndexFilter `json:"exclude"`
Include IndexFilter `json:"include"`
}
type IndexFilter struct {
Files []string `json:"files"`
Folders []string `json:"folders"`
FileEndsWith []string `json:"fileEndsWith"`
}
type Frontend struct {
Name string `json:"name"`
DisableExternal bool `json:"disableExternal"`
DisableUsedPercentage bool `json:"disableUsedPercentage"`
Files string `json:"files"`
Color string `json:"color"`
Name string `json:"name"`
DisableDefaultLinks bool `json:"disableDefaultLinks"`
DisableUsedPercentage bool `json:"disableUsedPercentage"`
Files string `json:"files"`
Color string `json:"color"`
ExternalLinks []ExternalLink `json:"externalLinks"`
}
type ExternalLink struct {
Text string `json:"text"`
Title string `json:"title"`
Url string `json:"url"`
}
// UserDefaults is a type that holds the default values
@@ -86,4 +137,5 @@ type UserDefaults struct {
Commands []string `json:"commands,omitempty"`
HideDotfiles bool `json:"hideDotfiles"`
DateFormat bool `json:"dateFormat"`
ThemeColor string `json:"themeColor"`
}
-1
View File
@@ -10,7 +10,6 @@ server:
port: 80
baseURL: "/"
address: ""
log: "stdout"
database: "mydb.db"
root: "/srv"
auth:
+18 -1
View File
@@ -1,10 +1,14 @@
package bolt
import (
"fmt"
"time"
"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"
)
@@ -59,7 +63,20 @@ func (s shareBackend) Gets(path string, id uint) ([]*share.Link, error) {
return v, errors.ErrNotExist
}
return v, err
filteredList := []*share.Link{}
// automatically delete and clear expired shares
for i := range v {
if v[i].Expire < time.Now().Unix() {
err = s.Delete(v[i].PasswordHash)
if err != nil {
logger.Error(fmt.Sprintf("expired share could not be deleted: %v", err.Error()))
}
} else {
filteredList = append(filteredList, v[i])
}
}
return filteredList, err
}
func (s shareBackend) Save(l *share.Link) error {
+7 -6
View File
@@ -2,7 +2,6 @@ package storage
import (
"fmt"
"log"
"os"
"path/filepath"
@@ -10,6 +9,7 @@ import (
"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"
@@ -116,16 +116,17 @@ func CreateUser(userInfo users.User, asAdmin bool) error {
newUser.Perm = settings.AdminPerms()
}
// create new home directory
userHome, err := settings.Config.MakeUserDir(newUser.Username, newUser.Scope, settings.Config.Server.Root)
userHome, err := settings.Config.MakeUserDir(newUser.Username, newUser.Scope, files.RootPaths["default"])
if err != nil {
log.Printf("create user: failed to mkdir user home dir: [%s]", userHome)
logger.Error(fmt.Sprintf("create user: failed to mkdir user home dir: [%s]", userHome))
return err
}
newUser.Scope = userHome
log.Printf("user: %s, home dir: [%s].", newUser.Username, userHome)
_, _, err = files.GetRealPath(settings.Config.Server.Root, newUser.Scope)
logger.Debug(fmt.Sprintf("user: %s, home dir: [%s].", newUser.Username, userHome))
idx := files.GetIndex("default")
_, _, err = idx.GetRealPath(newUser.Scope)
if err != nil {
log.Println("user path is not valid", newUser.Scope)
logger.Error(fmt.Sprintf("user path is not valid: %v", newUser.Scope))
return nil
}
err = store.Users.Save(&newUser)
+69 -2
View File
@@ -199,6 +199,12 @@ const docTemplate = `{
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for the desired source, default is used if not provided",
"name": "source",
"in": "query"
},
{
"type": "string",
"description": "Name for the desired source, default is used if not provided",
@@ -265,6 +271,12 @@ const docTemplate = `{
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for the desired source, default is used if not provided",
"name": "source",
"in": "query"
},
{
"type": "string",
"description": "Name for the desired source, default is used if not provided",
@@ -334,6 +346,12 @@ const docTemplate = `{
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for the desired source, default is used if not provided",
"name": "source",
"in": "query"
},
{
"type": "string",
"description": "Name for the desired source, default is used if not provided",
@@ -409,6 +427,12 @@ const docTemplate = `{
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for the desired source, default is used if not provided",
"name": "source",
"in": "query"
},
{
"type": "string",
"description": "Name for the desired source, default is used if not provided",
@@ -469,6 +493,12 @@ const docTemplate = `{
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for the desired source, default is used if not provided",
"name": "source",
"in": "query"
},
{
"type": "string",
"description": "Destination path for the resource",
@@ -857,7 +887,7 @@ const docTemplate = `{
"parameters": [
{
"type": "string",
"description": "Name for the desired source, default is used if not provided",
"description": "Source name for the desired source, default is used if not provided",
"name": "source",
"in": "query"
}
@@ -1141,30 +1171,37 @@ const docTemplate = `{
"type": "object",
"properties": {
"files": {
"description": "files in the directory",
"type": "array",
"items": {
"$ref": "#/definitions/files.ItemInfo"
}
},
"folders": {
"description": "folders in the directory",
"type": "array",
"items": {
"$ref": "#/definitions/files.ItemInfo"
}
},
"modified": {
"description": "modification time",
"type": "string"
},
"name": {
"description": "name of the file",
"type": "string"
},
"path": {
"description": "path scoped to the associated index",
"type": "string"
},
"size": {
"description": "length in bytes for regular files",
"type": "integer"
},
"type": {
"description": "type of the file, either \"directory\" or a file mimetype",
"type": "string"
}
}
@@ -1173,15 +1210,19 @@ const docTemplate = `{
"type": "object",
"properties": {
"modified": {
"description": "modification time",
"type": "string"
},
"name": {
"description": "name of the file",
"type": "string"
},
"size": {
"description": "length in bytes for regular files",
"type": "integer"
},
"type": {
"description": "type of the file, either \"directory\" or a file mimetype",
"type": "string"
}
}
@@ -1260,18 +1301,38 @@ const docTemplate = `{
}
}
},
"settings.ExternalLink": {
"type": "object",
"properties": {
"text": {
"type": "string"
},
"title": {
"type": "string"
},
"url": {
"type": "string"
}
}
},
"settings.Frontend": {
"type": "object",
"properties": {
"color": {
"type": "string"
},
"disableExternal": {
"disableDefaultLinks": {
"type": "boolean"
},
"disableUsedPercentage": {
"type": "boolean"
},
"externalLinks": {
"type": "array",
"items": {
"$ref": "#/definitions/settings.ExternalLink"
}
},
"files": {
"type": "string"
},
@@ -1342,6 +1403,9 @@ const docTemplate = `{
"stickySidebar": {
"type": "boolean"
},
"themeColor": {
"type": "string"
},
"viewMode": {
"type": "string"
}
@@ -1542,6 +1606,9 @@ const docTemplate = `{
"stickySidebar": {
"type": "boolean"
},
"themeColor": {
"type": "string"
},
"username": {
"type": "string"
},
+69 -2
View File
@@ -188,6 +188,12 @@
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for the desired source, default is used if not provided",
"name": "source",
"in": "query"
},
{
"type": "string",
"description": "Name for the desired source, default is used if not provided",
@@ -254,6 +260,12 @@
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for the desired source, default is used if not provided",
"name": "source",
"in": "query"
},
{
"type": "string",
"description": "Name for the desired source, default is used if not provided",
@@ -323,6 +335,12 @@
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for the desired source, default is used if not provided",
"name": "source",
"in": "query"
},
{
"type": "string",
"description": "Name for the desired source, default is used if not provided",
@@ -398,6 +416,12 @@
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for the desired source, default is used if not provided",
"name": "source",
"in": "query"
},
{
"type": "string",
"description": "Name for the desired source, default is used if not provided",
@@ -458,6 +482,12 @@
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name for the desired source, default is used if not provided",
"name": "source",
"in": "query"
},
{
"type": "string",
"description": "Destination path for the resource",
@@ -846,7 +876,7 @@
"parameters": [
{
"type": "string",
"description": "Name for the desired source, default is used if not provided",
"description": "Source name for the desired source, default is used if not provided",
"name": "source",
"in": "query"
}
@@ -1130,30 +1160,37 @@
"type": "object",
"properties": {
"files": {
"description": "files in the directory",
"type": "array",
"items": {
"$ref": "#/definitions/files.ItemInfo"
}
},
"folders": {
"description": "folders in the directory",
"type": "array",
"items": {
"$ref": "#/definitions/files.ItemInfo"
}
},
"modified": {
"description": "modification time",
"type": "string"
},
"name": {
"description": "name of the file",
"type": "string"
},
"path": {
"description": "path scoped to the associated index",
"type": "string"
},
"size": {
"description": "length in bytes for regular files",
"type": "integer"
},
"type": {
"description": "type of the file, either \"directory\" or a file mimetype",
"type": "string"
}
}
@@ -1162,15 +1199,19 @@
"type": "object",
"properties": {
"modified": {
"description": "modification time",
"type": "string"
},
"name": {
"description": "name of the file",
"type": "string"
},
"size": {
"description": "length in bytes for regular files",
"type": "integer"
},
"type": {
"description": "type of the file, either \"directory\" or a file mimetype",
"type": "string"
}
}
@@ -1249,18 +1290,38 @@
}
}
},
"settings.ExternalLink": {
"type": "object",
"properties": {
"text": {
"type": "string"
},
"title": {
"type": "string"
},
"url": {
"type": "string"
}
}
},
"settings.Frontend": {
"type": "object",
"properties": {
"color": {
"type": "string"
},
"disableExternal": {
"disableDefaultLinks": {
"type": "boolean"
},
"disableUsedPercentage": {
"type": "boolean"
},
"externalLinks": {
"type": "array",
"items": {
"$ref": "#/definitions/settings.ExternalLink"
}
},
"files": {
"type": "string"
},
@@ -1331,6 +1392,9 @@
"stickySidebar": {
"type": "boolean"
},
"themeColor": {
"type": "string"
},
"viewMode": {
"type": "string"
}
@@ -1531,6 +1595,9 @@
"stickySidebar": {
"type": "boolean"
},
"themeColor": {
"type": "string"
},
"username": {
"type": "string"
},
+50 -2
View File
@@ -2,33 +2,44 @@ definitions:
files.FileInfo:
properties:
files:
description: files in the directory
items:
$ref: '#/definitions/files.ItemInfo'
type: array
folders:
description: folders in the directory
items:
$ref: '#/definitions/files.ItemInfo'
type: array
modified:
description: modification time
type: string
name:
description: name of the file
type: string
path:
description: path scoped to the associated index
type: string
size:
description: length in bytes for regular files
type: integer
type:
description: type of the file, either "directory" or a file mimetype
type: string
type: object
files.ItemInfo:
properties:
modified:
description: modification time
type: string
name:
description: name of the file
type: string
size:
description: length in bytes for regular files
type: integer
type:
description: type of the file, either "directory" or a file mimetype
type: string
type: object
files.SearchResult:
@@ -79,14 +90,27 @@ definitions:
userHomeBasePath:
type: string
type: object
settings.ExternalLink:
properties:
text:
type: string
title:
type: string
url:
type: string
type: object
settings.Frontend:
properties:
color:
type: string
disableExternal:
disableDefaultLinks:
type: boolean
disableUsedPercentage:
type: boolean
externalLinks:
items:
$ref: '#/definitions/settings.ExternalLink'
type: array
files:
type: string
name:
@@ -133,6 +157,8 @@ definitions:
type: object
stickySidebar:
type: boolean
themeColor:
type: string
viewMode:
type: string
type: object
@@ -267,6 +293,8 @@ definitions:
$ref: '#/definitions/users.Sorting'
stickySidebar:
type: boolean
themeColor:
type: string
username:
type: string
viewMode:
@@ -398,6 +426,10 @@ paths:
name: path
required: true
type: string
- description: Source name for the desired source, default is used if not provided
in: query
name: source
type: string
- description: Name for the desired source, default is used if not provided
in: query
name: source
@@ -439,6 +471,10 @@ paths:
name: path
required: true
type: string
- description: Source name for the desired source, default is used if not provided
in: query
name: source
type: string
- description: Name for the desired source, default is used if not provided
in: query
name: source
@@ -483,6 +519,10 @@ paths:
name: from
required: true
type: string
- description: Source name for the desired source, default is used if not provided
in: query
name: source
type: string
- description: Destination path for the resource
in: query
name: destination
@@ -544,6 +584,10 @@ paths:
name: path
required: true
type: string
- description: Source name for the desired source, default is used if not provided
in: query
name: source
type: string
- description: Name for the desired source, default is used if not provided
in: query
name: source
@@ -594,6 +638,10 @@ paths:
name: path
required: true
type: string
- description: Source name for the desired source, default is used if not provided
in: query
name: source
type: string
- description: Name for the desired source, default is used if not provided
in: query
name: source
@@ -842,7 +890,7 @@ paths:
- application/json
description: Returns the total and used disk space for a specified directory.
parameters:
- description: Name for the desired source, default is used if not provided
- description: Source name for the desired source, default is used if not provided
in: query
name: source
type: string
View File
Whitespace-only changes.
View File
Whitespace-only changes.
-113
View File
@@ -1,113 +0,0 @@
== Running tests ==
/usr/local/go/bin/go
? github.com/gtsteffaniak/filebrowser [no test files]
? github.com/gtsteffaniak/filebrowser/auth [no test files]
? github.com/gtsteffaniak/filebrowser/cmd [no test files]
? github.com/gtsteffaniak/filebrowser/errors [no test files]
? github.com/gtsteffaniak/filebrowser/files [no test files]
=== RUN TestFileCache
--- PASS: TestFileCache (0.00s)
PASS
ok github.com/gtsteffaniak/filebrowser/diskcache (cached)
=== RUN TestCommonPrefix
=== RUN TestCommonPrefix/sub_folder
=== RUN TestCommonPrefix/relative_path
=== RUN TestCommonPrefix/no_common_path
=== RUN TestCommonPrefix/same_lvl
--- PASS: TestCommonPrefix (0.00s)
--- PASS: TestCommonPrefix/sub_folder (0.00s)
--- PASS: TestCommonPrefix/relative_path (0.00s)
--- PASS: TestCommonPrefix/no_common_path (0.00s)
--- PASS: TestCommonPrefix/same_lvl (0.00s)
PASS
ok github.com/gtsteffaniak/filebrowser/fileutils (cached)
? github.com/gtsteffaniak/filebrowser/settings [no test files]
? github.com/gtsteffaniak/filebrowser/share [no test files]
? github.com/gtsteffaniak/filebrowser/storage [no test files]
? github.com/gtsteffaniak/filebrowser/storage/bolt [no test files]
2023/09/02 13:07:17 Error opening YAML file: open filebrowser.yaml: no such file or directory
FAIL github.com/gtsteffaniak/filebrowser/http 0.008s
=== RUN TestService_Resize
=== RUN TestService_Resize/convert_to_png
=== RUN TestService_Resize/convert_to_tiff
=== RUN TestService_Resize/resize_bmp
=== RUN TestService_Resize/resize_with_medium_quality
=== RUN TestService_Resize/resize_with_low_quality
=== RUN TestService_Resize/get_thumbnail_from_file_with_APP0_JFIF
=== RUN TestService_Resize/fill_upscale
=== RUN TestService_Resize/fit_upscale
=== RUN TestService_Resize/convert_to_gif
=== RUN TestService_Resize/convert_to_bmp
=== RUN TestService_Resize/resize_tiff
=== RUN TestService_Resize/resize_with_high_quality
=== RUN TestService_Resize/fill_downscale
=== RUN TestService_Resize/keep_original_format
=== RUN TestService_Resize/convert_to_unknown
=== RUN TestService_Resize/get_thumbnail_from_file_without_APP0_JFIF
=== RUN TestService_Resize/resize_for_higher_quality_levels
=== RUN TestService_Resize/broken_file
=== RUN TestService_Resize/fit_downscale
=== RUN TestService_Resize/convert_to_jpeg
=== RUN TestService_Resize/resize_png
=== RUN TestService_Resize/resize_gif
=== RUN TestService_Resize/resize_with_unknown_quality
=== RUN TestService_Resize/resize_from_file_without_IFD1_thumbnail
--- PASS: TestService_Resize (1.36s)
--- PASS: TestService_Resize/convert_to_png (0.01s)
--- PASS: TestService_Resize/convert_to_tiff (0.01s)
--- PASS: TestService_Resize/resize_bmp (0.01s)
--- PASS: TestService_Resize/resize_with_medium_quality (0.01s)
--- PASS: TestService_Resize/resize_with_low_quality (0.01s)
--- PASS: TestService_Resize/get_thumbnail_from_file_with_APP0_JFIF (0.02s)
--- PASS: TestService_Resize/fill_upscale (0.01s)
--- PASS: TestService_Resize/fit_upscale (0.00s)
--- PASS: TestService_Resize/convert_to_gif (0.01s)
--- PASS: TestService_Resize/convert_to_bmp (0.01s)
--- PASS: TestService_Resize/resize_tiff (0.01s)
--- PASS: TestService_Resize/resize_with_high_quality (0.01s)
--- PASS: TestService_Resize/fill_downscale (0.01s)
--- PASS: TestService_Resize/keep_original_format (0.01s)
--- PASS: TestService_Resize/convert_to_unknown (0.01s)
--- PASS: TestService_Resize/get_thumbnail_from_file_without_APP0_JFIF (0.03s)
--- PASS: TestService_Resize/resize_for_higher_quality_levels (0.03s)
--- PASS: TestService_Resize/broken_file (0.00s)
--- PASS: TestService_Resize/fit_downscale (0.01s)
--- PASS: TestService_Resize/convert_to_jpeg (0.01s)
--- PASS: TestService_Resize/resize_png (0.02s)
--- PASS: TestService_Resize/resize_gif (0.02s)
--- PASS: TestService_Resize/resize_with_unknown_quality (0.01s)
--- PASS: TestService_Resize/resize_from_file_without_IFD1_thumbnail (1.09s)
=== RUN TestService_FormatFromExtension
=== RUN TestService_FormatFromExtension/gif
=== RUN TestService_FormatFromExtension/tiff
=== RUN TestService_FormatFromExtension/bmp
=== RUN TestService_FormatFromExtension/unknown
=== RUN TestService_FormatFromExtension/jpg
=== RUN TestService_FormatFromExtension/jpeg
=== RUN TestService_FormatFromExtension/png
--- PASS: TestService_FormatFromExtension (0.00s)
--- PASS: TestService_FormatFromExtension/gif (0.00s)
--- PASS: TestService_FormatFromExtension/tiff (0.00s)
--- PASS: TestService_FormatFromExtension/bmp (0.00s)
--- PASS: TestService_FormatFromExtension/unknown (0.00s)
--- PASS: TestService_FormatFromExtension/jpg (0.00s)
--- PASS: TestService_FormatFromExtension/jpeg (0.00s)
--- PASS: TestService_FormatFromExtension/png (0.00s)
PASS
ok github.com/gtsteffaniak/filebrowser/img (cached)
=== RUN TestMatchHidden
--- PASS: TestMatchHidden (0.00s)
PASS
ok github.com/gtsteffaniak/filebrowser/rules (cached)
2023/09/02 13:07:17 Error opening YAML file: open filebrowser.yaml: no such file or directory
FAIL github.com/gtsteffaniak/filebrowser/runner 0.007s
=== RUN TestParseSearch
--- PASS: TestParseSearch (0.00s)
PASS
ok github.com/gtsteffaniak/filebrowser/search (cached)
? github.com/gtsteffaniak/filebrowser/version [no test files]
testing: warning: no tests to run
PASS
ok github.com/gtsteffaniak/filebrowser/users (cached) [no tests to run]
FAIL
+1
View File
@@ -55,6 +55,7 @@ type User struct {
HideDotfiles bool `json:"hideDotfiles"`
DateFormat bool `json:"dateFormat"`
GallerySize int `json:"gallerySize"`
ThemeColor string `json:"themeColor"`
}
var PublicUser = User{
+13 -5
View File
@@ -2,17 +2,20 @@ package utils
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
math "math/rand"
"reflect"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
func CheckErr(source string, err error) {
if err != nil {
log.Fatalf("%s: %v", source, err)
logger.Fatal(fmt.Sprintf("%s: %v", source, err))
}
}
@@ -33,7 +36,7 @@ func CapitalizeFirst(s string) string {
return strings.ToUpper(string(s[0])) + s[1:]
}
func GenerateRandomHash(length int) string {
func InsecureRandomIdentifier(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
math.New(math.NewSource(time.Now().UnixNano()))
result := make([]byte, length)
@@ -49,7 +52,7 @@ func PrintStructFields(v interface{}) {
// Ensure the input is a struct
if val.Kind() != reflect.Struct {
fmt.Println("Provided value is not a struct")
logger.Debug("Provided value is not a struct")
return
}
@@ -66,7 +69,7 @@ func PrintStructFields(v interface{}) {
fieldValue = fieldValue[:100] + "..."
}
fmt.Printf("Field: %s, %s\n", fieldType.Name, fieldValue)
logger.Debug(fmt.Sprintf("Field: %s, %s\n", fieldType.Name, fieldValue))
}
}
@@ -84,3 +87,8 @@ func GetParentDirectoryPath(path string) string {
}
return path[:lastSlash]
}
func HashSHA256(data string) string {
bytes := sha256.Sum256([]byte(data))
return hex.EncodeToString(bytes[:])
}
-225
View File
@@ -1,225 +0,0 @@
# Configuration Help
This document covers the available configuration options, their defaults, and how they affect the functionality of filebrowser.
## All possible configurations
Here is an expanded config file which includes all possible configurations:
```
server:
CreateUserDir: false
UserHomeBasePath: ""
indexing: true
numImageProcessors: 4
socket: ""
tlsKey: ""
tlsCert: ""
enableThumbnails: false
resizePreview: true
typeDetectionByHeader: true
port: 80
baseURL: "/"
address: ""
log: "stdout"
database: "/database/database.db"
root: "/srv"
auth:
adminUsername: admin
adminPassword: admin
recaptcha:
host: ""
key: ""
secret: ""
tokenExpirationTime: 2h
header: ""
method: json
signup: false
frontend:
name: ""
disableExternal: false
disableUsedPercentage: true
files: ""
color: ""
userDefaults:
settingsAllowed: true
darkMode: false
scope: ""
locale: "en"
viewMode: ""
singleClick: true
sorting:
by: ""
asc: true
permissions:
admin: true
execute: true
create: true
rename: true
modify: true
delete: true
share: true
download: true
hideDotfiles: false
dateFormat: false
```
Here are the defaults if nothing is set:
```
server:
enableThumbnails: true
enableExec: false
port: 80
numImageProcessors: 4
baseURL: ""
database: database.db
log: stdout
root: /srv
auth:
adminUsername: admin
adminPassword: admin
method: password
recaptcha:
host: ""
userDefaults:
settingsAllowed: true
darkMode: false
scope: ""
locale: "en"
scope: "."
lockPassword: false
hideDotfiles: true
permissions:
create: true
rename: true
modify: true
delete: true
share: true
download: true
```
## About each configuration
# Configuration Settings Documentation
## About each configuration
### Server configuration settings
- `indexingInterval`: This optional paramter disables smart indexing and specifies a time in minutes the system waits before checking for filesystem changes. See [indexing readme](indexing.md) for more information.
- `indexing`: This enables or disables indexing. (Note: search will not work without indexing) Default: `true`
- `numImageProcessors`: This is the number of image processors available. Default: `4`
- `socket`: This is the socket configuration.
- `tlsKey`: This is the TLS key configuration.
- `tlsCert`: This is the TLS certificate configuration.
- `enableThumbnails`: This boolean value determines whether thumbnails are enabled on ui. Default: `true`
- `resizePreview`: This boolean value determines whether preview resizing is enabled. Default: `false`
- `typeDetectionByHeader`: This boolean value determines whether type detection is based on headers.
- `port`: This is the port number on which the server is running. Default: `80`
- `baseURL`: This is the base URL for the server. Default: `""`
- `address`: This is the server address configuration. Default: `0.0.0.0`
- `log`: This specifies the log destination. Default: `stdout`
- `database`: This is the database file path + filename that will be created if it does not already exist. If it exists, it will use the existing file. Default `database.db`
- `root`: This is the root directory path. Default: `/srv`
- `CreateUserDir`: Boolean to create user directory on user creation. Default: `false`
- `UserHomeBasePath`: String to define user home directory base path. Default: `""`
### Auth configuration settings
- `recaptcha`:
- `host`: This is the host for reCAPTCHA.
- `key`: This is the reCAPTCHA key.
- `secret`: This is the reCAPTCHA secret.
- `header`: This is the authentication header.
- `method`: This is the authentication method used (e.g., "json"). Possible values:
- `password` - username and password
- `hook` - hook authentication
- `proxy` - proxy authentication
- `oath` - oath authentication
- `noauth` - no authentication/login required.
- `Signup`: This boolean value indicates whether user signup is enabled on the login page. NOTE: Be mindful of `userDefaults` settings if enabled. Default: `false`
- `AdminUsername`: This is the username of the admin user. Default: `admin`
- `AdminPassword`: This is the password of the admin user. Default: `admin`
### Frontend configuration settings
- `name`: This is the name of the frontend.
- `disableExternal`: This boolean value determines whether external access is disabled.
- `disableUsedPercentage`: This boolean value determines whether used percentage is disabled.
- `files`: This is the files configuration.
- `theme`: This is the theme configuration.
- `color`: This is the color configuration.
### UserDefaults configuration settings
- `darkMode`: Determines whether dark mode is enabled for the user (`true` or `false`)
- `settingsAllowed`: Determines whether settings page is enabled for the user (`true` or `false`)
- `scope`: This is a scope of the permissions, "." or "./" means all directories, "./downloads" would mean only the downloads folder.
- `locale`: String locale configuration. Default: `en`
- `viewMode`: This is the view mode configuration. Possible values: `normal`, `compact`, `list`, and `gallery`. default: `normal`
- `singleClick`: This boolean value determines whether single-click is enabled. (`true` or `false`)
- `sorting`:
- `by`: This is the sorting method used (e.g., "asc").
- `asc`: This boolean value determines the sorting order is ascending or descending. (`true` or `false`)
- `permissions`:
- `admin`: This boolean value determines whether admin permissions are granted.
- `execute`: This boolean value determines whether execute permissions are granted.
- `create`: This boolean value determines whether create permissions are granted.
- `rename`: This boolean value determines whether rename permissions are granted.
- `modify`: This boolean value determines whether modify permissions are granted.
- `delete`: This boolean value determines whether delete permissions are granted.
- `share`: This boolean value determines whether share permissions are granted.
- `download`: This boolean value determines whether download permissions are granted.
- `api`: Ability to create and manage API keys.
- `hideDotfiles`: This boolean value determines whether dotfiles are hidden. (`true` or `false`)
- `dateFormat`: This boolean value determines whether date formatting is enabled. (`true` or `false`)
-13
View File
@@ -1,13 +0,0 @@
# Contributing Guide
If you would like to contribute, please open a pull request against main or the latest `dev_` branch thats currently in progress.
A PR is required to have:
1. A clear description about why it was opened
2. A short title that best describes the issue.
3. Test evidence for anything that is not self evident or covered by unit tests.
Unit tests should be updated to pass before merging. So, the best way to handle this is to create a fork and test your changes there, then merge to this repo. You can also create a draft pull request if it is not fully ready.
Please don't hesitate to open an issue for any ideas you have, but cannot contribute directly for whatever reason.
-189
View File
@@ -1,189 +0,0 @@
# About Indexing on FileBrowser Quantum
The most significant feature is the index, this document intends to demystify how it works so you can understand how to ensure your index closely matches the current state of your filesystem.
## How does the index work?
The approach used by this repo includes filesystem watchers that periodically scan the directory tree for changes. By default, this uses a smart scan strategy, but you can also configure a set interval in your config file.
The `scan interval` is the break time between scans and does not include the time a scan takes. A typical scan can vary dramatically, but here are some expectations for SSD-based disks:
| # folders | # files | time to index | memory usage (RAM) |
|---|---|---|---|
| 10,000 | 10,000 | ~ 0-5 seconds | 15 MB |
| 2,000 | 250,000 | ~ 0-5 seconds | 300 MB |
| 50,000 | 50,000 | ~ 5-30 seconds | 150 MB |
| 250,000 | 10,000 | ~ 2-5 minutes | 300 MB |
| 500,000 | 500,000 | ~ 5+ minutes | 500+ MB |
### Smart Scanning
1. There is a floating `smart scan interval` that ranges from **1 minute - 4 hours** depending on the complexity of your filesystem
2. The smart interval changes based on how often it discovers changed files:
- ```
// Schedule in minutes
var scanSchedule = []time.Duration{
5 * time.Minute, // 5 minute quick scan & 25 minutes for a full scan
10 * time.Minute,
20 * time.Minute, // [3] element is 20 minutes, reset anchor for full scan
40 * time.Minute,
1 * time.Hour,
2 * time.Hour,
3 * time.Hour,
4 * time.Hour, // 4 hours for quick scan & 20 hours for a full scan
}
```
3. The `smart scan interval` performs a `quick scan` 4 times in a row, followed by a 5th `full scan` which completely rebuilds the index.
- A `quick scan` is limited to detecting directory changes, but is 10x faster than a full scan. Here is what a quick scan can see:
1. New files or folders created.
2. Files or folders deleted.
3. Renaming of files or folders.
- A quick scan **cannot** detect when a file has been updated, for example when you save a file and the size increases.
- A `full scan` is a complete re-indexing. This is always more disk and computationally intense but will capture individual file changes.
4. The `smart scan interval` changes based on several things. A `simple` complexity enables scans every 1 minute if changes happen frequently and a maximum full scan interval of every 100 minutes. `high` complexity indicates a minimum scanning interval of 10 minutes.
- **under 10,000 folders** or **Under 3 seconds** to index is awlays considered `simple` complexity.
- **more than 500,000 folders** or **Over 2 minutes** to index is always considered `high` complexity.
### Manual Scanning Interval
If you don't like the behavior of smart scanning, you can configure set intervals instead by setting `indexingInterval` to a number greater than 0. This will make FileBrowser Quantum always scan at the given interval in minutes.
The scan behavior is still 4 quick scans at a given interval, followed by a 5th full scan.
### System requirements
You can expect FileBrowser Quantum to use 100 MB of RAM for a typical installation. If you have many files and folders then the requirement could climb to multiple Gigabytes. Please monitor your system on the first run to know your specific requirements.
### Why does FileBrowser Quantum index the way it does?
The smart indexing method uses filesystem scanners because it allows a low-footprint design that can cater to individual filesystem complexity. There are a few options for monitoring a filesystem for changes:
1. **Option 1**: Recursive Traversal with ReadDir
- This is quite computationally intensive but creates an accurate record of the filesystem
- Requires periodic scanning to remain updated
- Low overhead and straightforward implementation.
2. **Option 2**: Use File System Monitoring (Real-Time or Periodic Check) such as `fsnotify`
- This allows for event-based reactions to filesystem changes.
- Requires extra overhead.
- Relies on OS level features and behavior differs between OS's
- Requires OS-level configuration to ulimits in order to properly watch a large filesystem.
3. **Option 3**: Directory Metadata Heuristics.
- Using ModTime to determine when directory structures change.
- Has minimal insight into actual file changes.
- Much faster to scan for changes than Recursive transversal.
Ultimately, FileBrowser Quantum uses a combination of 1 and 3 to perform index updates. Using something like fsnotify is a non-starter for large filesystems, where it would require manual host OS tuning to work at all. Besides, I can essentially offer the same behavior by creating "watchers" for top-level folders (a feature to come in the future). However, right now there is a single root-level watcher that works over the entire index.
The main disadvantage of the approach is the delay caused by the scanning interval.
### How to manually refresh the index?
There is currently no way to manually trigger a new full indexing. This will come in a future release when the "jobs" functionality is added back.
However, if you want to force-refresh a certain directory, this happens every time you **view it** in the UI or via the resources API.
This also means the resources API is always up to date with the current status of the filesystem. When you "look" at a specific folder, you are causing the index to be refreshed at that location.
### What information does the index have?
You can see what the index looks like by using the resources API via the GET method, which returns individual directory information -- all of this information is stored in the index.
Here is an example:
```
{
"name": "filebrowser",
"size": 274467914,
"modified": "2024-11-23T19:18:57.68013727-06:00",
"type": "directory",
"files": [
{
"name": ".dockerignore",
"size": 73,
"modified": "2024-11-20T18:14:44.91135413-06:00",
"type": "blob"
},
{
"name": ".DS_Store",
"size": 6148,
"modified": "2024-11-22T14:45:15.901211088-06:00",
"type": "blob"
},
{
"name": ".gitignore",
"size": 455,
"modified": "2024-11-23T19:18:57.616132373-06:00",
"type": "blob"
},
{
"name": "CHANGELOG.md",
"size": 9325,
"modified": "2024-11-23T19:18:57.616646332-06:00",
"type": "text"
},
{
"name": "Dockerfile",
"size": 769,
"modified": "2024-11-23T19:18:57.616941333-06:00",
"type": "blob"
},
{
"name": "Dockerfile.playwright",
"size": 542,
"modified": "2024-11-23T19:18:57.617151875-06:00",
"type": "blob"
},
{
"name": "makefile",
"size": 1311,
"modified": "2024-11-23T19:18:57.68017352-06:00",
"type": "blob"
},
{
"name": "README.md",
"size": 10625,
"modified": "2024-11-23T19:18:57.617464334-06:00",
"type": "text"
}
],
"folders": [
{
"name": ".git",
"size": 60075460,
"modified": "2024-11-24T14:44:42.52180215-06:00",
"type": "directory"
},
{
"name": ".github",
"size": 11584,
"modified": "2024-11-20T18:14:44.911805335-06:00",
"type": "directory"
},
{
"name": "backend",
"size": 29247172,
"modified": "2024-11-23T19:18:57.667109624-06:00",
"type": "directory"
},
{
"name": "docs",
"size": 14272,
"modified": "2024-11-24T13:46:12.082024018-06:00",
"type": "directory"
},
{
"name": "frontend",
"size": 185090178,
"modified": "2024-11-24T14:44:39.880678934-06:00",
"type": "directory"
}
],
"path": "/filebrowser"
}
```
### Can I disable the index and still use FileBrowser Quantum?
You can disable the index by setting `indexing: false` in your config file. You will still be able to browse your files, but the search will not work and you may run into issues as it's not intended to be used without indexing.
I'm not sure why you would run it like this, if you have a good reason please open an issue request on how you would like it to work -- and why you would run it without the index.
-25
View File
@@ -1,25 +0,0 @@
# Migration help
It is possible to use the same database as used by filebrowser/filebrowser,
but you will need to follow the following process:
1. Create a configuration file as mentioned above.
2. Copy your database file from the original filebrowser to the path of
the new one.
3. Update the configuration file to use the database (under server in
filebrowser.yml)
4. If you are using docker, update the docker-compose file or docker run
command to use the config file as described in the install section
above.
5. If you are not using docker, just make sure you run filebrowser -c
filebrowser.yml and have a valid filebrowser config.
Note: share links will not work and will need to be re-created after migration.
The filebrowser Quantum application should run with the same user and rules that
you have from the original. But keep in mind the differences that may not work
the same way, but all user configuration should be available.
The windows binary is particularly untested, I would advise using docker if testing on windows.
-50
View File
@@ -1,50 +0,0 @@
# Q&A topics
## When will there be a stable release?
Please see [roadmap](roadmap.md) for future plans -- yes a stable release will eventually come.
## Can you add FileBrowser Quantum features to the original file browser?
This "Quantum" version fork was created because I wanted certain features that are a dramatic and opinionated departure from the OG filebrowser. If you look at the original filebrowser repo pull requests, you will find there are many basic features that remain open for many months or years with very little attention.
If the original filebrowser maintainers were more active and if I didn't have to worry about spending months or years playing politics about the concequences of these drastic changes, I would contribute to the original repository.
However, **I will not make an modifications to the original filebrowser**, for these reasons:
1. My changes are opinionated and I want full control over the experience (and consequences) of the changes. For example I removed the terminal, runners, command line flags, and more. These are changes that would probably be highly contested. I think the experience is better without them, or with the changes -- and I hope you agree.
2. Contributing to that open source project takes a long time, I may never see my changes actually make it in and don't want to waste my time trying for years, I only have so much time.
3. This project was originally a fork, but that quickly changed. There are hundereds of thousands of changes and complete departures from the original codebase. I can't simply "port" the features I write on this repo over to the OG file browser.
Both of these repos being open source means YOU can migrate these features if you want! Feel free to spend the effort and do so for the community. I am not the only one capable of doing it and I encourage this if you have the time, energy, and knowhow to do it.
## I notice a lot of things that don't work like the original file browser repo, how can I get this fixed?
Please open issues and/or pull requests from a forked repo if you notice issues that should be fixed. Some changes are intentional and I may have left things broken. For example, I am not fully confident user rules work in 100% of places. When you notice things, please let me know and I will check if its an intentional change or a bug.
## Is there a way to donate or support this project?
Nope... not yet! It's still "unfinished" in my opinion, so I don't want to ask for any money from it. But if you have a strong desire to donate, email info@quantumx-apps.com to get in touch.
## Is there an email or phone I can contact?
Yes - Please contact info@quantumx-apps.com for any off-github topics. If its related to a specific application or repo such as this filebrowser, please open an issue or pull request instead. Email is only for corrospondance unrelated to technical changes or issues.
## Can I fork this repo and use it?
This repo has the same license as the original filebrowser, apache-2.0. Feel free to use in any way that follows the license. I have no issues with anything personally -- its open source please do as you like. However, since this is a fork of the OG repo, I am not sure what the consequences are for a fork of this repo.
## Who are the maintainers for FileBrowser Quantum?
Right now, just me as a personal hobby and some small contributions from the community. Once I can release a confident stable version, I plan to publicize this application more on social media. Hopefully, in the future I could pick up some extra contributors.
I'm not looking for contributors at the moment, but if you want to me a contributor feel free to email me at info@quantumx-apps.com to see about getting contributor access.
## Are there plans to charge for this product?
No, this repo and project will always be free to use.
## Is there a discord for this fork?
Not yet, generally most interactions should happen on github for now.
-46
View File
@@ -1,46 +0,0 @@
# Planned Roadmap
Upcoming 0.3.x releases, ordered by priority:
- Bring Themes and Branding back.
- openoffice support https://github.com/filebrowser/filebrowser/pull/2954
- More filetype previews: eg. raw img, office, photoshop, vector, 3d files.
- Introduce jobs as replacement to runners.
- Add Job status to the sidebar
- index status.
- Job status from users
- upload status
- Opentelemetry metrics
- user access,
- file access
- download count
- last login
- more sign in support
- LDAP
- 2FA
- SSO
Upcoming 0.4.x release:
- Support for multiple filesystem sources https://github.com/filebrowser/filebrowser/issues/2514
- Onboarding process to add sources and configure them on first run.
- More indexing flexability
- option not to index hidden files/folders
- options folders to include/exclude from indexing
- implement more indexing runners for more efficienct filesystem watching
- tags support
Stable release (v1.0.0) - Planned 2025:
- Once under the hood changes for things like multiple sources, jobs support, etc
- More robust backend and frontend testing
- Currently a stable release does not exist primarily because things are still changing, configuration changes are happening frequently and will for the next
- Rebrand to QuantumX App suite umbrella branding and github repo change.
Unplanned Future releases:
- Add tools to sidebar
- duplicate file detector.
- bulk rename https://github.com/filebrowser/filebrowser/issues/2473
- support more source types such as minio, s3, and backblaze sources https://github.com/filebrowser/filebrowser/issues/2544
- Activity Log
- Comments support
- Trash Support
- starred/pinned files
- event based notifications
+20
View File
@@ -0,0 +1,20 @@
import { Browser, firefox, expect, Page } from "@playwright/test";
async function globalSetup() {
const browser: Browser = await firefox.launch();
const context = await browser.newContext();
const page: Page = await context.newPage();
await page.goto("http://127.0.0.1/login");
await page.getByPlaceholder("Username").fill("admin");
await page.getByPlaceholder("Password").fill("admin");
await page.getByRole("button", { name: "Login" }).click();
await page.waitForURL("**/files/", { timeout: 100 });
let cookies = await context.cookies();
expect(cookies.find((c) => c.name == "auth")?.value).toBeDefined();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.context().storageState({ path: "./loginAuth.json" });
await browser.close();
}
export default globalSetup
+3 -4
View File
@@ -10,22 +10,21 @@
"scripts": {
"dev": "vite dev",
"build": "vite build && cp -r dist/* ../backend/http/embed",
"build-windows": "vite build && robocopy dist ../backend/http/embed /e",
"build-docker": "vite build",
"watch": "vite build --watch",
"typecheck": "vue-tsc -p ./tsconfig.json --noEmit",
"lint": "eslint --ext .js,.vue,ts src",
"lint:fix": "eslint --fix src/",
"format": "prettier --write .",
"integration-test": "npx playwright test",
"test": "vitest run "
},
"dependencies": {
"@onlyoffice/document-editor-vue": "^1.4.0",
"ace-builds": "^1.24.2",
"clipboard": "^2.0.4",
"css-vars-ponyfill": "^2.4.3",
"file-loader": "^6.2.0",
"material-icons": "^1.10.5",
"material-symbols": "^0.27.2",
"normalize.css": "^8.0.1",
"qrcode.vue": "^3.4.1",
"vue": "^3.4.21",
@@ -34,8 +33,8 @@
"vue-router": "^4.3.0"
},
"devDependencies": {
"@playwright/test": "^1.49.1",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@playwright/test": "^1.42.1",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-typescript": "^13.0.0",
"eslint": "^8.57.0",
+6 -2
View File
@@ -10,19 +10,23 @@ import { defineConfig, devices } from "@playwright/test";
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./global-setup",
timeout: 3000,
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
forbidOnly: Boolean(process.env.CI),
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
workers: process.env.CI ? 1 : 1, // required for now! todo parallel some tests
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 5000,
storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1",
+2 -1
View File
@@ -13,6 +13,8 @@
<link rel="icon" type="image/png" sizes="256x256" href="{{ .StaticURL }}/img/icons/favicon-256x256.png">
<!--<link href="https://fonts.googleapis.com/css?family=Material+Icons|Material+Symbols+Outlined" rel="stylesheet">-->
<!-- Add to home screen for Android and modern mobile browsers -->
<link rel="manifest" id="manifestPlaceholder" crossorigin="use-credentials">
<meta name="theme-color" content="{{ if .Color }}{{ .Color }}{{ else }}#2979ff{{ end }}">
@@ -31,7 +33,6 @@
window.FileBrowser = JSON.parse('{{ .globalVars }}');
var dynamicManifest = {
"name": window.FileBrowser.Name || 'FileBrowser Quantum',
"short_name": window.FileBrowser.Name || 'FileBrowser',
"icons": [
{
"src": window.location.origin + "{{ .StaticURL }}/img/icons/android-chrome-256x256.png",
+32 -14
View File
@@ -2,6 +2,7 @@ import { fetchURL, adjustedData } from "./utils";
import { removePrefix, getApiPath } from "@/utils/url.js";
import { state } from "@/store";
import { notify } from "@/notify";
import { externalUrl } from "@/utils/constants";
// Notify if errors occur
export async function fetchFiles(url, content = false) {
@@ -125,47 +126,64 @@ export async function post(url, content = "", overwrite = false, onupload) {
}
export async function moveCopy(items, action = "copy", overwrite = false, rename = false) {
let promises = [];
let params = {
overwrite: overwrite,
action: action,
rename: rename,
}
};
try {
for (let item of items) {
// Create an array of fetch calls
let promises = items.map((item) => {
let toPath = encodeURIComponent(removePrefix(decodeURI(item.to), "files"));
let fromPath = encodeURIComponent(removePrefix(decodeURI(item.from), "files"));
let localParams = { ...params };
localParams.destination = toPath;
localParams.from = fromPath;
let localParams = { ...params, destination: toPath, from: fromPath };
const apiPath = getApiPath("api/resources", localParams);
promises.push(fetch(apiPath, { method: "PATCH" }));
}
return promises;
return fetch(apiPath, { method: "PATCH" }).then((response) => {
if (!response.ok) {
// Throw an error if the fetch fails
return response.text().then((text) => {
throw new Error(`Failed to move/copy: ${text || response.statusText}`);
});
}
return response;
});
});
// Await all promises and ensure errors propagate
await Promise.all(promises);
} catch (err) {
notify.showError(err.message || "Error moving/copying resources");
throw err;
throw err; // Re-throw the error to propagate it back to the caller
}
}
export async function checksum(url, algo) {
export async function checksum(path, algo) {
try {
const data = await resourceAction(`${url}?checksum=${algo}`, "GET");
return (await data.json()).checksums[algo];
const params = {
path: encodeURIComponent(removePrefix(path, "files")),
checksum: algo,
};
const apiPath = getApiPath("api/resources", params);
const res = await fetchURL(apiPath);
const data = await res.json();
return data.checksums[algo];
} catch (err) {
notify.showError(err.message || "Error fetching checksum");
throw err;
}
}
export function getDownloadURL(path, inline) {
export function getDownloadURL(path, inline, useExternal) {
try {
const params = {
files: encodeURIComponent(removePrefix(decodeURI(path),"files")),
...(inline && { inline: "true" }),
};
const apiPath = getApiPath("api/raw", params);
if (externalUrl && useExternal) {
return externalUrl+apiPath
}
return window.origin+apiPath
} catch (err) {
notify.showError(err.message || "Error getting download URL");
+4
View File
@@ -1,6 +1,7 @@
import { fetchURL, fetchJSON, adjustedData } from "./utils";
import { notify } from "@/notify";
import { getApiPath } from "@/utils/url.js";
import { externalUrl } from "@/utils/constants";
export async function list() {
const apiPath = getApiPath("api/shares");
@@ -41,5 +42,8 @@ export async function create(path, password = "", expires = "", unit = "hours")
}
export function getShareURL(share) {
if (externalUrl) {
return externalUrl+getApiPath(`share/${share.hash}`);
}
return window.origin+getApiPath(`share/${share.hash}`);
}
+1 -2
View File
@@ -67,8 +67,7 @@ export function adjustedData(data, url) {
// Combine folders and files into items
data.items = [...(data.folders || []), ...(data.files || [])];
data.items = data.items.map((item, index) => {
item.index = index;
data.items = data.items.map((item) => {
item.url = `${data.url}${item.name}`;
if (item.type === "directory") {
item.url += "/";
+4 -4
View File
@@ -23,10 +23,10 @@ describe('adjustedData', () => {
folders: [],
files: [],
items: [
{ name: "folder1", type: "directory", index: 0, url: "http://example.com/unit-testing/files/path/to/directory/folder1/" },
{ name: "folder2", type: "directory", index: 1, url: "http://example.com/unit-testing/files/path/to/directory/folder2/" },
{ name: "file1.txt", type: "file", index: 2, url: "http://example.com/unit-testing/files/path/to/directory/file1.txt" },
{ name: "file2.txt", type: "file", index: 3, url: "http://example.com/unit-testing/files/path/to/directory/file2.txt" },
{ name: "folder1", type: "directory", url: "http://example.com/unit-testing/files/path/to/directory/folder1/" },
{ name: "folder2", type: "directory", url: "http://example.com/unit-testing/files/path/to/directory/folder2/" },
{ name: "file1.txt", type: "file", url: "http://example.com/unit-testing/files/path/to/directory/file1.txt" },
{ name: "file2.txt", type: "file", url: "http://example.com/unit-testing/files/path/to/directory/file2.txt" },
],
};
Binary file not shown.
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<button @click="action" :aria-label="label" :title="label" class="action">
<button @click="action" :aria-label="label" :title="label" class="action no-select">
<i class="material-icons">{{ icon }}</i>
<span>{{ label }}</span>
<span v-if="counter > 0" class="counter">{{ counter }}</span>
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more