Compare commits

..
2 Commits
Author SHA1 Message Date
Graham Steffaniak c2869b8918 Hotfix create user routing (#241) 2024-11-21 10:00:04 -06:00
Graham Steffaniak e23f3a5f87 updated test order (#237) 2024-11-20 18:26:56 -06:00
244 changed files with 5455 additions and 7494 deletions

No files matched your search

+3 -28
View File
@@ -6,32 +6,7 @@ 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: ./_docker/Dockerfile.playwright
push: false
push_latest_to_registry:
needs: [ test_frontend ]
name: Push latest
runs-on: ubuntu-latest
steps:
@@ -39,9 +14,9 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Find latest beta tag
- name: Find latest tag
run: |
echo "LATEST_TAG=$(git describe --tags $(git rev-list --tags="*-beta*" --max-count=1))" >> $GITHUB_ENV
echo "LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`)" >> $GITHUB_ENV
echo "latest tag is $LATEST_TAG"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
@@ -65,7 +40,7 @@ jobs:
VERSION=${{ env.LATEST_TAG }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
file: ./_docker/Dockerfile
file: ./Dockerfile
push: true
tags: 'gtstef/filebrowser:latest'
labels: ${{ steps.meta.outputs.labels }}
+6 -15
View File
@@ -1,15 +1,14 @@
name: PR Request
name: pr-request
on:
pull_request:
branches:
- "dev/v[0-9]+.[0-9]+.[0-9]+"
- "beta/v[0-9]+.[0-9]+.[0-9]+"
- "stable/v[0-9]+.[0-9]+.[0-9]+"
- "main"
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
test_frontend:
name: Test Playwright
name: Push release
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -18,19 +17,11 @@ jobs:
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go build -o filebrowser .
- name: Build
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright
file: ./Dockerfile.playwright
push: false
push_pr_to_registry:
name: Push PR
@@ -56,7 +47,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+4 -11
View File
@@ -1,9 +1,9 @@
name: regular tests
name: dev tests
on:
push:
branches:
- "**"
- "*"
jobs:
test-backend:
@@ -21,7 +21,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
go-version: '1.22.5'
- uses: golangci/golangci-lint-action@v5
with:
version: v1.60
@@ -41,11 +41,4 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run lint
test-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run test
run: npm i eslint && npm run lint
@@ -1,40 +1,15 @@
name: stable release
name: version release
on:
push:
branches:
- "stable/v[0-9]+.[0-9]+.[0-9]+"
- "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: ./_docker/Dockerfile.playwright
push: false
create_release_tag:
needs: [ test_frontend ]
create_release:
name: Create Release
runs-on: ubuntu-latest
steps:
@@ -44,28 +19,23 @@ jobs:
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]+')-stable
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT
id: extract_branch
- uses: actions/setup-go@v5
with:
go-version: 'stable'
go-version: 1.22.2
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ steps.extract_branch.outputs.branch_name }}
target_commitish: ${{ steps.extract_branch.outputs.branch }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
tag_name: ${{ steps.extract_branch.outputs.branch }}
prerelease: false
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.tag_name }}
name: ${{ steps.extract_branch.outputs.branch }}
push_release_to_registry:
needs: [ test_frontend ]
name: Push release
runs-on: ubuntu-latest
steps:
@@ -90,7 +60,7 @@ jobs:
run: |
JSON="${{ steps.meta.outputs.tags }}"
# Use jq to remove 'v' from the version field
JSON=$(echo "$JSON" | sed 's/filebrowser:stable\/v/filebrowser:/')
JSON=$(echo "$JSON" | sed 's/filebrowser:v/filebrowser:/')
echo "cleaned_tag=$JSON" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
@@ -100,7 +70,7 @@ jobs:
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
file: ./_docker/Dockerfile
file: ./Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
-107
View File
@@ -1,107 +0,0 @@
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: ./_docker/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: ./_docker/Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+7 -7
View File
@@ -3,14 +3,14 @@ name: dev release
on:
push:
branches:
- "dev/v[0-9]+.[0-9]+.[0-9]+"
- "dev_v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
push_release_to_registry:
name: Push release
name: Push dev release
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -34,17 +34,17 @@ jobs:
run: |
JSON="${{ steps.meta.outputs.tags }}"
# Use jq to remove 'v' from the version field
JSON=$(echo "$JSON" | sed 's/filebrowser:dev\/v/filebrowser:dev_v/')
echo "cleaned_tag=$JSON" >> $GITHUB_OUTPUT
JSON=$(echo "$JSON" | sed 's/filebrowser:v/filebrowser:/')
echo "CLEANED_TAG=$JSON" >> $GITHUB_ENV
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
build-args: |
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
context: .
platforms: linux/amd64
file: ./_docker/Dockerfile
file: ./Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
tags: ${{ env.CLEANED_TAG }}
labels: ${{ steps.meta.outputs.labels }}
+2 -2
View File
@@ -1,9 +1,9 @@
name: tag update
name: tag
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+*"
- "v*"
permissions:
contents: write
+2 -8
View File
@@ -1,25 +1,19 @@
*.db
*.bak
*.log
*.mjs
_old
rice-box.go
.idea/
/backend/backend
/backend/filebrowser
/backend/filebrowser.exe
/backend/backend.exe
/filebrowser
/filebrowser.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
+1 -215
View File
@@ -2,220 +2,6 @@
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.5.2-beta
**New Features**:
- Markdown file preview https://github.com/gtsteffaniak/filebrowser/issues/343
- Easy access download button https://github.com/gtsteffaniak/filebrowser/issues/341
**Notes**:
- Adds message about what sharing means when creating a link.
- api log duration is now always in milliseconds for consistency.
- advanced index config option `fileEndsWith` is now respected.
- Added Informative error for missing files for certificate load https://github.com/gtsteffaniak/filebrowser/issues/354
**BugFixes**:
- onlyoffice close window missing files issue https://github.com/gtsteffaniak/filebrowser/issues/345
- fixed download link inside file preview
## v0.5.1-beta
> Note: I changed the [config](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration#example-auth-config) for password auth again... It was a mistake just to make it a boolean, so now you can provide options, going forward this allows for more.
**New Features**:
- password length requirement config via `auth.methods.password.minLength` as a number of characters required.
**Bugfixes**:
- NoAuth error message "resource not found"
- CLI user configuration works and simplified see examples in the [Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/CLI)
## v0.5.0-beta
> Note: This Beta release includes a configuration change: `auth.method` is now deprecated. This is done to allow multiple login methods at once. Auth methods are specified via `auth.methods` instead. see [example on the wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration#example-auth-config).
**New Features**:
- Upload progress notification https://github.com/gtsteffaniak/filebrowser/issues/303
- proxy auth auto create user when `auth.methods.proxy.createUser: true` while using proxy auth.
**Notes**:
- Context menu positioning tweaks.
- using /tmp cachedir is disabled by default, cache dir can be specified via `server.cacheDir: /tmp` to enable it. https://github.com/gtsteffaniak/filebrowser/issues/326
**Bugfixes**:
- Gracefully shutdown to protect database. https://github.com/gtsteffaniak/filebrowser/issues/317
- validates auth method provided before server startup.
- fix sidebar disk space usage calculation. https://github.com/gtsteffaniak/filebrowser/issues/315
- Fixed proxy auth header support (make sure your proxy and server are secure!). https://github.com/gtsteffaniak/filebrowser/issues/322
## v0.4.2-beta
**New Features**:
- Hidden files changes
- windows hidden file properties are respected -- when running on windows binary (not docker) with NTFS filesystem.
- windows "system" files are considered hidden.
- changed user property from `hideDotFiles` to `showHidden`. Defaults to false, so a user would need to must unhide hidden files if they want to view hidden files.
**Notes**:
- cleaned up old and deprecated config.
- removed unneeded "Global settings". All system configuration is done on config yaml, See configuration wiki for more help.
**Bugfixes**:
- Another fix for memory https://github.com/gtsteffaniak/filebrowser/issues/298
## 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.
## v0.3.3
**New Features**
- Navigating remembers your previous scroll position when opening items and then navigating backwards.
- New Icons with larger selection of file types
- file "type" is shown on item info page.
- added optional non-root "filebrowser" user for docker image. See https://github.com/gtsteffaniak/filebrowser/issues/251
- File preview supports more file types:
- images: jpg, bmp, gif, tiff, png, svg, heic, webp
**Notes**:
- The file "type" is now either "directory" or a specific mimetype such as "text/xml".
- update safari styling
**Bugfixes**:
- Delete/move file/folders sometimes wouldn't work.
- Possible fix for context menu not showing issue. See https://github.com/gtsteffaniak/filebrowser/issues/251
- Fixed drag/drop not refreshing immediately to reflect changes.
## v0.3.2
**New Features**
- Mobile search has the same features as desktop.
**Notes**:
- Added compression. Helpful for browsing folders with a large number of items. Considering https://github.com/gtsteffaniak/filebrowser/issues/201 resolved, although future pagination support will still come.
- Compressed download options limited to `.zip` and `.tar.gz`
- right-click context menu stays in view.
**Bugfixes**:
- search result links when non-default baseUrl configured
- frontend sort bug squashed https://github.com/gtsteffaniak/filebrowser/issues/230
- bug which caused "noauth" method not to work after v0.3.0 routes update
## v0.3.1
**New Features**
- Adds Smart Indexing by default.
**Notes**:
- Optimized api request response times via improved caching and simplified actions.
- User information persists more reliably.
- Added [indexing doc](./docs/indexing.md) to explain the expectations around indexing and how it works.
- The index should also use less RAM than it did in v0.3.0.
**Bugfixes**:
- Tweaked sorting by name, fixes case sensitive and numeric sorting. https://github.com/gtsteffaniak/filebrowser/issues/230
- Fixed unnecessary authentication status checks each route change
- Fix create file action issue.
- some small javascript related issues.
- Fixes pretty big bug viewing raw content in v0.3.0 (utf format message)
## v0.3.0
This Release focuses on the API and making it more accessible for developers to access functions without the UI.
@@ -367,7 +153,7 @@ This change focuses on minimizing and simplifying build process.
- The shell feature has been deprecated.
- Custom commands can be executed within the Docker container if needed.
- The JSON config file is no longer used.
- All configurations are now performed via the advanced `config.yaml`.
- All configurations are now performed via the advanced `filebrowser.yaml`.
- The only allowed flag is specifying the config file.
- Removed old code for migrating database versions.
- Eliminated all unused `cmd` code.
+4 -8
View File
@@ -6,8 +6,8 @@ COPY ./backend ./
#RUN swag init --output swagger/docs
RUN ln -s swagger /usr/local/go/src/
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA=${REVISION}'" \
-X 'github.com/gtsteffaniak/filebrowser/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM node:slim AS nbuild
@@ -20,12 +20,8 @@ RUN npm run build-docker
FROM alpine:latest
ENV FILEBROWSER_NO_EMBEDED="true"
RUN apk --no-cache add ca-certificates mailcap
WORKDIR /home/filebrowser
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
COPY --from=base --chown=filebrowser:1000 /app/filebrowser* ./
COPY --from=nbuild --chown=filebrowser:1000 /app/dist/ ./http/dist/
USER root
COPY --from=base /app/filebrowser* ./
# exposing default port for auto discovery.
EXPOSE 80
COPY --from=nbuild /app/dist/ ./http/dist/
ENTRYPOINT [ "./filebrowser" ]
+19
View File
@@ -0,0 +1,19 @@
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/filebrowser.yaml", "./" ]
COPY ./frontend/ ./frontend
WORKDIR /app/frontend
RUN npm run build-docker
WORKDIR /app
COPY --from=base /app/filebrowser* ./
RUN cp -R frontend/tests/ srv
ENV FILEBROWSER_NO_EMBEDED="true"
RUN ./filebrowser & sleep 2 && cd frontend && npx playwright test
+137 -63
View File
@@ -1,53 +1,50 @@
<div align="center">
[![Go Report Card](https://goreportcard.com/badge/github.com/gtsteffaniak/filebrowser/backend)](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/1c48cfb7646d4009aa8c6f71287670b8)](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
[![latest version](https://img.shields.io/github/release/gtsteffaniak/filebrowser/all.svg)](https://github.com/gtsteffaniak/filebrowser/releases)
[![DockerHub Pulls](https://img.shields.io/docker/pulls/gtstef/filebrowser?label=latest%20Docker%20pulls)](https://hub.docker.com/r/gtstef/filebrowser)
[![Apache-2.0 License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
<img width="150" src="https://github.com/user-attachments/assets/59986a2a-f960-4536-aa35-4a9a7c98ad48" title="Logo">
<h3>FileBrowser Quantum</h3>
A modern web-based file manager
<br/><br/>
<img width="800" src="https://github.com/user-attachments/assets/c991fc69-a05b-4f34-b915-0d3cded887a7" title="Main Screenshot">
</div>
<p align="center">
<a href="https://opensource.org/license/apache-2-0/"><img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg" alt="License: Apache-2.0"></a>
</p>
<p align="center">
<img src="frontend/public/img/icons/favicon-256x256.png" width="100" title="Login With Custom URL">
</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">
</p>
> [!WARNING]
> There is no stable version -- planned 2025.
> Starting with `v0.3.0` API routes have been slightly altered for friendly usage outside of the UI.
> If on windows, please use docker. The windows binary is unstable and may not work.
FileBrowser Quantum is a fork of the file browser open-source project with the following changes:
FileBrowser Quantum is a fork of the file browser opensource project with the following changes:
1. ✅ Indexes files (See [indexing Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.)
1. [x] Efficiently indexed files
- 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. ✅ Revamped UI
- Additional compact view mode as well as refreshed view mode styles.
- Many graphical and user experience improvements.
- right-click context menu
3. ✅ Revamped and simplified configuration via `config.yaml` config file.
4. ✅ Better listing browsing
- Instantly Switches view modes and sort order without reloading data.
- Folder sizes are displayed
- Navigating remembers the scroll position, navigating back keeps the last scroll position.
5. ✅ Developer API support
- Ability to create long-live API Tokens.
- Search Works with more type filters
- Enhanced interactive results page.
- file/folder sizes are shown in the response
1. [x] Revamped and simplified GUI navbar and sidebar menu.
- Additional compact view mode as well as refreshed view mode
styles.
1. [x] Revamped and simplified configuration via `filebrowser.yml` config file.
1. [x] Better listing browsing
- Switching view modes is instant
- Folder sizes are shown as well
- Changing Sort order is instant
- The entire directory is loaded in 1/3 the time
1. Developer API support
- Can create long-live API Tokens.
- Helpful Swagger page available at `/swagger` endpoint.
Notable features that this fork *does not* have (removed):
- ❌ jobs/runners are not supported yet (planned).
- ❌ per-user rules are not supported yet (planned).
- ❌ shell commands are completely removed and will not be returned.
## About
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 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.
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
@@ -58,11 +55,10 @@ focus of this fork is on a few key principles:
- Minimize external dependencies and standard library usage.
- Of course -- adding much-needed features.
For more, see the [Q&A Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A)
## Look
The UI has a simple three-component navigation system :
One way you can observe the improved user experience is how I changed
the UI. The Navbar is simplified to a three-component system :
1. (Left) The slide-out action panel button
2. (Middle) The powerful search bar.
@@ -76,36 +72,111 @@ 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/93b019de-d38f-4aaa-bde3-3ba4e99ecd25" title="Main Screenshot">
<img width="800" src="https://github.com/user-attachments/assets/75226dc4-9802-46f0-9e3c-e4403d3275da" title="Main Screenshot">
</p>
## Install and Configuration
## Install
See the [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration)
Using docker:
1. docker run (no persistent db):
```
docker run -it -v /path/to/folder:/srv -p 80:80 gtstef/filebrowser
```
1. docker compose:
- with local storage
```
services:
filebrowser:
volumes:
- '/path/to/folder:/srv' # required (for now not configurable)
- './database:/database' # optional if you want db to persist - configure a path under "database" dir in config file.
- './filebrowser.yaml:/filebrowser.yaml' # required
ports:
- '80:80'
image: gtstef/filebrowser
restart: always
```
- with network share
```
services:
filebrowser:
volumes:
- 'storage:/srv' # required (for now not configurable)
- './database:/database' # optional if you want db to persist - configure a path under "database" dir in config file.
- './filebrowser.yaml:/filebrowser.yaml' # required
ports:
- '80:80'
image: gtstef/filebrowser
restart: always
volumes:
storage:
driver_opts:
type: cifs
o: "username=admin,password=password,rw" # enter valid info here
device: "//192.168.1.100/share/" # enter valid info here
```
Not using docker (not recommended), download your binary from releases and run with your custom config file:
```
./filebrowser -c <filebrowser.yml or other /path/to/config.yaml>
```
## Command Line Usage
See the [CLI Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/CLI)
There are very few commands available. There are 3 actions done via command line:
1. Running the program, as shown on the install step. The only argument used is the config file, if you choose to override default "filebrowser.yaml"
2. Checking the version info via `./filebrowser version`
3. Updating the DB, which currently only supports adding users via `./filebrowser set -u username,password [-a] [-s "example/scope"]`
## API Usage
See the [API Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/API)
FileBrowser Quantum comes with a swagger page that can be accessed from the API section of settings or by going to `/swagger` to see the full list:
![image](https://github.com/user-attachments/assets/12abd1f6-21d3-4437-98ed-9b0da6cf2c73)
You use the token as a bearer token. For example in postman:
Successful Request:
<p align="center"><img width="500" alt="image" src="https://github.com/user-attachments/assets/4f18fa8a-8d87-4f40-9dc7-3d4407769b59"></p>
Failed Request
<p align="center"><img width="500" alt="image" src="https://github.com/user-attachments/assets/4da0deae-f93d-4d94-83b1-68806afb343a"></p>
## Configuration
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.
All configuration is now done via a single configuration file:
`filebrowser.yaml`, here is an example of minimal [configuration
file](./backend/filebrowser.yaml).
## Office File Support
View the [Configuration Help Page](./docs/configuration.md) for available
configuration options and other help.
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 and office related features.
## Migration from the original filebrowser
See the [Migration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Migration)
If you currently use the original filebrowser but want to try using this.
I would recommend that you start fresh without reusing the database. However,
If you want to migrate your existing database to FileBrowser Quantum,
visit the [migration
readme](./docs/migration.md)
## Comparison Chart
Application Name | <img width="48" src="https://github.com/user-attachments/assets/59986a2a-f960-4536-aa35-4a9a7c98ad48" > Quantum | <img width="48" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" src="https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Google_Drive_logo.png/480px-Google_Drive_logo.png" > Google_Drive | <img width="48" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
Application Name | <img width="48" src="frontend/public/img/icons/favicon-256x256.png" > Quantum | <img width="48" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" src="https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Google_Drive_logo.png/480px-Google_Drive_logo.png" > Google_Drive | <img width="48" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
--- | --- | --- | --- | --- | --- | --- |
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
@@ -115,15 +186,15 @@ 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 |
Docker image size | 22 MB | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 128 MB | 128 MB | 128 MB (main image) | 128 MB | ❌ | 4 GB |
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
price | free | free | free | free tier | free tier | $99+ |
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
Advanced Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Indexed Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Content-aware search? | ❌ | ❌ | ❌ | configurable | ✅ | ✅ |
@@ -142,15 +213,18 @@ Event-based notifications | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Metrics | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
file space quotas | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
office file support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
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 | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
Share collections of files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
## Roadmap
see [Roadmap Page](./docs/roadmap.md)
-6
View File
@@ -1,6 +0,0 @@
FROM gtstef/playwright-base
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
RUN ./filebrowser -c filebrowser-playwright.yaml & sleep 2 && cd ../frontend && npx playwright test
-4
View File
@@ -1,4 +0,0 @@
FROM node:22-slim
WORKDIR /app/frontend
RUN npm i @playwright/test
RUN npx playwright install --with-deps firefox
-15
View File
@@ -1,15 +0,0 @@
services:
nginx-proxy-auth:
image: nginx
container_name: nginx-proxy-auth
ports:
- "8080:80"
volumes:
- ./src/default.conf:/etc/nginx/conf.d/default.conf
filebrowser:
volumes:
- '../frontend:/home/frontend'
- "./src/config.yaml:/home/filebrowser/config.yaml"
build:
context: ../
dockerfile: ./_docker/Dockerfile
-8
View File
@@ -1,8 +0,0 @@
server:
port: 80
baseURL: "/"
root: "../frontend/tests/playwright-files"
auth:
method: proxy
header: X-Username
signup: false
-13
View File
@@ -1,13 +0,0 @@
server {
listen 80;
server_name localhost 127.0.0.1;
location / {
proxy_pass http://filebrowser;
proxy_set_header X-Username "proxy-user";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+6 -23
View File
@@ -6,20 +6,21 @@ builds:
# Build configuration for darwin and linux
- id: default
ldflags: &ldflags
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA={{ .ShortCommit }}
- -s -w -X github.com/gtsteffaniak/filebrowser/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/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
@@ -29,30 +30,12 @@ builds:
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: >
{{- if eq .Os "windows" -}}
{{.ProjectName}}
{{- else -}}
{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{.ProjectName}}
{{- end -}}
- name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
format: binary
checksum:
+1 -1
View File
@@ -3,7 +3,7 @@ package auth
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/users"
)
// Auther is the authentication interface.
+17 -17
View File
@@ -3,15 +3,15 @@ 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"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
)
type hookCred struct {
@@ -146,16 +146,16 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
if u == nil {
// create user with the provided credentials
d := &users.User{
Username: a.Cred.Username,
Password: a.Cred.Password,
Scope: a.Settings.UserDefaults.Scope,
Locale: a.Settings.UserDefaults.Locale,
ViewMode: a.Settings.UserDefaults.ViewMode,
SingleClick: a.Settings.UserDefaults.SingleClick,
Sorting: a.Settings.UserDefaults.Sorting,
Perm: a.Settings.UserDefaults.Perm,
Commands: a.Settings.UserDefaults.Commands,
ShowHidden: a.Settings.UserDefaults.ShowHidden,
Username: a.Cred.Username,
Password: a.Cred.Password,
Scope: a.Settings.UserDefaults.Scope,
Locale: a.Settings.UserDefaults.Locale,
ViewMode: a.Settings.UserDefaults.ViewMode,
SingleClick: a.Settings.UserDefaults.SingleClick,
Sorting: a.Settings.UserDefaults.Sorting,
Perm: a.Settings.UserDefaults.Perm,
Commands: a.Settings.UserDefaults.Commands,
HideDotfiles: a.Settings.UserDefaults.HideDotfiles,
}
u = a.GetUser(d)
@@ -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
logger.Debug(fmt.Sprintf("user: %s, home dir: [%s].", u.Username, userHome))
log.Printf("user: %s, home dir: [%s].", u.Username, userHome)
err = a.Users.Save(u)
if err != nil {
@@ -209,7 +209,7 @@ func (a *HookAuth) GetUser(d *users.User) *users.User {
By: d.Sorting.By,
},
Commands: d.Commands,
ShowHidden: d.ShowHidden,
HideDotfiles: d.HideDotfiles,
Perm: perms,
LockPassword: true,
}
@@ -232,7 +232,7 @@ var validHookFields = []string{
"user.sorting.by",
"user.sorting.asc",
"user.commands",
"user.showHidden",
"user.hideDotfiles",
"user.perm.admin",
"user.perm.execute",
"user.perm.create",
+4 -3
View File
@@ -7,8 +7,8 @@ import (
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
)
type jsonCred struct {
@@ -24,6 +24,7 @@ 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 {
@@ -46,7 +47,7 @@ func (a JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User,
return nil, os.ErrPermission
}
}
u, err := userStore.Get(files.RootPaths["default"], cred.Username)
u, err := userStore.Get(config.Server.Root, cred.Username)
if err != nil || !users.CheckPwd(cred.Password, u.Password) {
return nil, os.ErrPermission
}
+3 -3
View File
@@ -3,8 +3,8 @@ package auth
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
)
// MethodNoAuth is used to identify no auth.
@@ -15,7 +15,7 @@ type NoAuth struct{}
// Auth uses authenticates user 1.
func (a NoAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
return usr.Get(files.RootPaths["default"], uint(1))
return usr.Get(settings.Config.Server.Root, uint(1))
}
// LoginPage tells that no auth doesn't require a login page.
+5 -4
View File
@@ -4,9 +4,10 @@ import (
"net/http"
"os"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/users"
)
// MethodProxyAuth is used to identify no auth.
@@ -20,7 +21,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(files.RootPaths["default"], username)
user, err := usr.Get(settings.Config.Server.Root, username)
if err == errors.ErrNotExist {
return nil, os.ErrPermission
}
+3 -20
View File
@@ -1,7 +1,7 @@
package auth
import (
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/users"
)
// StorageBackend is a storage backend for auth storage.
@@ -17,25 +17,8 @@ type Storage struct {
}
// NewStorage creates a auth storage from a backend.
func NewStorage(back StorageBackend, userStore *users.Storage) (*Storage, error) {
store := &Storage{back: back, users: userStore}
err := store.Save(&JSONAuth{})
if err != nil {
return nil, err
}
err = store.Save(&ProxyAuth{})
if err != nil {
return nil, err
}
err = store.Save(&HookAuth{})
if err != nil {
return nil, err
}
err = store.Save(&NoAuth{})
if err != nil {
return nil, err
}
return store, nil
func NewStorage(back StorageBackend, userStore *users.Storage) *Storage {
return &Storage{back: back, users: userStore}
}
// Get wraps a StorageBackend.Get.
+28 -51
View File
@@ -1,66 +1,43 @@
== Running benchmark ==
/usr/local/go/bin/go
? 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]
? github.com/gtsteffaniak/filebrowser [no test files]
? github.com/gtsteffaniak/filebrowser/auth [no test files]
? github.com/gtsteffaniak/filebrowser/cmd [no test files]
PASS
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
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
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/backend/files
pkg: github.com/gtsteffaniak/filebrowser/files
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
BenchmarkFillIndex-8 2025/01/04 14:04:57 Initializing index and assessing file system complexity
2025/01/04 14:04:57 Starting full scan
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
BenchmarkFillIndex-8 10 3847878 ns/op 758424 B/op 5567 allocs/op
BenchmarkSearchAllIndexes-8 10 780431 ns/op 173444 B/op 2014 allocs/op
PASS
ok github.com/gtsteffaniak/filebrowser/backend/files 5.094s
ok github.com/gtsteffaniak/filebrowser/files 0.073s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/fileutils 0.002s
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>
PASS
ok github.com/gtsteffaniak/filebrowser/backend/http 0.184s
ok github.com/gtsteffaniak/filebrowser/http 0.080s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/img 0.123s
ok github.com/gtsteffaniak/filebrowser/img 0.137s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/runner 0.004s
ok github.com/gtsteffaniak/filebrowser/rules 0.002s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/settings 0.005s
? github.com/gtsteffaniak/filebrowser/backend/share [no test files]
? github.com/gtsteffaniak/filebrowser/backend/storage [no test files]
? github.com/gtsteffaniak/filebrowser/backend/storage/bolt [no test files]
? github.com/gtsteffaniak/filebrowser/backend/swagger/docs [no test files]
ok github.com/gtsteffaniak/filebrowser/runner 0.003s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/users 0.003s
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]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/utils 0.002s
? github.com/gtsteffaniak/filebrowser/backend/version [no test files]
ok github.com/gtsteffaniak/filebrowser/users 0.002s
? github.com/gtsteffaniak/filebrowser/utils [no test files]
? github.com/gtsteffaniak/filebrowser/version [no test files]
-87
View File
@@ -1,87 +0,0 @@
package cache
import (
"sync"
"time"
)
var (
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 {
newCache := KeyCache{
data: make(map[string]cachedValue),
expiresAfter: expires, // default
}
go newCache.cleanupExpiredJob(cleanup)
return &newCache
}
type KeyCache struct {
data map[string]cachedValue
mu sync.RWMutex
expiresAfter time.Duration
}
type cachedValue struct {
value interface{}
expiresAt time.Time
}
func (c *KeyCache) Set(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = cachedValue{
value: value,
expiresAt: time.Now().Add(c.expiresAfter),
}
}
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()
c.data[key] = cachedValue{
value: value,
expiresAt: time.Now().Add(exp),
}
}
func (c *KeyCache) Get(key string) interface{} {
c.mu.RLock()
defer c.mu.RUnlock()
cached, ok := c.data[key]
if !ok || time.Now().After(cached.expiresAt) {
return nil
}
return cached.value
}
func (c *KeyCache) cleanupExpired() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for key, cached := range c.data {
if now.After(cached.expiresAt) {
delete(c.data, key)
}
}
}
// should automatically run for all cache types as part of init.
func (c *KeyCache) cleanupExpiredJob(frequency time.Duration) {
ticker := time.NewTicker(frequency)
defer ticker.Stop()
for range ticker.C {
c.cleanupExpired()
}
}
+59 -129
View File
@@ -1,26 +1,23 @@
package cmd
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"github.com/gtsteffaniak/filebrowser/backend/diskcache"
"github.com/gtsteffaniak/filebrowser/backend/files"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/img"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
"github.com/gtsteffaniak/filebrowser/diskcache"
"github.com/gtsteffaniak/filebrowser/files"
fbhttp "github.com/gtsteffaniak/filebrowser/http"
"github.com/gtsteffaniak/filebrowser/img"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/swagger/docs"
"github.com/swaggo/swag"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/version"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/version"
)
func getStore(config string) (*storage.Storage, bool) {
@@ -28,22 +25,20 @@ func getStore(config string) (*storage.Storage, bool) {
settings.Initialize(config)
store, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
if err != nil {
logger.Fatal(fmt.Sprintf("could not load db info: %v", err))
log.Fatal("could not load db info: ", err)
}
return store, hasDB
}
func generalUsage() {
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
`)
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")
}
func StartFilebrowser() {
@@ -52,7 +47,7 @@ func StartFilebrowser() {
var help bool
// Override the default usage output to use generalUsage()
flag.Usage = generalUsage
flag.StringVar(&configPath, "c", "config.yaml", "Path to the config file, default: config.yaml")
flag.StringVar(&configPath, "c", "filebrowser.yaml", "Path to the config file.")
flag.BoolVar(&help, "h", false, "Get help about commands")
// Parse global flags (before subcommands)
@@ -72,151 +67,86 @@ func StartFilebrowser() {
setCmd.StringVar(&user, "u", "", "Comma-separated username and password: \"set -u <username>,<password>\"")
setCmd.BoolVar(&asAdmin, "a", false, "Create user as admin user, used in combination with -u")
setCmd.StringVar(&scope, "s", "", "Specify a user scope, otherwise default user config scope is used")
setCmd.StringVar(&dbConfig, "c", "config.yaml", "Path to the config file, default: config.yaml")
// Create context and channels for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{}) // Signals server has stopped
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
setCmd.StringVar(&dbConfig, "c", "filebrowser.yaml", "Path to the config file.")
// Parse subcommand flags only if a subcommand is specified
if len(os.Args) > 1 {
switch os.Args[1] {
case "set":
err := setCmd.Parse(os.Args[2:])
err := setCmd.Parse(os.Args)
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
userInfo := strings.Split(user, ",")
if len(userInfo) < 2 {
fmt.Printf("not enough info to create user: \"set -u username,password\", only provided %v\n", userInfo)
fmt.Println("not enough info to create user: \"set -u username,password\"")
setCmd.PrintDefaults()
os.Exit(1)
}
username := userInfo[0]
password := userInfo[1]
store, ok := getStore(dbConfig)
if !ok {
logger.Fatal("could not load db info")
}
user, err := store.Users.Get("", username)
if err != nil {
newUser := users.User{
Username: username,
Password: password,
}
if scope != "" {
newUser.Scope = scope
} else {
newUser.Scope = settings.Config.UserDefaults.Scope
}
// Create the user logic
if asAdmin {
logger.Info(fmt.Sprintf("Creating user as admin: %s\n", username))
} else {
logger.Info(fmt.Sprintf("Creating non-admin user: %s\n", username))
}
err = storage.CreateUser(newUser, asAdmin)
if err != nil {
logger.Error(fmt.Sprintf("could not create user: %v", err))
}
return
}
user.Password = password
if scope != "" {
user.Scope = scope
}
getStore(dbConfig)
// Create the user logic
if asAdmin {
user.Perm.Admin = true
log.Printf("Creating user as admin: %s\n", username)
} else {
log.Printf("Creating user: %s\n", username)
}
err = store.Users.Save(user)
newUser := users.User{
Username: username,
Password: password,
}
if scope != "" {
newUser.Scope = scope
}
err = storage.CreateUser(newUser, asAdmin)
if err != nil {
logger.Error(fmt.Sprintf("could not update user: %v", err))
log.Fatal("Could not create user: ", err)
}
fmt.Printf("successfully updated user: %s\n", username)
return
case "version":
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
Version : %v
Commit : %v
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
`, version.Version, version.CommitSHA, version.Version)
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)
return
}
}
store, dbExists := getStore(configPath)
indexingInterval := fmt.Sprint(settings.Config.Server.IndexingInterval, " minutes")
if !settings.Config.Server.Indexing {
indexingInterval = "disabled"
}
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
}
sources := []string{}
for _, v := range settings.Config.Server.Sources {
sources = append(sources, v.Name+": "+v.Path)
}
log.Printf("Initializing FileBrowser Quantum (%v)\n", version.Version)
log.Printf("Using Config file : %v", configPath)
log.Println("Embeded frontend :", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true")
log.Println(database)
log.Println("Sources :", settings.Config.Server.Root)
log.Println("Indexing interval :", indexingInterval)
authMethods := []string{}
if settings.Config.Auth.Methods.PasswordAuth.Enabled {
authMethods = append(authMethods, "Password")
}
if settings.Config.Auth.Methods.ProxyAuth.Enabled {
authMethods = append(authMethods, "Proxy")
}
if settings.Config.Auth.Methods.NoAuth {
logger.Warning("Configured with no authentication, this is not recommended.")
authMethods = []string{"Disabled"}
}
logger.Info(fmt.Sprintf("Initializing FileBrowser Quantum (%v)", version.Version))
logger.Info(fmt.Sprintf("Using Config file : %v", configPath))
logger.Info(fmt.Sprintf("Auth Methods : %v", authMethods))
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)
sourceConfigs := settings.Config.Server.Sources
if len(sourceConfigs) == 0 {
logger.Fatal("No sources configured, exiting...")
go files.InitializeIndex(serverConfig.IndexingInterval, serverConfig.Indexing)
if err := rootCMD(store, &serverConfig); err != nil {
log.Fatal("Error starting filebrowser:", err)
}
for _, source := range sourceConfigs {
go files.Initialize(source)
}
// Start the rootCMD in a goroutine
go func() {
if err := rootCMD(ctx, store, &serverConfig, shutdownComplete); err != nil {
logger.Fatal(fmt.Sprintf("Error starting filebrowser: %v", err))
}
close(done) // Signal that the server has stopped
}()
// Wait for a shutdown signal or the server to stop
select {
case <-signalChan:
logger.Info("Received shutdown signal. Shutting down gracefully...")
cancel() // Trigger context cancellation
case <-done:
logger.Info("Server stopped unexpectedly. Shutting down...")
}
<-shutdownComplete // Ensure we don't exit prematurely
// Wait for the server to stop
logger.Info("Shutdown complete.")
}
func rootCMD(ctx context.Context, store *storage.Storage, serverConfig *settings.Server, shutdownComplete chan struct{}) error {
func rootCMD(store *storage.Storage, serverConfig *settings.Server) error {
if serverConfig.NumImageProcessors < 1 {
logger.Fatal("Image resize workers count could not be < 1")
log.Fatal("Image resize workers count could not be < 1")
}
imgSvc := img.New(serverConfig.NumImageProcessors)
cacheDir := settings.Config.Server.CacheDir
cacheDir := "/tmp"
var fileCache diskcache.Interface
// Use file cache if cacheDir is specified
@@ -224,13 +154,13 @@ func rootCMD(ctx context.Context, store *storage.Storage, serverConfig *settings
var err error
fileCache, err = diskcache.NewFileCache(cacheDir)
if err != nil {
logger.Fatal(fmt.Sprintf("failed to create file cache: %v", err))
log.Fatalf("failed to create file cache: %v", err)
}
} else {
// No-op cache if no cacheDir is specified
fileCache = diskcache.NewNoOp()
}
fbhttp.StartHttp(ctx, imgSvc, store, fileCache, shutdownComplete)
fbhttp.StartHttp(imgSvc, store, fileCache)
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package cmd
import (
"strconv"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
)
func init() {
rulesCmd.AddCommand(rulesRmCommand)
rulesRmCommand.Flags().Uint("index", 0, "index of rule to remove")
_ = rulesRmCommand.MarkFlagRequired("index")
}
var rulesRmCommand = &cobra.Command{
Use: "rm <index> [index_end]",
Short: "Remove a global rule or user rule",
Long: `Remove a global rule or user rule. The provided index
is the same that's printed when you run 'rules ls'. Note
that after each removal/addition, the index of the
commands change. So be careful when removing them after each
other.
You can also specify an optional parameter (index_end) so
you can remove all commands from 'index' to 'index_end',
including 'index_end'.`,
Args: func(cmd *cobra.Command, args []string) error {
if err := cobra.RangeArgs(1, 2)(cmd, args); err != nil { //nolint:gomnd
return err
}
for _, arg := range args {
if _, err := strconv.Atoi(arg); err != nil {
return err
}
}
return nil
},
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
i, err := strconv.Atoi(args[0])
utils.CheckErr("strconv.Atoi", err)
f := i
if len(args) == 2 { //nolint:gomnd
f, err = strconv.Atoi(args[1])
utils.CheckErr("strconv.Atoi", err)
}
user := func(u *users.User) {
u.Rules = append(u.Rules[:i], u.Rules[f+1:]...)
err := store.Users.Save(u)
utils.CheckErr("store.Users.Save", err)
}
global := func(s *settings.Settings) {
s.Rules = append(s.Rules[:i], s.Rules[f+1:]...)
err := store.Settings.Save(s)
utils.CheckErr("store.Settings.Save", err)
}
runRules(store, cmd, user, global)
}),
}
+86
View File
@@ -0,0 +1,86 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
)
func init() {
rulesCmd.PersistentFlags().StringP("username", "u", "", "username of user to which the rules apply")
rulesCmd.PersistentFlags().UintP("id", "i", 0, "id of user to which the rules apply")
}
var rulesCmd = &cobra.Command{
Use: "rules",
Short: "Rules management utility",
Long: `On each subcommand you'll have available at least two flags:
"username" and "id". You must either set only one of them
or none. If you set one of them, the command will apply to
an user, otherwise it will be applied to the global set or
rules.`,
Args: cobra.NoArgs,
}
func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User), globalFn func(*settings.Settings)) {
id := getUserIdentifier(cmd.Flags())
if id != nil {
user, err := st.Users.Get("", id)
utils.CheckErr("st.Users.Get", err)
if usersFn != nil {
usersFn(user)
}
printRules(user.Rules, id)
return
}
s, err := st.Settings.Get()
utils.CheckErr("st.Settings.Get", err)
if globalFn != nil {
globalFn(s)
}
printRules(s.Rules, id)
}
func getUserIdentifier(flags *pflag.FlagSet) interface{} {
id := mustGetUint(flags, "id")
username := mustGetString(flags, "username")
if id != 0 {
return id
} else if username != "" {
return username
}
return nil
}
func printRules(rulez []users.Rule, id interface{}) {
for id, rule := range rulez {
fmt.Printf("(%d) ", id)
if rule.Regex {
if rule.Allow {
fmt.Printf("Allow Regex: \t%s\n", rule.Regexp.Raw)
} else {
fmt.Printf("Disallow Regex: \t%s\n", rule.Regexp.Raw)
}
} else {
if rule.Allow {
fmt.Printf("Allow Path: \t%s\n", rule.Path)
} else {
fmt.Printf("Disallow Path: \t%s\n", rule.Path)
}
}
}
}
+59
View File
@@ -0,0 +1,59 @@
package cmd
import (
"regexp"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
)
func init() {
rulesCmd.AddCommand(rulesAddCmd)
rulesAddCmd.Flags().BoolP("allow", "a", false, "indicates this is an allow rule")
rulesAddCmd.Flags().BoolP("regex", "r", false, "indicates this is a regex rule")
}
var rulesAddCmd = &cobra.Command{
Use: "add <path|expression>",
Short: "Add a global rule or user rule",
Long: `Add a global rule or user rule.`,
Args: cobra.ExactArgs(1),
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
allow := mustGetBool(cmd.Flags(), "allow")
regex := mustGetBool(cmd.Flags(), "regex")
exp := args[0]
if regex {
regexp.MustCompile(exp)
}
rule := users.Rule{
Allow: allow,
Regex: regex,
}
if regex {
rule.Regexp = &users.Regexp{Raw: exp}
} else {
rule.Path = exp
}
user := func(u *users.User) {
u.Rules = append(u.Rules, rule)
err := store.Users.Save(u)
utils.CheckErr("store.Users.Save", err)
}
global := func(s *settings.Settings) {
s.Rules = append(s.Rules, rule)
err := store.Settings.Save(s)
utils.CheckErr("store.Settings.Save", err)
}
runRules(store, cmd, user, global)
}),
}
+20
View File
@@ -0,0 +1,20 @@
package cmd
import (
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/spf13/cobra"
)
func init() {
rulesCmd.AddCommand(rulesLsCommand)
}
var rulesLsCommand = &cobra.Command{
Use: "ls",
Short: "List global rules or user specific rules",
Long: `List global rules or user specific rules.`,
Args: cobra.NoArgs,
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
runRules(store, cmd, nil, nil)
}),
}
+54
View File
@@ -0,0 +1,54 @@
package cmd
import (
"fmt"
"os"
"strconv"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/users"
)
var usersCmd = &cobra.Command{
Use: "users",
Short: "Users management utility",
Long: `Users management utility.`,
Args: cobra.NoArgs,
}
func printUsers(usrs []*users.User) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) //nolint:gomnd
fmt.Fprintln(w, "ID\tUsername\tScope\tLocale\tV. Mode\tS.Click\tAdmin\tExecute\tCreate\tRename\tModify\tDelete\tShare\tDownload\tPwd Lock")
for _, u := range usrs {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t\n",
u.ID,
u.Username,
u.Scope,
u.Locale,
u.ViewMode,
u.SingleClick,
u.Perm.Admin,
u.Perm.Execute,
u.Perm.Create,
u.Perm.Rename,
u.Perm.Modify,
u.Perm.Delete,
u.Perm.Share,
u.Perm.Download,
u.LockPassword,
)
}
w.Flush()
}
func parseUsernameOrID(arg string) (username string, id uint) {
id64, err := strconv.ParseUint(arg, 10, 64)
if err != nil {
return arg, 0
}
return "", uint(id64)
}
+42
View File
@@ -0,0 +1,42 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
)
func init() {
usersCmd.AddCommand(usersAddCmd)
}
var usersAddCmd = &cobra.Command{
Use: "add <username> <password>",
Short: "Create a new user",
Long: `Create a new user and add it to the database.`,
Args: cobra.ExactArgs(2), //nolint:gomnd
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
user := &users.User{
Username: args[0],
Password: args[1],
LockPassword: mustGetBool(cmd.Flags(), "lockPassword"),
}
servSettings, err := store.Settings.GetServer()
utils.CheckErr("store.Settings.GetServer()", err)
// since getUserDefaults() polluted s.Defaults.Scope
// which makes the Scope not the one saved in the db
// we need the right s.Defaults.Scope here
s2, err := store.Settings.Get()
utils.CheckErr("store.Settings.Get()", err)
userHome, err := s2.MakeUserDir(user.Username, user.Scope, servSettings.Root)
utils.CheckErr("s2.MakeUserDir", err)
user.Scope = userHome
err = store.Users.Save(user)
utils.CheckErr("store.Users.Save", err)
printUsers([]*users.User{user})
}),
}
+26
View File
@@ -0,0 +1,26 @@
package cmd
import (
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/utils"
"github.com/spf13/cobra"
)
func init() {
usersCmd.AddCommand(usersExportCmd)
}
var usersExportCmd = &cobra.Command{
Use: "export <path>",
Short: "Export all users to a file.",
Long: `Export all users to a json or yaml file. Please indicate the
path to the file where you want to write the users.`,
Args: jsonYamlArg,
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
list, err := store.Users.Gets("")
utils.CheckErr("store.Users.Gets", err)
err = marshal(args[0], list)
utils.CheckErr("marshal", err)
}),
}
+53
View File
@@ -0,0 +1,53 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
)
func init() {
usersCmd.AddCommand(usersFindCmd)
usersCmd.AddCommand(usersLsCmd)
}
var usersFindCmd = &cobra.Command{
Use: "find <id|username>",
Short: "Find a user by username or id",
Long: `Find a user by username or id. If no flag is set, all users will be printed.`,
Args: cobra.ExactArgs(1),
Run: findUsers,
}
var usersLsCmd = &cobra.Command{
Use: "ls",
Short: "List all users.",
Args: cobra.NoArgs,
Run: findUsers,
}
var findUsers = cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
var (
list []*users.User
user *users.User
err error
)
if len(args) == 1 {
username, id := parseUsernameOrID(args[0])
if username != "" {
user, err = store.Users.Get("", username)
} else {
user, err = store.Users.Get("", id)
}
list = []*users.User{user}
} else {
list, err = store.Users.Gets("")
}
utils.CheckErr("findUsers", err)
printUsers(list)
})
+88
View File
@@ -0,0 +1,88 @@
package cmd
import (
"errors"
"fmt"
"os"
"strconv"
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
)
func init() {
usersCmd.AddCommand(usersImportCmd)
usersImportCmd.Flags().Bool("overwrite", false, "overwrite users with the same id/username combo")
usersImportCmd.Flags().Bool("replace", false, "replace the entire user base")
}
var usersImportCmd = &cobra.Command{
Use: "import <path>",
Short: "Import users from a file",
Long: `Import users from a file. The path must be for a json or yaml
file. You can use this command to import new users to your
installation. For that, just don't place their ID on the files
list or set it to 0.`,
Args: jsonYamlArg,
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
fd, err := os.Open(args[0])
utils.CheckErr("os.Open", err)
defer fd.Close()
list := []*users.User{}
err = unmarshal(args[0], &list)
utils.CheckErr("unmarshal", err)
if mustGetBool(cmd.Flags(), "replace") {
oldUsers, err := store.Users.Gets("")
utils.CheckErr("store.Users.Gets", err)
err = marshal("users.backup.json", list)
utils.CheckErr("marshal users.backup.json", err)
for _, user := range oldUsers {
err = store.Users.Delete(user.ID)
utils.CheckErr("store.Users.Delete", err)
}
}
overwrite := mustGetBool(cmd.Flags(), "overwrite")
for _, user := range list {
onDB, err := store.Users.Get("", user.ID)
// User exists in DB.
if err == nil {
if !overwrite {
newErr := errors.New("user " + strconv.Itoa(int(user.ID)) + " is already registered")
utils.CheckErr("", newErr)
}
// If the usernames mismatch, check if there is another one in the DB
// with the new username. If there is, print an error and cancel the
// operation
if user.Username != onDB.Username {
if conflictuous, err := store.Users.Get("", user.Username); err == nil { //nolint:govet
newErr := usernameConflictError(user.Username, conflictuous.ID, user.ID)
utils.CheckErr("usernameConflictError", newErr)
}
}
} else {
// If it doesn't exist, set the ID to 0 to automatically get a new
// one that make sense in this DB.
user.ID = 0
}
err = store.Users.Save(user)
utils.CheckErr("store.Users.Save", err)
}
}),
}
func usernameConflictError(username string, originalID, newID uint) error {
return fmt.Errorf(`can't import user with ID %d and username "%s" because the username is already registred with the user %d`,
newID, username, originalID)
}
+33
View File
@@ -0,0 +1,33 @@
package cmd
import (
"log"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/utils"
"github.com/spf13/cobra"
)
func init() {
usersCmd.AddCommand(usersRmCmd)
}
var usersRmCmd = &cobra.Command{
Use: "rm <id|username>",
Short: "Delete a user by username or id",
Long: `Delete a user by username or id`,
Args: cobra.ExactArgs(1),
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
username, id := parseUsernameOrID(args[0])
var err error
if username != "" {
err = store.Users.Delete(username)
} else {
err = store.Users.Delete(id)
}
utils.CheckErr("usersRmCmd", err)
log.Println("user deleted successfully")
}),
}
+40
View File
@@ -0,0 +1,40 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
)
func init() {
usersCmd.AddCommand(usersUpdateCmd)
}
var usersUpdateCmd = &cobra.Command{
Use: "update <id|username>",
Short: "Updates an existing user",
Long: `Updates an existing user. Set the flags for the
options you want to change.`,
Args: cobra.ExactArgs(1),
Run: cobraCmd(func(cmd *cobra.Command, args []string, store *storage.Storage) {
username, id := parseUsernameOrID(args[0])
var (
err error
user *users.User
)
if id != 0 {
user, err = store.Users.Get("", id)
} else {
user, err = store.Users.Get("", username)
}
utils.CheckErr("store.Users.Get", err)
err = store.Users.Update(user)
utils.CheckErr("store.Users.Update", err)
printUsers([]*users.User{user})
}),
}
+88
View File
@@ -0,0 +1,88 @@
package cmd
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"github.com/goccy/go-yaml"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/utils"
)
func mustGetString(flags *pflag.FlagSet, flag string) string {
s, err := flags.GetString(flag)
utils.CheckErr("mustGetString", err)
return s
}
func mustGetBool(flags *pflag.FlagSet, flag string) bool {
b, err := flags.GetBool(flag)
utils.CheckErr("mustGetBool", err)
return b
}
func mustGetUint(flags *pflag.FlagSet, flag string) uint {
b, err := flags.GetUint(flag)
utils.CheckErr("mustGetUint", err)
return b
}
type cobraFunc func(cmd *cobra.Command, args []string)
type pythonFunc func(cmd *cobra.Command, args []string, store *storage.Storage)
func marshal(filename string, data interface{}) error {
fd, err := os.Create(filename)
utils.CheckErr("os.Create", err)
defer fd.Close()
switch ext := filepath.Ext(filename); ext {
case ".json":
encoder := json.NewEncoder(fd)
encoder.SetIndent("", " ")
return encoder.Encode(data)
case ".yml", ".yaml": //nolint:goconst
_, err := yaml.Marshal(fd)
return err
default:
return errors.New("invalid format: " + ext)
}
}
func unmarshal(filename string, data interface{}) error {
fd, err := os.Open(filename)
utils.CheckErr("os.Open", err)
defer fd.Close()
switch ext := filepath.Ext(filename); ext {
case ".json":
return json.NewDecoder(fd).Decode(data)
case ".yml", ".yaml":
return yaml.NewDecoder(fd).Decode(data)
default:
return errors.New("invalid format: " + ext)
}
}
func jsonYamlArg(cmd *cobra.Command, args []string) error {
if err := cobra.ExactArgs(1)(cmd, args); err != nil {
return err
}
switch ext := filepath.Ext(args[0]); ext {
case ".json", ".yml", ".yaml":
return nil
default:
return errors.New("invalid format: " + ext)
}
}
func cobraCmd(fn pythonFunc) cobraFunc {
return func(cmd *cobra.Command, args []string) {
}
}
-2
View File
@@ -1,2 +0,0 @@
server:
root: "/srv"
BIN
View File
Binary file not shown.
-27
View File
@@ -1,27 +0,0 @@
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: "."
singleClick: false
permissions:
admin: false
create: false
rename: false
modify: false
delete: false
share: false
download: false
@@ -1,13 +1,15 @@
server:
port: 80
baseURL: "/"
root: "/srv"
root: "./srv"
auth:
method: password
signup: false
userDefaults:
darkMode: true
disableSettings: false
scope: "."
hideDotfiles: true
singleClick: false
permissions:
admin: false
-9
View File
@@ -1,9 +0,0 @@
//go:build !windows
// +build !windows
package files
func checkWindowsHidden(realpath string) bool {
// Non-Windows platforms don't support hidden attributes in the same way
return false
}
-33
View File
@@ -1,33 +0,0 @@
//go:build windows
// +build windows
package files
import (
"golang.org/x/sys/windows"
)
func checkWindowsHidden(realpath string) bool {
// Convert the realpath to a UTF-16 pointer
pointer, err := windows.UTF16PtrFromString(realpath)
if err != nil {
return false
}
// Get the file attributes
attributes, err := windows.GetFileAttributes(pointer)
if err != nil {
return false
}
// Check if the hidden attribute is set
if attributes&windows.FILE_ATTRIBUTE_HIDDEN != 0 {
return true
}
// Optional: Check for system attribute
if attributes&windows.FILE_ATTRIBUTE_SYSTEM != 0 {
return true
}
return false
}
+15 -112
View File
@@ -2,7 +2,6 @@ package files
import (
"mime"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -15,96 +14,24 @@ var AllFiletypeOptions = []string{
"archive",
"video",
"doc",
"dir",
"text",
}
// Document file extensions
var documentTypes = []string{
// Common Document Formats
".doc", ".docx", // Microsoft Word
".pdf", // Portable Document Format
".odt", // OpenDocument Text
".rtf", // Rich Text Format
".conf",
".bash_history",
".gitignore",
".htpasswd",
".profile",
".dockerignore",
".editorconfig",
// Presentation Formats
".ppt", ".pptx", // Microsoft PowerPoint
".odp", // OpenDocument Presentation
// google docs
".gdoc",
// google sheet
".gsheet",
// Spreadsheet Formats
".xls", ".xlsx", // Microsoft Excel
".ods", // OpenDocument Spreadsheet
// Other Document Formats
".epub", // Electronic Publication
".mobi", // Amazon Kindle
".fb2", // FictionBook
".word",
".pdf",
".doc",
".docx",
}
var onlyOfficeSupported = []string{
".doc", ".docm", ".docx", ".dot", ".dotm", ".dotx", ".epub",
".fb2", ".fodt", ".htm", ".html", ".mht", ".mhtml", ".odt",
".ott", ".rtf", ".stw", ".sxw", ".txt", ".wps", ".wpt", ".xml",
".csv", ".et", ".ett", ".fods", ".ods", ".ots", ".sxc", ".xls",
".xlsb", ".xlsm", ".xlsx", ".xlt", ".xltm", ".xltx", ".dps",
".dpt", ".fodp", ".odp", ".otp", ".pot", ".potm", ".potx",
".pps", ".ppsm", ".ppsx", ".ppt", ".pptm", ".pptx", ".sxi",
".djvu", ".docxf", ".oform", ".oxps", ".pdf", ".xps",
}
// Text-based file extensions
var textTypes = []string{
// Common Text Formats
".txt",
".md", // Markdown
// Scripting and Programming Languages
".sh", // Bash script
".py", // Python
".js", // JavaScript
".ts", // TypeScript
".php", // PHP
".rb", // Ruby
".go", // Go
".java", // Java
".c", ".cpp", // C/C++
".cs", // C#
".swift", // Swift
// Configuration Files
".yaml", ".yml", // YAML
".json", // JSON
".xml", // XML
".ini", // INI
".toml", // TOML
".cfg", // Configuration file
// Other Text-Based Formats
".css", // Cascading Style Sheets
".html", ".htm", // HyperText Markup Language
".sql", // SQL
".csv", // Comma-Separated Values
".tsv", // Tab-Separated Values
".log", // Log file
".bat", // Batch file
".ps1", // PowerShell script
".tex", // LaTeX
".bib", // BibTeX
".text",
".sh",
".yaml",
".yml",
".json",
".env",
}
// Compressed file extensions
var compressedFile = []string{
".7z",
".rar",
@@ -112,12 +39,6 @@ var compressedFile = []string{
".tar",
".gz",
".xz",
".bz2",
".tgz", // tar.gz
".tbz2", // tar.bz2
".lzma",
".lz4",
".zstd",
}
type SearchOptions struct {
@@ -127,18 +48,8 @@ type SearchOptions struct {
Terms []string
}
func extendedMimeTypeCheck(extension string) string {
if isDoc(extension) {
return "application/document"
}
if isText(extension) {
return "text/plain"
}
return "blob"
}
func ParseSearch(value string) SearchOptions {
opts := SearchOptions{
func ParseSearch(value string) *SearchOptions {
opts := &SearchOptions{
Conditions: map[string]bool{
"exact": strings.Contains(value, "case:exact"),
},
@@ -234,6 +145,8 @@ func IsMatchingType(extension string, matchType string) bool {
switch matchType {
case "doc":
return isDoc(extension)
case "pdf":
return extension == ".pdf"
case "text":
return isText(extension)
case "archive":
@@ -268,13 +181,3 @@ 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
}
+1 -1
View File
@@ -22,7 +22,7 @@ func TestIsMatchingType(t *testing.T) {
extension string
expectedType string
}{
{".pdf", "doc"},
{".pdf", "pdf"},
{".doc", "doc"},
{".docx", "doc"},
{".json", "text"},
+340 -180
View File
@@ -11,22 +11,17 @@ import (
"io"
"mime"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"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"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/fileutils"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
)
var (
@@ -34,44 +29,44 @@ var (
pathMutexesMu sync.Mutex // Mutex to protect the pathMutexes map
)
type ItemInfo struct {
Name string `json:"name"` // name of the file
Size int64 `json:"size"` // length in bytes for regular files
ModTime time.Time `json:"modified"` // modification time
Type string `json:"type"` // type of the file, either "directory" or a file mimetype
Hidden bool `json:"hidden"` // whether the file is hidden
type ReducedItem struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModTime time.Time `json:"modified"`
Type string `json:"type"`
Mode os.FileMode `json:"-"`
Content string `json:"content,omitempty"`
}
// FileInfo describes a file.
// reduced item is non-recursive reduced "Items", used to pass flat items array
type FileInfo struct {
ItemInfo
Files []ItemInfo `json:"files"` // files in the directory
Folders []ItemInfo `json:"folders"` // folders in the directory
Path string `json:"path"` // path scoped to the associated index
}
// for efficiency, a response will be a pointer to the data
// extra calculated fields can be added here
type ExtendedFileInfo struct {
*FileInfo
Content string `json:"content,omitempty"` // text content of a file, if requested
Subtitles []string `json:"subtitles,omitempty"` // subtitles for video files
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
Token string `json:"token,omitempty"` // token for the file -- used for sharing
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source"` // associated index source for the file
RealPath string `json:"-"`
Files []ReducedItem `json:"-"`
Dirs map[string]*FileInfo `json:"-"`
Path string `json:"path"`
Name string `json:"name"`
Items []ReducedItem `json:"items"`
Size int64 `json:"size"`
Extension string `json:"-"`
ModTime time.Time `json:"modified"`
CacheTime time.Time `json:"-"`
Mode os.FileMode `json:"-"`
IsSymlink bool `json:"isSymlink,omitempty"`
Type string `json:"type"`
Subtitles []string `json:"subtitles,omitempty"`
Content string `json:"content,omitempty"`
Checksums map[string]string `json:"checksums,omitempty"`
Token string `json:"token,omitempty"`
}
// FileOptions are the options when getting a file info.
type FileOptions struct {
Path string // realpath
Source string
IsDir bool
Modify bool
Expand bool
ReadHeader bool
Token string
Checker users.Checker
Content bool
}
@@ -80,90 +75,206 @@ func (f FileOptions) Components() (string, string) {
return filepath.Dir(f.Path), filepath.Base(f.Path)
}
func FileInfoFaster(opts FileOptions) (ExtendedFileInfo, error) {
response := ExtendedFileInfo{}
if opts.Source == "" {
opts.Source = "default"
}
index := GetIndex(opts.Source)
if index == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
}
func FileInfoFaster(opts FileOptions) (*FileInfo, error) {
index := GetIndex(rootPath)
opts.Path = index.makeIndexPath(opts.Path)
// Lock access for the specific path
pathMutex := getMutex(opts.Path)
pathMutex.Lock()
defer pathMutex.Unlock()
if !opts.Checker.Check(opts.Path) {
return response, os.ErrPermission
return nil, os.ErrPermission
}
realPath, isDir, err := index.GetRealPath(opts.Path)
_, isDir, err := GetRealPath(opts.Path)
if err != nil {
return response, err
return nil, err
}
opts.IsDir = isDir
// TODO : whats the best way to save trips to disk here?
// disabled using cache because its not clear if this is helping or hurting
// check if the file exists in the index
//info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
//if exists {
// err := RefreshFileInfo(opts)
// if err != nil {
// return info, err
// }
// if opts.Content {
// content := ""
// content, err = getContent(opts.Path)
// if err != nil {
// return info, err
// }
// info.Content = content
// }
// return info, nil
//}
err = index.RefreshFileInfo(opts)
if err != nil {
return response, err
}
info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
if exists {
// Let's not refresh if less than a second has passed
if time.Since(info.CacheTime) > time.Second {
RefreshFileInfo(opts) //nolint:errcheck
}
if opts.Content {
content := ""
content, err = getContent(opts.Path)
if err != nil {
return info, err
}
info.Content = content
}
return info, nil
}
err = RefreshFileInfo(opts)
if err != nil {
return nil, err
}
info, exists = index.GetReducedMetadata(opts.Path, opts.IsDir)
if !exists {
return response, err
return nil, err
}
if opts.Content {
content, err := getContent("default", opts.Path)
content, err := getContent(opts.Path)
if err != nil {
return response, err
return info, err
}
response.Content = content
info.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
return info, 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
func RefreshFileInfo(opts FileOptions) error {
refreshOptions := FileOptions{
Path: opts.Path,
IsDir: opts.IsDir,
Token: opts.Token,
}
return key
index := GetIndex(rootPath)
if !refreshOptions.IsDir {
refreshOptions.Path = index.makeIndexPath(filepath.Dir(refreshOptions.Path))
refreshOptions.IsDir = true
} else {
refreshOptions.Path = index.makeIndexPath(refreshOptions.Path)
}
current, exists := index.GetMetadataInfo(refreshOptions.Path, true)
file, err := stat(refreshOptions)
if err != nil {
return fmt.Errorf("file/folder does not exist to refresh data: %s", refreshOptions.Path)
}
//utils.PrintStructFields(*file)
result := index.UpdateMetadata(file)
if !result {
return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path)
}
if !exists {
return nil
}
if current.Size != file.Size {
index.recursiveUpdateDirSizes(filepath.Dir(refreshOptions.Path), file, current.Size)
}
return nil
}
func stat(opts FileOptions) (*FileInfo, error) {
realPath, _, err := GetRealPath(rootPath, opts.Path)
if err != nil {
return nil, err
}
info, err := os.Lstat(realPath)
if err != nil {
return nil, err
}
file := &FileInfo{
Path: opts.Path,
Name: filepath.Base(opts.Path),
ModTime: info.ModTime(),
Mode: info.Mode(),
Size: info.Size(),
Extension: filepath.Ext(info.Name()),
Token: opts.Token,
}
if info.IsDir() {
// Open and read directory contents
dir, err := os.Open(realPath)
if err != nil {
return nil, err
}
defer dir.Close()
dirInfo, err := dir.Stat()
if err != nil {
return nil, err
}
index := GetIndex(rootPath)
// Check cached metadata to decide if refresh is needed
cachedParentDir, exists := index.GetMetadataInfo(opts.Path, true)
if exists && dirInfo.ModTime().Before(cachedParentDir.CacheTime) {
return cachedParentDir, nil
}
// Read directory contents and process
files, err := dir.Readdir(-1)
if err != nil {
return nil, err
}
file.Files = []ReducedItem{}
file.Dirs = map[string]*FileInfo{}
var totalSize int64
for _, item := range files {
itemPath := filepath.Join(realPath, item.Name())
if item.IsDir() {
itemInfo := &FileInfo{
Name: item.Name(),
ModTime: item.ModTime(),
Mode: item.Mode(),
}
if exists {
// if directory size was already cached use that.
cachedDir, ok := cachedParentDir.Dirs[item.Name()]
if ok {
itemInfo.Size = cachedDir.Size
}
}
file.Dirs[item.Name()] = itemInfo
totalSize += itemInfo.Size
} else {
itemInfo := ReducedItem{
Name: item.Name(),
Size: item.Size(),
ModTime: item.ModTime(),
Mode: item.Mode(),
}
if IsSymlink(item.Mode()) {
itemInfo.Type = "symlink"
info, err := os.Stat(itemPath)
if err == nil {
itemInfo.Name = info.Name()
itemInfo.ModTime = info.ModTime()
itemInfo.Size = info.Size()
itemInfo.Mode = info.Mode()
} else {
file.Type = "invalid_link"
}
}
if file.Type != "invalid_link" {
err := itemInfo.detectType(itemPath, true, opts.Content, opts.ReadHeader)
if err != nil {
fmt.Printf("failed to detect type for %v: %v \n", itemPath, err)
}
file.Files = append(file.Files, itemInfo)
}
totalSize += itemInfo.Size
}
}
file.Size = totalSize
}
return file, nil
}
// Checksum checksums a given File for a given User, using a specific
// algorithm. The checksums data is saved on File object.
func GetChecksum(fullPath, algo string) (map[string]string, error) {
subs := map[string]string{}
reader, err := os.Open(fullPath)
func (i *FileInfo) Checksum(algo string) error {
if i.Checksums == nil {
i.Checksums = map[string]string{}
}
fullpath := filepath.Join(i.Path, i.Name)
reader, err := os.Open(fullpath)
if err != nil {
return subs, err
return err
}
defer reader.Close()
@@ -176,67 +287,89 @@ func GetChecksum(fullPath, algo string) (map[string]string, error) {
h, ok := hashFuncs[algo]
if !ok {
return subs, errors.ErrInvalidOption
return errors.ErrInvalidOption
}
_, err = io.Copy(h, reader)
if err != nil {
return subs, err
return err
}
subs[algo] = hex.EncodeToString(h.Sum(nil))
return subs, nil
i.Checksums[algo] = hex.EncodeToString(h.Sum(nil))
return nil
}
func DeleteFiles(source, absPath string, dirPath string) error {
// RealPath gets the real path for the file, resolving symlinks if supported.
func (i *FileInfo) RealPath() string {
realPath, err := filepath.EvalSymlinks(i.Path)
if err == nil {
return realPath
}
return i.Path
}
func GetRealPath(relativePath ...string) (string, bool, error) {
combined := []string{settings.Config.Server.Root}
for _, path := range relativePath {
combined = append(combined, strings.TrimPrefix(path, settings.Config.Server.Root))
}
joinedPath := filepath.Join(combined...)
// Convert relative path to absolute path
absolutePath, err := filepath.Abs(joinedPath)
if err != nil {
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", combined, err)
}
// Resolve symlinks and get the real path
return resolveSymlinks(absolutePath)
}
func DeleteFiles(absPath string, opts FileOptions) error {
err := os.RemoveAll(absPath)
if err != nil {
return err
}
index := GetIndex(source)
refreshConfig := FileOptions{Path: dirPath, IsDir: true}
err = index.RefreshFileInfo(refreshConfig)
err = RefreshFileInfo(opts)
if err != nil {
return err
}
return nil
}
func MoveResource(source, realsrc, realdst string, isSrcDir bool) error {
func MoveResource(realsrc, realdst string, isSrcDir bool) error {
err := fileutils.MoveFile(realsrc, realdst)
if err != nil {
return err
}
index := GetIndex(source)
// refresh info for source and dest
err = index.RefreshFileInfo(FileOptions{
err = RefreshFileInfo(FileOptions{
Path: realsrc,
IsDir: isSrcDir,
})
if err != nil {
return fmt.Errorf("could not refresh index for source: %v", err)
return errors.ErrEmptyKey
}
refreshConfig := FileOptions{Path: realdst, IsDir: true}
if !isSrcDir {
refreshConfig.Path = filepath.Dir(realdst)
}
err = index.RefreshFileInfo(refreshConfig)
err = RefreshFileInfo(refreshConfig)
if err != nil {
return fmt.Errorf("could not refresh index for dest: %v", err)
return errors.ErrEmptyKey
}
return nil
}
func CopyResource(source, realsrc, realdst string, isSrcDir bool) error {
func CopyResource(realsrc, realdst string, isSrcDir bool) error {
err := fileutils.CopyFile(realsrc, realdst)
if err != nil {
return err
}
index := GetIndex(source)
refreshConfig := FileOptions{Path: realdst, IsDir: true}
if !isSrcDir {
refreshConfig.Path = filepath.Dir(realdst)
}
err = index.RefreshFileInfo(refreshConfig)
err = RefreshFileInfo(refreshConfig)
if err != nil {
return errors.ErrEmptyKey
}
@@ -244,14 +377,13 @@ func CopyResource(source, realsrc, realdst string, isSrcDir bool) error {
}
func WriteDirectory(opts FileOptions) error {
idx := GetIndex(opts.Source)
realPath, _, _ := idx.GetRealPath(opts.Path)
realPath, _, _ := GetRealPath(rootPath, opts.Path)
// Ensure the parent directories exist
err := os.MkdirAll(realPath, 0775)
if err != nil {
return err
}
err = idx.RefreshFileInfo(opts)
err = RefreshFileInfo(opts)
if err != nil {
return errors.ErrEmptyKey
}
@@ -259,11 +391,13 @@ func WriteDirectory(opts FileOptions) error {
}
func WriteFile(opts FileOptions, in io.Reader) error {
idx := GetIndex(opts.Source)
dst, _, _ := idx.GetRealPath(opts.Path)
dst := opts.Path
parentDir := filepath.Dir(dst)
// Split the directory from the destination path
dir := filepath.Dir(dst)
// Create the directory and all necessary parents
err := os.MkdirAll(parentDir, 0775)
err := os.MkdirAll(dir, 0775)
if err != nil {
return err
}
@@ -281,42 +415,42 @@ func WriteFile(opts FileOptions, in io.Reader) error {
return err
}
opts.Path = parentDir
opts.IsDir = true
return idx.RefreshFileInfo(opts)
err = RefreshFileInfo(opts)
if err != nil {
return errors.ErrEmptyKey
}
return nil
}
// resolveSymlinks resolves symlinks in the given path
func resolveSymlinks(path string) (string, bool, error) {
for {
// Get the file info using os.Lstat to handle symlinks
// Get the file info
info, err := os.Lstat(path)
if err != nil {
return path, false, fmt.Errorf("could not stat path: %s, %v", path, err)
return path, false, fmt.Errorf("could not stat path: %v, %s", path, err)
}
// Check if the path is a symlink
// Check if it's a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Read the symlink target
target, err := os.Readlink(path)
if err != nil {
return path, false, fmt.Errorf("could not read symlink: %s, %v", path, err)
return path, false, err
}
// Resolve the symlink's target relative to its directory
// This ensures the resolved path is absolute and correctly calculated
// Resolve the target relative to the symlink's directory
path = filepath.Join(filepath.Dir(path), target)
} else {
// Not a symlink, so return the resolved path and whether it's a directory
isDir := info.IsDir()
return path, isDir, nil
// Not a symlink, so return the resolved path and check if it's a directory
return path, info.IsDir(), nil
}
}
}
// addContent reads and sets content based on the file type.
func getContent(source, path string) (string, error) {
idx := GetIndex(source)
realPath, _, err := idx.GetRealPath(path)
func getContent(path string) (string, error) {
realPath, _, err := GetRealPath(rootPath, path)
if err != nil {
return "", err
}
@@ -327,7 +461,7 @@ func getContent(source, path string) (string, error) {
}
stringContent := string(content)
if !utf8.ValidString(stringContent) {
return "", nil
return "", fmt.Errorf("file is not utf8 encoded")
}
if stringContent == "" {
return "empty-file-x6OlSil", nil
@@ -335,35 +469,88 @@ func getContent(source, path string) (string, error) {
return stringContent, nil
}
// DetectType detects the MIME type of a file and updates the ItemInfo struct.
func (i *ItemInfo) DetectType(realPath string, saveContent bool) {
// detectType detects the file type.
func (i *ReducedItem) detectType(path string, modify, saveContent, readHeader bool) error {
name := i.Name
ext := filepath.Ext(name)
// Attempt MIME detection by file extension
if ext == ".md" {
i.Type = "text/markdown"
return
}
i.Type = strings.Split(mime.TypeByExtension(ext), ";")[0]
if i.Type == "" {
i.Type = extendedMimeTypeCheck(ext)
}
if i.Type == "blob" {
// Read only the first 512 bytes for efficient MIME detection
file, err := os.Open(realPath)
if err != nil {
} else {
defer file.Close()
buffer := make([]byte, 512)
n, _ := file.Read(buffer) // Ignore errors from Read
i.Type = strings.Split(http.DetectContentType(buffer[:n]), ";")[0]
var contentErr error
var contentString string
if saveContent {
contentString, contentErr = getContent(path)
if contentErr == nil {
i.Content = contentString
}
}
if IsNamedPipe(i.Mode) {
i.Type = "blob"
return contentErr
}
ext := filepath.Ext(name)
var buffer []byte
if readHeader {
buffer = i.readFirstBytes(path)
mimetype := mime.TypeByExtension(ext)
if mimetype == "" {
http.DetectContentType(buffer)
}
}
for _, fileType := range AllFiletypeOptions {
if IsMatchingType(ext, fileType) {
i.Type = fileType
}
switch i.Type {
case "text":
if !modify {
i.Type = "textImmutable"
}
if saveContent {
return contentErr
}
case "video":
// TODO add back somewhere else, not during metadata fetch
//parentDir := strings.TrimRight(path, name)
//i.detectSubtitles(parentDir)
case "doc":
if ext == ".pdf" {
i.Type = "pdf"
return nil
}
if saveContent {
return nil
}
}
}
if i.Type == "" {
i.Type = "blob"
if saveContent {
return contentErr
}
}
return nil
}
// readFirstBytes reads the first bytes of the file.
func (i *ReducedItem) readFirstBytes(path string) []byte {
file, err := os.Open(path)
if err != nil {
i.Type = "blob"
return nil
}
defer file.Close()
buffer := make([]byte, 512) //nolint:gomnd
n, err := file.Read(buffer)
if err != nil && err != io.EOF {
i.Type = "blob"
return nil
}
return buffer[:n]
}
// TODO add subtitles back
// detectSubtitles detects subtitles for video files.
//func (i *FileInfo) detectSubtitles(path string) {
// if i.Type != "video" {
@@ -433,30 +620,3 @@ func Exists(path string) bool {
}
return false
}
func (info *FileInfo) SortItems() {
sort.Slice(info.Folders, func(i, j int) bool {
nameWithoutExt := strings.Split(info.Folders[i].Name, ".")[0]
nameWithoutExt2 := strings.Split(info.Folders[j].Name, ".")[0]
// Convert strings to integers for numeric sorting if both are numeric
numI, errI := strconv.Atoi(nameWithoutExt)
numJ, errJ := strconv.Atoi(nameWithoutExt2)
if errI == nil && errJ == nil {
return numI < numJ
}
// Fallback to case-insensitive lexicographical sorting
return strings.ToLower(info.Folders[i].Name) < strings.ToLower(info.Folders[j].Name)
})
sort.Slice(info.Files, func(i, j int) bool {
nameWithoutExt := strings.Split(info.Files[i].Name, ".")[0]
nameWithoutExt2 := strings.Split(info.Files[j].Name, ".")[0]
// Convert strings to integers for numeric sorting if both are numeric
numI, errI := strconv.Atoi(nameWithoutExt)
numJ, errJ := strconv.Atoi(nameWithoutExt2)
if errI == nil && errJ == nil {
return numI < numJ
}
// Fallback to case-insensitive lexicographical sorting
return strings.ToLower(info.Files[i].Name) < strings.ToLower(info.Files[j].Name)
})
}
+5 -109
View File
@@ -3,11 +3,8 @@ package files
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/settings"
)
func Test_GetRealPath(t *testing.T) {
@@ -15,7 +12,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
@@ -33,20 +30,20 @@ func Test_GetRealPath(t *testing.T) {
path string
isDir bool
}{
path: "",
path: "backend/files",
isDir: true,
},
},
{
name: "current directory",
paths: []string{
"./files/file.go",
"./file.go",
},
want: struct {
path string
isDir bool
}{
path: "/files/file.go",
path: "backend/files/file.go",
isDir: false,
},
},
@@ -64,15 +61,9 @@ 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, _ := idx.GetRealPath(tt.paths...)
realPath, isDir, _ := 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)
@@ -80,98 +71,3 @@ func Test_GetRealPath(t *testing.T) {
})
}
}
func TestSortItems(t *testing.T) {
tests := []struct {
name string
input FileInfo
expected FileInfo
}{
{
name: "Numeric and Lexicographical Sorting",
input: FileInfo{
Folders: []ItemInfo{
{Name: "10.txt"},
{Name: "2.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []ItemInfo{
{Name: "File2.txt"},
{Name: "File10.txt"},
{Name: "File1"},
{Name: "banana"},
},
},
expected: FileInfo{
Folders: []ItemInfo{
{Name: "2.txt"},
{Name: "10.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []ItemInfo{
{Name: "banana"},
{Name: "File1"},
{Name: "File10.txt"},
{Name: "File2.txt"},
},
},
},
{
name: "Only Lexicographical Sorting",
input: FileInfo{
Folders: []ItemInfo{
{Name: "dog.txt"},
{Name: "Cat.txt"},
{Name: "apple"},
},
Files: []ItemInfo{
{Name: "Zebra"},
{Name: "apple"},
{Name: "cat"},
},
},
expected: FileInfo{
Folders: []ItemInfo{
{Name: "apple"},
{Name: "Cat.txt"},
{Name: "dog.txt"},
},
Files: []ItemInfo{
{Name: "apple"},
{Name: "cat"},
{Name: "Zebra"},
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
test.input.SortItems()
getNames := func(items []ItemInfo) []string {
names := []string{}
for _, folder := range items {
names = append(names, folder.Name)
}
return names
}
actualFolderNames := getNames(test.input.Folders)
expectedFolderNames := getNames(test.expected.Folders)
if !reflect.DeepEqual(actualFolderNames, expectedFolderNames) {
t.Errorf("Folders not sorted correctly.\nGot: %v\nExpected: %v", actualFolderNames, expectedFolderNames)
}
actualFileNames := getNames(test.input.Files)
expectedFileNames := getNames(test.expected.Files)
if !reflect.DeepEqual(actualFileNames, expectedFileNames) {
t.Errorf("Files not sorted correctly.\nGot: %v\nExpected: %v", actualFileNames, expectedFileNames)
}
})
}
}
+204
View File
@@ -0,0 +1,204 @@
package files
import (
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/settings"
)
type Index struct {
Root string
Directories map[string]*FileInfo
NumDirs int
NumFiles int
inProgress bool
LastIndexed time.Time
mu sync.RWMutex
}
var (
rootPath string = "/srv"
indexes []*Index
indexesMutex sync.RWMutex
)
func InitializeIndex(intervalMinutes uint32, schedule bool) {
if schedule {
go indexingScheduler(intervalMinutes)
}
}
func indexingScheduler(intervalMinutes uint32) {
if settings.Config.Server.Root != "" {
rootPath = settings.Config.Server.Root
}
si := GetIndex(rootPath)
for {
startTime := time.Now()
// Set the indexing flag to indicate that indexing is in progress
si.resetCount()
// Perform the indexing operation
err := si.indexFiles("/")
// Reset the indexing flag to indicate that indexing has finished
si.inProgress = false
// Update the LastIndexed time
si.LastIndexed = time.Now()
if err != nil {
log.Printf("Error during indexing: %v", err)
}
if si.NumFiles+si.NumDirs > 0 {
timeIndexedInSeconds := int(time.Since(startTime).Seconds())
log.Println("Successfully indexed files.")
log.Printf("Time spent indexing: %v seconds\n", timeIndexedInSeconds)
log.Printf("Files found: %v\n", si.NumFiles)
log.Printf("Directories found: %v\n", si.NumDirs)
}
// Sleep for the specified interval
time.Sleep(time.Duration(intervalMinutes) * time.Minute)
}
}
// Define a function to recursively index files and directories
func (si *Index) indexFiles(adjustedPath string) error {
realPath := strings.TrimRight(si.Root, "/") + adjustedPath
// Open the directory
dir, err := os.Open(realPath)
if err != nil {
si.RemoveDirectory(adjustedPath) // Remove if it can't be opened
return err
}
defer dir.Close()
dirInfo, err := dir.Stat()
if err != nil {
return err
}
// Skip directories that haven't been modified since the last index
if dirInfo.ModTime().Before(si.LastIndexed) {
return nil
}
// Read directory contents
files, err := dir.Readdir(-1)
if err != nil {
return err
}
var totalSize int64
var numDirs, numFiles int
fileInfos := []ReducedItem{}
dirInfos := map[string]*FileInfo{}
combinedPath := adjustedPath + "/"
if adjustedPath == "/" {
combinedPath = "/"
}
// Process each file and directory in the current directory
for _, file := range files {
itemInfo := &FileInfo{
ModTime: file.ModTime(),
}
if file.IsDir() {
itemInfo.Name = file.Name()
itemInfo.Path = combinedPath + file.Name()
// Recursively index the subdirectory
err := si.indexFiles(itemInfo.Path)
if err != nil {
log.Printf("Failed to index directory %s: %v", itemInfo.Path, err)
continue
}
// Fetch the metadata for the subdirectory after indexing
subDirInfo, exists := si.GetMetadataInfo(itemInfo.Path, true)
if exists {
itemInfo.Size = subDirInfo.Size
totalSize += subDirInfo.Size // Add subdirectory size to the total
}
dirInfos[itemInfo.Name] = itemInfo
numDirs++
} else {
itemInfo := &ReducedItem{
Name: file.Name(),
ModTime: file.ModTime(),
Size: file.Size(),
Mode: file.Mode(),
}
_ = itemInfo.detectType(combinedPath+file.Name(), true, false, false)
fileInfos = append(fileInfos, *itemInfo)
totalSize += itemInfo.Size
numFiles++
}
}
// Create FileInfo for the current directory
dirFileInfo := &FileInfo{
Path: adjustedPath,
Files: fileInfos,
Dirs: dirInfos,
Size: totalSize,
ModTime: dirInfo.ModTime(),
}
// Update the current directory metadata in the index
si.UpdateMetadata(dirFileInfo)
si.NumDirs += numDirs
si.NumFiles += numFiles
return nil
}
func (si *Index) makeIndexPath(subPath string) string {
if strings.HasPrefix(subPath, "./") {
subPath = strings.TrimPrefix(subPath, ".")
}
if strings.HasPrefix(subPath, ".") || si.Root == subPath {
return "/"
}
// clean path
subPath = strings.TrimSuffix(subPath, "/")
// remove index prefix
adjustedPath := strings.TrimPrefix(subPath, si.Root)
// remove trailing slash
adjustedPath = strings.TrimSuffix(adjustedPath, "/")
if !strings.HasPrefix(adjustedPath, "/") {
adjustedPath = "/" + adjustedPath
}
return adjustedPath
}
//func getParentPath(path string) string {
// // Trim trailing slash for consistency
// path = strings.TrimSuffix(path, "/")
// if path == "" || path == "/" {
// return "" // Root has no parent
// }
//
// lastSlash := strings.LastIndex(path, "/")
// if lastSlash == -1 {
// return "/" // Parent of a top-level directory
// }
// return path[:lastSlash]
//}
func (si *Index) recursiveUpdateDirSizes(parentDir string, childInfo *FileInfo, previousSize int64) {
childDirName := filepath.Base(childInfo.Path)
if parentDir == childDirName {
return
}
dir, exists := si.GetMetadataInfo(parentDir, true)
if !exists {
return
}
dir.Dirs[childDirName] = childInfo
newSize := dir.Size - previousSize + childInfo.Size
dir.Size += newSize
si.UpdateMetadata(dir)
dir, _ = si.GetMetadataInfo(parentDir, true)
si.recursiveUpdateDirSizes(filepath.Dir(parentDir), dir, newSize)
}
-350
View File
@@ -1,350 +0,0 @@
package files
import (
"fmt"
"os"
"path/filepath"
"runtime"
"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 {
settings.Source
Directories map[string]*FileInfo
NumDirs uint64
NumFiles uint64
NumDeleted uint64
FilesChangedDuringIndexing bool
currentSchedule int
assessment string
indexingTime int
LastIndexed time.Time
SmartModifier time.Duration
mu sync.RWMutex
scannerMu sync.Mutex
}
var (
indexes map[string]*Index
indexesMutex sync.RWMutex
RootPaths map[string]string
)
func Initialize(source settings.Source) {
indexesMutex.RLock()
newIndex := Index{
Source: source,
Directories: make(map[string]*FileInfo),
}
if RootPaths == nil {
RootPaths = make(map[string]string)
}
RootPaths[source.Name] = source.Path
indexes = make(map[string]*Index)
indexes[newIndex.Source.Name] = &newIndex
indexesMutex.RUnlock()
if !newIndex.Source.Config.Disabled {
time.Sleep(time.Second)
logger.Info("Initializing index and assessing file system complexity")
newIndex.RunIndexing("/", false)
go newIndex.setupIndexingScanners()
} else {
logger.Debug("Indexing disabled for source: " + newIndex.Source.Name)
}
}
// Define a function to recursively index files and directories
func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) error {
realPath := strings.TrimRight(idx.Source.Path, "/") + adjustedPath
// Open the directory
dir, err := os.Open(realPath)
if err != nil {
idx.RemoveDirectory(adjustedPath) // Remove, must have been deleted
return err
}
defer dir.Close()
dirInfo, err := dir.Stat()
if err != nil {
return err
}
combinedPath := adjustedPath + "/"
if adjustedPath == "/" {
combinedPath = "/"
}
// get whats currently in cache
idx.mu.RLock()
cacheDirItems := []ItemInfo{}
modChange := true // default to true
cachedDir, exists := idx.Directories[adjustedPath]
if exists && quick {
modChange = dirInfo.ModTime() != cachedDir.ModTime
cacheDirItems = cachedDir.Folders
}
idx.mu.RUnlock()
// If the directory has not been modified since the last index, skip expensive readdir
// recursively check cached dirs for mod time changes as well
if !modChange && recursive {
for _, item := range cacheDirItems {
err = idx.indexDirectory(combinedPath+item.Name, quick, true)
if err != nil {
logger.Error(fmt.Sprintf("error indexing directory %v : %v", combinedPath+item.Name, err))
}
}
return nil
}
if quick {
idx.mu.Lock()
idx.FilesChangedDuringIndexing = true
idx.mu.Unlock()
}
// Read directory contents
files, err := dir.Readdir(-1)
if err != nil {
return err
}
var totalSize int64
fileInfos := []ItemInfo{}
dirInfos := []ItemInfo{}
// Process each file and directory in the current directory
for _, file := range files {
isHidden := isHidden(file, idx.Path+combinedPath)
isDir := file.IsDir()
fullCombined := combinedPath + file.Name()
if idx.shouldSkip(isDir, isHidden, fullCombined) {
continue
}
itemInfo := &ItemInfo{
Name: file.Name(),
ModTime: file.ModTime(),
Hidden: isHidden,
}
// fix for .app files on macos which are technically directories, but we don't want to treat them as such
if isDir && strings.HasSuffix(file.Name(), ".app") {
isDir = false
}
if isDir {
// skip non-indexable dirs.
if file.Name() == "$RECYCLE.BIN" || file.Name() == "System Volume Information" {
continue
}
dirPath := combinedPath + file.Name()
if recursive {
// Recursively index the subdirectory
err = idx.indexDirectory(dirPath, quick, recursive)
if err != nil {
logger.Error(fmt.Sprintf("Failed to index directory %s: %v", dirPath, err))
continue
}
}
realDirInfo, exists := idx.GetMetadataInfo(dirPath, true)
if exists {
itemInfo.Size = realDirInfo.Size
}
totalSize += itemInfo.Size
itemInfo.Type = "directory"
dirInfos = append(dirInfos, *itemInfo)
idx.NumDirs++
} else {
itemInfo.DetectType(fullCombined, false)
itemInfo.Size = file.Size()
fileInfos = append(fileInfos, *itemInfo)
totalSize += itemInfo.Size
idx.NumFiles++
}
}
if totalSize == 0 && idx.Source.Config.IgnoreZeroSizeFolders {
return nil
}
// Create FileInfo for the current directory
dirFileInfo := &FileInfo{
Path: adjustedPath,
Files: fileInfos,
Folders: dirInfos,
}
dirFileInfo.ItemInfo = ItemInfo{
Name: dirInfo.Name(),
Type: "directory",
Size: totalSize,
ModTime: dirInfo.ModTime(),
}
dirFileInfo.SortItems()
// Update the current directory metadata in the index
idx.UpdateMetadata(dirFileInfo)
return nil
}
func (idx *Index) makeIndexPath(subPath string) string {
if strings.HasPrefix(subPath, "./") {
subPath = strings.TrimPrefix(subPath, ".")
}
if idx.Source.Path == subPath || subPath == "." {
return "/"
}
// clean path
subPath = strings.TrimSuffix(subPath, "/")
// remove index prefix
adjustedPath := strings.TrimPrefix(subPath, idx.Source.Path)
adjustedPath = strings.ReplaceAll(adjustedPath, "\\", "/")
// remove trailing slash
adjustedPath = strings.TrimSuffix(adjustedPath, "/")
if !strings.HasPrefix(adjustedPath, "/") {
adjustedPath = "/" + adjustedPath
}
return adjustedPath
}
func (idx *Index) recursiveUpdateDirSizes(childInfo *FileInfo, previousSize int64) {
parentDir := utils.GetParentDirectoryPath(childInfo.Path)
parentInfo, exists := idx.GetMetadataInfo(parentDir, true)
if !exists || parentDir == "" {
return
}
newSize := parentInfo.Size - previousSize + childInfo.Size
parentInfo.Size += newSize
idx.UpdateMetadata(parentInfo)
idx.recursiveUpdateDirSizes(parentInfo, newSize)
}
func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
combined := append([]string{idx.Source.Path}, relativePath...)
joinedPath := filepath.Join(combined...)
isDir, _ := 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 = idx.makeIndexPath(filepath.Dir(refreshOptions.Path))
refreshOptions.IsDir = true
} else {
refreshOptions.Path = idx.makeIndexPath(refreshOptions.Path)
}
err := idx.indexDirectory(refreshOptions.Path, false, false)
if err != nil {
return fmt.Errorf("file/folder does not exist to refresh data: %s", refreshOptions.Path)
}
file, exists := idx.GetMetadataInfo(refreshOptions.Path, true)
if !exists {
return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path)
}
current, firstExisted := idx.GetMetadataInfo(refreshOptions.Path, true)
refreshParentInfo := firstExisted && current.Size != file.Size
//utils.PrintStructFields(*file)
result := idx.UpdateMetadata(file)
if !result {
return fmt.Errorf("file/folder does not exist in metadata: %s", refreshOptions.Path)
}
if !exists {
return nil
}
if refreshParentInfo {
idx.recursiveUpdateDirSizes(file, current.Size)
}
return nil
}
func isHidden(file os.FileInfo, srcPath string) bool {
// Check if the file starts with a dot (common on Unix systems)
if file.Name()[0] == '.' {
return true
}
if runtime.GOOS == "windows" {
return checkWindowsHidden(filepath.Join(srcPath, file.Name()))
}
// Default behavior for non-Windows systems
return false
}
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
}
}
if !isDir && len(idx.Source.Config.Include.FileEndsWith) > 0 {
shouldSkip := true
for _, end := range idx.Source.Config.Include.FileEndsWith {
if strings.HasSuffix(fullCombined, end) {
shouldSkip = false
break
}
}
if shouldSkip {
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
}
if !isDir && len(idx.Source.Config.Exclude.FileEndsWith) > 0 {
shouldSkip := false
for _, end := range idx.Source.Config.Exclude.FileEndsWith {
if strings.HasSuffix(fullCombined, end) {
shouldSkip = true
break
}
}
return shouldSkip
}
return false
}
-126
View File
@@ -1,126 +0,0 @@
package files
import (
"fmt"
"time"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
// schedule in minutes
var scanSchedule = []time.Duration{
5 * time.Minute, // 5 minute quick scan & 25 minutes for a full scan
10 * time.Minute,
20 * time.Minute, // [3] element is 20 minutes, reset anchor for full scan
40 * time.Minute,
1 * time.Hour,
2 * time.Hour,
3 * time.Hour,
4 * time.Hour, // 4 hours for quick scan & 20 hours for a full scan
}
func (idx *Index) newScanner(origin string) {
fullScanAnchor := 3
fullScanCounter := 0 // every 5th scan is a full scan
for {
// Determine sleep time with modifiers
fullScanCounter++
sleepTime := scanSchedule[idx.currentSchedule] + idx.SmartModifier
if idx.assessment == "simple" {
sleepTime = scanSchedule[idx.currentSchedule] - idx.SmartModifier
}
if idx.Source.Config.IndexingInterval > 0 {
sleepTime = time.Duration(idx.Source.Config.IndexingInterval) * time.Minute
}
// Log and sleep before indexing
logger.Debug(fmt.Sprintf("Next scan in %v", sleepTime))
time.Sleep(sleepTime)
idx.scannerMu.Lock()
if fullScanCounter == 5 {
idx.RunIndexing(origin, false) // Full scan
fullScanCounter = 0
} else {
idx.RunIndexing(origin, true) // Quick scan
}
idx.scannerMu.Unlock()
// Adjust schedule based on file changes
if idx.FilesChangedDuringIndexing {
// Move to at least the full-scan anchor or reduce interval
if idx.currentSchedule > fullScanAnchor {
idx.currentSchedule = fullScanAnchor
} else if idx.currentSchedule > 0 {
idx.currentSchedule--
}
} else {
// Increment toward the longest interval if no changes
if idx.currentSchedule < len(scanSchedule)-1 {
idx.currentSchedule++
}
}
if idx.assessment == "simple" && idx.currentSchedule > 3 {
idx.currentSchedule = 3
}
// Ensure `currentSchedule` stays within bounds
if idx.currentSchedule < 0 {
idx.currentSchedule = 0
} else if idx.currentSchedule >= len(scanSchedule) {
idx.currentSchedule = len(scanSchedule) - 1
}
}
}
func (idx *Index) RunIndexing(origin string, quick bool) {
prevNumDirs := idx.NumDirs
prevNumFiles := idx.NumFiles
if quick {
logger.Debug("Starting quick scan")
} else {
logger.Debug("Starting full scan")
idx.NumDirs = 0
idx.NumFiles = 0
}
startTime := time.Now()
idx.FilesChangedDuringIndexing = false
// Perform the indexing operation
err := idx.indexDirectory("/", quick, true)
if err != nil {
logger.Error(fmt.Sprintf("Error during indexing: %v", err))
}
firstRun := idx.LastIndexed == time.Time{}
// Update the LastIndexed time
idx.LastIndexed = time.Now()
idx.indexingTime = int(time.Since(startTime).Seconds())
if !quick {
// update smart indexing
if idx.indexingTime < 3 || idx.NumDirs < 10000 {
idx.assessment = "simple"
idx.SmartModifier = 4 * time.Minute
} else if idx.indexingTime > 120 || idx.NumDirs > 500000 {
idx.assessment = "complex"
modifier := idx.indexingTime / 10 // seconds
idx.SmartModifier = time.Duration(modifier) * time.Minute
} else {
idx.assessment = "normal"
}
if firstRun {
logger.Info(fmt.Sprintf("Index assessment : complexity=%v directories=%v files=%v", idx.assessment, idx.NumDirs, idx.NumFiles))
} else {
logger.Debug(fmt.Sprintf("Index assessment : complexity=%v directories=%v files=%v", idx.assessment, idx.NumDirs, idx.NumFiles))
}
if idx.NumDirs != prevNumDirs || idx.NumFiles != prevNumFiles {
idx.FilesChangedDuringIndexing = true
}
}
if firstRun {
logger.Info(fmt.Sprintf("Time spent indexing : %v seconds", idx.indexingTime))
} else {
logger.Debug(fmt.Sprintf("Time spent indexing : %v seconds", idx.indexingTime))
}
}
func (idx *Index) setupIndexingScanners() {
go idx.newScanner("/")
}
+67 -141
View File
@@ -3,34 +3,32 @@ package files
import (
"encoding/json"
"math/rand"
"path/filepath"
"reflect"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/settings"
)
func BenchmarkFillIndex(b *testing.B) {
Initialize(settings.Source{
Name: "test",
Path: "/srv",
})
idx := GetIndex("test")
InitializeIndex(5, false)
si := GetIndex(settings.Config.Server.Root)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
idx.createMockData(50, 3) // 1000 dirs, 3 files per dir
si.createMockData(50, 3) // 1000 dirs, 3 files per dir
}
}
func (idx *Index) createMockData(numDirs, numFilesPerDir int) {
func (si *Index) createMockData(numDirs, numFilesPerDir int) {
for i := 0; i < numDirs; i++ {
dirPath := generateRandomPath(rand.Intn(3) + 1)
files := []ItemInfo{} // Slice of FileInfo
files := []ReducedItem{} // Slice of FileInfo
// Simulating files and directories with FileInfo
for j := 0; j < numFilesPerDir; j++ {
newFile := ItemInfo{
newFile := ReducedItem{
Name: "file-" + getRandomTerm() + getRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
@@ -39,14 +37,54 @@ func (idx *Index) createMockData(numDirs, numFilesPerDir int) {
files = append(files, newFile)
}
dirInfo := &FileInfo{
Name: filepath.Base(dirPath),
Path: dirPath,
Files: files,
}
idx.UpdateMetadata(dirInfo)
si.UpdateMetadata(dirInfo)
}
}
func generateRandomPath(levels int) string {
rand.New(rand.NewSource(time.Now().UnixNano()))
dirName := "srv"
for i := 0; i < levels; i++ {
dirName += "/" + getRandomTerm()
}
return dirName
}
func getRandomTerm() string {
wordbank := []string{
"hi", "test", "other", "name",
"cool", "things", "more", "items",
}
rand.New(rand.NewSource(time.Now().UnixNano()))
index := rand.Intn(len(wordbank))
return wordbank[index]
}
func getRandomExtension() string {
wordbank := []string{
".txt", ".mp3", ".mov", ".doc",
".mp4", ".bak", ".zip", ".jpg",
}
rand.New(rand.NewSource(time.Now().UnixNano()))
index := rand.Intn(len(wordbank))
return wordbank[index]
}
func generateRandomSearchTerms(numTerms int) []string {
// Generate random search terms
searchTerms := make([]string, numTerms)
for i := 0; i < numTerms; i++ {
searchTerms[i] = getRandomTerm()
}
return searchTerms
}
// JSONBytesEqual compares the JSON in two byte slices.
func JSONBytesEqual(a, b []byte) (bool, error) {
var j, j2 interface{}
@@ -75,148 +113,36 @@ func TestGetIndex(t *testing.T) {
}
}
func TestMakeIndexPath(t *testing.T) {
func TestInitializeIndex(t *testing.T) {
type args struct {
intervalMinutes uint32
}
tests := []struct {
name string
subPath string
expected string
name string
args args
}{
// Linux
{"Root path returns slash", "/", "/"},
{"Dot-prefixed returns slash", ".", "/"},
{"Double-dot prefix ignored", "./", "/"},
{"Dot prefix followed by text", "./test", "/test"},
{"Dot prefix followed by text", ".test", "/.test"},
{"Hidden file at root", "/.test", "/.test"},
{"Trailing slash removed", "/test/", "/test"},
{"Subpath without root prefix", "/other/test", "/other/test"},
{"Complex nested paths", "/nested/path", "/nested/path"},
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := &Index{Source: settings.Source{Path: "/"}}
result := idx.makeIndexPath(tt.subPath)
if result != tt.expected {
t.Errorf("makeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
}
})
}
tests = []struct {
name string
subPath string
expected string
}{
// Windows
{"Mixed slash", "/first\\second", "/first/second"},
{"Windows slash", "\\first\\second", "/first/second"},
{"Windows full path", "C:\\Users\\testfolder\\nestedfolder", "/testfolder/nestedfolder"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := &Index{Source: settings.Source{Path: "C:\\Users"}}
result := idx.makeIndexPath(tt.subPath)
if result != tt.expected {
t.Errorf("makeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
}
InitializeIndex(tt.args.intervalMinutes, false)
})
}
}
func TestMakeIndexPathRoot(t *testing.T) {
tests := []struct {
name string
subPath string
expected string
}{
// Linux
{"Root path returns slash", "/rootpath", "/"},
func Test_indexingScheduler(t *testing.T) {
type args struct {
intervalMinutes uint32
}
tests := []struct {
name string
args args
}{
// TODO: Add test cases.
}
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)
}
indexingScheduler(tt.args.intervalMinutes)
})
}
}
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)
}
}
}
-71
View File
@@ -1,71 +0,0 @@
package files
import (
"time"
"math/rand"
)
func CreateMockData(numDirs, numFilesPerDir int) FileInfo {
dir := FileInfo{}
dir.Path = "/here/is/your/mock/dir"
for i := 0; i < numDirs; i++ {
newFile := ItemInfo{
Name: "file-" + getRandomTerm() + getRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
Type: "blob",
}
dir.Folders = append(dir.Folders, newFile)
}
// Simulating files and directories with FileInfo
for j := 0; j < numFilesPerDir; j++ {
newFile := ItemInfo{
Name: "file-" + getRandomTerm() + getRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
Type: "blob",
}
dir.Files = append(dir.Files, newFile)
}
return dir
}
func generateRandomPath(levels int) string {
rand.New(rand.NewSource(time.Now().UnixNano()))
dirName := "srv"
for i := 0; i < levels; i++ {
dirName += "/" + getRandomTerm()
}
return dirName
}
func getRandomTerm() string {
wordbank := []string{
"hi", "test", "other", "name",
"cool", "things", "more", "items",
}
rand.New(rand.NewSource(time.Now().UnixNano()))
index := rand.Intn(len(wordbank))
return wordbank[index]
}
func getRandomExtension() string {
wordbank := []string{
".txt", ".mp3", ".mov", ".doc",
".mp4", ".bak", ".zip", ".jpg",
}
rand.New(rand.NewSource(time.Now().UnixNano()))
index := rand.Intn(len(wordbank))
return wordbank[index]
}
func generateRandomSearchTerms(numTerms int) []string {
// Generate random search terms
searchTerms := make([]string, numTerms)
for i := 0; i < numTerms; i++ {
searchTerms[i] = getRandomTerm()
}
return searchTerms
}
+30 -35
View File
@@ -6,8 +6,7 @@ import (
"strings"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/utils"
)
var (
@@ -15,28 +14,21 @@ var (
maxSearchResults = 100
)
type SearchResult struct {
type searchResult struct {
Path string `json:"path"`
Type string `json:"type"`
Size int64 `json:"size"`
}
func (idx *Index) Search(search string, scope string, sourceSession string) []SearchResult {
func (si *Index) Search(search string, scope string, sourceSession string) []searchResult {
// Remove slashes
scope = idx.makeIndexPath(scope)
runningHash := utils.InsecureRandomIdentifier(4)
scope = si.makeIndexPath(scope)
runningHash := utils.GenerateRandomHash(4)
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
searchOptions := ParseSearch(search)
results := make(map[string]SearchResult, 0)
results := make(map[string]searchResult, 0)
count := 0
var directories []string
cachedDirs, ok := cache.SearchResults.Get(idx.Source.Path + scope).([]string)
if ok {
directories = cachedDirs
} else {
directories = idx.getDirsInScope(scope)
cache.SearchResults.Set(idx.Source.Path+scope, directories)
}
directories := si.getDirsInScope(scope)
for _, searchTerm := range searchOptions.Terms {
if searchTerm == "" {
continue
@@ -44,55 +36,58 @@ func (idx *Index) Search(search string, scope string, sourceSession string) []Se
if count > maxSearchResults {
break
}
idx.mu.Lock()
si.mu.Lock()
for _, dirName := range directories {
scopedPath := strings.TrimPrefix(strings.TrimPrefix(dirName, scope), "/") + "/"
idx.mu.Unlock()
dir, found := idx.GetReducedMetadata(dirName, true)
idx.mu.Lock()
si.mu.Unlock()
dir, found := si.GetReducedMetadata(dirName, true)
si.mu.Lock()
if !found {
continue
}
if count > maxSearchResults {
break
}
reducedDir := ItemInfo{
reducedDir := ReducedItem{
Name: filepath.Base(dirName),
Type: "directory",
Size: dir.Size,
}
matches := reducedDir.containsSearchTerm(searchTerm, searchOptions)
if matches {
results[scopedPath] = SearchResult{Path: scopedPath, Type: "directory", Size: dir.Size}
scopedPath := strings.TrimPrefix(strings.TrimPrefix(dirName, scope), "/") + "/"
results[scopedPath] = searchResult{Path: scopedPath, Type: "directory", Size: dir.Size}
count++
}
// search files first
for _, item := range dir.Files {
for _, item := range dir.Items {
fullPath := dirName + "/" + item.Name
scopedPath := strings.TrimPrefix(strings.TrimPrefix(fullPath, scope), "/")
if item.Type == "directory" {
scopedPath += "/"
fullPath += "/"
}
value, found := sessionInProgress.Load(sourceSession)
if !found || value != runningHash {
idx.mu.Unlock()
return []SearchResult{}
si.mu.Unlock()
return []searchResult{}
}
if count > maxSearchResults {
break
}
matches := item.containsSearchTerm(searchTerm, searchOptions)
if matches {
results[scopedPath] = SearchResult{Path: scopedPath, Type: item.Type, Size: item.Size}
scopedPath := strings.TrimPrefix(strings.TrimPrefix(fullPath, scope), "/")
results[scopedPath] = searchResult{Path: scopedPath, Type: item.Type, Size: item.Size}
count++
}
}
}
idx.mu.Unlock()
si.mu.Unlock()
}
// Sort keys based on the number of elements in the path after splitting by "/"
sortedKeys := make([]SearchResult, 0, len(results))
sortedKeys := make([]searchResult, 0, len(results))
for _, v := range results {
sortedKeys = append(sortedKeys, v)
}
@@ -108,7 +103,7 @@ func (idx *Index) Search(search string, scope string, sourceSession string) []Se
// returns true if the file name contains the search term
// returns file type if the file name contains the search term
// returns size of file/dir if the file name contains the search term
func (fi ItemInfo) containsSearchTerm(searchTerm string, options SearchOptions) bool {
func (fi ReducedItem) containsSearchTerm(searchTerm string, options *SearchOptions) bool {
fileTypes := map[string]bool{}
largerThan := int64(options.LargerThan) * 1024 * 1024
@@ -170,11 +165,11 @@ func (fi ItemInfo) containsSearchTerm(searchTerm string, options SearchOptions)
return true
}
func (idx *Index) getDirsInScope(scope string) []string {
func (si *Index) getDirsInScope(scope string) []string {
newList := []string{}
idx.mu.Lock()
defer idx.mu.Unlock()
for k := range idx.Directories {
si.mu.Lock()
defer si.mu.Unlock()
for k := range si.Directories {
if strings.HasPrefix(k, scope) || scope == "" {
newList = append(newList, k)
}
+30 -33
View File
@@ -4,15 +4,14 @@ import (
"reflect"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/stretchr/testify/assert"
)
func BenchmarkSearchAllIndexes(b *testing.B) {
Initialize(settings.Source{Name: "test", Path: "/srv"})
idx := GetIndex("test")
InitializeIndex(5, false)
si := GetIndex(rootPath)
idx.createMockData(50, 3) // 50 dirs, 3 files per dir
si.createMockData(50, 3) // 50 dirs, 3 files per dir
// Generate 100 random search terms
searchTerms := generateRandomSearchTerms(100)
@@ -22,7 +21,7 @@ func BenchmarkSearchAllIndexes(b *testing.B) {
for i := 0; i < b.N; i++ {
// Execute the SearchAllIndexes function
for _, term := range searchTerms {
idx.Search(term, "/", "test")
si.Search(term, "/", "test")
}
}
}
@@ -30,25 +29,25 @@ func BenchmarkSearchAllIndexes(b *testing.B) {
func TestParseSearch(t *testing.T) {
tests := []struct {
input string
want SearchOptions
want *SearchOptions
}{
{
input: "my test search",
want: SearchOptions{
want: &SearchOptions{
Conditions: map[string]bool{"exact": false},
Terms: []string{"my test search"},
},
},
{
input: "case:exact my|test|search",
want: SearchOptions{
want: &SearchOptions{
Conditions: map[string]bool{"exact": true},
Terms: []string{"my", "test", "search"},
},
},
{
input: "type:largerThan=100 type:smallerThan=1000 test",
want: SearchOptions{
want: &SearchOptions{
Conditions: map[string]bool{"exact": false, "larger": true, "smaller": true},
Terms: []string{"test"},
LargerThan: 100,
@@ -57,7 +56,7 @@ func TestParseSearch(t *testing.T) {
},
{
input: "type:audio thisfile",
want: SearchOptions{
want: &SearchOptions{
Conditions: map[string]bool{"exact": false, "audio": true},
Terms: []string{"thisfile"},
},
@@ -75,14 +74,14 @@ func TestParseSearch(t *testing.T) {
}
func TestSearchWhileIndexing(t *testing.T) {
Initialize(settings.Source{Name: "test", Path: "/srv"})
idx := GetIndex("test")
InitializeIndex(5, false)
si := GetIndex(rootPath)
searchTerms := generateRandomSearchTerms(10)
for i := 0; i < 5; i++ {
go idx.createMockData(100, 100) // Creating mock data concurrently
go si.createMockData(100, 100) // Creating mock data concurrently
for _, term := range searchTerms {
go idx.Search(term, "/", "test") // Search concurrently
go si.Search(term, "/", "test") // Search concurrently
}
}
}
@@ -90,29 +89,27 @@ func TestSearchWhileIndexing(t *testing.T) {
func TestSearchIndexes(t *testing.T) {
index := Index{
Directories: map[string]*FileInfo{
"/test": {Files: []ItemInfo{{Name: "audio1.wav", Type: "audio"}}},
"/test/path": {Files: []ItemInfo{{Name: "file.txt", Type: "text"}}},
"/new/test": {Files: []ItemInfo{
"/test": {Files: []ReducedItem{{Name: "audio1.wav", Type: "audio"}}},
"/test/path": {Files: []ReducedItem{{Name: "file.txt", Type: "text"}}},
"/new/test": {Files: []ReducedItem{
{Name: "audio.wav", Type: "audio"},
{Name: "video.mp4", Type: "video"},
{Name: "video.MP4", Type: "video"},
}},
"/new/test/path": {Files: []ItemInfo{{Name: "archive.zip", Type: "archive"}}},
"/new/test/path": {Files: []ReducedItem{{Name: "archive.zip", Type: "archive"}}},
"/firstDir": {
Files: []ItemInfo{
Files: []ReducedItem{
{Name: "archive.zip", Size: 100, Type: "archive"},
},
Folders: []ItemInfo{
{Name: "thisIsDir", Type: "directory", Size: 2 * 1024 * 1024},
Dirs: map[string]*FileInfo{
"thisIsDir": {Name: "thisIsDir", Size: 2 * 1024 * 1024},
},
},
"/firstDir/thisIsDir": {
Files: []ItemInfo{
Files: []ReducedItem{
{Name: "hi.txt", Type: "text"},
},
ItemInfo: ItemInfo{
Size: 2 * 1024 * 1024,
},
Size: 2 * 1024 * 1024,
},
},
}
@@ -120,12 +117,12 @@ func TestSearchIndexes(t *testing.T) {
tests := []struct {
search string
scope string
expectedResult []SearchResult
expectedResult []searchResult
}{
{
search: "audio",
scope: "/new/",
expectedResult: []SearchResult{
expectedResult: []searchResult{
{
Path: "test/audio.wav",
Type: "audio",
@@ -136,7 +133,7 @@ func TestSearchIndexes(t *testing.T) {
{
search: "test",
scope: "/",
expectedResult: []SearchResult{
expectedResult: []searchResult{
{
Path: "test/",
Type: "directory",
@@ -152,7 +149,7 @@ func TestSearchIndexes(t *testing.T) {
{
search: "archive",
scope: "/",
expectedResult: []SearchResult{
expectedResult: []searchResult{
{
Path: "firstDir/archive.zip",
Type: "archive",
@@ -168,7 +165,7 @@ func TestSearchIndexes(t *testing.T) {
{
search: "arch",
scope: "/firstDir",
expectedResult: []SearchResult{
expectedResult: []searchResult{
{
Path: "archive.zip",
Type: "archive",
@@ -179,7 +176,7 @@ func TestSearchIndexes(t *testing.T) {
{
search: "isdir",
scope: "/",
expectedResult: []SearchResult{
expectedResult: []searchResult{
{
Path: "firstDir/thisIsDir/",
Type: "directory",
@@ -190,7 +187,7 @@ func TestSearchIndexes(t *testing.T) {
{
search: "IsDir type:largerThan=1",
scope: "/",
expectedResult: []SearchResult{
expectedResult: []searchResult{
{
Path: "firstDir/thisIsDir/",
Type: "directory",
@@ -201,7 +198,7 @@ func TestSearchIndexes(t *testing.T) {
{
search: "video",
scope: "/",
expectedResult: []SearchResult{
expectedResult: []searchResult{
{
Path: "new/test/video.MP4",
Type: "video",
+106 -47
View File
@@ -1,79 +1,138 @@
package files
import (
"log"
"path/filepath"
"sort"
"time"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/settings"
)
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
func (idx *Index) UpdateMetadata(info *FileInfo) bool {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.Directories[info.Path] = info
func (si *Index) UpdateMetadata(info *FileInfo) bool {
si.mu.Lock()
defer si.mu.Unlock()
info.CacheTime = time.Now()
si.Directories[info.Path] = info
return true
}
// GetMetadataInfo retrieves the FileInfo from the specified directory in the index.
func (idx *Index) GetReducedMetadata(target string, isDir bool) (*FileInfo, bool) {
idx.mu.Lock()
defer idx.mu.Unlock()
checkDir := idx.makeIndexPath(target)
func (si *Index) GetReducedMetadata(target string, isDir bool) (*FileInfo, bool) {
si.mu.RLock()
defer si.mu.RUnlock()
checkDir := si.makeIndexPath(target)
if !isDir {
checkDir = idx.makeIndexPath(filepath.Dir(target))
checkDir = si.makeIndexPath(filepath.Dir(target))
}
dir, exists := idx.Directories[checkDir]
dir, exists := si.Directories[checkDir]
if !exists {
return nil, false
}
if isDir {
return dir, true
}
// handle file
if checkDir == "/" {
checkDir = ""
}
baseName := filepath.Base(target)
for _, item := range dir.Files {
if item.Name == baseName {
return &FileInfo{
Path: checkDir + "/" + item.Name,
ItemInfo: item,
}, true
if !isDir {
if checkDir == "/" {
checkDir = ""
}
}
return nil, false
baseName := filepath.Base(target)
for _, item := range dir.Files {
if item.Name == baseName {
return &FileInfo{
Name: item.Name,
Size: item.Size,
ModTime: item.ModTime,
Type: item.Type,
Path: checkDir + "/" + item.Name,
}, true
}
}
return nil, false
}
cleanedItems := []ReducedItem{}
for name, item := range dir.Dirs {
cleanedItems = append(cleanedItems, ReducedItem{
Name: name,
Size: item.Size,
ModTime: item.ModTime,
Type: "directory",
})
}
cleanedItems = append(cleanedItems, dir.Files...)
sort.Slice(cleanedItems, func(i, j int) bool {
return cleanedItems[i].Name < cleanedItems[j].Name
})
dirname := filepath.Base(dir.Path)
if dirname == "." {
dirname = "/"
}
// construct file info
return &FileInfo{
Name: dirname,
Type: "directory",
Items: cleanedItems,
ModTime: dir.ModTime,
Size: dir.Size,
}, true
}
// GetMetadataInfo retrieves the FileInfo from the specified directory in the index.
func (idx *Index) GetMetadataInfo(target string, isDir bool) (*FileInfo, bool) {
idx.mu.RLock()
defer idx.mu.RUnlock()
checkDir := idx.makeIndexPath(target)
func (si *Index) GetMetadataInfo(target string, isDir bool) (*FileInfo, bool) {
si.mu.RLock()
defer si.mu.RUnlock()
checkDir := si.makeIndexPath(target)
if !isDir {
checkDir = idx.makeIndexPath(filepath.Dir(target))
checkDir = si.makeIndexPath(filepath.Dir(target))
}
dir, exists := idx.Directories[checkDir]
dir, exists := si.Directories[checkDir]
return dir, exists
}
func (idx *Index) RemoveDirectory(path string) {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.NumDeleted++
delete(idx.Directories, path)
func (si *Index) RemoveDirectory(path string) {
si.mu.Lock()
defer si.mu.Unlock()
delete(si.Directories, path)
}
func GetIndex(name string) *Index {
indexesMutex.Lock()
defer indexesMutex.Unlock()
index, ok := indexes[name]
if !ok {
return &Index{
Source: settings.Source{Name: "default", Path: "."},
func (si *Index) UpdateCount(given string) {
si.mu.Lock()
defer si.mu.Unlock()
if given == "files" {
si.NumFiles++
} else if given == "dirs" {
si.NumDirs++
} else {
log.Println("could not update unknown type: ", given)
}
}
func (si *Index) resetCount() {
si.mu.Lock()
defer si.mu.Unlock()
si.NumDirs = 0
si.NumFiles = 0
si.inProgress = true
}
func GetIndex(root string) *Index {
for _, index := range indexes {
if index.Root == root {
return index
}
}
return index
if settings.Config.Server.Root != "" {
rootPath = settings.Config.Server.Root
}
newIndex := &Index{
Root: rootPath,
Directories: map[string]*FileInfo{},
NumDirs: 0,
NumFiles: 0,
inProgress: false,
}
newIndex.Directories["/"] = &FileInfo{}
indexesMutex.Lock()
indexes = append(indexes, newIndex)
indexesMutex.Unlock()
return newIndex
}
+51 -35
View File
@@ -3,7 +3,6 @@ package files
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/stretchr/testify/assert"
)
@@ -35,7 +34,7 @@ func TestGetFileMetadataSize(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
fileInfo, _ := testIndex.GetReducedMetadata(tt.adjustedPath, true)
// Iterate over fileInfo.Items to look for expectedName
for _, item := range fileInfo.Files {
for _, item := range fileInfo.Items {
// Assert the existence and the name
if item.Name == tt.expectedName {
assert.Equal(t, tt.expectedSize, item.Size)
@@ -90,8 +89,8 @@ func TestGetFileMetadata(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fileInfo, exists := testIndex.GetReducedMetadata(tt.adjustedPath, tt.isDir)
if !exists {
fileInfo, _ := testIndex.GetReducedMetadata(tt.adjustedPath, tt.isDir)
if fileInfo == nil {
found := false
assert.Equal(t, tt.expectedExists, found)
return
@@ -99,7 +98,7 @@ func TestGetFileMetadata(t *testing.T) {
found := false
if tt.isDir {
// Iterate over fileInfo.Items to look for expectedName
for _, item := range fileInfo.Files {
for _, item := range fileInfo.Items {
// Assert the existence and the name
if item.Name == tt.expectedName {
found = true
@@ -121,7 +120,9 @@ func TestGetFileMetadata(t *testing.T) {
func TestUpdateFileMetadata(t *testing.T) {
info := &FileInfo{
Path: "/testpath",
Files: []ItemInfo{
Name: "testpath",
Type: "directory",
Files: []ReducedItem{
{Name: "testfile.txt"},
{Name: "anotherfile.txt"},
},
@@ -164,11 +165,9 @@ func TestSetDirectoryInfo(t *testing.T) {
Directories: map[string]*FileInfo{
"/testpath": {
Path: "/testpath",
ItemInfo: ItemInfo{
Name: "testpath",
Type: "directory",
},
Files: []ItemInfo{
Name: "testpath",
Type: "directory",
Items: []ReducedItem{
{Name: "testfile.txt"},
{Name: "anotherfile.txt"},
},
@@ -177,17 +176,15 @@ func TestSetDirectoryInfo(t *testing.T) {
}
dir := &FileInfo{
Path: "/newPath",
ItemInfo: ItemInfo{
Name: "newPath",
Type: "directory",
},
Files: []ItemInfo{
Name: "newPath",
Type: "directory",
Items: []ReducedItem{
{Name: "testfile.txt"},
},
}
index.UpdateMetadata(dir)
storedDir, exists := index.Directories["/newPath"]
if !exists || storedDir.Files[0].Name != "testfile.txt" {
if !exists || storedDir.Items[0].Name != "testfile.txt" {
t.Fatalf("expected SetDirectoryInfo to store directory info correctly")
}
}
@@ -206,37 +203,56 @@ func TestRemoveDirectory(t *testing.T) {
}
}
// Test for UpdateCount
func TestUpdateCount(t *testing.T) {
index := &Index{}
index.UpdateCount("files")
if index.NumFiles != 1 {
t.Fatalf("expected NumFiles to be 1 after UpdateCount('files')")
}
if index.NumFiles != 1 {
t.Fatalf("expected NumFiles to be 1 after UpdateCount('files')")
}
index.UpdateCount("dirs")
if index.NumDirs != 1 {
t.Fatalf("expected NumDirs to be 1 after UpdateCount('dirs')")
}
index.UpdateCount("unknown")
// Just ensure it does not panic or update any counters
if index.NumFiles != 1 || index.NumDirs != 1 {
t.Fatalf("expected counts to remain unchanged for unknown type")
}
index.resetCount()
if index.NumFiles != 0 || index.NumDirs != 0 || !index.inProgress {
t.Fatalf("expected resetCount to reset counts and set inProgress to true")
}
}
func init() {
testIndex = Index{
Source: settings.Source{
Path: "/",
Name: "test",
},
NumFiles: 10,
NumDirs: 5,
Root: "/",
NumFiles: 10,
NumDirs: 5,
inProgress: false,
Directories: map[string]*FileInfo{
"/testpath": {
Path: "/testpath",
ItemInfo: ItemInfo{
Name: "testpath",
Type: "directory",
},
Files: []ItemInfo{
Name: "testpath",
Type: "directory",
Files: []ReducedItem{
{Name: "testfile.txt", Size: 100},
{Name: "anotherfile.txt", Size: 100},
},
},
"/anotherpath": {
Path: "/anotherpath",
ItemInfo: ItemInfo{
Name: "anotherpath",
Type: "directory",
},
Files: []ItemInfo{
Name: "anotherpath",
Type: "directory",
Files: []ReducedItem{
{Name: "afile.txt", Size: 100},
},
Folders: []ItemInfo{
{Name: "directory", Type: "directory", Size: 100},
Dirs: map[string]*FileInfo{
"directory": {Name: "directory", Type: "directory", Size: 100},
},
},
},
+3 -63
View File
@@ -1,7 +1,6 @@
package fileutils
import (
"fmt"
"io"
"os"
"path"
@@ -12,46 +11,24 @@ 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 or directory from source to dest and returns an error if any.
// CopyFile copies a file 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 {
@@ -59,7 +36,7 @@ func copySingleFile(source, dest string) error {
}
defer src.Close()
// Create the destination directory if needed.
// Makes the directory needed to create the dst file.
err = os.MkdirAll(filepath.Dir(dest), 0775) //nolint:gomnd
if err != nil {
return err
@@ -91,43 +68,6 @@ func copySingleFile(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.
+25 -14
View File
@@ -1,31 +1,34 @@
module github.com/gtsteffaniak/filebrowser/backend
module github.com/gtsteffaniak/filebrowser
go 1.23
toolchain go1.23.1
go 1.22.5
require (
github.com/asdine/storm/v3 v3.2.1
github.com/disintegration/imaging v1.6.2
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568
github.com/goccy/go-yaml v1.15.17
github.com/goccy/go-yaml v1.14.3
github.com/golang-jwt/jwt/v4 v4.5.1
github.com/google/go-cmp v0.6.0
github.com/marusama/semaphore/v2 v2.5.0
github.com/mholt/archiver/v3 v3.5.1
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/afero v1.11.0
github.com/stretchr/testify v1.10.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.33.0
golang.org/x/image v0.24.0
golang.org/x/sys v0.30.0
golang.org/x/text v0.22.0
golang.org/x/crypto v0.29.0
golang.org/x/image v0.22.0
golang.org/x/text v0.20.0
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/go-errors/errors v1.5.1 // indirect
@@ -36,15 +39,23 @@ 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.9.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/nwaples/rardecode v1.1.3 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/tools v0.29.0 // indirect
go.etcd.io/bbolt v1.3.11 // indirect
golang.org/x/net v0.31.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/tools v0.27.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+65 -22
View File
@@ -4,12 +4,19 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
@@ -46,8 +53,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.17 h1:dK4FbbTTEOZTLH/NW3/xBqg0JdC14YKVmYwS9GT3H60=
github.com/goccy/go-yaml v1.15.17/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.14.3 h1:8tVD+aqqPLWisSEhM+6wWoiURWXCx6BwaTKS6ZeITgM=
github.com/goccy/go-yaml v1.14.3/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/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=
@@ -60,14 +67,26 @@ github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
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=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -75,42 +94,65 @@ 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.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/marusama/semaphore/v2 v2.5.0 h1:o/1QJD9DBYOWRnDhPwDVAXQn6mQYD0gZaS1Tpx6DJGM=
github.com/marusama/semaphore/v2 v2.5.0/go.mod h1:z9nMiNUekt/LTpTUQdpp+4sJeYqUGpwMHfW0Z8V8fnQ=
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
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.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g=
golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
@@ -125,12 +167,12 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/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.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -145,8 +187,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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/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=
@@ -155,14 +197,15 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-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.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o=
golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/users"
)
func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
+18 -56
View File
@@ -2,10 +2,9 @@ package http
import (
"encoding/json"
libError "errors"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strings"
"sync"
@@ -13,15 +12,11 @@ import (
"github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v4/request"
"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"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
)
var (
@@ -46,18 +41,9 @@ 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, " ")
@@ -67,6 +53,14 @@ 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")
}
@@ -75,13 +69,8 @@ func extractToken(r *http.Request) (string, error) {
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
if !config.Auth.Methods.PasswordAuth.Enabled {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
// currently only supports user/pass
// Get the authentication method from the settings
auther, err := store.Auth.Get("password")
auther, err := store.Auth.Get(config.Auth.Method)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
@@ -133,14 +122,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, files.RootPaths["default"])
userHome, err := config.MakeUserDir(user.Username, user.Scope, config.Server.Root)
if err != nil {
logger.Error(fmt.Sprintf("create user: failed to mkdir user home dir: [%s]", userHome))
log.Printf("create user: failed to mkdir user home dir: [%s]", userHome)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
user.Scope = userHome
logger.Debug(fmt.Sprintf("new user: %s, home dir: [%s].", user.Username, userHome))
log.Printf("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)
@@ -157,8 +146,7 @@ func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
}
func printToken(w http.ResponseWriter, _ *http.Request, user *users.User) (int, error) {
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Perm)
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.GenerateRandomHash(4), time.Hour*2, user.Perm)
if err != nil {
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, err
@@ -219,29 +207,3 @@ func makeSignedTokenAPI(user *users.User, name string, duration time.Duration, p
}
return claim, err
}
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
if l.PasswordHash == "" {
return 200, nil
}
if r.URL.Query().Get("token") == l.Token {
return 200, nil
}
password := r.Header.Get("X-SHARE-PASSWORD")
password, err := url.QueryUnescape(password)
if err != nil {
return http.StatusUnauthorized, err
}
if password == "" {
return http.StatusUnauthorized, nil
}
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return http.StatusUnauthorized, nil
}
return 401, err
}
return 200, nil
}
+44 -121
View File
@@ -1,27 +1,23 @@
package http
import (
"compress/gzip"
"encoding/json"
"fmt"
"log"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/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"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/runner"
"github.com/gtsteffaniak/filebrowser/users"
)
type requestContext struct {
user *users.User
*runner.Runner
raw interface{}
token string
raw interface{}
}
type HttpResponse struct {
@@ -30,8 +26,6 @@ type HttpResponse struct {
Token string `json:"token,omitempty"`
}
var FileInfoFasterFunc = files.FileInfoFaster
// Updated handleFunc to match the new signature
type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error)
@@ -45,29 +39,30 @@ func withHashFileHelper(fn handleFunc) handleFunc {
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
if err != nil {
return http.StatusNotFound, fmt.Errorf("share not found")
return http.StatusNotFound, err
}
// Authenticate the share request if needed
var status int
if link.Hash != "" {
status, err = authenticateShareRequest(r, link)
if err != nil || status != http.StatusOK {
return status, fmt.Errorf("could not authenticate share request")
return status, err
}
}
// Retrieve the user (using the public user by default)
user := &users.PublicUser
// Get file information with options
file, err := FileInfoFasterFunc(files.FileOptions{
Path: filepath.Join(user.Scope, link.Path+"/"+path),
Modify: user.Perm.Modify,
Expand: true,
Checker: user, // Call your checker function here
file, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(user.Scope, link.Path+"/"+path),
Modify: user.Perm.Modify,
Expand: true,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: user, // Call your checker function here
Token: link.Token,
})
file.Token = link.Token
if err != nil {
return errToStatus(err), fmt.Errorf("error fetching share from server")
return errToStatus(err), err
}
// Set the file info in the `data` object
@@ -94,44 +89,6 @@ 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 config.Auth.Methods.NoAuth {
var err error
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(files.RootPaths["default"], uint(1))
if err != nil {
logger.Error(fmt.Sprintf("no auth: %v", err))
return http.StatusInternalServerError, err
}
return fn(w, r, data)
}
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
var err error
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(files.RootPaths["default"], proxyUser)
if err != nil {
if err.Error() != "the resource does not exist" {
return http.StatusInternalServerError, err
}
if config.Auth.Methods.ProxyAuth.CreateUser {
newUser := settings.ApplyUserDefaults(users.User{
Username: proxyUser,
})
err := store.Users.Save(&newUser)
if err != nil {
return http.StatusInternalServerError, err
}
data.user, err = store.Users.Get(files.RootPaths["default"], proxyUser)
if err != nil {
return http.StatusInternalServerError, err
}
} else {
return http.StatusUnauthorized, fmt.Errorf("proxy authentication failed - no user found")
}
}
setUserInResponseWriter(w, data.user)
return fn(w, r, data)
}
keyFunc := func(token *jwt.Token) (interface{}, error) {
return config.Auth.Key, nil
}
@@ -139,7 +96,6 @@ 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)
@@ -156,15 +112,11 @@ 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(files.RootPaths["default"], tk.BelongsTo)
data.user, err = store.Users.Get(config.Server.Root, 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)
}
@@ -206,14 +158,14 @@ func wrapHandler(fn handleFunc) http.HandlerFunc {
// Marshal the error response to JSON
errorBytes, marshalErr := json.Marshal(response)
if marshalErr != nil {
logger.Error(fmt.Sprintf("Error marshalling error response: %v", marshalErr))
log.Printf("Error marshalling error response: %v", marshalErr)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Write the JSON error response
if _, writeErr := w.Write(errorBytes); writeErr != nil {
logger.Error(fmt.Sprintf("Error writing error response: %v", writeErr))
log.Printf("Error writing error response: %v", writeErr)
}
return
}
@@ -266,8 +218,6 @@ type ResponseWriterWrapper struct {
http.ResponseWriter
StatusCode int
wroteHeader bool
PayloadSize int
User string
}
// WriteHeader captures the status code and ensures it's only written once
@@ -290,79 +240,52 @@ 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.
// LoggingMiddleware logs each request and its status code
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap the ResponseWriter to capture the status code.
// Wrap the ResponseWriter to capture the status code
wrappedWriter := &ResponseWriterWrapper{ResponseWriter: w, StatusCode: http.StatusOK}
// Call the next handler.
// Call the next handler
next.ServeHTTP(wrappedWriter, r)
// Capture the full URL path including the query parameters.
// 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
}
truncUser := wrappedWriter.User
if len(truncUser) > 12 {
truncUser = truncUser[:10] + ".."
}
duration := time.Since(start)
logger.Api(
fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"",
r.Method,
wrappedWriter.StatusCode, // Captured status code
r.RemoteAddr,
truncUser,
fmt.Sprintf("%vms", duration.Milliseconds()),
fullURL), wrappedWriter.StatusCode)
// Log the request and its status code
log.Printf("%s%-7s | %3d | %-15s | %-12s | \"%s\"%s",
color,
r.Method,
wrappedWriter.StatusCode, // Now capturing the correct status
r.RemoteAddr,
time.Since(start).String(),
fullURL,
"\033[0m", // Reset color
)
})
}
func renderJSON(w http.ResponseWriter, r *http.Request, data interface{}) (int, error) {
func renderJSON(w http.ResponseWriter, _ *http.Request, data interface{}) (int, error) {
marsh, err := json.Marshal(data)
if err != nil {
return http.StatusInternalServerError, err
}
// Calculate size in KB
payloadSizeKB := len(marsh) / 1024
// Check if the client accepts gzip encoding and hasn't explicitly disabled it
if acceptsGzip(r) && payloadSizeKB > 10 {
// Enable gzip compression
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
if _, err := gz.Write(marsh); err != nil {
return http.StatusInternalServerError, err
}
} else {
// Normal response without compression
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if _, err := w.Write(marsh); err != nil {
return http.StatusInternalServerError, err
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if _, err := w.Write(marsh); err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
}
func acceptsGzip(r *http.Request) bool {
ae := r.Header.Get("Accept-Encoding")
return ae != "" && strings.Contains(ae, "gzip")
}
+13 -32
View File
@@ -8,15 +8,14 @@ import (
"time"
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/diskcache"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/img"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/diskcache"
"github.com/gtsteffaniak/filebrowser/img"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/share"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/storage/bolt"
"github.com/gtsteffaniak/filebrowser/users"
"github.com/gtsteffaniak/filebrowser/utils"
)
func setupTestEnv(t *testing.T) {
@@ -38,27 +37,6 @@ func setupTestEnv(t *testing.T) {
fileCache = diskcache.NewNoOp() // mocked
imgSvc = img.New(1) // mocked
config = &settings.Config // mocked
mockFileInfoFaster(t) // Mock FileInfoFasterFunc for this test
}
func mockFileInfoFaster(t *testing.T) {
// Backup the original function
originalFileInfoFaster := FileInfoFasterFunc
// Defer restoration of the original function
t.Cleanup(func() { FileInfoFasterFunc = originalFileInfoFaster })
// Mock the function to skip execution
FileInfoFasterFunc = func(opts files.FileOptions) (files.ExtendedFileInfo, error) {
return files.ExtendedFileInfo{
FileInfo: &files.FileInfo{
Path: opts.Path,
ItemInfo: files.ItemInfo{
Name: "mocked_file",
Size: 12345,
},
},
}, nil
}
}
func TestWithAdminHelper(t *testing.T) {
@@ -105,7 +83,7 @@ func TestWithAdminHelper(t *testing.T) {
data := &requestContext{
user: tc.user,
}
token, err := makeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, tc.user.Perm)
token, err := makeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.GenerateRandomHash(4), time.Hour*2, tc.user.Perm)
if err != nil {
t.Fatalf("Error making token for request: %v", err)
}
@@ -219,7 +197,10 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
req := newTestRequest(t, tc.share.Hash, tc.token, tc.password, tc.extraHeaders)
// Serve the request
status, _ := handler(recorder, req, &requestContext{})
status, err := handler(recorder, req, &requestContext{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Check if the response matches the expected status code
if status != tc.expectedStatusCode {
-208
View File
@@ -1,208 +0,0 @@
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,
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)
}
+31 -45
View File
@@ -8,12 +8,9 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/img"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/img"
)
type ImgService interface {
@@ -44,31 +41,35 @@ 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,
Checker: d.user,
file, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Expand: true,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
})
fileInfo := response.FileInfo
if err != nil {
return errToStatus(err), err
}
if fileInfo.Type == "directory" {
realPath, _, err := files.GetRealPath(file.Path)
if err != nil {
return http.StatusInternalServerError, err
}
file.Path = realPath
if file.Type == "directory" {
return http.StatusBadRequest, fmt.Errorf("can't create preview for directory")
}
setContentDisposition(w, r, fileInfo.Name)
if !strings.HasPrefix(fileInfo.Type, "image") {
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", fileInfo.Type)
setContentDisposition(w, r, file)
if file.Type != "image" {
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", file.Type)
}
if (previewSize == "large" && !config.Server.ResizePreview) ||
@@ -76,39 +77,40 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
if !d.user.Perm.Download {
return http.StatusAccepted, nil
}
return rawFileHandler(w, r, fileInfo)
return rawFileHandler(w, r, file)
}
format, err := imgSvc.FormatFromExtension(filepath.Ext(fileInfo.Name))
format, err := imgSvc.FormatFromExtension(filepath.Ext(file.Name))
// Unsupported extensions directly return the raw data
if err == img.ErrUnsupportedFormat || format == img.FormatGif {
if !d.user.Perm.Download {
return http.StatusAccepted, nil
}
return rawFileHandler(w, r, fileInfo)
return rawFileHandler(w, r, file)
}
if err != nil {
return errToStatus(err), err
}
cacheKey := previewCacheKey(response.RealPath, previewSize, fileInfo.ModTime)
cacheKey := previewCacheKey(file, previewSize)
resizedImage, ok, err := fileCache.Load(r.Context(), cacheKey)
if err != nil {
return errToStatus(err), err
}
if !ok {
resizedImage, err = createPreview(imgSvc, fileCache, response, previewSize)
resizedImage, err = createPreview(imgSvc, fileCache, file, previewSize)
if err != nil {
return errToStatus(err), err
}
}
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, response.RealPath, fileInfo.ModTime, bytes.NewReader(resizedImage))
http.ServeContent(w, r, file.Path, file.ModTime, bytes.NewReader(resizedImage))
return 0, nil
}
func createPreview(imgSvc ImgService, fileCache FileCache, file files.ExtendedFileInfo, previewSize string) ([]byte, error) {
fd, err := os.Open(file.RealPath)
func createPreview(imgSvc ImgService, fileCache FileCache, file *files.FileInfo, previewSize string) ([]byte, error) {
fd, err := os.Open(file.Path)
if err != nil {
return nil, err
}
@@ -139,9 +141,9 @@ func createPreview(imgSvc ImgService, fileCache FileCache, file files.ExtendedFi
}
go func() {
cacheKey := previewCacheKey(file.RealPath, previewSize, file.FileInfo.ModTime)
cacheKey := previewCacheKey(file, previewSize)
if err := fileCache.Store(context.Background(), cacheKey, buf.Bytes()); err != nil {
logger.Error(fmt.Sprintf("failed to cache resized image: %v", err))
fmt.Printf("failed to cache resized image: %v", err)
}
}()
@@ -149,22 +151,6 @@ func createPreview(imgSvc ImgService, fileCache FileCache, file files.ExtendedFi
}
// Generates a cache key for the preview image
func previewCacheKey(realPath, previewSize string, modTime time.Time) string {
return fmt.Sprintf("%x%x%x", realPath, modTime.Unix(), previewSize)
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file *files.FileInfo) (int, error) {
idx := files.GetIndex("default")
realPath, _, _ := idx.GetRealPath(file.Path)
fd, err := os.Open(realPath)
if err != nil {
return http.StatusInternalServerError, err
}
defer fd.Close()
setContentDisposition(w, r, file.Name)
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, file.Name, file.ModTime, fd)
return 0, nil
func previewCacheKey(f *files.FileInfo, previewSize string) string {
return fmt.Sprintf("%x%x%x", f.RealPath(), f.ModTime.Unix(), previewSize)
}
+55 -6
View File
@@ -2,22 +2,28 @@ package http
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/users"
"golang.org/x/crypto/bcrypt"
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/share"
"github.com/gtsteffaniak/filebrowser/users"
_ "github.com/gtsteffaniak/filebrowser/swagger/docs"
)
func publicShareHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
file, ok := d.raw.(files.ExtendedFileInfo)
file, ok := d.raw.(*files.FileInfo)
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, files.RootPaths["default"])
file.Path = strings.TrimPrefix(file.Path, settings.Config.Server.Root)
return renderJSON(w, r, file)
}
@@ -28,6 +34,49 @@ func publicUserGetHandler(w http.ResponseWriter, r *http.Request) {
if err != nil {
http.Error(w, http.StatusText(status), status)
}
}
func publicDlHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
file, _ := d.raw.(*files.FileInfo)
if file == nil {
return http.StatusInternalServerError, fmt.Errorf("failed to assert type files.FileInfo")
}
if d.user == nil {
return http.StatusUnauthorized, fmt.Errorf("failed to get user")
}
if file.Type == "directory" {
return rawDirHandler(w, r, d, file)
}
return rawFileHandler(w, r, file)
}
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
if l.PasswordHash == "" {
return 200, nil
}
if r.URL.Query().Get("token") == l.Token {
return 200, nil
}
password := r.Header.Get("X-SHARE-PASSWORD")
password, err := url.QueryUnescape(password)
if err != nil {
return http.StatusUnauthorized, err
}
if password == "" {
return http.StatusUnauthorized, nil
}
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return http.StatusUnauthorized, nil
}
return 401, err
}
return 200, nil
}
// health godoc
+149 -192
View File
@@ -1,28 +1,79 @@
package http
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
gopath "path"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/mholt/archiver/v3"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/fileutils"
"github.com/gtsteffaniak/filebrowser/users"
)
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
func slashClean(name string) string {
if name == "" || name[0] != '/' {
name = "/" + name
}
return gopath.Clean(name)
}
func parseQueryFiles(r *http.Request, f *files.FileInfo, _ *users.User) ([]string, error) {
var fileSlice []string
names := strings.Split(r.URL.Query().Get("files"), ",")
if len(names) == 0 {
fileSlice = append(fileSlice, f.Path)
} else {
for _, name := range names {
name, err := url.QueryUnescape(strings.Replace(name, "+", "%2B", -1)) //nolint:govet
if err != nil {
return nil, err
}
name = slashClean(name)
fileSlice = append(fileSlice, filepath.Join(f.Path, name))
}
}
return fileSlice, nil
}
// nolint: goconst,nolintlint
func parseQueryAlgorithm(r *http.Request) (string, archiver.Writer, error) {
// TODO: use enum
switch r.URL.Query().Get("algo") {
case "zip", "true", "":
return ".zip", archiver.NewZip(), nil
case "tar":
return ".tar", archiver.NewTar(), nil
case "targz":
return ".tar.gz", archiver.NewTarGz(), nil
case "tarbz2":
return ".tar.bz2", archiver.NewTarBz2(), nil
case "tarxz":
return ".tar.xz", archiver.NewTarXz(), nil
case "tarlz4":
return ".tar.lz4", archiver.NewTarLz4(), nil
case "tarsz":
return ".tar.sz", archiver.NewTarSz(), nil
default:
return "", nil, errors.New("format not implemented")
}
}
func setContentDisposition(w http.ResponseWriter, r *http.Request, file *files.FileInfo) {
if r.URL.Query().Get("inline") == "true" {
w.Header().Set("Content-Disposition", "inline")
} else {
// As per RFC6266 section 4.3
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(fileName))
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(file.Name))
}
}
@@ -32,240 +83,146 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
// @Tags Resources
// @Accept json
// @Produce json
// @Param files query string true "Comma-separated list of specific files within the directory (required)"
// @Param path query string true "Path to the file or directory"
// @Param files query string false "Comma-separated list of specific files within the directory (optional)"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip', 'tar', 'targz', 'tarbz2', 'tarxz', 'tarlz4', 'tarsz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
// @Failure 202 {object} map[string]string "Download permissions required"
// @Failure 400 {object} map[string]string "Invalid request path"
// @Failure 404 {object} map[string]string "File or directory not found"
// @Failure 415 {object} map[string]string "Unsupported file type for preview"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/raw [get]
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Perm.Download {
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)
path := r.URL.Query().Get("path")
file, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
})
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
return errToStatus(err), err
}
fileList := strings.Split(files, ",")
for i, f := range fileList {
fileList[i] = filepath.Join(filePrefix, f)
if files.IsNamedPipe(file.Mode) {
setContentDisposition(w, r, file)
return 0, nil
}
return rawFilesHandler(w, r, d, fileList)
if file.Type == "directory" {
return rawDirHandler(w, r, d, file)
}
return rawFileHandler(w, r, file)
}
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) {
func addFile(ar archiver.Writer, d *requestContext, path, commonPath string) error {
if !d.user.Check(path) {
return nil
}
info, err := os.Stat(realPath)
info, err := os.Stat(path)
if err != nil {
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
}
// Calculate the relative path
relPath, err := filepath.Rel(realPath, filePath) // Use realPath directly
if err != nil {
return err
}
// Normalize for tar: convert \ to /
relPath = filepath.ToSlash(relPath)
// Skip adding `.` (current directory)
if relPath == "." {
return nil
}
// Prepend base folder name unless flatten is true
if !flatten {
relPath = filepath.Join(baseName, relPath)
relPath = filepath.ToSlash(relPath) // Ensure normalized separators
}
if fileInfo.IsDir() {
if tarWriter != nil {
header := &tar.Header{
Name: relPath + "/",
Mode: 0755,
Typeflag: tar.TypeDir,
ModTime: fileInfo.ModTime(),
}
return tarWriter.WriteHeader(header)
}
if zipWriter != nil {
_, err := zipWriter.Create(relPath + "/")
return err
}
return nil
}
return addSingleFile(filePath, relPath, zipWriter, tarWriter)
})
if !info.IsDir() && !info.Mode().IsRegular() {
return nil
}
// For a single file, use the base name as the archive path
return addSingleFile(realPath, baseName, zipWriter, tarWriter)
}
func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWriter *tar.Writer) error {
file, err := os.Open(realPath)
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
info, err := os.Stat(realPath)
if err != nil {
return err
if path != commonPath {
filename := strings.TrimPrefix(path, commonPath)
filename = strings.TrimPrefix(filename, string(filepath.Separator))
err = ar.Write(archiver.File{
FileInfo: archiver.FileInfo{
FileInfo: info,
CustomName: filename,
},
ReadCloser: file,
})
if err != nil {
return err
}
}
if tarWriter != nil {
header, err := tar.FileInfoHeader(info, "")
if info.IsDir() {
names, err := file.Readdirnames(0)
if err != nil {
return err
}
// Ensure correct relative path
header.Name = filepath.ToSlash(archivePath)
if err = tarWriter.WriteHeader(header); err != nil {
return err
}
_, err = io.Copy(tarWriter, file)
return err
}
if zipWriter != nil {
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
for _, name := range names {
fPath := filepath.Join(path, name)
err = addFile(ar, d, fPath, commonPath)
if err != nil {
log.Printf("Failed to archive %s: %v", fPath, err)
}
}
header.Name = archivePath
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return err
}
_, err = io.Copy(writer, file)
return err
}
return nil
}
func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext, fileList []string) (int, error) {
filePath := fileList[0]
fileName := filepath.Base(filePath)
idx := files.GetIndex("default")
realPath, isDir, err := idx.GetRealPath(d.user.Scope, filePath)
func rawDirHandler(w http.ResponseWriter, r *http.Request, d *requestContext, file *files.FileInfo) (int, error) {
filenames, err := parseQueryFiles(r, file, d.user)
if err != nil {
return http.StatusInternalServerError, err
}
if len(fileList) == 1 && !isDir {
fd, err2 := os.Open(realPath)
if err2 != nil {
return http.StatusInternalServerError, err
}
defer fd.Close()
// Get file information
fileInfo, err3 := fd.Stat()
if err3 != nil {
return http.StatusInternalServerError, err
}
// Set headers and serve the file
setContentDisposition(w, r, fileName)
w.Header().Set("Cache-Control", "private")
// Serve the content
http.ServeContent(w, r, fileName, fileInfo.ModTime(), fd)
return 0, nil
}
algo := r.URL.Query().Get("algo")
var extension string
switch algo {
case "zip", "true", "":
extension = ".zip"
case "tar.gz":
extension = ".tar.gz"
default:
return http.StatusInternalServerError, errors.New("format not implemented")
}
baseDirName := filepath.Base(filepath.Dir(realPath))
if baseDirName == "" || baseDirName == "/" {
baseDirName = "download"
}
if len(fileList) == 1 && isDir {
baseDirName = filepath.Base(realPath)
}
downloadFileName := url.PathEscape(baseDirName + extension)
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+downloadFileName)
// Create the archive and stream it directly to the response
if extension == ".zip" {
err = createZip(w, d, fileList...)
} else {
err = createTarGz(w, d, fileList...)
}
extension, ar, err := parseQueryAlgorithm(r)
if err != nil {
return http.StatusInternalServerError, err
}
err = ar.Create(w)
if err != nil {
return http.StatusInternalServerError, err
}
defer ar.Close()
commonDir := fileutils.CommonPrefix(filepath.Separator, filenames...)
name := filepath.Base(commonDir)
if name == "." || name == "" || name == string(filepath.Separator) {
name = file.Name
}
// Prefix used to distinguish a filelist generated
// archive from the full directory archive
if len(filenames) > 1 {
name = "_" + name
}
name += extension
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(name))
for _, fname := range filenames {
err = addFile(ar, d, fname, commonDir)
if err != nil {
log.Printf("Failed to archive %s: %v", fname, err)
}
}
return 0, nil
}
func createZip(w io.Writer, d *requestContext, filenames ...string) error {
zipWriter := zip.NewWriter(w)
defer zipWriter.Close()
// Check if we have exactly one directory
//flatten := len(filenames) == 1
for _, fname := range filenames {
err := addFile(fname, d, nil, zipWriter, false)
if err != nil {
logger.Error(fmt.Sprintf("Failed to add %s to ZIP: %v", fname, err))
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file *files.FileInfo) (int, error) {
realPath, _, _ := files.GetRealPath(file.Path)
fd, err := os.Open(realPath)
if err != nil {
return http.StatusInternalServerError, err
}
defer fd.Close()
return nil
}
func createTarGz(w io.Writer, d *requestContext, filenames ...string) error {
gzWriter := gzip.NewWriter(w)
defer gzWriter.Close()
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, false)
if err != nil {
logger.Error(fmt.Sprintf("Failed to add %s to TAR.GZ: %v", fname, err))
}
}
return nil
setContentDisposition(w, r, file)
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, file.Name, file.ModTime, fd)
return 0, nil
}
+106 -169
View File
@@ -8,15 +8,12 @@ import (
"os"
"path"
"path/filepath"
"strconv"
"strings"
"github.com/shirou/gopsutil/v3/disk"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/files"
)
// resourceGetHandler retrieves information about a resource.
@@ -26,7 +23,6 @@ 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"
@@ -35,42 +31,31 @@ 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) {
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Source: source,
Expand: true,
Checker: d.user,
Content: r.URL.Query().Get("content") == "true",
// TODO source := r.URL.Query().Get("source")
path := r.URL.Query().Get("path")
file, err := files.FileInfoFaster(files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Expand: true,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
Content: r.URL.Query().Get("content") == "true",
})
if err != nil {
return errToStatus(err), err
}
if fileInfo.Type == "directory" {
return renderJSON(w, r, fileInfo)
if file.Type == "directory" {
return renderJSON(w, r, file)
}
if algo := r.URL.Query().Get("checksum"); algo != "" {
idx := files.GetIndex(source)
realPath, _, _ := idx.GetRealPath(d.user.Scope, path)
checksums, err := files.GetChecksum(realPath, algo)
if checksum := r.URL.Query().Get("checksum"); checksum != "" {
err := file.Checksum(checksum)
if err == errors.ErrInvalidOption {
return http.StatusBadRequest, nil
} else if err != nil {
return http.StatusInternalServerError, err
}
fileInfo.Checksums = checksums
}
return renderJSON(w, r, fileInfo)
return renderJSON(w, r, file)
}
@@ -81,7 +66,6 @@ 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"
@@ -90,35 +74,34 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// @Router /api/resources [delete]
func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// TODO source := r.URL.Query().Get("source")
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)
}
path := r.URL.Query().Get("path")
if path == "/" || !d.user.Perm.Delete {
return http.StatusForbidden, nil
}
fileOpts := files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
Checker: d.user,
realPath, isDir, err := files.GetRealPath(d.user.Scope, path)
if err != nil {
return http.StatusNotFound, err
}
fileInfo, err := files.FileInfoFaster(fileOpts)
fileOpts := files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
IsDir: isDir,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
}
file, err := files.FileInfoFaster(fileOpts)
if err != nil {
return errToStatus(err), err
}
// delete thumbnails
delThumbs(r.Context(), fileCache, fileInfo)
err = delThumbs(r.Context(), fileCache, file)
if err != nil {
return errToStatus(err), err
}
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath))
err = files.DeleteFiles(realPath, fileOpts)
if err != nil {
return errToStatus(err), err
}
@@ -133,7 +116,6 @@ 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"
@@ -143,35 +125,27 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [post]
func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
encodedPath := r.URL.Query().Get("path")
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)
}
// TODO source := r.URL.Query().Get("source")
path := r.URL.Query().Get("path")
if !d.user.Perm.Create || !d.user.Check(path) {
return http.StatusForbidden, nil
}
fileOpts := files.FileOptions{
Path: filepath.Join(d.user.Scope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
Checker: d.user,
Path: filepath.Join(d.user.Scope, path),
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
}
// Directories creation on POST.
if strings.HasSuffix(path, "/") {
err = files.WriteDirectory(fileOpts)
err := files.WriteDirectory(fileOpts)
if err != nil {
return errToStatus(err), err
}
return http.StatusOK, nil
}
fileInfo, err := files.FileInfoFaster(fileOpts)
file, err := files.FileInfoFaster(fileOpts)
if err == nil {
if r.URL.Query().Get("override") != "true" {
return http.StatusConflict, nil
@@ -182,14 +156,13 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusForbidden, nil
}
delThumbs(r.Context(), fileCache, fileInfo)
err = delThumbs(r.Context(), fileCache, file)
if err != nil {
return errToStatus(err), err
}
}
err = files.WriteFile(fileOpts, r.Body)
if err != nil {
return errToStatus(err), err
}
return http.StatusOK, nil
return errToStatus(err), err
}
// resourcePutHandler updates an existing file resource.
@@ -199,7 +172,6 @@ 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"
@@ -208,18 +180,8 @@ 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) {
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
}
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// TODO source := r.URL.Query().Get("source")
path := r.URL.Query().Get("path")
if !d.user.Perm.Modify || !d.user.Check(path) {
return http.StatusForbidden, nil
}
@@ -229,12 +191,17 @@ 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: filepath.Join(d.user.Scope, path),
Source: source,
Modify: d.user.Perm.Modify,
Expand: false,
Checker: d.user,
Path: realPath,
IsDir: isDir,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: config.Server.TypeDetectionByHeader,
Checker: d.user,
}
err = files.WriteFile(fileOpts, r.Body)
return errToStatus(err), err
@@ -247,7 +214,6 @@ 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"
@@ -260,37 +226,27 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// @Router /api/resources [patch]
func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// TODO source := r.URL.Query().Get("source")
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)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
src := r.URL.Query().Get("from")
dst := r.URL.Query().Get("destination")
dst, err = url.QueryUnescape(dst)
action := r.URL.Query().Get("action")
dst, err := url.QueryUnescape(dst)
if !d.user.Check(src) || !d.user.Check(dst) {
return http.StatusForbidden, nil
}
if err != nil {
return errToStatus(err), err
}
if !d.user.Check(src) || !d.user.Check(dst) {
return http.StatusForbidden, fmt.Errorf("forbidden: user rules deny access to source or destination")
}
if dst == "/" || src == "/" {
return http.StatusForbidden, fmt.Errorf("forbidden: source or destination is attempting to modify root")
return http.StatusForbidden, nil
}
idx := files.GetIndex(source)
// check target dir exists
parentDir, _, err := idx.GetRealPath(d.user.Scope, filepath.Dir(dst))
parentDir, _, err := files.GetRealPath(d.user.Scope, filepath.Dir(dst))
if err != nil {
return http.StatusNotFound, err
}
realDest := parentDir + "/" + filepath.Base(dst)
realSrc, isSrcDir, err := idx.GetRealPath(d.user.Scope, src)
realSrc, isSrcDir, err := files.GetRealPath(d.user.Scope, src)
if err != nil {
return http.StatusNotFound, err
}
@@ -301,14 +257,12 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
}
// Permission for overwriting the file
if overwrite && !d.user.Perm.Modify {
return http.StatusForbidden, fmt.Errorf("forbidden: user does not have permission to overwrite file")
return http.StatusForbidden, nil
}
err = d.Runner.RunHook(func() error {
return patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir, source)
err = d.RunHook(func() error {
return patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir)
}, 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
}
@@ -328,28 +282,27 @@ func addVersionSuffix(source string) string {
return source
}
func delThumbs(ctx context.Context, fileCache FileCache, file files.ExtendedFileInfo) {
err := fileCache.Delete(ctx, previewCacheKey(file.RealPath, "small", file.FileInfo.ModTime))
if err != nil {
logger.Debug(fmt.Sprintf("Could not delete small thumbnail: %v", err))
func delThumbs(ctx context.Context, fileCache FileCache, file *files.FileInfo) error {
if err := fileCache.Delete(ctx, previewCacheKey(file, "small")); err != nil {
return err
}
return nil
}
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, fileCache FileCache, isSrcDir bool, index string) error {
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, fileCache FileCache, isSrcDir bool) error {
switch action {
// TODO: use enum
case "copy":
if !d.user.Perm.Create {
return errors.ErrPermissionDenied
}
err := files.CopyResource(index, src, dst, isSrcDir)
return err
return files.CopyResource(src, dst, isSrcDir)
case "rename", "move":
if !d.user.Perm.Rename {
return errors.ErrPermissionDenied
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
file, err := files.FileInfoFaster(files.FileOptions{
Path: src,
Source: index,
IsDir: isSrcDir,
Modify: d.user.Perm.Modify,
Expand: false,
@@ -361,8 +314,11 @@ func patchAction(ctx context.Context, action, src, dst string, d *requestContext
}
// delete thumbnails
delThumbs(ctx, fileCache, fileInfo)
return files.MoveResource(index, src, dst, isSrcDir)
err = delThumbs(ctx, fileCache, file)
if err != nil {
return err
}
return files.MoveResource(src, dst, isSrcDir)
default:
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
}
@@ -379,7 +335,7 @@ type DiskUsageResponse struct {
// @Tags Resources
// @Accept json
// @Produce json
// @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 {object} DiskUsageResponse "Disk usage details"
// @Failure 404 {object} map[string]string "Directory not found"
// @Failure 500 {object} map[string]string "Internal server error"
@@ -387,52 +343,33 @@ type DiskUsageResponse struct {
func diskUsage(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
source = "/"
}
value, ok := cache.DiskUsage.Get(source).(DiskUsageResponse)
if ok {
return renderJSON(w, r, &value)
}
rootPath, ok := files.RootPaths[source]
if !ok {
return 400, fmt.Errorf("bad source path provided: %v", source)
}
usage, err := disk.UsageWithContext(r.Context(), rootPath)
file, err := files.FileInfoFaster(files.FileOptions{
Path: source,
Checker: d.user,
})
if err != nil {
return errToStatus(err), err
}
latestUsage := DiskUsageResponse{
fPath := file.RealPath()
if file.Type != "directory" {
return http.StatusBadRequest, fmt.Errorf("path is not a directory")
}
usage, err := disk.UsageWithContext(r.Context(), fPath)
if err != nil {
return errToStatus(err), err
}
return renderJSON(w, r, &DiskUsageResponse{
Total: usage.Total,
Used: usage.Used,
}
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)
isNotDir := r.URL.Query().Get("isDir") == "false" // default to isDir true
index := files.GetIndex(source)
info, _ := index.GetReducedMetadata(path, !isNotDir)
path := r.URL.Query().Get("path")
isDir := r.URL.Query().Get("isDir") == "true"
index := files.GetIndex(config.Server.Root)
info, _ := index.GetReducedMetadata(path, isDir)
renderJSON(w, r, info) // nolint:errcheck
}
func mockData(w http.ResponseWriter, r *http.Request) {
d := r.URL.Query().Get("numDirs")
f := r.URL.Query().Get("numFiles")
NumDirs, err := strconv.Atoi(d)
numFiles, err2 := strconv.Atoi(f)
if err != nil || err2 != nil {
return
}
mockDir := files.CreateMockData(NumDirs, numFiles)
renderJSON(w, r, mockDir) // nolint:errcheck
}
+49 -78
View File
@@ -1,20 +1,18 @@
package http
import (
"context"
"crypto/tls"
"embed"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"text/template"
"time"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/version"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/version"
httpSwagger "github.com/swaggo/http-swagger" // http-swagger middleware
)
@@ -45,7 +43,7 @@ var (
assetFs fs.FS
)
func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage, cache FileCache, shutdownComplete chan struct{}) {
func StartHttp(Service ImgService, storage *storage.Storage, cache FileCache) {
store = storage
fileCache = cache
@@ -58,7 +56,7 @@ func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage
// Embedded mode: Serve files from the embedded assets
assetFs, err = fs.Sub(assets, "embed")
if err != nil {
logger.Fatal("Could not embed frontend. Does dist exist?")
log.Fatal("Could not embed frontend. Does dist exist?")
}
} else {
assetFs = dirFS{Dir: http.Dir("http/dist")}
@@ -98,7 +96,6 @@ func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage
api.HandleFunc("GET /preview", withUser(previewHandler))
if version.Version == "testing" || version.Version == "untracked" {
api.HandleFunc("GET /inspectIndex", inspectIndex)
api.HandleFunc("GET /mockData", mockData)
}
// Share routes
@@ -109,16 +106,13 @@ func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage
// Public routes
api.HandleFunc("GET /public/publicUser", publicUserGetHandler)
api.HandleFunc("GET /public/dl", withHashFile(rawHandler))
api.HandleFunc("GET /public/dl", withHashFile(publicDlHandler))
api.HandleFunc("GET /public/share", withHashFile(publicShareHandler))
// Settings routes
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
api.HandleFunc("PUT /settings", withAdmin(settingsPutHandler))
api.HandleFunc("GET /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))
@@ -128,7 +122,7 @@ func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage
router.HandleFunc(config.Server.BaseURL, indexHandler)
// health
router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler)
router.HandleFunc(fmt.Sprintf("GET %vhealth/", config.Server.BaseURL), healthHandler)
// Swagger
router.Handle(fmt.Sprintf("%vswagger/", config.Server.BaseURL),
@@ -142,74 +136,51 @@ func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage
var scheme string
port := ""
srv := &http.Server{
Addr: fmt.Sprintf(":%v", config.Server.Port),
Handler: muxWithMiddleware(router),
}
go func() {
// Determine whether to use HTTPS (TLS) or HTTP
if config.Server.TLSCert != "" && config.Server.TLSKey != "" {
// Load the TLS certificate and key
cer, err := tls.LoadX509KeyPair(config.Server.TLSCert, config.Server.TLSKey)
if err != nil {
logger.Fatal(fmt.Sprintf("Could not load certificate: %v", err))
}
// Create a custom TLS configuration
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cer},
}
// Set HTTPS scheme and default port for TLS
scheme = "https"
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)
logger.Info(fmt.Sprintf("Running at : %s", fullURL))
// Create a TLS listener and serve
listener, err := tls.Listen("tcp", srv.Addr, tlsConfig)
if err != nil {
logger.Fatal(fmt.Sprintf("Could not start TLS server: %v", err))
}
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
logger.Fatal(fmt.Sprintf("Server error: %v", err))
}
} else {
// Set HTTP scheme and the default port for HTTP
scheme = "http"
if config.Server.Port != 80 {
port = fmt.Sprintf(":%d", config.Server.Port)
}
// Build the full URL with host and port
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
logger.Info(fmt.Sprintf("Running at : %s", fullURL))
// Start HTTP server
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal(fmt.Sprintf("Server error: %v", err))
}
// Determine whether to use HTTPS (TLS) or HTTP
if config.Server.TLSCert != "" && config.Server.TLSKey != "" {
// Load the TLS certificate and key
cer, err := tls.LoadX509KeyPair(config.Server.TLSCert, config.Server.TLSKey)
if err != nil {
log.Fatalf("could not load certificate: %v", err)
}
}()
// Wait for context cancellation to shut down the server
<-ctx.Done()
logger.Info("Shutting down HTTP server...")
// Create a custom TLS listener
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cer},
}
// Graceful shutdown with a timeout - 30 seconds, in case downloads are happening
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error(fmt.Sprintf("HTTP server forced to shut down: %v", err))
// Set HTTPS scheme and default port for TLS
scheme = "https"
// Listen on TCP and wrap with TLS
listener, err := tls.Listen("tcp", fmt.Sprintf(":%v", config.Server.Port), tlsConfig)
if err != nil {
log.Fatalf("could not start TLS server: %v", err)
}
if config.Server.Port != 443 {
port = fmt.Sprintf(":%d", config.Server.Port)
}
// Build the full URL with host and port
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
log.Printf("Running at : %s", fullURL)
err = http.Serve(listener, muxWithMiddleware(router))
if err != nil {
log.Fatalf("could not start server: %v", err)
}
} else {
logger.Info("HTTP server shut down gracefully.")
// Set HTTP scheme and the default port for HTTP
scheme = "http"
if config.Server.Port != 443 {
port = fmt.Sprintf(":%d", config.Server.Port)
}
// Build the full URL with host and port
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
log.Printf("Running at : %s", fullURL)
err := http.ListenAndServe(fmt.Sprintf(":%v", config.Server.Port), muxWithMiddleware(router))
if err != nil {
log.Fatalf("could not start server: %v", err)
}
}
// Signal that shutdown is complete
close(shutdownComplete)
}
+4 -7
View File
@@ -4,7 +4,8 @@ import (
"net/http"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/settings"
)
// searchHandler handles search requests for files based on the provided query.
@@ -48,20 +49,16 @@ import (
// @Param query query string true "Search query"
// @Param scope query string false "path within user scope to search, for example '/first/second' to search within the second directory only"
// @Param SessionId header string false "User session ID, add unique value to prevent collisions"
// @Success 200 {array} files.SearchResult "List of search results"
// @Success 200 {array} files.searchResult "List of search results"
// @Failure 400 {object} map[string]string "Bad Request"
// @Router /api/search [get]
func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
query := r.URL.Query().Get("query")
source := r.URL.Query().Get("source")
if source == "" {
source = "default"
}
searchScope := strings.TrimPrefix(r.URL.Query().Get("scope"), ".")
searchScope = strings.TrimPrefix(searchScope, "/")
// Retrieve the User-Agent and X-Auth headers from the request
sessionId := r.Header.Get("SessionId")
index := files.GetIndex(source)
index := files.GetIndex(settings.Config.Server.Root)
userScope := strings.TrimPrefix(d.user.Scope, ".")
combinedScope := strings.TrimPrefix(userScope+"/"+searchScope, "/")
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"encoding/json"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
)
type settingsData struct {
+4 -5
View File
@@ -12,8 +12,8 @@ import (
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/share"
)
// shareListHandler returns a list of all share links.
@@ -68,9 +68,8 @@ 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")
return http.StatusInternalServerError, err
}
return renderJSON(w, r, s)
}
@@ -189,7 +188,7 @@ func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, e
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
if err != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("failed to hash password")
return nil, http.StatusInternalServerError, fmt.Errorf("failed to hash password: %w", err)
}
return hash, 0, nil
+13 -16
View File
@@ -4,16 +4,17 @@ import (
"encoding/json"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"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"
"github.com/gtsteffaniak/filebrowser/auth"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/version"
)
var templateRenderer *TemplateRenderer
@@ -36,7 +37,7 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentType string) {
w.Header().Set("Content-Type", contentType)
auther, err := store.Auth.Get("password")
auther, err := store.Auth.Get(config.Auth.Method)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
@@ -44,27 +45,23 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
data := map[string]interface{}{
"Name": config.Frontend.Name,
"DisableExternal": config.Frontend.DisableDefaultLinks,
"DisableExternal": config.Frontend.DisableExternal,
"DisableUsedPercentage": config.Frontend.DisableUsedPercentage,
"darkMode": settings.Config.UserDefaults.DarkMode,
"Color": config.Frontend.Color,
"BaseURL": config.Server.BaseURL,
"Version": version.Version,
"CommitSHA": version.CommitSHA,
"StaticURL": config.Server.BaseURL + "static",
"StaticURL": path.Join(config.Server.BaseURL, "static"),
"Signup": settings.Config.Auth.Signup,
"NoAuth": config.Auth.Methods.NoAuth,
"PasswordAuth": config.Auth.Methods.PasswordAuth,
"NoAuth": config.Auth.Method == "noauth",
"AuthMethod": config.Auth.Method,
"LoginPage": auther.LoginPage(),
"CSS": false,
"ReCaptcha": false,
"EnableThumbs": config.Server.EnableThumbnails,
"ResizePreview": config.Server.ResizePreview,
"EnableExec": config.Server.EnableExec,
"ReCaptchaHost": config.Auth.Recaptcha.Host,
"ExternalLinks": config.Frontend.ExternalLinks,
"ExternalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"OnlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
}
if config.Frontend.Files != "" {
@@ -72,7 +69,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
_, err := os.Stat(fPath) //nolint:govet
if err != nil && !os.IsNotExist(err) {
logger.Error(fmt.Sprintf("couldn't load custom styles: %v", err))
log.Printf("couldn't load custom styles: %v", err)
}
if err == nil {
@@ -80,8 +77,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
}
}
if config.Auth.Methods.PasswordAuth.Enabled {
raw, err := store.Auth.Get("password") //nolint:govet
if config.Auth.Method == "password" {
raw, err := store.Auth.Get(config.Auth.Method) //nolint:govet
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
+17 -22
View File
@@ -12,10 +12,10 @@ import (
"golang.org/x/text/cases"
"golang.org/x/text/language"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/storage"
"github.com/gtsteffaniak/filebrowser/users"
)
var (
@@ -55,29 +55,26 @@ func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
if givenUserIdString == "self" {
givenUserId = d.user.ID
} else if givenUserIdString == "" {
userList, err := store.Users.Gets(files.RootPaths["default"])
if !d.user.Perm.Admin {
return http.StatusForbidden, nil
}
users, err := store.Users.Gets(config.Server.Root)
if err != nil {
return http.StatusInternalServerError, err
}
selfUserList := []*users.User{}
for _, u := range userList {
for _, u := range users {
u.Password = ""
}
for _, u := range users {
u.ApiKeys = nil
if u.ID == d.user.ID {
selfUserList = append(selfUserList, u)
}
}
sort.Slice(userList, func(i, j int) bool {
return userList[i].ID < userList[j].ID
sort.Slice(users, func(i, j int) bool {
return users[i].ID < users[j].ID
})
if !d.user.Perm.Admin {
userList = selfUserList
}
return renderJSON(w, r, userList)
return renderJSON(w, r, users)
} else {
num, _ := strconv.ParseUint(givenUserIdString, 10, 32)
givenUserId = uint(num)
@@ -88,7 +85,7 @@ func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
}
// Fetch the user details
u, err := store.Users.Get(files.RootPaths["default"], givenUserId)
u, err := store.Users.Get(config.Server.Root, givenUserId)
if err == errors.ErrNotExist {
return http.StatusNotFound, err
}
@@ -159,8 +156,7 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
// Validate the user's scope
idx := files.GetIndex("default")
_, _, err := idx.GetRealPath(d.user.Scope)
_, _, err := files.GetRealPath(config.Server.Root, d.user.Scope)
if err != nil {
return http.StatusBadRequest, err
}
@@ -218,8 +214,7 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
}
// Validate the user's scope
idx := files.GetIndex("default")
_, _, err := idx.GetRealPath(d.user.Scope)
_, _, err := files.GetRealPath(config.Server.Root, d.user.Scope)
if err != nil {
return http.StatusBadRequest, err
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http"
"os"
libErrors "github.com/gtsteffaniak/filebrowser/backend/errors"
libErrors "github.com/gtsteffaniak/filebrowser/errors"
)
func errToStatus(err error) int {
+5 -43
View File
@@ -11,6 +11,7 @@ import (
"github.com/disintegration/imaging"
"github.com/dsoprea/go-exif/v3"
"github.com/marusama/semaphore/v2"
exifcommon "github.com/dsoprea/go-exif/v3/common"
)
@@ -20,32 +21,12 @@ var ErrUnsupportedFormat = errors.New("unsupported image format")
// Service
type Service struct {
sem chan struct{}
sem semaphore.Semaphore
}
// New initializes the service with a specified number of workers (concurrency limit).
func New(workers int) *Service {
return &Service{
sem: make(chan struct{}, workers), // Buffered channel to limit concurrency.
}
}
// acquire blocks until a worker is available or the context is canceled.
func (s *Service) acquire(ctx context.Context) error {
select {
case s.sem <- struct{}{}: // Reserve a worker.
return nil
case <-ctx.Done(): // Context canceled or deadline exceeded.
return ctx.Err()
}
}
// release frees up a worker slot.
func (s *Service) release() {
select {
case <-s.sem: // Free a worker slot.
default:
// Shouldn't happen, but guard against releasing more than acquired.
sem: semaphore.New(workers),
}
}
@@ -155,10 +136,10 @@ func WithQuality(quality Quality) Option {
}
func (s *Service) Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...Option) error {
if err := s.acquire(ctx); err != nil {
if err := s.sem.Acquire(ctx, 1); err != nil {
return err
}
defer s.release()
defer s.sem.Release(1)
format, wrappedReader, err := s.detectFormat(in)
if err != nil {
@@ -261,22 +242,3 @@ func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
thm, err := ifd.Thumbnail()
return thm, wrappedReader, err
}
// CreateThumbnail takes raw image data and creates a thumbnail image.
func CreateThumbnail(rawData io.Reader, width, height int) (image.Image, error) {
// Decode the raw image to get an image.Image.
img, _, err := image.Decode(rawData)
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
// Resize the image to create a thumbnail using the specified dimensions.
thumb := imaging.Fit(img, width, height, imaging.Lanczos)
// Optionally, convert the thumbnail to grayscale if needed.
// Uncomment the line below if you want the result to be grayscale.
// thumb = imaging.Grayscale(thumb)
// Return the resized thumbnail image.
return thumb, nil
}
-1
View File
@@ -1 +0,0 @@
../../frontend/tests/playwright-files/myfolder/testdata/
File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

-141
View File
@@ -1,141 +0,0 @@
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
@@ -1,146 +0,0 @@
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)
}
+1 -1
View File
@@ -1,7 +1,7 @@
package main
import (
"github.com/gtsteffaniak/filebrowser/backend/cmd"
"github.com/gtsteffaniak/filebrowser/cmd"
)
func main() {
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+17
View File
@@ -0,0 +1,17 @@
#!/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
+1 -1
View File
@@ -3,7 +3,7 @@ package runner
import (
"os/exec"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/settings"
)
// ParseCommand parses the command taking in account if the current
+5 -6
View File
@@ -7,9 +7,9 @@ import (
"os/exec"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/files"
"github.com/gtsteffaniak/filebrowser/settings"
"github.com/gtsteffaniak/filebrowser/users"
)
// Runner is a commands runner.
@@ -20,9 +20,8 @@ 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 {
idx := files.GetIndex("default")
path, _, _ = idx.GetRealPath(user.Scope, path)
dst, _, _ = idx.GetRealPath(user.Scope, dst)
path, _, _ = files.GetRealPath(user.Scope, path)
dst, _, _ = files.GetRealPath(user.Scope, dst)
if r.Enabled {
if val, ok := r.Commands["before_"+evt]; ok {
+14
View File
@@ -0,0 +1,14 @@
#!/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
@@ -0,0 +1,11 @@
#!/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
+29 -99
View File
@@ -1,121 +1,63 @@
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"
"github.com/gtsteffaniak/filebrowser/users"
)
var Config Settings
func Initialize(configFile string) {
yamlData, err := loadConfigFile(configFile)
if err != nil && configFile != "config.yaml" {
logger.Fatal("Could not load specified config file: " + err.Error())
}
if err != nil {
logger.Warning(fmt.Sprintf("Could not load config file '%v', using default settings: %v", configFile, err))
}
yamlData := loadConfigFile(configFile)
Config = setDefaults()
err = yaml.Unmarshal(yamlData, &Config)
err := yaml.Unmarshal(yamlData, &Config)
if err != nil {
logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err))
}
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)
}
}
if Config.Auth.Method != "" {
logger.Warning("The `auth.method` setting is deprecated and will be removed in a future version. Please use `auth.methods` instead.")
log.Fatalf("Error unmarshaling YAML data: %v", err)
}
Config.UserDefaults.Perm = Config.UserDefaults.Permissions
// Convert relative path to absolute path
if len(Config.Server.Sources) > 0 {
if Config.Server.Root != "" {
logger.Warning("`server.root` is configured but will be ignored in favor of `server.sources`")
}
// TODO allow multiple 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,
},
}
realRoot, err := filepath.Abs(Config.Server.Root)
if err != nil {
log.Fatalf("Error getting root path: %v", err)
}
_, 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",
})
}
}
func loadConfigFile(configFile string) ([]byte, error) {
func loadConfigFile(configFile string) []byte {
// Open and read the YAML file
yamlFile, err := os.Open(configFile)
if err != nil {
return nil, err
log.Println(err)
os.Exit(1)
}
defer yamlFile.Close()
stat, err := yamlFile.Stat()
if err != nil {
return nil, err
log.Fatalf("error getting file information: %s", err.Error())
}
yamlData := make([]byte, stat.Size())
_, err = yamlFile.Read(yamlData)
if err != nil {
return nil, err
log.Fatalf("Error reading YAML data: %v", err)
}
return yamlData, nil
return yamlData
}
func setDefaults() Settings {
@@ -124,46 +66,34 @@ func setDefaults() Settings {
EnableThumbnails: true,
ResizePreview: false,
EnableExec: false,
IndexingInterval: 5,
Port: 80,
NumImageProcessors: 4,
BaseURL: "",
Database: "database.db",
Root: ".",
Log: "stdout",
Root: "/srv",
Indexing: true,
},
Auth: Auth{
AdminUsername: "admin",
AdminPassword: "admin",
TokenExpirationHours: 2,
Signup: false,
TokenExpirationTime: "2h",
AdminUsername: "admin",
AdminPassword: "admin",
Method: "password",
Signup: false,
Recaptcha: Recaptcha{
Host: "",
},
Methods: LoginMethods{
ProxyAuth: ProxyAuthConfig{
Enabled: false,
CreateUser: false,
Header: "",
},
NoAuth: false,
PasswordAuth: PasswordAuthConfig{
Enabled: true,
MinLength: 5,
},
},
},
Frontend: Frontend{
Name: "FileBrowser Quantum",
},
UserDefaults: UserDefaults{
StickySidebar: true,
Scope: ".",
LockPassword: false,
ShowHidden: false,
DarkMode: true,
HideDotfiles: true,
DarkMode: false,
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)
}
})
+2 -3
View File
@@ -3,13 +3,12 @@ package settings
import (
"errors"
"fmt"
"log"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
var (
@@ -23,7 +22,7 @@ func (s *Settings) MakeUserDir(username, userScope, serverRoot string) (string,
if userScope == "" && s.Server.CreateUserDir {
username = cleanUsername(username)
if username == "" || username == "-" || username == "." {
logger.Error(fmt.Sprintf("create user: invalid user for home dir creation: [%s]", username))
log.Printf("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)
+1 -1
View File
@@ -3,7 +3,7 @@ package settings
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/users"
)
func TestSettings_MakeUserDir(t *testing.T) {
+2 -2
View File
@@ -3,7 +3,7 @@ package settings
import (
"crypto/rand"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/users"
)
const DefaultUsersHomeBasePath = "/users"
@@ -52,7 +52,7 @@ func ApplyUserDefaults(u users.User) users.User {
u.Perm = Config.UserDefaults.Perm
u.Sorting = Config.UserDefaults.Sorting
u.Commands = Config.UserDefaults.Commands
u.ShowHidden = Config.UserDefaults.ShowHidden
u.HideDotfiles = Config.UserDefaults.HideDotfiles
u.DateFormat = Config.UserDefaults.DateFormat
return u
}
+10 -7
View File
@@ -1,23 +1,22 @@
package settings
import (
"fmt"
"log"
"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 {
logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err))
log.Fatalf("Error unmarshaling YAML data: %v", err)
}
// Use go-cmp to compare the two structs
if diff := cmp.Diff(newConfig, Config); diff == "" {
@@ -26,26 +25,30 @@ 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 {
logger.Fatal(fmt.Sprintf("Error unmarshaling YAML data: %v", err))
log.Fatalf("Error unmarshaling YAML data: %v", err)
}
testCases := []struct {
fieldName string
globalVal interface{}
newVal interface{}
}{
{"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:\nConfig.%s: %v \nSetConfig: %v \n", tc.fieldName, 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)
}
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
package settings
import (
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/errors"
"github.com/gtsteffaniak/filebrowser/users"
)
// StorageBackend is a settings storage backend.
+40 -117
View File
@@ -1,15 +1,7 @@
package settings
import (
"github.com/gtsteffaniak/filebrowser/backend/users"
)
type AllowedMethods string
const (
ProxyAuth AllowedMethods = "proxyAuth"
NoAuth AllowedMethods = "noAuth"
PasswordAuth AllowedMethods = "passwordAuth"
"github.com/gtsteffaniak/filebrowser/users"
)
type Settings struct {
@@ -21,37 +13,19 @@ type Settings struct {
Frontend Frontend `json:"frontend"`
Users []UserDefaults `json:"users,omitempty"`
UserDefaults UserDefaults `json:"userDefaults"`
Integrations Integrations `json:"integrations"`
}
type Auth struct {
TokenExpirationHours int `json:"tokenExpirationHours"`
Recaptcha Recaptcha `json:"recaptcha"`
Methods LoginMethods `json:"methods"`
Command string `json:"command"`
Signup bool `json:"signup"`
Method string `json:"method"`
Shell string `json:"shell"`
Key []byte `json:"key"`
AdminUsername string `json:"adminUsername"`
AdminPassword string `json:"adminPassword"`
}
type LoginMethods struct {
ProxyAuth ProxyAuthConfig `json:"proxy"`
NoAuth bool `json:"noauth"`
PasswordAuth PasswordAuthConfig `json:"password"`
}
type PasswordAuthConfig struct {
Enabled bool `json:"enabled"`
MinLength int `json:"minLength"`
}
type ProxyAuthConfig struct {
Enabled bool `json:"enabled"`
CreateUser bool `json:"createUser"`
Header string `json:"header"`
TokenExpirationTime string `json:"tokenExpirationTime"`
Recaptcha Recaptcha `json:"recaptcha"`
Header string `json:"header"`
Method string `json:"method"`
Command string `json:"command"`
Signup bool `json:"signup"`
Shell string `json:"shell"`
AdminUsername string `json:"adminUsername"`
AdminPassword string `json:"adminPassword"`
Key []byte `json:"key"`
}
type Recaptcha struct {
@@ -61,82 +35,33 @@ type Recaptcha struct {
}
type Server struct {
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"`
AuthHook string `json:"authHook"`
Port int `json:"port"`
BaseURL string `json:"baseURL"`
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
CacheDir string `json:"cacheDir"`
}
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"`
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"`
}
type Frontend struct {
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"`
Name string `json:"name"`
DisableExternal bool `json:"disableExternal"`
DisableUsedPercentage bool `json:"disableUsedPercentage"`
Files string `json:"files"`
Color string `json:"color"`
}
// UserDefaults is a type that holds the default values
@@ -156,11 +81,9 @@ type UserDefaults struct {
By string `json:"by"`
Asc bool `json:"asc"`
} `json:"sorting"`
Perm users.Permissions `json:"perm"`
Permissions users.Permissions `json:"permissions"`
Commands []string `json:"commands,omitempty"`
ShowHidden bool `json:"showHidden"`
DateFormat bool `json:"dateFormat"`
ThemeColor string `json:"themeColor"`
QuickDownload bool `json:"quickDownload"`
Perm users.Permissions `json:"perm"`
Permissions users.Permissions `json:"permissions"`
Commands []string `json:"commands,omitempty"`
HideDotfiles bool `json:"hideDotfiles"`
DateFormat bool `json:"dateFormat"`
}
+6 -1
View File
@@ -6,16 +6,20 @@ server:
tlsCert: ""
enableThumbnails: false
resizePreview: true
typeDetectionByHeader: true
port: 80
baseURL: "/"
address: ""
log: "stdout"
database: "mydb.db"
root: "."
root: "/srv"
auth:
recaptcha:
host: ""
key: ""
secret: ""
header: ""
method: json
command: ""
signup: false
shell: ""
@@ -44,4 +48,5 @@ userDefaults:
delete: true
share: true
download: true
hideDotfiles: false
dateFormat: false
Loaded 100 of 244 files, more files were not shown because too many files have changed in this diff. Show more