├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── create-dev-branch.yaml │ ├── main.yaml │ ├── pr.yaml │ ├── promote-beta-to-stable.yaml │ ├── promote-dev-to-beta.yaml │ ├── regular-tests.yaml │ ├── release_beta.yaml │ ├── release_dev.yaml │ ├── release_docker.yaml │ ├── release_stable.yaml │ └── tag.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── _docker ├── Dockerfile ├── Dockerfile.playwright-base ├── Dockerfile.playwright-general ├── Dockerfile.playwright-noauth ├── Dockerfile.playwright-proxy ├── docker-compose.yaml └── src │ ├── general │ ├── backend │ │ └── config.yaml │ └── frontend │ │ └── playwright.config.ts │ ├── noauth │ ├── backend │ │ └── config.yaml │ └── frontend │ │ └── playwright.config.ts │ └── proxy │ ├── backend │ ├── config.yaml │ └── default.conf │ └── frontend │ └── playwright.config.ts ├── backend ├── .golangci.yml ├── .goreleaser.yaml ├── adapters │ └── fs │ │ ├── diskcache │ │ ├── cache.go │ │ ├── file_cache.go │ │ ├── file_cache_test.go │ │ └── noop_cache.go │ │ ├── files │ │ ├── file_test.go │ │ ├── files.go │ │ ├── mime.go │ │ └── user.go │ │ └── fileutils │ │ ├── copy.go │ │ ├── dir.go │ │ ├── file.go │ │ └── file_test.go ├── auth │ ├── auth.go │ ├── hook.go │ ├── json.go │ ├── none.go │ ├── proxy.go │ ├── storage.go │ └── totp.go ├── benchmark_results.txt ├── cmd │ ├── cli.go │ ├── office.go │ ├── root.go │ ├── user.go │ └── user_test.go ├── common │ ├── errors │ │ └── errors.go │ ├── settings │ │ ├── auth.go │ │ ├── config.go │ │ ├── config_test.go │ │ ├── generator.go │ │ ├── invalidConfig.yaml │ │ ├── settings.go │ │ ├── storage.go │ │ ├── structs.go │ │ └── validConfig.yaml │ ├── utils │ │ ├── main.go │ │ ├── main_test.go │ │ └── mocks.go │ └── version │ │ └── version.go ├── config.media.yaml ├── config.yaml ├── database │ ├── share │ │ ├── share.go │ │ └── storage.go │ ├── storage │ │ ├── bolt │ │ │ ├── auth.go │ │ │ ├── bolt.go │ │ │ ├── config.go │ │ │ ├── share.go │ │ │ ├── users.go │ │ │ └── utils.go │ │ └── storage.go │ └── users │ │ ├── password.go │ │ ├── storage.go │ │ ├── storage_test.go │ │ └── users.go ├── events │ └── eventRouter.go ├── go.mod ├── go.sum ├── http │ ├── api.go │ ├── auth.go │ ├── embed │ │ └── .gitignore │ ├── httpEvents.go │ ├── httpJobs.go │ ├── httpRouter.go │ ├── middleware.go │ ├── middleware_test.go │ ├── oidc.go │ ├── onlyOffice.go │ ├── preview.go │ ├── public.go │ ├── raw.go │ ├── resource.go │ ├── search.go │ ├── settings.go │ ├── share.go │ ├── static.go │ ├── swagger.go │ ├── totp.go │ ├── users.go │ └── utils.go ├── indexing │ ├── checkLinuxStub.go │ ├── checkWindows.go │ ├── indexingFiles.go │ ├── indexingSchedule.go │ ├── indexing_test.go │ ├── iteminfo │ │ ├── conditions.go │ │ ├── conditions_test.go │ │ ├── fileinfo.go │ │ ├── searchQuery.go │ │ └── utils.go │ ├── mock.go │ ├── mutate.go │ ├── mutate_test.go │ ├── search.go │ └── search_test.go ├── main.go ├── preview │ ├── image.go │ ├── image_enum.go │ ├── image_test.go │ ├── office.go │ ├── preview.go │ ├── sync.go │ ├── testdata │ └── video.go ├── run_benchmark.sh ├── run_check_coverage.sh ├── run_fmt.sh └── swagger │ └── docs │ ├── docs.go │ ├── swagger.json │ └── swagger.yaml ├── frontend ├── .eslintrc.json ├── frontend ├── package.json ├── public │ ├── config.generated.yaml │ ├── img │ │ ├── icons │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-256x256.png │ │ │ ├── browserconfig.xml │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-256x256.png │ │ │ ├── favicon.ico │ │ │ └── mstile-256x256.png │ │ └── logo.png │ └── index.html ├── scripts │ └── sync-translations.js ├── src │ ├── App.vue │ ├── api │ │ ├── commands.js │ │ ├── files.js │ │ ├── index.js │ │ ├── public.js │ │ ├── search.js │ │ ├── settings.js │ │ ├── share.js │ │ ├── users.js │ │ ├── utils.js │ │ └── utils.test.js │ ├── assets │ │ └── fonts │ │ │ ├── material │ │ │ ├── icons.woff2 │ │ │ └── symbols-outlined.woff │ │ │ └── roboto │ │ │ ├── bold-cyrillic-ext.woff2 │ │ │ ├── bold-cyrillic.woff2 │ │ │ ├── bold-greek-ext.woff2 │ │ │ ├── bold-greek.woff2 │ │ │ ├── bold-latin-ext.woff2 │ │ │ ├── bold-latin.woff2 │ │ │ ├── bold-vietnamese.woff2 │ │ │ ├── medium-cyrillic-ext.woff2 │ │ │ ├── medium-cyrillic.woff2 │ │ │ ├── medium-greek-ext.woff2 │ │ │ ├── medium-greek.woff2 │ │ │ ├── medium-latin-ext.woff2 │ │ │ ├── medium-latin.woff2 │ │ │ ├── medium-vietnamese.woff2 │ │ │ ├── normal-cyrillic-ext.woff2 │ │ │ ├── normal-cyrillic.woff2 │ │ │ ├── normal-greek-ext.woff2 │ │ │ ├── normal-greek.woff2 │ │ │ ├── normal-latin-ext.woff2 │ │ │ ├── normal-latin.woff2 │ │ │ └── normal-vietnamese.woff2 │ ├── components │ │ ├── Action.vue │ │ ├── Breadcrumbs.vue │ │ ├── ButtonGroup.vue │ │ ├── ContextMenu.vue │ │ ├── Notifications.vue │ │ ├── ProgressBar.vue │ │ ├── Search.vue │ │ ├── files │ │ │ ├── ExtendedImage.vue │ │ │ ├── Icon.vue │ │ │ ├── ListingItem.vue │ │ │ ├── PopupPreview.vue │ │ │ └── Scrollbar.vue │ │ ├── prompts │ │ │ ├── ActionApi.vue │ │ │ ├── Copy.vue │ │ │ ├── CreateApi.vue │ │ │ ├── Delete.vue │ │ │ ├── DeleteUser.vue │ │ │ ├── Download.vue │ │ │ ├── FileList.vue │ │ │ ├── Help.vue │ │ │ ├── Info.vue │ │ │ ├── Move.vue │ │ │ ├── NewDir.vue │ │ │ ├── NewFile.vue │ │ │ ├── Prompts.vue │ │ │ ├── Rename.vue │ │ │ ├── Replace.vue │ │ │ ├── ReplaceRename.vue │ │ │ ├── Share.vue │ │ │ ├── ShareDelete.vue │ │ │ ├── Totp.vue │ │ │ └── Upload.vue │ │ ├── settings │ │ │ ├── Languages.vue │ │ │ ├── Permissions.vue │ │ │ ├── Themes.vue │ │ │ ├── ToggleSwitch.vue │ │ │ ├── UserForm.vue │ │ │ └── ViewMode.vue │ │ └── sidebar │ │ │ ├── General.vue │ │ │ ├── Settings.vue │ │ │ └── Sidebar.vue │ ├── css │ │ ├── _buttons.css │ │ ├── _inputs.css │ │ ├── _share.css │ │ ├── _variables.css │ │ ├── base.css │ │ ├── dark.css │ │ ├── dashboard.css │ │ ├── fonts.css │ │ ├── header.css │ │ ├── listing.css │ │ ├── login.css │ │ ├── mobile.css │ │ └── styles.css │ ├── global.d.ts │ ├── i18n │ │ ├── ar.json │ │ ├── de.json │ │ ├── el.json │ │ ├── en.json │ │ ├── es.json │ │ ├── fr.json │ │ ├── he.json │ │ ├── hu.json │ │ ├── index.ts │ │ ├── is.json │ │ ├── it.json │ │ ├── ja.json │ │ ├── ko.json │ │ ├── nl-be.json │ │ ├── pl.json │ │ ├── pt-br.json │ │ ├── pt.json │ │ ├── ro.json │ │ ├── ru.json │ │ ├── sk.json │ │ ├── sv-se.json │ │ ├── tr.json │ │ ├── ua.json │ │ ├── zh-cn.json │ │ └── zh-tw.json │ ├── main.ts │ ├── notify │ │ ├── events.js │ │ ├── index.ts │ │ ├── loadingSpinner.js │ │ └── message.js │ ├── router │ │ └── index.ts │ ├── store │ │ ├── eventBus.js │ │ ├── getters.js │ │ ├── index.ts │ │ ├── modules │ │ │ └── upload.js │ │ ├── mutations.js │ │ └── state.js │ ├── utils │ │ ├── auth.js │ │ ├── buttons.js │ │ ├── constants.js │ │ ├── cookie.js │ │ ├── deepclone.ts │ │ ├── download.js │ │ ├── files.js │ │ ├── files.test.js │ │ ├── filesizes.js │ │ ├── filesizes.test.js │ │ ├── index.ts │ │ ├── mimetype.js │ │ ├── moment.js │ │ ├── sort.js │ │ ├── sort.test.js │ │ ├── subtitles.js │ │ ├── throttle.js │ │ ├── upload.js │ │ ├── url.js │ │ └── url.test.js │ └── views │ │ ├── Errors.vue │ │ ├── Files.vue │ │ ├── Layout.vue │ │ ├── Login.vue │ │ ├── Settings.vue │ │ ├── Share.vue │ │ ├── bars │ │ └── Default.vue │ │ ├── files │ │ ├── Editor.vue │ │ ├── ListingView.vue │ │ ├── MarkdownViewer.vue │ │ ├── OnlyOfficeEditor.vue │ │ └── Preview.vue │ │ └── settings │ │ ├── Api.vue │ │ ├── Global.vue │ │ ├── Profile.vue │ │ ├── Shares.vue │ │ ├── User.vue │ │ └── Users.vue ├── tests-playwright │ ├── general │ │ ├── auth.spec.ts │ │ ├── file-actions.spec.ts │ │ ├── navigation.spec.ts │ │ ├── settings.spec.ts │ │ ├── share.spec.ts │ │ └── theme-branding.spec.ts │ ├── global-setup.ts │ ├── noauth-setup.ts │ ├── noauth │ │ ├── file-actions.spec.ts │ │ ├── navigation.spec.ts │ │ ├── preview.spec.ts │ │ ├── share.spec.ts │ │ └── theme-branding.spec.ts │ ├── proxy │ │ └── preview.spec.ts │ └── test-setup.ts ├── tests │ ├── mocks │ │ └── setup.js │ └── playwright-files │ │ ├── 1file1.txt │ │ ├── copyme.txt │ │ ├── deleteme.txt │ │ ├── file.tar.gz │ │ ├── files │ │ ├── for testing.md │ │ └── nested │ │ │ ├── binary.dat │ │ │ └── graham.xlsx │ │ ├── folder#hash │ │ └── file#.sh │ │ └── myfolder │ │ └── testdata │ │ ├── 20130612_142406.jpg │ │ ├── IMG_2578.JPG │ │ └── gray-sample.jpg ├── tsconfig.json └── vite.config.ts └── makefile /.dockerignore: -------------------------------------------------------------------------------- 1 | backend/**.db 2 | backend/tmp 3 | backend/vendor/** 4 | frontend/dist/** 5 | frontend/node_modules/** -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | --- 5 | 6 | **Description** 7 | 8 | 9 | **Expected behaviour** 10 | 11 | 12 | **What is happening instead?** 13 | 14 | 15 | **Additional context** 16 | 17 | 18 | **How to reproduce?** 19 | 20 | 21 | **Files** 22 | 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | --- 5 | 6 | **Is your feature request related to a problem? Please describe.** 7 | 8 | 9 | **Describe the solution you'd like** 10 | 11 | 12 | **Describe alternatives you've considered** 13 | 14 | 15 | **Additional context** 16 | 17 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Description** 2 | 3 | According to the [contributing guide](https://github.com/gtsteffaniak/filebrowser/wiki/Contributing#contributing-as-an-unofficial-contributor), A PR should contain: 4 | 5 | - [ ] A clear description of why it was opened. 6 | - [ ] A short title that best describes the change. 7 | - [ ] Must pass unit and integration tests, which can be run checked locally prior to opening a PR. 8 | - [ ] Any additional details for functionality not covered by tests. 9 | 10 | **Additional Details** 11 | -------------------------------------------------------------------------------- /.github/workflows/create-dev-branch.yaml: -------------------------------------------------------------------------------- 1 | name: Create Dev Version 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "Version to create (format: 0.0.0)" 8 | required: true 9 | type: string 10 | 11 | jobs: 12 | create-dev-branch: 13 | name: Create Dev Branch 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Check out repository 18 | uses: actions/checkout@v4 19 | with: 20 | token: ${{ secrets.PAT }} # Uses the Personal Access Token 21 | 22 | - name: Validate version format 23 | id: validate_version 24 | run: | 25 | VERSION="${{ github.event.inputs.version }}" 26 | if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 27 | echo "❌ ERROR: Version must be in '0.0.0' format." 28 | exit 1 29 | fi 30 | echo "✅ Version format validated: $VERSION" 31 | 32 | - name: Check if branch already exists 33 | id: check_branch 34 | run: | 35 | VERSION="${{ github.event.inputs.version }}" 36 | DEV_BRANCH="dev/v$VERSION" 37 | 38 | if git ls-remote --exit-code origin "$DEV_BRANCH"; then 39 | echo "❌ ERROR: Branch '$DEV_BRANCH' already exists!" 40 | exit 1 41 | fi 42 | echo "✅ Branch '$DEV_BRANCH' does not exist." 43 | 44 | - name: Create and push new dev branch 45 | run: | 46 | VERSION="${{ github.event.inputs.version }}" 47 | DEV_BRANCH="dev/v$VERSION" 48 | 49 | # Fetch latest changes 50 | git fetch origin main 51 | 52 | # Create new branch from main 53 | git checkout -b "$DEV_BRANCH" origin/main 54 | 55 | # Push branch to remote 56 | git push origin "$DEV_BRANCH" 57 | 58 | echo "✅ Successfully created branch '$DEV_BRANCH'!" 59 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: main release 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | 8 | jobs: 9 | test_playwright: 10 | name: Test Playwright 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | - name: Set up QEMU 16 | uses: docker/setup-qemu-action@v3.0.0 17 | - name: Set up Docker Buildx 18 | uses: docker/setup-buildx-action@v3.0.0 19 | - uses: actions/setup-node@v4 20 | - working-directory: frontend 21 | run: npm i && npm run build 22 | - uses: actions/setup-go@v5 23 | with: 24 | go-version: 'stable' 25 | - working-directory: backend 26 | run: go build -o filebrowser . 27 | - name: Build 28 | uses: docker/build-push-action@v6 29 | with: 30 | context: . 31 | file: ./_docker/Dockerfile.playwright-general 32 | push: false 33 | -------------------------------------------------------------------------------- /.github/workflows/regular-tests.yaml: -------------------------------------------------------------------------------- 1 | name: regular tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | 8 | jobs: 9 | test-backend: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-go@v5 14 | with: 15 | go-version: 'stable' 16 | - working-directory: backend 17 | run: go test -race -v ./... 18 | lint-backend: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-go@v5 23 | with: 24 | go-version: 'stable' 25 | - uses: golangci/golangci-lint-action@v5 26 | with: 27 | version: 'v1.64' 28 | working-directory: backend 29 | format-backend: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: actions/setup-go@v5 34 | with: 35 | go-version: 'stable' 36 | - working-directory: backend 37 | run: go fmt ./... 38 | lint-frontend: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v4 42 | - uses: actions/setup-node@v4 43 | - working-directory: frontend 44 | run: npm i && npm run lint 45 | test-frontend: 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@v4 49 | - uses: actions/setup-node@v4 50 | - working-directory: frontend 51 | run: npm i && npm run test 52 | -------------------------------------------------------------------------------- /.github/workflows/release_beta.yaml: -------------------------------------------------------------------------------- 1 | name: beta release 2 | 3 | on: 4 | push: 5 | branches: 6 | - "beta/v[0-9]+.[0-9]+.[0-9]+" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | test_playwright: 13 | name: Test Playwright - regular 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v3.0.0 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v3.0.0 22 | - uses: actions/setup-node@v4 23 | - working-directory: frontend 24 | run: npm i && npm run build 25 | - uses: actions/setup-go@v5 26 | with: 27 | go-version: 'stable' 28 | - working-directory: backend 29 | run: go build -o filebrowser . 30 | - name: Build 31 | uses: docker/build-push-action@v6 32 | with: 33 | context: . 34 | file: ./_docker/Dockerfile.playwright-general 35 | push: false 36 | create_release_tag: 37 | needs: [ test_playwright ] 38 | name: Create Release 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Checkout code 42 | uses: actions/checkout@v4 43 | with: 44 | token: ${{ secrets.PAT }} 45 | - name: Extract branch name 46 | shell: bash 47 | run: | 48 | original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}} 49 | echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT 50 | tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta 51 | echo "tag_name=$tag_name" >> $GITHUB_OUTPUT 52 | id: extract_branch 53 | - uses: actions/setup-go@v5 54 | with: 55 | go-version: 'stable' 56 | - name: Create Release 57 | uses: softprops/action-gh-release@v2 58 | with: 59 | target_commitish: ${{ github.sha }} 60 | token: ${{ secrets.PAT }} 61 | tag_name: ${{ steps.extract_branch.outputs.tag_name }} 62 | prerelease: false # change this to false when stable gets released 63 | make_latest: true # change this to false when stable gets released 64 | draft: false 65 | generate_release_notes: true 66 | name: ${{ steps.extract_branch.outputs.tag_name }} 67 | -------------------------------------------------------------------------------- /.github/workflows/release_dev.yaml: -------------------------------------------------------------------------------- 1 | name: dev release 2 | 3 | on: 4 | push: 5 | branches: 6 | - "dev/v[0-9]+.[0-9]+.[0-9]+" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | push_release_to_registry: 13 | name: Push release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v3.0.0 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v3.0.0 22 | - name: Login to Docker Hub 23 | uses: docker/login-action@v3 24 | with: 25 | username: ${{ secrets.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | - name: Login to GitHub Container Registry 28 | uses: docker/login-action@v3 29 | with: 30 | registry: ghcr.io 31 | username: ${{github.actor}} 32 | password: ${{ secrets.PAT }} 33 | - name: Extract metadata (tags, labels) for Docker and GHCR 34 | id: meta 35 | uses: docker/metadata-action@v5 36 | with: 37 | images: | 38 | gtstef/filebrowser 39 | ghcr.io/gtsteffaniak/filebrowser 40 | - name: Modify version names 41 | id: modify-json 42 | run: | 43 | TAGS="${{ steps.meta.outputs.tags }}" 44 | # Apply modifications to both docker.io and ghcr.io tags 45 | CLEANED_TAGS=$(echo "$TAGS" | sed 's/filebrowser:dev-v/filebrowser:/g' | sed -E 's/(filebrowser:[0-9]+\.[0-9]+\.[0-9]+)/\1-dev/g') 46 | # Add dev tag to both registries 47 | CLEANED_TAGS="$CLEANED_TAGS,gtstef/filebrowser:dev,ghcr.io/gtsteffaniak/filebrowser:dev" 48 | echo "cleaned_tag<> $GITHUB_OUTPUT 49 | echo "$CLEANED_TAGS" >> $GITHUB_OUTPUT 50 | echo "EOF" >> $GITHUB_OUTPUT 51 | - name: Build and push 52 | uses: docker/build-push-action@v6 53 | with: 54 | context: . 55 | build-args: | 56 | VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} 57 | REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }} 58 | platforms: linux/amd64 59 | file: ./_docker/Dockerfile 60 | push: true 61 | tags: ${{ steps.modify-json.outputs.cleaned_tag }} 62 | labels: ${{ steps.meta.outputs.labels }} 63 | -------------------------------------------------------------------------------- /.github/workflows/release_stable.yaml: -------------------------------------------------------------------------------- 1 | name: stable release 2 | 3 | on: 4 | push: 5 | branches: 6 | - "stable/v[0-9]+.[0-9]+.[0-9]+" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | test_playwright: 13 | name: Test Playwright 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v3.0.0 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v3.0.0 22 | - uses: actions/setup-node@v4 23 | - working-directory: frontend 24 | run: npm i && npm run build 25 | - uses: actions/setup-go@v5 26 | with: 27 | go-version: 'stable' 28 | - working-directory: backend 29 | run: go build -o filebrowser . 30 | - name: Build 31 | uses: docker/build-push-action@v6 32 | with: 33 | context: . 34 | file: ./_docker/Dockerfile.playwright-general 35 | push: false 36 | create_release_tag: 37 | needs: [ test_playwright ] 38 | name: Create Release 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Checkout code 42 | uses: actions/checkout@v4 43 | with: 44 | token: ${{ secrets.PAT }} 45 | - name: Extract branch name 46 | shell: bash 47 | run: | 48 | original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}} 49 | echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT 50 | tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-stable 51 | echo "tag_name=$tag_name" >> $GITHUB_OUTPUT 52 | id: extract_branch 53 | - uses: actions/setup-go@v5 54 | with: 55 | go-version: 'stable' 56 | - name: Create Release 57 | uses: softprops/action-gh-release@v2 58 | with: 59 | target_commitish: ${{ github.sha }} 60 | token: ${{ secrets.PAT }} 61 | tag_name: ${{ steps.extract_branch.outputs.tag_name }} 62 | prerelease: false 63 | draft: false 64 | generate_release_notes: true 65 | name: ${{ steps.extract_branch.outputs.tag_name }} 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | *.bak 3 | *.log 4 | *.mjs 5 | _old 6 | rice-box.go 7 | .idea/ 8 | /backend/backend 9 | /backend/filebrowser 10 | /backend/filebrowser.exe 11 | /backend/backend.exe 12 | /backend/tmp 13 | /frontend/dist 14 | /frontend/pkg 15 | /frontend/test-results 16 | /frontend/tests/playwright-files/demo* 17 | /frontend/loginAuth.json 18 | /frontend/package-lock.json 19 | /backend/vendor 20 | /backend/*.cov 21 | /backend/test_config.yaml 22 | /backend/generated.yaml 23 | /backend/srv 24 | /backend/http/dist 25 | /backend/http/embed/* 26 | 27 | .DS_Store 28 | node_modules 29 | 30 | # local env files 31 | .env.local 32 | .env.*.local 33 | 34 | # Log files 35 | npm-debug.log* 36 | yarn-debug.log* 37 | yarn-error.log* 38 | 39 | # Editor directories and files 40 | .idea 41 | .vscode 42 | *.suo 43 | *.ntvs* 44 | *.njsproj 45 | *.sln 46 | *.sw* 47 | bin/ 48 | build/ 49 | *__debug* 50 | -------------------------------------------------------------------------------- /_docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine AS base 2 | ARG VERSION 3 | ARG REVISION 4 | WORKDIR /app 5 | COPY ./backend ./ 6 | #RUN swag init --output swagger/docs 7 | RUN ln -s swagger /usr/local/go/src/ 8 | RUN go build -ldflags="-w -s \ 9 | -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \ 10 | -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \ 11 | -o filebrowser . 12 | 13 | FROM node:slim AS nbuild 14 | WORKDIR /app 15 | COPY ./frontend/package.json ./ 16 | RUN npm i --maxsockets 1 17 | COPY ./frontend/ ./ 18 | RUN npm run build-docker 19 | 20 | FROM alpine:latest 21 | 22 | RUN apk add --no-cache curl xz && \ 23 | ARCH=$(uname -m) && \ 24 | case "$ARCH" in \ 25 | x86_64) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" ;; \ 26 | aarch64) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz" ;; \ 27 | armv7l) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-armhf-static.tar.xz" ;; \ 28 | *) echo "Unsupported architecture: $ARCH" && exit 1 ;; \ 29 | esac && \ 30 | mkdir -p /tmp/ffmpeg && \ 31 | curl -L "$URL" | tar -xJ -C /tmp/ffmpeg && \ 32 | mv /tmp/ffmpeg/ffmpeg-*/ffmpeg /usr/local/bin/ && \ 33 | mv /tmp/ffmpeg/ffmpeg-*/ffprobe /usr/local/bin/ && \ 34 | chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe && \ 35 | rm -rf /tmp/ffmpeg && \ 36 | apk del curl xz && \ 37 | rm -rf /var/cache/apk/* 38 | 39 | ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/" 40 | ENV FILEBROWSER_NO_EMBEDED="true" 41 | ENV PATH="$PATH:/home/filebrowser" 42 | RUN apk --no-cache add ca-certificates mailcap tzdata 43 | RUN adduser -D -s /bin/true -u 1000 filebrowser 44 | USER filebrowser 45 | WORKDIR /home/filebrowser 46 | COPY --from=base --chown=filebrowser:1000 /app/filebrowser* ./ 47 | COPY --from=base --chown=filebrowser:1000 /app/config.media.yaml ./config.yaml 48 | COPY --from=nbuild --chown=filebrowser:1000 /app/dist/ ./http/dist/ 49 | 50 | ## sanity checks 51 | RUN [ "filebrowser", "version" ] 52 | RUN [ "ffmpeg", "-version" ] 53 | RUN [ "ffprobe", "-version" ] 54 | 55 | USER root 56 | # exposing default port for auto discovery. 57 | EXPOSE 80 58 | ENTRYPOINT [ "./filebrowser" ] 59 | -------------------------------------------------------------------------------- /_docker/Dockerfile.playwright-base: -------------------------------------------------------------------------------- 1 | FROM node:22-slim 2 | WORKDIR /app/frontend 3 | RUN npm i @playwright/test 4 | RUN npx playwright install --with-deps firefox 5 | -------------------------------------------------------------------------------- /_docker/Dockerfile.playwright-general: -------------------------------------------------------------------------------- 1 | FROM gtstef/playwright-base 2 | WORKDIR /app 3 | COPY [ "./_docker/src/general/", "./" ] 4 | WORKDIR /app/frontend 5 | COPY [ "./frontend/", "./" ] 6 | WORKDIR /app/backend/ 7 | COPY [ "./backend/filebrowser*", "./"] 8 | RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test 9 | -------------------------------------------------------------------------------- /_docker/Dockerfile.playwright-noauth: -------------------------------------------------------------------------------- 1 | FROM gtstef/playwright-base 2 | WORKDIR /app 3 | COPY [ "./_docker/src/noauth/", "./" ] 4 | WORKDIR /app/frontend 5 | COPY [ "./frontend/", "./" ] 6 | WORKDIR /app/backend/ 7 | COPY [ "./backend/filebrowser", "./"] 8 | RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test 9 | -------------------------------------------------------------------------------- /_docker/Dockerfile.playwright-proxy: -------------------------------------------------------------------------------- 1 | FROM gtstef/playwright-base 2 | WORKDIR /app 3 | COPY [ "./_docker/src/proxy/", "./" ] 4 | WORKDIR /app/frontend 5 | COPY [ "./frontend/", "./" ] 6 | WORKDIR /app/backend/ 7 | COPY [ "./backend/filebrowser", "./"] 8 | RUN apt update && apt install nginx -y 9 | RUN mv default.conf /etc/nginx/conf.d/default.conf 10 | RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf 11 | RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test 12 | -------------------------------------------------------------------------------- /_docker/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | nginx-proxy-auth: 3 | image: nginx 4 | container_name: nginx-proxy-auth 5 | ports: 6 | - "80:80" 7 | volumes: 8 | - ./src/proxy/backend/default.conf:/etc/nginx/conf.d/default.conf 9 | filebrowser: 10 | hostname: filebrowser 11 | volumes: 12 | - '../frontend:/home/frontend' 13 | - "./src/proxy/backend/config.yaml:/home/filebrowser/config.yaml" 14 | build: 15 | context: ../ 16 | dockerfile: ./_docker/Dockerfile 17 | -------------------------------------------------------------------------------- /_docker/src/general/backend/config.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 80 3 | baseURL: "/" 4 | logging: 5 | - levels: "info|error|debug" 6 | sources: 7 | - path: "../frontend/tests/playwright-files" 8 | - path: "." 9 | name: "docker" 10 | frontend: 11 | name: "Graham's Filebrowser" 12 | disableDefaultLinks: true 13 | externalLinks: 14 | - text: "A playwright test" 15 | url: "https://playwright.dev/" 16 | title: "Playwright" -------------------------------------------------------------------------------- /_docker/src/general/frontend/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | globalSetup: "./tests-playwright/global-setup", 14 | timeout: 5000, 15 | testDir: "./tests-playwright/general", 16 | /* Run tests in files in parallel */ 17 | fullyParallel: false, 18 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 19 | forbidOnly: false, 20 | /* Retry on CI only */ 21 | retries: 2, 22 | /* Opt out of parallel tests on CI. */ 23 | workers: 1, // required for now! todo parallel some tests 24 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 25 | reporter: "line", 26 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 27 | use: { 28 | actionTimeout: 5000, 29 | storageState: "loginAuth.json", 30 | /* Base URL to use in actions like `await page.goto('/')`. */ 31 | baseURL: "http://127.0.0.1/", 32 | 33 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 34 | trace: "on-first-retry", 35 | 36 | /* Set default locale to English (US) */ 37 | locale: "en-US", 38 | }, 39 | 40 | /* Configure projects for major browsers */ 41 | projects: [ 42 | { 43 | name: "firefox", 44 | use: { ...devices["Desktop Firefox"] }, 45 | }, 46 | ], 47 | }); 48 | -------------------------------------------------------------------------------- /_docker/src/noauth/backend/config.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 80 3 | baseURL: "/files/" 4 | logging: 5 | - levels: "info|error|debug" 6 | sources: 7 | - path: "../frontend/tests/playwright-files" 8 | auth: 9 | methods: 10 | noauth: true 11 | frontend: 12 | name: "Graham's Filebrowser" 13 | disableDefaultLinks: true 14 | externalLinks: 15 | - text: "A playwright test" 16 | url: "https://playwright.dev/" 17 | title: "Playwright" 18 | userDefaults: 19 | permissions: 20 | realtime: true -------------------------------------------------------------------------------- /_docker/src/noauth/frontend/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | globalSetup: "./tests-playwright/noauth-setup", 14 | timeout: 5000, 15 | testDir: "./tests-playwright/noauth", 16 | /* Run tests in files in parallel */ 17 | fullyParallel: false, 18 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 19 | forbidOnly: false, 20 | /* Retry on CI only */ 21 | retries: 2, 22 | /* Opt out of parallel tests on CI. */ 23 | workers: 1, // required for now! todo parallel some tests 24 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 25 | reporter: "line", 26 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 27 | use: { 28 | storageState: "noauth.json", 29 | actionTimeout: 5000, 30 | //storageState: "loginAuth.json", 31 | /* Base URL to use in actions like `await page.goto('/')`. */ 32 | baseURL: "http://127.0.0.1", 33 | 34 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 35 | trace: "on-first-retry", 36 | 37 | /* Set default locale to English (US) */ 38 | locale: "en-US", 39 | }, 40 | 41 | /* Configure projects for major browsers */ 42 | projects: [ 43 | { 44 | name: "firefox", 45 | use: { ...devices["Desktop Firefox"] }, 46 | }, 47 | ], 48 | }); 49 | -------------------------------------------------------------------------------- /_docker/src/proxy/backend/config.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8080 3 | baseURL: "/" 4 | logging: 5 | - levels: "info|error|debug" 6 | sources: 7 | - path: "../frontend/tests/playwright-files" 8 | config: 9 | defaultEnabled: true 10 | createUserDir: true 11 | 12 | frontend: 13 | name: "Graham's Filebrowser" 14 | disableDefaultLinks: true 15 | externalLinks: 16 | - text: "A playwright test" 17 | url: "https://playwright.dev/" 18 | title: "Playwright" 19 | 20 | auth: 21 | methods: 22 | proxy: 23 | enabled: true 24 | header: "X-Username" 25 | createUser: true 26 | 27 | userDefaults: 28 | darkMode: true 29 | disableSettings: false 30 | singleClick: false 31 | permissions: 32 | admin: false 33 | modify: true 34 | share: false 35 | api: false -------------------------------------------------------------------------------- /_docker/src/proxy/backend/default.conf: -------------------------------------------------------------------------------- 1 | 2 | map $remote_addr $uuid { 3 | default "demo-${remote_addr}"; 4 | } 5 | 6 | server { 7 | listen 80; 8 | server_name localhost 127.0.0.1; 9 | 10 | location / { 11 | proxy_set_header X-Username $uuid; 12 | add_header X-Username $uuid; 13 | proxy_pass http://filebrowser:8080; 14 | proxy_set_header Host $host; 15 | proxy_set_header X-Real-IP $remote_addr; 16 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 17 | proxy_set_header X-Forwarded-Proto $scheme; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /_docker/src/proxy/frontend/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | //globalSetup: "./global-setup", 14 | timeout: 3000, 15 | testDir: "./tests-playwright/proxy", 16 | /* Run tests in files in parallel */ 17 | fullyParallel: false, 18 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 19 | forbidOnly: false, 20 | /* Retry on CI only */ 21 | retries: 2, 22 | /* Opt out of parallel tests on CI. */ 23 | workers: 1, // required for now! todo parallel some tests 24 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 25 | reporter: "line", 26 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 27 | use: { 28 | actionTimeout: 3000, 29 | //storageState: "loginAuth.json", 30 | /* Base URL to use in actions like `await page.goto('/')`. */ 31 | baseURL: "http://127.0.0.1", 32 | 33 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 34 | trace: "on-first-retry", 35 | 36 | /* Set default locale to English (US) */ 37 | locale: "en-US", 38 | }, 39 | 40 | /* Configure projects for major browsers */ 41 | projects: [ 42 | { 43 | name: "firefox", 44 | use: { ...devices["Desktop Firefox"] }, 45 | }, 46 | ], 47 | }); 48 | -------------------------------------------------------------------------------- /backend/.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # .goreleaser.yaml 2 | project_name: filebrowser 3 | version: 2 4 | 5 | builds: 6 | # Build configuration for darwin and linux 7 | - id: default 8 | ldflags: &ldflags 9 | - -s -w -X github.com/gtsteffaniak/filebrowser/backend/common/version.Version={{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA={{ .ShortCommit }} 10 | main: main.go 11 | binary: filebrowser 12 | goos: 13 | - linux 14 | goarch: 15 | - amd64 16 | - arm 17 | - arm64 18 | goarm: 19 | - "6" 20 | - "7" 21 | hooks: 22 | post: 23 | - upx {{ .Path }} # Compress the binary with UPX 24 | 25 | # Build configuration for windows without arm 26 | - id: windows 27 | ldflags: *ldflags 28 | main: main.go 29 | binary: filebrowser 30 | goos: 31 | - windows 32 | goarch: 33 | - amd64 34 | hooks: 35 | post: 36 | - upx {{ .Path }} # Compress the binary with UPX 37 | 38 | # Build configuration for macos without upx 39 | - id: macos 40 | ldflags: *ldflags 41 | main: main.go 42 | binary: filebrowser 43 | goos: 44 | - darwin 45 | goarch: 46 | - amd64 47 | - arm64 48 | 49 | # Build configuration for freebsd without arm & upx 50 | - id: freeBSD 51 | ldflags: *ldflags 52 | main: main.go 53 | binary: filebrowser 54 | goos: 55 | - freebsd 56 | goarch: 57 | - amd64 58 | 59 | archives: 60 | - name_template: > 61 | {{- if eq .Os "windows" -}} 62 | {{.ProjectName}} 63 | {{- else -}} 64 | {{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{.ProjectName}} 65 | {{- end -}} 66 | format: binary 67 | 68 | checksum: 69 | disable: true 70 | 71 | -------------------------------------------------------------------------------- /backend/adapters/fs/diskcache/cache.go: -------------------------------------------------------------------------------- 1 | package diskcache 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type Interface interface { 8 | Store(ctx context.Context, key string, value []byte) error 9 | Load(ctx context.Context, key string) (value []byte, exist bool, err error) 10 | Delete(ctx context.Context, key string) error 11 | } 12 | -------------------------------------------------------------------------------- /backend/adapters/fs/diskcache/file_cache_test.go: -------------------------------------------------------------------------------- 1 | package diskcache 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestFileCache(t *testing.T) { 13 | ctx := context.Background() 14 | const ( 15 | key = "key" 16 | value = "some text" 17 | newValue = "new text" 18 | cacheRoot = "cache" 19 | cachedFilePath = "a/62/a62f2225bf70bfaccbc7f1ef2a397836717377de" 20 | ) 21 | 22 | // Create temporary directory for the cache 23 | cacheDir, err := os.MkdirTemp("", cacheRoot) 24 | require.NoError(t, err) 25 | defer os.RemoveAll(cacheDir) // Clean up 26 | 27 | cache, err := NewFileCache(cacheDir) 28 | require.NoError(t, err) 29 | 30 | // store new key 31 | err = cache.Store(ctx, key, []byte(value)) 32 | require.NoError(t, err) 33 | checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, value) 34 | 35 | // update existing key 36 | err = cache.Store(ctx, key, []byte(newValue)) 37 | require.NoError(t, err) 38 | checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, newValue) 39 | 40 | // delete key 41 | err = cache.Delete(ctx, key) 42 | require.NoError(t, err) 43 | exists := fileExists(filepath.Join(cacheDir, cachedFilePath)) 44 | require.False(t, exists) 45 | } 46 | 47 | func checkValue(t *testing.T, ctx context.Context, cache *FileCache, fileFullPath string, key, wantValue string) { 48 | t.Helper() 49 | // check actual file content 50 | b, err := os.ReadFile(fileFullPath) 51 | require.NoError(t, err) 52 | require.Equal(t, wantValue, string(b)) 53 | 54 | // check cache content 55 | b, ok, err := cache.Load(ctx, key) 56 | require.NoError(t, err) 57 | require.True(t, ok) 58 | require.Equal(t, wantValue, string(b)) 59 | } 60 | 61 | func fileExists(filename string) bool { 62 | info, err := os.Stat(filename) 63 | if os.IsNotExist(err) { 64 | return false 65 | } 66 | return !info.IsDir() 67 | } 68 | -------------------------------------------------------------------------------- /backend/adapters/fs/diskcache/noop_cache.go: -------------------------------------------------------------------------------- 1 | package diskcache 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type NoOp struct { 8 | } 9 | 10 | func NewNoOp() *NoOp { 11 | return &NoOp{} 12 | } 13 | 14 | func (n *NoOp) Store(ctx context.Context, key string, value []byte) error { 15 | return nil 16 | } 17 | 18 | func (n *NoOp) Load(ctx context.Context, key string) (value []byte, exist bool, err error) { 19 | return nil, false, nil 20 | } 21 | 22 | func (n *NoOp) Delete(ctx context.Context, key string) error { 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /backend/adapters/fs/files/user.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/gtsteffaniak/filebrowser/backend/common/settings" 9 | "github.com/gtsteffaniak/filebrowser/backend/database/users" 10 | ) 11 | 12 | // MakeUserDir makes the user directory according to settings. 13 | func MakeUserDir(fullPath string) error { 14 | if err := os.MkdirAll(fullPath, os.ModePerm); err != nil { 15 | return err 16 | } 17 | return nil 18 | } 19 | 20 | func MakeUserDirs(u *users.User, disableScopeChange bool) error { 21 | cleanedUserName := users.CleanUsername(u.Username) 22 | if cleanedUserName == "" || cleanedUserName == "-" || cleanedUserName == "." { 23 | return fmt.Errorf("create user: invalid user for home dir creation: [%s]", u.Username) 24 | } 25 | for i, scope := range u.Scopes { 26 | source, ok := settings.Config.Server.SourceMap[scope.Name] 27 | if !ok { 28 | return fmt.Errorf("create user: source not found: %s", scope.Name) 29 | } 30 | // create directory and append user name 31 | if filepath.Base(scope.Scope) != cleanedUserName && source.Config.CreateUserDir && !disableScopeChange { 32 | fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName) 33 | parentDir := filepath.Join(source.Path, scope.Scope) 34 | // validate that scope path exists 35 | if !Exists(parentDir) { 36 | return fmt.Errorf("create user: scope path does not exist: %s", scope.Scope) 37 | } 38 | scope.Scope = filepath.Join(scope.Scope, cleanedUserName) 39 | err := MakeUserDir(fullPath) 40 | if err != nil { 41 | return fmt.Errorf("create user: failed to create user home dir: %s", err) 42 | } 43 | } else if filepath.Base(scope.Scope) == cleanedUserName && source.Config.CreateUserDir { 44 | // create directory exactly as specified 45 | fullPath := filepath.Join(source.Path, scope.Scope) 46 | parentDir := filepath.Dir(fullPath) 47 | if !Exists(parentDir) { 48 | return fmt.Errorf("create user: scope folder does not exist: %s", parentDir) 49 | } 50 | err := MakeUserDir(fullPath) 51 | if err != nil { 52 | return fmt.Errorf("create user: failed to create user home dir: %s", err) 53 | } 54 | } else { 55 | // just assigning scope to path provided, so just check that it exists 56 | path := filepath.Join(source.Path, scope.Scope) 57 | if !Exists(path) { 58 | return fmt.Errorf("create user: scope folder does not exist: %s", path) 59 | } 60 | } 61 | u.Scopes[i] = scope 62 | } 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /backend/adapters/fs/fileutils/copy.go: -------------------------------------------------------------------------------- 1 | package fileutils 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // Copy copies a file or folder from one place to another. 9 | func CopyHelper(src, dst string) error { 10 | src = filepath.Clean(src) 11 | if src == "" { 12 | return os.ErrNotExist 13 | } 14 | 15 | dst = filepath.Clean(dst) 16 | if dst == "" { 17 | return os.ErrNotExist 18 | } 19 | 20 | if src == "/" || dst == "/" { 21 | // Prohibit copying from or to the root directory. 22 | return os.ErrInvalid 23 | } 24 | 25 | if dst == src { 26 | return os.ErrInvalid 27 | } 28 | 29 | info, err := os.Stat(src) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | if info.IsDir() { 35 | return CopyDir(src, dst) 36 | } 37 | 38 | return CopyFile(src, dst) 39 | } 40 | -------------------------------------------------------------------------------- /backend/adapters/fs/fileutils/dir.go: -------------------------------------------------------------------------------- 1 | package fileutils 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // CopyDir copies a directory from source to dest and all 10 | // of its sub-directories. It doesn't stop if it finds an error 11 | // during the copy. Returns an error if any. 12 | func CopyDir(source, dest string) error { 13 | // Get properties of source. 14 | srcinfo, err := os.Stat(source) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | // Create the destination directory. 20 | err = os.MkdirAll(dest, srcinfo.Mode()) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | dir, err := os.Open(source) 26 | if err != nil { 27 | return err 28 | } 29 | defer dir.Close() 30 | 31 | obs, err := dir.Readdir(-1) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | var errs []error 37 | 38 | for _, obj := range obs { 39 | fsource := filepath.Join(source, obj.Name()) 40 | fdest := filepath.Join(dest, obj.Name()) 41 | 42 | if obj.IsDir() { 43 | // Create sub-directories, recursively. 44 | err = CopyDir(fsource, fdest) 45 | if err != nil { 46 | errs = append(errs, err) 47 | } 48 | } else { 49 | // Perform the file copy. 50 | err = CopyFile(fsource, fdest) 51 | if err != nil { 52 | errs = append(errs, err) 53 | } 54 | } 55 | } 56 | 57 | var errString string 58 | for _, err := range errs { 59 | errString += err.Error() + "\n" 60 | } 61 | 62 | if errString != "" { 63 | return errors.New(errString) 64 | } 65 | 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /backend/adapters/fs/fileutils/file_test.go: -------------------------------------------------------------------------------- 1 | package fileutils 2 | 3 | import "testing" 4 | 5 | func TestCommonPrefix(t *testing.T) { 6 | testCases := map[string]struct { 7 | paths []string 8 | want string 9 | }{ 10 | "same lvl": { 11 | paths: []string{ 12 | "/home/user/file1", 13 | "/home/user/file2", 14 | }, 15 | want: "/home/user", 16 | }, 17 | "sub folder": { 18 | paths: []string{ 19 | "/home/user/folder", 20 | "/home/user/folder/file", 21 | }, 22 | want: "/home/user/folder", 23 | }, 24 | "relative path": { 25 | paths: []string{ 26 | "/home/user/folder", 27 | "/home/user/folder/../folder2", 28 | }, 29 | want: "/home/user", 30 | }, 31 | "no common path": { 32 | paths: []string{ 33 | "/home/user/folder", 34 | "/etc/file", 35 | }, 36 | want: "", 37 | }, 38 | } 39 | for name, tt := range testCases { 40 | t.Run(name, func(t *testing.T) { 41 | if got := CommonPrefix('/', tt.paths...); got != tt.want { 42 | t.Errorf("CommonPrefix() = %v, want %v", got, tt.want) 43 | } 44 | }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /backend/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "net/http" 5 | "sync" 6 | 7 | "github.com/gtsteffaniak/filebrowser/backend/database/users" 8 | ) 9 | 10 | var ( 11 | revokedApiKeyList map[string]bool 12 | revokeMu sync.Mutex 13 | ) 14 | 15 | // Auther is the authentication interface. 16 | type Auther interface { 17 | // Auth is called to authenticate a request. 18 | Auth(r *http.Request, userStore *users.Storage) (*users.User, error) 19 | // LoginPage indicates if this auther needs a login page. 20 | LoginPage() bool 21 | } 22 | 23 | func IsRevokedApiKey(key string) bool { 24 | _, exists := revokedApiKeyList[key] 25 | return exists 26 | } 27 | 28 | func RevokeAPIKey(key string) { 29 | revokeMu.Lock() 30 | delete(revokedApiKeyList, key) 31 | revokeMu.Unlock() 32 | } 33 | -------------------------------------------------------------------------------- /backend/auth/none.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gtsteffaniak/filebrowser/backend/database/users" 7 | ) 8 | 9 | // MethodNoAuth is used to identify no auth. 10 | const MethodNoAuth = "noauth" 11 | 12 | // NoAuth is no auth implementation of auther. 13 | type NoAuth struct{} 14 | 15 | // Auth uses authenticates user 1. 16 | func (a NoAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) { 17 | return usr.Get(uint(1)) 18 | } 19 | 20 | // LoginPage tells that no auth doesn't require a login page. 21 | func (a NoAuth) LoginPage() bool { 22 | return false 23 | } 24 | -------------------------------------------------------------------------------- /backend/auth/proxy.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | 7 | "github.com/gtsteffaniak/filebrowser/backend/common/errors" 8 | "github.com/gtsteffaniak/filebrowser/backend/database/users" 9 | ) 10 | 11 | // MethodProxyAuth is used to identify no auth. 12 | const MethodProxyAuth = "proxy" 13 | 14 | // ProxyAuth is a proxy implementation of an auther. 15 | type ProxyAuth struct { 16 | Header string `json:"header"` 17 | } 18 | 19 | // Auth authenticates the user via an HTTP header. 20 | func (a ProxyAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) { 21 | username := r.Header.Get(a.Header) 22 | user, err := usr.Get(username) 23 | if err == errors.ErrNotExist { 24 | return nil, os.ErrPermission 25 | } 26 | 27 | return user, err 28 | } 29 | 30 | // LoginPage tells that proxy auth doesn't require a login page. 31 | func (a ProxyAuth) LoginPage() bool { 32 | return false 33 | } 34 | -------------------------------------------------------------------------------- /backend/auth/storage.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "encoding/base64" 5 | 6 | "github.com/gtsteffaniak/filebrowser/backend/common/settings" 7 | "github.com/gtsteffaniak/filebrowser/backend/database/users" 8 | "github.com/gtsteffaniak/go-logger/logger" 9 | ) 10 | 11 | // StorageBackend is a storage backend for auth storage. 12 | type StorageBackend interface { 13 | Get(string) (Auther, error) 14 | Save(Auther) error 15 | } 16 | 17 | // Storage is a auth storage. 18 | type Storage struct { 19 | back StorageBackend 20 | users *users.Storage 21 | } 22 | 23 | // NewStorage creates a auth storage from a backend. 24 | func NewStorage(back StorageBackend, userStore *users.Storage) (*Storage, error) { 25 | store := &Storage{back: back, users: userStore} 26 | err := store.Save(&JSONAuth{}) 27 | if err != nil { 28 | return nil, err 29 | } 30 | err = store.Save(&ProxyAuth{}) 31 | if err != nil { 32 | return nil, err 33 | } 34 | err = store.Save(&HookAuth{}) 35 | if err != nil { 36 | return nil, err 37 | } 38 | err = store.Save(&NoAuth{}) 39 | if err != nil { 40 | return nil, err 41 | } 42 | encryptionKey, err = base64.StdEncoding.DecodeString(settings.Config.Auth.TotpSecret) 43 | if err != nil { 44 | logger.Warning("failed to decode TOTP secret, using default key. This is insecure and should be fixed.") 45 | } 46 | return store, nil 47 | } 48 | 49 | // Get wraps a StorageBackend.Get. 50 | func (s *Storage) Get(t string) (Auther, error) { 51 | return s.back.Get(t) 52 | } 53 | 54 | // Save wraps a StorageBackend.Save. 55 | func (s *Storage) Save(a Auther) error { 56 | return s.back.Save(a) 57 | } 58 | -------------------------------------------------------------------------------- /backend/cmd/office.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gtsteffaniak/filebrowser/backend/common/settings" 7 | "github.com/gtsteffaniak/go-logger/logger" 8 | ) 9 | 10 | // healthcheck attempt to save test file against configured url 11 | func validateOfficeIntegration() { 12 | if settings.Config.Integrations.OnlyOffice.Url != "" { 13 | // get url health 14 | // get request against the url 15 | _, err := http.NewRequest("GET", settings.Config.Integrations.OnlyOffice.Url, nil) 16 | if err != nil { 17 | logger.Warning("Could not create request to only office Url, make sure its valid and running") 18 | return 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/common/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrEmptyKey = errors.New("empty key") 7 | ErrExist = errors.New("the resource already exists") 8 | ErrNotExist = errors.New("the resource does not exist") 9 | ErrEmptyPassword = errors.New("password is empty") 10 | ErrEmptyUsername = errors.New("username is empty") 11 | ErrEmptyRequest = errors.New("empty request") 12 | ErrScopeIsRelative = errors.New("scope is a relative path") 13 | ErrInvalidDataType = errors.New("invalid data type") 14 | ErrIsDirectory = errors.New("file is directory") 15 | ErrInvalidOption = errors.New("invalid option") 16 | ErrInvalidAuthMethod = errors.New("invalid auth method") 17 | ErrPermissionDenied = errors.New("permission denied") 18 | ErrInvalidRequestParams = errors.New("invalid request params") 19 | ErrSourceIsParent = errors.New("source is parent") 20 | ErrRootUserDeletion = errors.New("user with id 1 can't be deleted") 21 | ErrNoTotpProvided = errors.New("OTP code is required for user") 22 | ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not yet configured") 23 | ErrUnauthorized = errors.New("user unauthorized") 24 | ) 25 | -------------------------------------------------------------------------------- /backend/common/settings/config_test.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | func TestInitialize(t *testing.T) { 12 | type args struct { 13 | configFile string 14 | } 15 | tests := []struct { 16 | name string 17 | args args 18 | }{ 19 | // TODO: Add test cases. 20 | } 21 | for _, tt := range tests { 22 | t.Run(tt.name, func(t *testing.T) { 23 | Initialize(tt.args.configFile) 24 | }) 25 | } 26 | } 27 | 28 | func Test_setDefaults(t *testing.T) { 29 | tests := []struct { 30 | name string 31 | want Settings 32 | }{ 33 | // TODO: Add test cases. 34 | } 35 | for _, tt := range tests { 36 | t.Run(tt.name, func(t *testing.T) { 37 | if got := setDefaults(); !reflect.DeepEqual(got, tt.want) { 38 | t.Errorf("setDefaults() = %v, want %v", got, tt.want) 39 | } 40 | }) 41 | } 42 | } 43 | 44 | func TestConfigLoadChanged(t *testing.T) { 45 | defaultConfig := setDefaults() 46 | err := loadConfigWithDefaults("./validConfig.yaml") 47 | if err != nil { 48 | t.Fatalf("error loading config file: %v", err) 49 | } 50 | // Use go-cmp to compare the two structs 51 | if diff := cmp.Diff(defaultConfig, Config); diff == "" { 52 | t.Errorf("No change when there should have been (-want +got):\n%s", diff) 53 | } 54 | } 55 | 56 | func TestConfigLoadEnvVars(t *testing.T) { 57 | defaultConfig := setDefaults() 58 | expectedKey := "MYKEY" 59 | // mock environment variables 60 | os.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", expectedKey) 61 | err := loadConfigWithDefaults("./validConfig.yaml") 62 | if err != nil { 63 | t.Fatalf("error loading config file: %v", err) 64 | } 65 | if Config.Integrations.OnlyOffice.Secret != expectedKey { 66 | t.Errorf("Expected OnlyOffice.Secret to be '%v', got '%s'", expectedKey, Config.Integrations.OnlyOffice.Secret) 67 | } 68 | // Use go-cmp to compare the two structs 69 | if diff := cmp.Diff(defaultConfig, Config); diff == "" { 70 | t.Errorf("No change when there should have been (-want +got):\n%s", diff) 71 | } 72 | } 73 | 74 | func TestConfigLoadSpecificValues(t *testing.T) { 75 | defaultConfig := setDefaults() 76 | err := loadConfigWithDefaults("./validConfig.yaml") 77 | if err != nil { 78 | t.Fatalf("error loading config file: %v", err) 79 | } 80 | testCases := []struct { 81 | fieldName string 82 | globalVal interface{} 83 | newVal interface{} 84 | }{ 85 | {"Server.Database", Config.Server.Database, defaultConfig.Server.Database}, 86 | } 87 | 88 | for _, tc := range testCases { 89 | if tc.globalVal == tc.newVal { 90 | t.Errorf("Differences should have been found:\nConfig.%s: %v \nSetConfig: %v \n", tc.fieldName, tc.globalVal, tc.newVal) 91 | } 92 | } 93 | } 94 | 95 | func TestInvalidConfig(t *testing.T) { 96 | configFile := "./invalidConfig.yaml" 97 | err := loadConfigWithDefaults(configFile) 98 | if err == nil { 99 | t.Fatalf("expected error loading config file %s, got nil", configFile) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /backend/common/settings/invalidConfig.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | indexingInterval: 5 3 | numImageProcessors: 4 4 | socket: "" 5 | tlsKey: "" 6 | tlsCert: "" 7 | enableThumbnails: false 8 | resizePreview: true 9 | port: 80 10 | baseURL: "/" 11 | database: "mydb.db" 12 | root: "." 13 | auth: 14 | recaptcha: 15 | host: "" 16 | key: "" 17 | secret: "" 18 | header: "" 19 | command: "" 20 | signup: false 21 | shell: "" 22 | frontend: 23 | name: "" 24 | disableExternal: true 25 | disableUsedPercentage: true 26 | files: "" 27 | color: "" 28 | userDefaults: 29 | scope: "" 30 | locale: "" 31 | viewMode: "" 32 | darkMode: true 33 | disableSettings: false 34 | singleClick: true 35 | sorting: 36 | by: "" 37 | asc: true 38 | permissions: 39 | admin: true 40 | execute: true 41 | create: true 42 | rename: true 43 | modify: true 44 | delete: true 45 | share: true 46 | download: true 47 | dateFormat: false 48 | -------------------------------------------------------------------------------- /backend/common/settings/settings.go: -------------------------------------------------------------------------------- 1 | //go:generate go run ./tools/yaml.go -input=common/settings/settings.go -output=config.generated.yaml 2 | package settings 3 | 4 | import ( 5 | "crypto/rand" 6 | 7 | "github.com/gtsteffaniak/filebrowser/backend/database/users" 8 | ) 9 | 10 | const DefaultUsersHomeBasePath = "/users" 11 | 12 | // AuthMethod describes an authentication method. 13 | type AuthMethod string 14 | 15 | // GenerateKey generates a key of 512 bits. 16 | func GenerateKey() ([]byte, error) { 17 | b := make([]byte, 64) //nolint:gomnd 18 | _, err := rand.Read(b) 19 | // Note that err == nil only if we read len(b) bytes. 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | return b, nil 25 | } 26 | 27 | func GetSettingsConfig(nameType string, Value string) string { 28 | return nameType + Value 29 | } 30 | 31 | func AdminPerms() users.Permissions { 32 | return users.Permissions{ 33 | Modify: true, 34 | Share: true, 35 | Admin: true, 36 | Api: true, 37 | } 38 | } 39 | 40 | // Apply applies the default options to a user. 41 | func ApplyUserDefaults(u *users.User) { 42 | u.StickySidebar = Config.UserDefaults.StickySidebar 43 | u.DisableSettings = Config.UserDefaults.DisableSettings 44 | u.DarkMode = Config.UserDefaults.DarkMode 45 | u.Locale = Config.UserDefaults.Locale 46 | u.ViewMode = Config.UserDefaults.ViewMode 47 | u.SingleClick = Config.UserDefaults.SingleClick 48 | u.Permissions = Config.UserDefaults.Permissions 49 | u.Preview = Config.UserDefaults.Preview 50 | u.ShowHidden = Config.UserDefaults.ShowHidden 51 | u.DateFormat = Config.UserDefaults.DateFormat 52 | u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt 53 | u.ThemeColor = Config.UserDefaults.ThemeColor 54 | u.GallerySize = Config.UserDefaults.GallerySize 55 | u.QuickDownload = Config.UserDefaults.QuickDownload 56 | u.LockPassword = Config.UserDefaults.LockPassword 57 | if len(u.Scopes) == 0 { 58 | for _, source := range Config.Server.Sources { 59 | if source.Config.DefaultEnabled { 60 | u.Scopes = append(u.Scopes, users.SourceScope{ 61 | Name: source.Path, // backend name is path 62 | Scope: source.Config.DefaultUserScope, 63 | }) 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /backend/common/settings/storage.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "github.com/gtsteffaniak/filebrowser/backend/common/errors" 5 | ) 6 | 7 | // StorageBackend is a settings storage backend. 8 | type StorageBackend interface { 9 | Get() (*Settings, error) 10 | Save(*Settings) error 11 | GetServer() (*Server, error) 12 | SaveServer(*Server) error 13 | } 14 | 15 | // Storage is a settings storage. 16 | type Storage struct { 17 | back StorageBackend 18 | } 19 | 20 | // NewStorage creates a settings storage from a backend. 21 | func NewStorage(back StorageBackend) *Storage { 22 | return &Storage{back: back} 23 | } 24 | 25 | // Get returns the settings for the current instance. 26 | func (s *Storage) Get() (*Settings, error) { 27 | set, err := s.back.Get() 28 | if err != nil { 29 | return nil, err 30 | } 31 | return set, nil 32 | } 33 | 34 | // Save saves the settings for the current instance. 35 | func (s *Storage) Save(set *Settings) error { 36 | if len(set.Auth.Key) == 0 { 37 | return errors.ErrEmptyKey 38 | } 39 | 40 | if set.UserDefaults.Locale == "" { 41 | set.UserDefaults.Locale = "en" 42 | } 43 | 44 | if set.UserDefaults.ViewMode == "" { 45 | set.UserDefaults.ViewMode = "normal" 46 | } 47 | 48 | err := s.back.Save(set) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | return nil 54 | } 55 | 56 | // GetServer wraps StorageBackend.GetServer. 57 | func (s *Storage) GetServer() (*Server, error) { 58 | return s.back.GetServer() 59 | } 60 | 61 | // SaveServer wraps StorageBackend.SaveServer and adds some verification. 62 | func (s *Storage) SaveServer(ser *Server) error { 63 | return s.back.SaveServer(ser) 64 | } 65 | -------------------------------------------------------------------------------- /backend/common/settings/validConfig.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | numImageProcessors: 4 3 | socket: "" 4 | tlsKey: "" 5 | tlsCert: "" 6 | disablePreviews: false 7 | disablePreviewResize: false 8 | port: 80 9 | baseURL: "/" 10 | database: "mydb.db" 11 | sources: 12 | - path: "." 13 | integrations: 14 | office: 15 | url: "http://localhost:9052" 16 | secret: 6H1H7GzUk2alRkNxAPav0W02g8JS5oUd 17 | auth: 18 | methods: 19 | password: 20 | enabled: true 21 | frontend: 22 | name: "" 23 | disableUsedPercentage: true 24 | userDefaults: 25 | locale: "" 26 | viewMode: "" 27 | darkMode: true 28 | disableSettings: false 29 | singleClick: true 30 | permissions: 31 | admin: true 32 | modify: true 33 | share: true 34 | api: true 35 | dateFormat: false 36 | -------------------------------------------------------------------------------- /backend/common/utils/main_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGetParentDirectoryPath(t *testing.T) { 8 | tests := []struct { 9 | input string 10 | expectedOutput string 11 | }{ 12 | {input: "/", expectedOutput: ""}, // Root directory 13 | {input: "/subfolder", expectedOutput: "/"}, // Single subfolder 14 | {input: "/sub/sub/", expectedOutput: "/sub"}, // Nested subfolder with trailing slash 15 | {input: "/subfolder/", expectedOutput: "/"}, // Relative path with trailing slash 16 | {input: "", expectedOutput: ""}, // Empty string treated as root 17 | {input: "/sub/subfolder", expectedOutput: "/sub"}, // Double slash in path 18 | {input: "/sub/subfolder/deep/nested/", expectedOutput: "/sub/subfolder/deep"}, // Double slash in path 19 | } 20 | 21 | for _, test := range tests { 22 | t.Run(test.input, func(t *testing.T) { 23 | actualOutput := GetParentDirectoryPath(test.input) 24 | if actualOutput != test.expectedOutput { 25 | t.Errorf("\n\tinput %q\n\texpected %q\n\tgot %q", 26 | test.input, test.expectedOutput, actualOutput) 27 | } 28 | }) 29 | } 30 | } 31 | 32 | func TestCapitalizeFirst(t *testing.T) { 33 | tests := []struct { 34 | input string 35 | expectedOutput string 36 | }{ 37 | {input: "", expectedOutput: ""}, // Empty string 38 | {input: "a", expectedOutput: "A"}, // Single lowercase letter 39 | {input: "A", expectedOutput: "A"}, // Single uppercase letter 40 | {input: "hello", expectedOutput: "Hello"}, // All lowercase 41 | {input: "Hello", expectedOutput: "Hello"}, // Already capitalized 42 | {input: "123hello", expectedOutput: "123hello"}, // Non-alphabetic first character 43 | {input: "hELLO", expectedOutput: "HELLO"}, // Mixed case 44 | {input: " hello", expectedOutput: " hello"}, // Leading space, no capitalization 45 | {input: "hello world", expectedOutput: "Hello world"}, // Phrase with spaces 46 | {input: " hello world", expectedOutput: " hello world"}, // Phrase with leading space 47 | {input: "123 hello world", expectedOutput: "123 hello world"}, // Numbers before text 48 | } 49 | 50 | for _, test := range tests { 51 | t.Run(test.input, func(t *testing.T) { 52 | actualOutput := CapitalizeFirst(test.input) 53 | if actualOutput != test.expectedOutput { 54 | t.Errorf("\n\tinput %q\n\texpected %q\n\tgot %q", 55 | test.input, test.expectedOutput, actualOutput) 56 | } 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /backend/common/utils/mocks.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "time" 5 | 6 | "math/rand" 7 | 8 | "github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo" 9 | ) 10 | 11 | func CreateMockData(numDirs, numFilesPerDir int) iteminfo.FileInfo { 12 | dir := iteminfo.FileInfo{} 13 | dir.Path = "/here/is/your/mock/dir" 14 | for i := 0; i < numDirs; i++ { 15 | newFile := iteminfo.ItemInfo{ 16 | Name: "file-" + GetRandomTerm() + GetRandomExtension(), 17 | Size: rand.Int63n(1000), // Random size 18 | ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time 19 | Type: "blob", 20 | } 21 | dir.Folders = append(dir.Folders, newFile) 22 | } 23 | // Simulating files and directories with FileInfo 24 | for j := 0; j < numFilesPerDir; j++ { 25 | newFile := iteminfo.ItemInfo{ 26 | Name: "file-" + GetRandomTerm() + GetRandomExtension(), 27 | Size: rand.Int63n(1000), // Random size 28 | ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time 29 | Type: "blob", 30 | } 31 | dir.Files = append(dir.Files, newFile) 32 | } 33 | return dir 34 | } 35 | 36 | func GenerateRandomPath(levels int) string { 37 | rand.New(rand.NewSource(time.Now().UnixNano())) 38 | dirName := "srv" 39 | for i := 0; i < levels; i++ { 40 | dirName += "/" + GetRandomTerm() 41 | } 42 | return dirName 43 | } 44 | 45 | func GetRandomTerm() string { 46 | wordbank := []string{ 47 | "hi", "test", "other", "name", 48 | "cool", "things", "more", "items", 49 | } 50 | rand.New(rand.NewSource(time.Now().UnixNano())) 51 | 52 | index := rand.Intn(len(wordbank)) 53 | return wordbank[index] 54 | } 55 | 56 | func GetRandomExtension() string { 57 | wordbank := []string{ 58 | ".txt", ".mp3", ".mov", ".doc", 59 | ".mp4", ".bak", ".zip", ".jpg", 60 | } 61 | rand.New(rand.NewSource(time.Now().UnixNano())) 62 | index := rand.Intn(len(wordbank)) 63 | return wordbank[index] 64 | } 65 | 66 | func GenerateRandomSearchTerms(numTerms int) []string { 67 | // Generate random search terms 68 | searchTerms := make([]string, numTerms) 69 | for i := 0; i < numTerms; i++ { 70 | searchTerms[i] = GetRandomTerm() 71 | } 72 | return searchTerms 73 | } 74 | -------------------------------------------------------------------------------- /backend/common/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | var ( 4 | // Dynamically updated during build via build args 5 | Version = "untracked" 6 | CommitSHA = "untracked" 7 | ) 8 | -------------------------------------------------------------------------------- /backend/config.media.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | logging: 3 | - levels: "info|warning|error" 4 | sources: 5 | - path: "/srv" 6 | userDefaults: 7 | preview: 8 | image: true 9 | popup: true 10 | video: true 11 | highQuality: true -------------------------------------------------------------------------------- /backend/config.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 80 3 | baseURL: "/" 4 | logging: 5 | - levels: "info|warning|error" 6 | sources: 7 | - path: "/srv" 8 | userDefaults: 9 | preview: 10 | image: true 11 | popup: true 12 | video: false 13 | office: false 14 | highQuality: false 15 | darkMode: true 16 | disableSettings: false 17 | singleClick: false 18 | permissions: 19 | admin: false 20 | modify: false 21 | share: false 22 | api: false 23 | -------------------------------------------------------------------------------- /backend/database/share/share.go: -------------------------------------------------------------------------------- 1 | package share 2 | 3 | type CreateBody struct { 4 | Password string `json:"password"` 5 | Expires string `json:"expires"` 6 | Unit string `json:"unit"` 7 | } 8 | 9 | // Link is the information needed to build a shareable link. 10 | type Link struct { 11 | Hash string `json:"hash" storm:"id,index"` 12 | Path string `json:"path" storm:"index"` 13 | Source string `json:"source" storm:"index"` 14 | UserID uint `json:"userID"` 15 | Expire int64 `json:"expire"` 16 | PasswordHash string `json:"password_hash,omitempty"` 17 | // Token is a random value that will only be set when PasswordHash is set. It is 18 | // URL-Safe and is used to download links in password-protected shares via a 19 | // query arg. 20 | Token string `json:"token,omitempty"` 21 | } 22 | -------------------------------------------------------------------------------- /backend/database/storage/bolt/auth.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | storm "github.com/asdine/storm/v3" 5 | "github.com/gtsteffaniak/filebrowser/backend/auth" 6 | "github.com/gtsteffaniak/filebrowser/backend/common/errors" 7 | ) 8 | 9 | type authBackend struct { 10 | db *storm.DB 11 | } 12 | 13 | func (s authBackend) Get(t string) (auth.Auther, error) { 14 | var auther auth.Auther 15 | switch t { 16 | case "password": 17 | auther = &auth.JSONAuth{} 18 | case "proxy": 19 | auther = &auth.ProxyAuth{} 20 | case "hook": 21 | auther = &auth.HookAuth{} 22 | case "noauth": 23 | auther = &auth.NoAuth{} 24 | default: 25 | return nil, errors.ErrInvalidAuthMethod 26 | } 27 | return auther, get(s.db, "auther", auther) 28 | } 29 | 30 | func (s authBackend) Save(a auth.Auther) error { 31 | return Save(s.db, "auther", a) 32 | } 33 | -------------------------------------------------------------------------------- /backend/database/storage/bolt/bolt.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | storm "github.com/asdine/storm/v3" 5 | 6 | "github.com/gtsteffaniak/filebrowser/backend/auth" 7 | "github.com/gtsteffaniak/filebrowser/backend/common/settings" 8 | "github.com/gtsteffaniak/filebrowser/backend/database/share" 9 | "github.com/gtsteffaniak/filebrowser/backend/database/users" 10 | ) 11 | 12 | // NewStorage creates a storage.Storage based on Bolt DB. 13 | func NewStorage(db *storm.DB) (*auth.Storage, *users.Storage, *share.Storage, *settings.Storage, error) { 14 | userStore := users.NewStorage(usersBackend{db: db}) 15 | shareStore := share.NewStorage(shareBackend{db: db}) 16 | settingsStore := settings.NewStorage(settingsBackend{db: db}) 17 | authStore, err := auth.NewStorage(authBackend{db: db}, userStore) 18 | if err != nil { 19 | return nil, nil, nil, nil, err 20 | } 21 | return authStore, userStore, shareStore, settingsStore, nil 22 | } 23 | -------------------------------------------------------------------------------- /backend/database/storage/bolt/config.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | storm "github.com/asdine/storm/v3" 5 | "github.com/gtsteffaniak/filebrowser/backend/common/settings" 6 | ) 7 | 8 | type settingsBackend struct { 9 | db *storm.DB 10 | } 11 | 12 | func (s settingsBackend) Get() (*settings.Settings, error) { 13 | set := &settings.Settings{} 14 | return set, get(s.db, "settings", set) 15 | } 16 | 17 | func (s settingsBackend) Save(set *settings.Settings) error { 18 | return Save(s.db, "settings", set) 19 | } 20 | 21 | func (s settingsBackend) GetServer() (*settings.Server, error) { 22 | server := &settings.Server{ 23 | Port: 80, 24 | NumImageProcessors: 1, 25 | } 26 | return server, get(s.db, "server", server) 27 | } 28 | 29 | func (s settingsBackend) SaveServer(server *settings.Server) error { 30 | return Save(s.db, "server", server) 31 | } 32 | -------------------------------------------------------------------------------- /backend/database/storage/bolt/share.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | "time" 5 | 6 | storm "github.com/asdine/storm/v3" 7 | "github.com/asdine/storm/v3/q" 8 | 9 | "github.com/gtsteffaniak/filebrowser/backend/common/errors" 10 | "github.com/gtsteffaniak/filebrowser/backend/database/share" 11 | "github.com/gtsteffaniak/go-logger/logger" 12 | ) 13 | 14 | type shareBackend struct { 15 | db *storm.DB 16 | } 17 | 18 | func (s shareBackend) All() ([]*share.Link, error) { 19 | var v []*share.Link 20 | err := s.db.All(&v) 21 | if err == storm.ErrNotFound { 22 | return v, errors.ErrNotExist 23 | } 24 | 25 | return v, err 26 | } 27 | 28 | func (s shareBackend) FindByUserID(id uint) ([]*share.Link, error) { 29 | var v []*share.Link 30 | err := s.db.Select(q.Eq("UserID", id)).Find(&v) 31 | if err == storm.ErrNotFound { 32 | return v, errors.ErrNotExist 33 | } 34 | 35 | return v, err 36 | } 37 | 38 | func (s shareBackend) GetByHash(hash string) (*share.Link, error) { 39 | var v share.Link 40 | err := s.db.One("Hash", hash, &v) 41 | if err == storm.ErrNotFound { 42 | return nil, errors.ErrNotExist 43 | } 44 | 45 | return &v, err 46 | } 47 | 48 | func (s shareBackend) GetPermanent(path, source string, id uint) (*share.Link, error) { 49 | var v share.Link 50 | // TODO remove legacy and return notfound errors 51 | _ = s.db.Select(q.Eq("Path", path), q.Eq("Source", source), q.Eq("Expire", 0), q.Eq("UserID", id)).First(&v) 52 | return &v, nil 53 | } 54 | 55 | func (s shareBackend) Gets(path, sourcePath string, id uint) ([]*share.Link, error) { 56 | var v []*share.Link 57 | _ = s.db.Select(q.Eq("Path", path), q.Eq("Source", sourcePath), q.Eq("UserID", id)).Find(&v) 58 | filteredList := []*share.Link{} 59 | var err error 60 | // through and filter out expired share 61 | for i := range v { 62 | if v[i].Expire < time.Now().Unix() && v[i].Expire != 0 { 63 | err = s.Delete(v[i].PasswordHash) 64 | if err != nil { 65 | logger.Errorf("expired share could not be deleted: %v", err.Error()) 66 | } 67 | } else { 68 | filteredList = append(filteredList, v[i]) 69 | } 70 | } 71 | return filteredList, err 72 | } 73 | 74 | func (s shareBackend) Save(l *share.Link) error { 75 | return s.db.Save(l) 76 | } 77 | 78 | func (s shareBackend) Delete(hash string) error { 79 | err := s.db.DeleteStruct(&share.Link{Hash: hash}) 80 | if err == storm.ErrNotFound { 81 | return nil 82 | } 83 | return err 84 | } 85 | -------------------------------------------------------------------------------- /backend/database/storage/bolt/utils.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | storm "github.com/asdine/storm/v3" 5 | 6 | "github.com/gtsteffaniak/filebrowser/backend/common/errors" 7 | ) 8 | 9 | func get(db *storm.DB, name string, to interface{}) error { 10 | err := db.Get("config", name, to) 11 | if err == storm.ErrNotFound { 12 | return errors.ErrNotExist 13 | } 14 | 15 | return err 16 | } 17 | 18 | func Save(db *storm.DB, name string, from interface{}) error { 19 | return db.Set("config", name, from) 20 | } 21 | -------------------------------------------------------------------------------- /backend/database/users/password.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "golang.org/x/crypto/bcrypt" 5 | ) 6 | 7 | // HashPwd hashes a password. 8 | func HashPwd(password string) (string, error) { 9 | bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 10 | return string(bytes), err 11 | } 12 | 13 | // CheckPwd checks if a password is correct. 14 | func CheckPwd(password, hash string) error { 15 | return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 16 | } 17 | -------------------------------------------------------------------------------- /backend/database/users/storage_test.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | // Interface is implemented by storage 4 | var _ Store = &Storage{} 5 | -------------------------------------------------------------------------------- /backend/http/embed/.gitignore: -------------------------------------------------------------------------------- 1 | * -------------------------------------------------------------------------------- /backend/http/httpEvents.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/gtsteffaniak/filebrowser/backend/common/settings" 9 | "github.com/gtsteffaniak/filebrowser/backend/events" 10 | "github.com/gtsteffaniak/go-logger/logger" 11 | ) 12 | 13 | type messenger struct { 14 | flusher http.Flusher 15 | writer io.Writer 16 | } 17 | 18 | // expects message with double quotes around string 19 | func (msgr messenger) sendEvent(eventType, message string) error { 20 | _, err := fmt.Fprintf(msgr.writer, "data: {\"eventType\":\"%s\",\"message\":%s}\n\n", eventType, message) 21 | if err != nil { 22 | return err 23 | } 24 | msgr.flusher.Flush() 25 | return nil 26 | } 27 | 28 | func sseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) { 29 | if !d.user.Permissions.Realtime { 30 | return http.StatusForbidden, fmt.Errorf("realtime is disabled for this user") 31 | } 32 | 33 | w.Header().Set("Content-Type", "text/event-stream") 34 | w.Header().Set("Cache-Control", "no-cache") 35 | w.Header().Set("Connection", "keep-alive") 36 | w.Header().Set("Access-Control-Allow-Origin", "*") 37 | 38 | sessionId := r.URL.Query().Get("sessionId") 39 | username := d.user.Username 40 | 41 | f, ok := w.(http.Flusher) 42 | if !ok { 43 | logger.Debugf("error: ResponseWriter does not support Flusher. User: %s, SessionId: %s", username, sessionId) 44 | return http.StatusInternalServerError, fmt.Errorf("streaming not supported") 45 | } 46 | 47 | msgr := messenger{flusher: f, writer: w} 48 | clientGone := r.Context().Done() 49 | 50 | // Initial ack 51 | if err := msgr.sendEvent("acknowledge", "\"connection established\""); err != nil { 52 | return http.StatusInternalServerError, fmt.Errorf("error sending message: %v, user: %s, SessionId: %s", err, username, sessionId) 53 | } 54 | 55 | // Register this client with the events system 56 | sendChan := events.Register(username, settings.GetSources(d.user)) 57 | defer events.Unregister(username, sendChan) 58 | 59 | for { 60 | select { 61 | case <-d.ctx.Done(): 62 | _ = msgr.sendEvent("notification", "\"the server is shutting down\"") 63 | return http.StatusOK, nil 64 | 65 | case <-clientGone: 66 | logger.Debugf("client disconnected. user: %s, SessionId: %s", username, sessionId) 67 | return http.StatusOK, nil 68 | 69 | case msg := <-events.BroadcastChan: 70 | if err := msgr.sendEvent(msg.EventType, msg.Message); err != nil { 71 | return http.StatusInternalServerError, fmt.Errorf("error sending broadcast: %v, user: %s", err, username) 72 | } 73 | 74 | case msg := <-sendChan: 75 | if err := msgr.sendEvent(msg.EventType, msg.Message); err != nil { 76 | return http.StatusInternalServerError, fmt.Errorf("error sending targeted message: %v, user: %s", err, username) 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /backend/http/httpJobs.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gtsteffaniak/filebrowser/backend/common/settings" 7 | "github.com/gtsteffaniak/filebrowser/backend/indexing" 8 | "github.com/gtsteffaniak/go-logger/logger" 9 | ) 10 | 11 | func getJobsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) { 12 | sources := settings.GetSources(d.user) 13 | reducedIndexes := map[string]indexing.ReducedIndex{} 14 | for _, source := range sources { 15 | reducedIndex, err := indexing.GetIndexInfo(source) 16 | if err != nil { 17 | logger.Debugf("error getting index info: %v", err) 18 | continue 19 | } 20 | reducedIndexes[source] = reducedIndex 21 | } 22 | return renderJSON(w, r, reducedIndexes) 23 | } 24 | -------------------------------------------------------------------------------- /backend/http/settings.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // settingsGetHandler retrieves the current system settings. 8 | // @Summary Get system settings 9 | // @Description Returns the current configuration settings for signup, user directories, rules, frontend. 10 | // @Tags Settings 11 | // @Accept json 12 | // @Produce json 13 | // @Param property query string false "Property to retrieve: `userDefaults`, `frontend`, `auth`, `server`, `sources`" 14 | // @Success 200 {object} settings.Settings "System settings data" 15 | // @Router /api/settings [get] 16 | func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) { 17 | property := r.URL.Query().Get("property") 18 | if property != "" { 19 | // get property by name 20 | switch property { 21 | case "userDefaults": 22 | return renderJSON(w, r, config.UserDefaults) 23 | case "frontend": 24 | return renderJSON(w, r, config.Frontend) 25 | case "auth": 26 | return renderJSON(w, r, config.Auth) 27 | case "server": 28 | return renderJSON(w, r, config.Server) 29 | case "sources": 30 | return renderJSON(w, r, config.Server.Sources) 31 | default: 32 | return http.StatusNotFound, nil 33 | } 34 | } 35 | return renderJSON(w, r, config) 36 | } 37 | -------------------------------------------------------------------------------- /backend/http/swagger.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | httpSwagger "github.com/swaggo/http-swagger" 7 | ) 8 | 9 | func swaggerHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) { 10 | if !d.user.Permissions.Api { 11 | return http.StatusForbidden, nil 12 | } 13 | httpSwagger.Handler( 14 | httpSwagger.URL(config.Server.BaseURL+"swagger/doc.json"), 15 | httpSwagger.DeepLinking(true), 16 | httpSwagger.DocExpansion("none"), 17 | httpSwagger.DomID("swagger-ui"), 18 | ).ServeHTTP(w, r) 19 | return http.StatusOK, nil 20 | } 21 | -------------------------------------------------------------------------------- /backend/http/totp.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gtsteffaniak/filebrowser/backend/auth" 8 | "github.com/gtsteffaniak/go-logger/logger" 9 | ) 10 | 11 | // generateOTPHandler handles the generation of a new TOTP secret and QR code. 12 | // @Summary Generate OTP 13 | // @Description Generates a new TOTP secret and QR code for the authenticated user. 14 | // @Tags OTP 15 | // @Accept json 16 | // @Produce json 17 | // @Security ApiKeyAuth 18 | // @Success 200 {object} map[string]string "OTP secret generated successfully." 19 | // @Failure 500 {object} map[string]string "Internal server error" 20 | // @Router /api/auth/otp/generate [post] 21 | func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) { 22 | logger.Debug("Generating OTP for user:", d.user.Username) 23 | url, err := auth.GenerateOtpForUser(d.user, store.Users) 24 | if err != nil { 25 | return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err) 26 | } 27 | response := map[string]string{ 28 | "message": "OTP secret generated successfully.", 29 | "url": url, // The otpauth:// URL for QR code generation 30 | } 31 | return renderJSON(w, r, response) 32 | } 33 | 34 | // verifyOTPHandler handles the verification of a TOTP code. 35 | // @Summary Verify OTP 36 | // @Description Verifies the provided TOTP code for the authenticated user. 37 | // @Tags OTP 38 | // @Accept json 39 | // @Produce json 40 | // @Security ApiKeyAuth 41 | // @Param code query string true "TOTP code to verify" 42 | // @Success 200 {object} HttpResponse "OTP token is valid." 43 | // @Failure 401 {object} map[string]string "Unauthorized - invalid TOTP token" 44 | // @Router /api/auth/otp/verify [post] 45 | func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) { 46 | code := r.URL.Query().Get("code") 47 | if code == "" { 48 | return http.StatusUnauthorized, fmt.Errorf("code is required") 49 | } 50 | err := auth.VerifyTotpCode(d.user, code, store.Users) 51 | if err != nil { 52 | return http.StatusUnauthorized, fmt.Errorf("invalid OTP token") 53 | } 54 | response := HttpResponse{ 55 | Status: http.StatusOK, 56 | Message: "OTP token is valid.", 57 | } 58 | // On success, return a simple confirmation. 59 | return renderJSON(w, r, response) 60 | } 61 | -------------------------------------------------------------------------------- /backend/http/utils.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "os" 7 | 8 | libErrors "github.com/gtsteffaniak/filebrowser/backend/common/errors" 9 | ) 10 | 11 | func errToStatus(err error) int { 12 | switch { 13 | case err == nil: 14 | return http.StatusOK 15 | case os.IsPermission(err): 16 | return http.StatusForbidden 17 | case os.IsNotExist(err), err == libErrors.ErrNotExist: 18 | return http.StatusNotFound 19 | case os.IsExist(err), err == libErrors.ErrExist: 20 | return http.StatusConflict 21 | case errors.Is(err, libErrors.ErrPermissionDenied): 22 | return http.StatusForbidden 23 | case errors.Is(err, libErrors.ErrInvalidRequestParams): 24 | return http.StatusBadRequest 25 | case errors.Is(err, libErrors.ErrRootUserDeletion): 26 | return http.StatusForbidden 27 | default: 28 | return http.StatusInternalServerError 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /backend/indexing/checkLinuxStub.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package indexing 5 | 6 | func CheckWindowsHidden(realpath string) bool { 7 | // Non-Windows platforms don't support hidden attributes in the same way 8 | return false 9 | } 10 | -------------------------------------------------------------------------------- /backend/indexing/checkWindows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package indexing 5 | 6 | import ( 7 | "golang.org/x/sys/windows" 8 | ) 9 | 10 | func CheckWindowsHidden(realpath string) bool { 11 | // Convert the realpath to a UTF-16 pointer 12 | pointer, err := windows.UTF16PtrFromString(realpath) 13 | if err != nil { 14 | return false 15 | } 16 | 17 | // Get the file attributes 18 | attributes, err := windows.GetFileAttributes(pointer) 19 | if err != nil { 20 | return false 21 | } 22 | 23 | // Check if the hidden attribute is set 24 | if attributes&windows.FILE_ATTRIBUTE_HIDDEN != 0 { 25 | return true 26 | } 27 | 28 | // Optional: Check for system attribute 29 | if attributes&windows.FILE_ATTRIBUTE_SYSTEM != 0 { 30 | return true 31 | } 32 | return false 33 | } 34 | -------------------------------------------------------------------------------- /backend/indexing/iteminfo/fileinfo.go: -------------------------------------------------------------------------------- 1 | package iteminfo 2 | 3 | import ( 4 | "path/filepath" 5 | "time" 6 | ) 7 | 8 | type ItemInfo struct { 9 | Name string `json:"name"` // name of the file 10 | Size int64 `json:"size"` // length in bytes for regular files 11 | ModTime time.Time `json:"modified"` // modification time 12 | Type string `json:"type"` // type of the file, either "directory" or a file mimetype 13 | Hidden bool `json:"hidden"` // whether the file is hidden 14 | } 15 | 16 | // FileInfo describes a file. 17 | // reduced item is non-recursive reduced "Items", used to pass flat items array 18 | type FileInfo struct { 19 | ItemInfo 20 | Files []ItemInfo `json:"files"` // files in the directory 21 | Folders []ItemInfo `json:"folders"` // folders in the directory 22 | Path string `json:"path"` // path scoped to the associated index 23 | } 24 | 25 | // for efficiency, a response will be a pointer to the data 26 | // extra calculated fields can be added here 27 | type ExtendedFileInfo struct { 28 | FileInfo 29 | Content string `json:"content,omitempty"` // text content of a file, if requested 30 | Subtitles []string `json:"subtitles,omitempty"` // subtitles for video files 31 | Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file 32 | Token string `json:"token,omitempty"` // token for the file -- used for sharing 33 | OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files 34 | Source string `json:"source"` // associated index source for the file 35 | RealPath string `json:"-"` 36 | } 37 | 38 | // FileOptions are the options when getting a file info. 39 | type FileOptions struct { 40 | Path string // realpath 41 | Source string 42 | IsDir bool 43 | Modify bool 44 | Expand bool 45 | ReadHeader bool 46 | Content bool 47 | } 48 | 49 | func (f FileOptions) Components() (string, string) { 50 | return filepath.Dir(f.Path), filepath.Base(f.Path) 51 | } 52 | -------------------------------------------------------------------------------- /backend/indexing/iteminfo/searchQuery.go: -------------------------------------------------------------------------------- 1 | package iteminfo 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | var typeRegexp = regexp.MustCompile(`type:(\S+)`) 9 | 10 | type SearchOptions struct { 11 | Conditions map[string]bool 12 | LargerThan int 13 | SmallerThan int 14 | Terms []string 15 | } 16 | 17 | func ParseSearch(value string) SearchOptions { 18 | opts := SearchOptions{ 19 | Conditions: map[string]bool{ 20 | "exact": strings.Contains(value, "case:exact"), 21 | }, 22 | Terms: []string{}, 23 | } 24 | 25 | // removes the options from the value 26 | value = strings.Replace(value, "case:exact", "", -1) 27 | value = strings.TrimSpace(value) 28 | 29 | types := typeRegexp.FindAllStringSubmatch(value, -1) 30 | for _, filterType := range types { 31 | if len(filterType) == 1 { 32 | continue 33 | } 34 | filter := filterType[1] 35 | switch filter { 36 | case "image": 37 | opts.Conditions["image"] = true 38 | case "audio", "music": 39 | opts.Conditions["audio"] = true 40 | case "video": 41 | opts.Conditions["video"] = true 42 | case "doc": 43 | opts.Conditions["doc"] = true 44 | case "archive": 45 | opts.Conditions["archive"] = true 46 | case "folder": 47 | opts.Conditions["dir"] = true 48 | case "file": 49 | opts.Conditions["dir"] = false 50 | } 51 | if len(filter) < 8 { 52 | continue 53 | } 54 | if strings.HasPrefix(filter, "largerThan=") { 55 | opts.Conditions["larger"] = true 56 | size := strings.TrimPrefix(filter, "largerThan=") 57 | opts.LargerThan = UpdateSize(size) 58 | } 59 | if strings.HasPrefix(filter, "smallerThan=") { 60 | opts.Conditions["smaller"] = true 61 | size := strings.TrimPrefix(filter, "smallerThan=") 62 | opts.SmallerThan = UpdateSize(size) 63 | } 64 | } 65 | 66 | if len(types) > 0 { 67 | // Remove the fields from the search value 68 | value = typeRegexp.ReplaceAllString(value, "") 69 | } 70 | 71 | if value == "" { 72 | return opts 73 | } 74 | 75 | // if the value starts with " and finishes what that character, we will 76 | // only search for that term 77 | if value[0] == '"' && value[len(value)-1] == '"' { 78 | unique := strings.TrimPrefix(value, "\"") 79 | unique = strings.TrimSuffix(unique, "\"") 80 | 81 | opts.Terms = []string{unique} 82 | return opts 83 | } 84 | value = strings.TrimSpace(value) 85 | opts.Terms = strings.Split(value, "|") 86 | return opts 87 | } 88 | -------------------------------------------------------------------------------- /backend/indexing/iteminfo/utils.go: -------------------------------------------------------------------------------- 1 | package iteminfo 2 | 3 | import ( 4 | "sort" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/gtsteffaniak/go-logger/logger" 9 | ) 10 | 11 | // detects subtitles for video files. 12 | func (i *ExtendedFileInfo) DetectSubtitles(parentInfo *FileInfo) { 13 | if !strings.HasPrefix(i.Type, "video") { 14 | logger.Debug("subtitles are not supported for this file : " + i.Name) 15 | return 16 | } 17 | 18 | base := strings.Split(i.Name, ".")[0] 19 | for _, f := range parentInfo.Files { 20 | baseName := strings.Split(f.Name, ".")[0] 21 | if baseName != base { 22 | continue 23 | } 24 | 25 | for _, subtitleExt := range []string{".vtt", ".srt", ".lrc", ".sbv", ".ass", ".ssa", ".sub", ".smi"} { 26 | if strings.HasSuffix(f.Name, subtitleExt) { 27 | fullPathBase := strings.Split(i.Path, ".")[0] 28 | i.Subtitles = append(i.Subtitles, fullPathBase+subtitleExt) 29 | } 30 | } 31 | } 32 | } 33 | 34 | func (info *FileInfo) SortItems() { 35 | sort.Slice(info.Folders, func(i, j int) bool { 36 | nameWithoutExt := strings.Split(info.Folders[i].Name, ".")[0] 37 | nameWithoutExt2 := strings.Split(info.Folders[j].Name, ".")[0] 38 | // Convert strings to integers for numeric sorting if both are numeric 39 | numI, errI := strconv.Atoi(nameWithoutExt) 40 | numJ, errJ := strconv.Atoi(nameWithoutExt2) 41 | if errI == nil && errJ == nil { 42 | return numI < numJ 43 | } 44 | // Fallback to case-insensitive lexicographical sorting 45 | return strings.ToLower(info.Folders[i].Name) < strings.ToLower(info.Folders[j].Name) 46 | }) 47 | sort.Slice(info.Files, func(i, j int) bool { 48 | nameWithoutExt := strings.Split(info.Files[i].Name, ".")[0] 49 | nameWithoutExt2 := strings.Split(info.Files[j].Name, ".")[0] 50 | // Convert strings to integers for numeric sorting if both are numeric 51 | numI, errI := strconv.Atoi(nameWithoutExt) 52 | numJ, errJ := strconv.Atoi(nameWithoutExt2) 53 | if errI == nil && errJ == nil { 54 | return numI < numJ 55 | } 56 | // Fallback to case-insensitive lexicographical sorting 57 | return strings.ToLower(info.Files[i].Name) < strings.ToLower(info.Files[j].Name) 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /backend/indexing/mock.go: -------------------------------------------------------------------------------- 1 | package indexing 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | 7 | "github.com/gtsteffaniak/filebrowser/backend/common/utils" 8 | "github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo" 9 | ) 10 | 11 | func (idx *Index) CreateMockData(numDirs, numFilesPerDir int) { 12 | for i := 0; i < numDirs; i++ { 13 | dirPath := utils.GenerateRandomPath(rand.Intn(3) + 1) 14 | files := []iteminfo.ItemInfo{} // Slice of FileInfo 15 | 16 | // Simulating files and directories with FileInfo 17 | for j := 0; j < numFilesPerDir; j++ { 18 | newFile := iteminfo.ItemInfo{ 19 | Name: "file-" + utils.GetRandomTerm() + utils.GetRandomExtension(), 20 | Size: rand.Int63n(1000), // Random size 21 | ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time 22 | Type: "blob", 23 | } 24 | files = append(files, newFile) 25 | } 26 | dirInfo := &iteminfo.FileInfo{ 27 | Path: dirPath, 28 | Files: files, 29 | } 30 | 31 | idx.UpdateMetadata(dirInfo) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gtsteffaniak/filebrowser/backend/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.StartFilebrowser() 9 | } 10 | -------------------------------------------------------------------------------- /backend/preview/sync.go: -------------------------------------------------------------------------------- 1 | package preview 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | func (s *Service) acquire(ctx context.Context) error { 8 | select { 9 | case s.sem <- struct{}{}: 10 | return nil 11 | case <-ctx.Done(): 12 | return ctx.Err() 13 | } 14 | } 15 | 16 | func (s *Service) release() { 17 | select { 18 | case <-s.sem: 19 | default: 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/preview/testdata: -------------------------------------------------------------------------------- 1 | ../../frontend/tests/playwright-files/myfolder/testdata/ -------------------------------------------------------------------------------- /backend/preview/video.go: -------------------------------------------------------------------------------- 1 | package preview 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/gtsteffaniak/go-logger/logger" 12 | ) 13 | 14 | // GenerateVideoPreview generates a single preview image from a video using ffmpeg. 15 | // videoPath: path to the input video file. 16 | // outputPath: path where the generated preview image will be saved (e.g., "/tmp/preview.jpg"). 17 | // seekTime: how many seconds into the video to seek before capturing the frame. 18 | func (s *Service) GenerateVideoPreview(videoPath, outputPath string, percentageSeek int) error { 19 | // Step 1: Get video duration from the container format 20 | probeCmd := exec.Command( 21 | s.ffprobePath, 22 | "-v", "error", 23 | // Use format=duration for better compatibility 24 | "-show_entries", "format=duration", 25 | "-of", "default=noprint_wrappers=1:nokey=1", 26 | videoPath, 27 | ) 28 | 29 | var probeOut bytes.Buffer 30 | probeCmd.Stdout = &probeOut 31 | if s.debug { 32 | probeCmd.Stderr = os.Stderr 33 | } 34 | if err := probeCmd.Run(); err != nil { 35 | logger.Errorf("ffprobe command failed on file '%v' : %v", videoPath, err) 36 | return fmt.Errorf("ffprobe failed: %w", err) 37 | } 38 | 39 | durationStr := strings.TrimSpace(probeOut.String()) 40 | if durationStr == "" || durationStr == "N/A" { 41 | logger.Errorf("could not determine video duration for file '%v' using duration info '%v'", videoPath, durationStr) 42 | return fmt.Errorf("could not determine video duration") 43 | } 44 | 45 | durationFloat, err := strconv.ParseFloat(durationStr, 64) 46 | if err != nil { 47 | // The original error you saw would be caught here if "N/A" was still the output 48 | return fmt.Errorf("invalid duration: %v", err) 49 | } 50 | 51 | if durationFloat <= 0 { 52 | return fmt.Errorf("video duration must be positive") 53 | } 54 | 55 | // The rest of your function remains the same... 56 | // Step 2: Get the duration of the video in whole seconds 57 | duration := int(durationFloat) 58 | 59 | // Step 3: Calculate seek time based on percentageSeek (percentage value) 60 | seekSeconds := duration * percentageSeek / 100 61 | 62 | // Step 4: Convert seekSeconds to string for ffmpeg command 63 | seekTime := strconv.Itoa(seekSeconds) 64 | // Step 5: Extract frame at seek time 65 | cmd := exec.Command( 66 | s.ffmpegPath, 67 | "-ss", seekTime, 68 | "-i", videoPath, 69 | "-frames:v", "1", 70 | "-q:v", "10", 71 | "-y", // overwrite output 72 | outputPath, 73 | ) 74 | 75 | if s.debug { 76 | cmd.Stdout = os.Stdout 77 | cmd.Stderr = os.Stderr 78 | } 79 | 80 | return cmd.Run() 81 | } 82 | -------------------------------------------------------------------------------- /backend/run_benchmark.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ## TEST file used by docker testing containers 3 | checkExit() { 4 | if [ "$?" -ne 0 ];then 5 | exit 1 6 | fi 7 | } 8 | 9 | if command -v go &> /dev/null 10 | then 11 | printf "\n == Running benchmark == \n" 12 | go test -bench=. -benchtime=10x -benchmem ./... 13 | checkExit 14 | else 15 | echo "ERROR: unable to perform tests" 16 | exit 1 17 | fi 18 | -------------------------------------------------------------------------------- /backend/run_check_coverage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | go test -race -v -coverpkg=./... -coverprofile=coverage.cov ./... 3 | go tool cover -html=coverage.cov 4 | -------------------------------------------------------------------------------- /backend/run_fmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | for i in $(find $(pwd) -name '*.go');do gofmt -w $i;done 3 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true 5 | }, 6 | "extends": [ 7 | "plugin:vue/vue3-essential", 8 | "eslint:recommended", 9 | "@vue/eslint-config-typescript", 10 | "plugin:@intlify/vue-i18n/recommended" 11 | ], 12 | "settings": { 13 | "vue-i18n": { 14 | // Path to your locale message directory or a glob pattern. 15 | // The plugin will use these files to determine available keys. 16 | // Make sure 'en.json' is present here. 17 | "localeDir": "./src/i18n/*.json" 18 | 19 | // If your i18n instance is initialized with a fallbackLocale, 20 | // the linter might use that. Ensure 'en' is effectively the master. 21 | // fallbackLocale: 'en', // This might be part of your i18n setup, not ESLint settings 22 | } 23 | }, 24 | "rules": { 25 | // vue-i18n rules: 26 | // This rule will check if the key used in $t() exists in your locale messages. 27 | // By default, it checks against all locales. We want it to primarily use 'en.json' 28 | // as the source of truth. The plugin often infers this from your i18n setup, 29 | // but we want to be explicit or ensure our sync script makes 'en.json' the master. 30 | "@intlify/vue-i18n/no-missing-keys": "error", 31 | // Optional: Warn about unused keys in your 'en.json' 32 | // This requires configuring the `localeDir` setting below. 33 | "@intlify/vue-i18n/no-unused-keys": ["warn", { 34 | "src": "./src", // Path to your source files 35 | "extensions": [".js", ".vue"] 36 | // Important: This tells the rule to check unused keys specifically in en.json 37 | // by making it the single source of truth for what "should" exist. 38 | // However, the rule usually checks keys NOT used in your Vue code. 39 | // The primary goal is `no-missing-keys` for keys used in the template. 40 | }], 41 | 42 | "@intlify/vue-i18n/no-raw-text": [ 43 | "warn", 44 | { 45 | "ignoreNodes": ["i", "v-icon"] 46 | } 47 | ], 48 | 49 | // If you have your sync script, you might not need this one for other locales. 50 | "@intlify/vue-i18n/no-missing-keys-in-other-locales": "off", 51 | "vue/multi-word-component-names": "off", 52 | "vue/no-mutating-props": [ 53 | "error", 54 | { 55 | "shallowOnly": true 56 | } 57 | ] 58 | // no-undef is already included in 59 | // @vue/eslint-config-typescript 60 | }, 61 | "parserOptions": { 62 | "ecmaVersion": "latest", 63 | "sourceType": "module" 64 | } 65 | } -------------------------------------------------------------------------------- /frontend/frontend: -------------------------------------------------------------------------------- 1 | frontend -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "filebrowser-frontend", 3 | "version": "3.0.0", 4 | "private": true, 5 | "type": "module", 6 | "engines": { 7 | "npm": ">=7.0.0", 8 | "node": ">=18.0.0" 9 | }, 10 | "scripts": { 11 | "dev": "vite dev", 12 | "build": "vite build && cp -r dist/* ../backend/http/embed", 13 | "build-windows": "vite build && robocopy dist ../backend/http/embed /e", 14 | "build-docker": "vite build", 15 | "watch": "vite build --watch", 16 | "typecheck": "vue-tsc -p ./tsconfig.json --noEmit", 17 | "lint": "eslint --ext .js,.vue,ts src", 18 | "lint:fix": "eslint --fix src/", 19 | "i18n:sync": "node ./scripts/sync-translations.js", 20 | "format": "prettier --write .", 21 | "test": "vitest run " 22 | }, 23 | "dependencies": { 24 | "@onlyoffice/document-editor-vue": "^1.4.0", 25 | "ace-builds": "^1.24.2", 26 | "axios": "^1.7.9", 27 | "clipboard": "^2.0.4", 28 | "css-vars-ponyfill": "^2.4.3", 29 | "dompurify": "^3.2.4", 30 | "file-loader": "^6.2.0", 31 | "glob": "^9.3.5", 32 | "highlight.js": "^11.11.1", 33 | "marked": "^15.0.6", 34 | "normalize.css": "^8.0.1", 35 | "qrcode.vue": "^3.4.1", 36 | "srt-support-for-html5-videos": "^2.6.11", 37 | "vue": "^3.4.21", 38 | "vue-i18n": "^9.10.2", 39 | "vue-lazyload": "^3.0.0", 40 | "vue-router": "^4.3.0" 41 | }, 42 | "devDependencies": { 43 | "@intlify/eslint-plugin-vue-i18n": "^3.2.0", 44 | "@intlify/unplugin-vue-i18n": "^4.0.0", 45 | "@playwright/test": "^1.49.1", 46 | "@vitejs/plugin-vue": "^5.0.4", 47 | "@vue/eslint-config-typescript": "^13.0.0", 48 | "deepl-node": "^1.18.0", 49 | "eslint": "^8.57.0", 50 | "eslint-config-prettier": "^9.1.0", 51 | "eslint-plugin-vue": "^9.24.0", 52 | "fs-extra": "^11.3.0", 53 | "jsdom": "^25.0.1", 54 | "vite": "^6.2.0", 55 | "vite-plugin-compression2": "^1.0.0", 56 | "vitest": "^3.0.7", 57 | "vue-tsc": "^2.0.7" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /frontend/public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- 1 | favicon-256x256.png -------------------------------------------------------------------------------- /frontend/public/img/icons/android-chrome-256x256.png: -------------------------------------------------------------------------------- 1 | favicon-256x256.png -------------------------------------------------------------------------------- /frontend/public/img/icons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #455a64 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- 1 | favicon-256x256.png -------------------------------------------------------------------------------- /frontend/public/img/icons/favicon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/public/img/icons/favicon-256x256.png -------------------------------------------------------------------------------- /frontend/public/img/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/public/img/icons/favicon.ico -------------------------------------------------------------------------------- /frontend/public/img/icons/mstile-256x256.png: -------------------------------------------------------------------------------- 1 | favicon-256x256.png -------------------------------------------------------------------------------- /frontend/public/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/public/img/logo.png -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | 25 | 30 | -------------------------------------------------------------------------------- /frontend/src/api/commands.js: -------------------------------------------------------------------------------- 1 | import { baseURL } from "@/utils/constants"; 2 | 3 | const ssl = window.location.protocol === "https:"; 4 | const protocol = ssl ? "wss:" : "ws:"; 5 | 6 | export default function command(url, command, onmessage, onclose) { 7 | url = `${protocol}//${window.location.host}${baseURL}api/command${url}`; 8 | let conn = new window.WebSocket(url); 9 | conn.onopen = () => conn.send(command); 10 | conn.onmessage = onmessage; 11 | conn.onclose = onclose; 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/api/index.js: -------------------------------------------------------------------------------- 1 | import * as filesApi from "./files"; 2 | import * as shareApi from "./share"; 3 | import * as usersApi from "./users"; 4 | import * as settingsApi from "./settings"; 5 | import * as publicApi from "./public"; 6 | import search from "./search"; 7 | 8 | export { filesApi, shareApi, usersApi, settingsApi, publicApi, search }; 9 | -------------------------------------------------------------------------------- /frontend/src/api/public.js: -------------------------------------------------------------------------------- 1 | import { adjustedData } from "./utils"; 2 | import { getApiPath } from "@/utils/url.js"; 3 | import { notify } from "@/notify"; 4 | 5 | // Fetch public share data 6 | export async function fetchPub(path, hash, password = "") { 7 | const params = { path, hash } 8 | const apiPath = getApiPath("api/public/share", params); 9 | const response = await fetch(apiPath, { 10 | headers: { 11 | "X-SHARE-PASSWORD": password ? encodeURIComponent(password) : "", 12 | }, 13 | }); 14 | 15 | if (!response.ok) { 16 | const error = new Error("Failed to connect to the server."); 17 | error.status = response.status; 18 | throw error; 19 | } 20 | let data = await response.json() 21 | const adjusted = adjustedData(data, `/share/${hash}${path}`); 22 | return adjusted 23 | } 24 | 25 | // Download files with given parameters 26 | export function download(format, files) { 27 | let fileargs = '' 28 | if (files.length === 1) { 29 | fileargs = decodeURI(files[0]) + '||' 30 | } else { 31 | for (let file of files) { 32 | fileargs += decodeURI(file) + '||' 33 | } 34 | } 35 | fileargs = fileargs.slice(0, -2) // remove trailing "||" 36 | const apiPath = getApiPath('api/public/dl', { 37 | files: encodeURIComponent(fileargs), 38 | hash: format.hash, 39 | }) 40 | const url = window.origin + apiPath 41 | // Create a temporary element to trigger the download 42 | const link = document.createElement('a') 43 | link.href = url 44 | link.setAttribute('download', '') // Ensures it triggers a download 45 | document.body.appendChild(link) 46 | link.click() 47 | document.body.removeChild(link) // Clean up 48 | 49 | } 50 | 51 | // Get the public user data 52 | export async function getPublicUser() { 53 | try { 54 | const apiPath = getApiPath("api/public/publicUser"); 55 | const response = await fetch(apiPath); 56 | return response.json(); 57 | } catch (err) { 58 | notify.showError(err.message || "Error fetching public user"); 59 | throw err; 60 | } 61 | } 62 | 63 | // Generate a download URL 64 | export function getDownloadURL(share,files) { 65 | const params = { 66 | path: share.path, 67 | files: files, 68 | hash: share.hash, 69 | token: share.token, 70 | ...(share.inline && { inline: 'true' }) 71 | } 72 | const apiPath = getApiPath("api/public/dl", params); 73 | return window.origin+apiPath 74 | } 75 | -------------------------------------------------------------------------------- /frontend/src/api/search.js: -------------------------------------------------------------------------------- 1 | import { fetchURL } from "./utils"; 2 | import { notify } from "@/notify"; // Import notify for error handling 3 | import { getApiPath } from "@/utils/url.js"; 4 | 5 | export default async function search(base, source, query) { 6 | try { 7 | query = encodeURIComponent(query); 8 | if (!base.endsWith("/")) { 9 | base += "/"; 10 | } 11 | const apiPath = getApiPath("api/search", { scope: encodeURIComponent(base), query: query, source: source }); 12 | const res = await fetchURL(apiPath); 13 | let data = await res.json(); 14 | 15 | return data 16 | } catch (err) { 17 | notify.showError(err.message || "Error occurred during search"); 18 | throw err; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/api/settings.js: -------------------------------------------------------------------------------- 1 | import { fetchURL, fetchJSON } from "./utils"; 2 | import { getApiPath } from "@/utils/url.js"; 3 | 4 | 5 | export function get(property="") { 6 | const path = getApiPath("api/settings", { property }); 7 | return fetchJSON(path); 8 | } 9 | 10 | export async function update(settings) { 11 | await fetchURL("api/settings", { 12 | method: "PUT", 13 | body: JSON.stringify(settings), 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/api/share.js: -------------------------------------------------------------------------------- 1 | import { fetchURL, fetchJSON, adjustedData } from "./utils"; 2 | import { state } from '@/store' 3 | import { notify } from "@/notify"; 4 | import { getApiPath,removePrefix } from "@/utils/url.js"; 5 | import { externalUrl,baseURL } from "@/utils/constants"; 6 | 7 | export async function list() { 8 | const apiPath = getApiPath("api/shares"); 9 | return fetchJSON(apiPath); 10 | } 11 | 12 | export async function get(path, source) { 13 | try { 14 | const params = { path, source }; 15 | const apiPath = getApiPath("api/share",params); 16 | let data = fetchJSON(apiPath); 17 | return adjustedData(data, path); 18 | } catch (err) { 19 | notify.showError(err.message || "Error fetching data"); 20 | throw err; 21 | } 22 | } 23 | 24 | export async function remove(hash) { 25 | const params = { hash }; 26 | const apiPath = getApiPath("api/share",params); 27 | await fetchURL(apiPath, { 28 | method: "DELETE", 29 | }); 30 | } 31 | 32 | export async function create(path, source, password = "", expires = "", unit = "hours") { 33 | const params = { path: encodeURIComponent(path), source: source }; 34 | const apiPath = getApiPath("api/share",params); 35 | let body = "{}"; 36 | if (password != "" || expires !== "" || unit !== "hours") { 37 | body = JSON.stringify({ password: password, expires: expires, unit: unit }); 38 | } 39 | return fetchJSON(apiPath, { 40 | method: "POST", 41 | body: body, 42 | }); 43 | } 44 | 45 | export function getShareURL(share) { 46 | if (externalUrl) { 47 | const apiPath = getApiPath(`share/${share.hash}`) 48 | return externalUrl + removePrefix(apiPath, baseURL); 49 | } 50 | return window.origin+getApiPath(`share/${share.hash}`); 51 | } 52 | 53 | export function getPreviewURL(hash, path) { 54 | try { 55 | const params = { 56 | path: encodeURIComponent(path), 57 | size: state.user.preview.highQuality ? 'large' : 'small', 58 | hash: hash, 59 | inline: 'true' 60 | } 61 | const apiPath = getApiPath('api/public/preview', params) 62 | return window.origin + apiPath 63 | } catch (err) { 64 | notify.showError(err.message || 'Error getting preview URL') 65 | throw err 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/api/utils.js: -------------------------------------------------------------------------------- 1 | import { state } from "@/store"; 2 | import { renew, logout } from "@/utils/auth"; 3 | import { notify } from "@/notify"; 4 | 5 | export async function fetchURL(url, opts, auth = true) { 6 | opts = opts || {}; 7 | opts.headers = opts.headers || {}; 8 | 9 | let { headers, ...rest } = opts; 10 | 11 | let res; 12 | try { 13 | res = await fetch(url, { 14 | headers: { 15 | "sessionId": state.sessionId, 16 | ...headers, 17 | }, 18 | ...rest, 19 | }); 20 | } catch (e) { 21 | let message = e; 22 | if (e == "TypeError: Failed to fetch") { 23 | message = "Failed to connect to the server, is it still running?"; 24 | } 25 | const error = new Error(message); 26 | throw error; 27 | } 28 | 29 | if (auth && res.headers.get("X-Renew-Token") === "true") { 30 | await renew(state.jwt); 31 | } 32 | 33 | if (res.status < 200 || res.status > 299) { 34 | let error = new Error(await res.text()); 35 | error.status = res.status; 36 | 37 | if (auth && res.status == 401) { 38 | logout(); 39 | } 40 | 41 | throw error; 42 | } 43 | 44 | return res; 45 | } 46 | 47 | export async function fetchJSON(url, opts) { 48 | const res = await fetchURL(url, opts); 49 | if (res.status < 300) { 50 | return res.json(); 51 | } else { 52 | notify.showError("received status: "+res.status+" on url " + url); 53 | throw new Error(res.status); 54 | } 55 | } 56 | 57 | export function adjustedData(data, url) { 58 | data.url = url; 59 | if (data.type === "directory") { 60 | if (!data.url.endsWith("/")) data.url += "/"; 61 | 62 | // Combine folders and files into items 63 | data.items = [...(data.folders || []), ...(data.files || [])]; 64 | 65 | data.items = data.items.map((item) => { 66 | item.url = `${data.url}${encodeURIComponent(item.name)}`; 67 | item.source = data.source 68 | if (data.path == "/") { 69 | item.path = `/${item.name}` 70 | } else { 71 | item.path = `${data.path}/${item.name}` 72 | } 73 | if (item.type === "directory") { 74 | item.url += "/"; 75 | } 76 | return item; 77 | }); 78 | } 79 | if (data.files) { 80 | data.files = [] 81 | } 82 | if (data.folders) { 83 | data.folders = [] 84 | } 85 | return data; 86 | } 87 | 88 | -------------------------------------------------------------------------------- /frontend/src/assets/fonts/material/icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/material/icons.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/material/symbols-outlined.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/material/symbols-outlined.woff -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/bold-cyrillic-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/bold-cyrillic-ext.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/bold-cyrillic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/bold-cyrillic.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/bold-greek-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/bold-greek-ext.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/bold-greek.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/bold-greek.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/bold-latin-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/bold-latin-ext.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/bold-latin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/bold-latin.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/bold-vietnamese.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/bold-vietnamese.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/medium-cyrillic-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/medium-cyrillic-ext.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/medium-cyrillic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/medium-cyrillic.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/medium-greek-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/medium-greek-ext.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/medium-greek.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/medium-greek.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/medium-latin-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/medium-latin-ext.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/medium-latin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/medium-latin.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/medium-vietnamese.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/medium-vietnamese.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/normal-cyrillic-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/normal-cyrillic-ext.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/normal-cyrillic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/normal-cyrillic.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/normal-greek-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/normal-greek-ext.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/normal-greek.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/normal-greek.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/normal-latin-ext.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/normal-latin-ext.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/normal-latin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/normal-latin.woff2 -------------------------------------------------------------------------------- /frontend/src/assets/fonts/roboto/normal-vietnamese.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/src/assets/fonts/roboto/normal-vietnamese.woff2 -------------------------------------------------------------------------------- /frontend/src/components/Notifications.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 23 | 24 | 29 | -------------------------------------------------------------------------------- /frontend/src/components/prompts/DeleteUser.vue: -------------------------------------------------------------------------------- 1 | 22 | 90 | -------------------------------------------------------------------------------- /frontend/src/components/prompts/Download.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 42 | -------------------------------------------------------------------------------- /frontend/src/components/prompts/Help.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 47 | -------------------------------------------------------------------------------- /frontend/src/components/prompts/NewDir.vue: -------------------------------------------------------------------------------- 1 | 39 | 108 | -------------------------------------------------------------------------------- /frontend/src/components/prompts/NewFile.vue: -------------------------------------------------------------------------------- 1 | 39 | 90 | -------------------------------------------------------------------------------- /frontend/src/components/prompts/Replace.vue: -------------------------------------------------------------------------------- 1 | 31 | 48 | -------------------------------------------------------------------------------- /frontend/src/components/prompts/ReplaceRename.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 55 | -------------------------------------------------------------------------------- /frontend/src/components/prompts/ShareDelete.vue: -------------------------------------------------------------------------------- 1 | 26 | 49 | -------------------------------------------------------------------------------- /frontend/src/components/settings/Languages.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 57 | 60 | -------------------------------------------------------------------------------- /frontend/src/components/settings/Permissions.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 37 | -------------------------------------------------------------------------------- /frontend/src/components/settings/Themes.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 19 | -------------------------------------------------------------------------------- /frontend/src/components/settings/ToggleSwitch.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 31 | 32 | 99 | -------------------------------------------------------------------------------- /frontend/src/components/settings/ViewMode.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 25 | -------------------------------------------------------------------------------- /frontend/src/components/sidebar/Settings.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 49 | 60 | -------------------------------------------------------------------------------- /frontend/src/css/_buttons.css: -------------------------------------------------------------------------------- 1 | .button { 2 | outline: 0; 3 | border: 0; 4 | padding: .5em 1em; 5 | border-radius: 1em; 6 | cursor: pointer; 7 | background: var(--primaryColor); 8 | color: white; 9 | border: 1px solid rgba(0, 0, 0, 0.05); 10 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.05); 11 | transition: .1s ease all; 12 | } 13 | 14 | .button:hover { 15 | background-color: var(--dark-blue); 16 | } 17 | 18 | .button--block { 19 | margin: 0 0 0.5em; 20 | display: block; 21 | width: 100%; 22 | } 23 | 24 | .button--red { 25 | background: var(--red); 26 | } 27 | 28 | .button--blue { 29 | background: var(--blue); 30 | } 31 | 32 | .button--flat { 33 | color: var(--primaryColor); 34 | background: transparent; 35 | box-shadow: 0 0 0; 36 | border: 0; 37 | text-transform: uppercase; 38 | } 39 | 40 | .button--flat:hover { 41 | background: var(--moon-grey); 42 | } 43 | 44 | .button--flat.button--red { 45 | color: var(--dark-red); 46 | } 47 | 48 | .button--flat.button--grey { 49 | color: #6f6f6f; 50 | } 51 | 52 | .button[disabled] { 53 | opacity: .5; 54 | cursor: not-allowed; 55 | } 56 | -------------------------------------------------------------------------------- /frontend/src/css/_inputs.css: -------------------------------------------------------------------------------- 1 | .input { 2 | border-radius: 1em; 3 | padding: .5em 1em; 4 | background: white; 5 | border: 1px solid rgba(0, 0, 0, 0.1); 6 | transition: .2s ease all; 7 | color: #333; 8 | margin: 0; 9 | } 10 | 11 | .input:hover, 12 | .input:focus { 13 | border-color: rgba(0, 0, 0, 0.2); 14 | } 15 | 16 | .input--block { 17 | margin-bottom: .5em; 18 | display: block; 19 | width: 100%; 20 | } 21 | 22 | .input--textarea { 23 | line-height: 1.15; 24 | font-family: monospace; 25 | min-height: 10em; 26 | resize: vertical; 27 | } 28 | 29 | .input--red { 30 | background: #fcd0cd; 31 | } 32 | 33 | .input--green { 34 | background: #c9f2da; 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/css/_share.css: -------------------------------------------------------------------------------- 1 | .share { 2 | display: flex; 3 | flex-wrap: wrap; 4 | justify-content: center; 5 | align-items: flex-start; 6 | } 7 | 8 | @media (max-width: 800px) { 9 | .share { 10 | display: block; 11 | } 12 | } 13 | 14 | .share__box { 15 | box-shadow: rgba(0, 0, 0, 0.06) 0px 1px 3px, rgba(0, 0, 0, 0.12) 0px 1px 2px; 16 | background: #fff; 17 | border-radius: 1em; 18 | margin: 5px; 19 | overflow: hidden; 20 | } 21 | 22 | .share__box__header { 23 | padding: 1em; 24 | text-align: center; 25 | } 26 | 27 | .share__box__icon i { 28 | font-size: 10em; 29 | color: #40c4ff; 30 | } 31 | 32 | .share__box__center { 33 | text-align: center; 34 | } 35 | 36 | .share__box__info { 37 | flex: 1 1 18em; 38 | } 39 | 40 | .share__box__element { 41 | padding: 1em; 42 | border-top: 1px solid rgba(0, 0, 0, 0.1); 43 | word-break: break-all; 44 | } 45 | 46 | .share__box__element .button { 47 | display: inline-block; 48 | } 49 | 50 | .share__box__element .button i { 51 | display: block; 52 | margin-bottom: 4px; 53 | } 54 | 55 | .share__box__items { 56 | text-align: left; 57 | flex: 10 0 25em; 58 | } 59 | 60 | .share__box__items #listingView.list .item { 61 | cursor: pointer; 62 | border-left: 0; 63 | border-right: 0; 64 | border-bottom: 0; 65 | border-top: 1px solid rgba(0, 0, 0, 0.1); 66 | } 67 | 68 | .share__box__items #listingView.list .item .name { 69 | width: 50%; 70 | } 71 | 72 | .share__box__items #listingView.list .item .modified { 73 | width: 25%; 74 | } 75 | 76 | .share__wrong__password { 77 | background: var(--red); 78 | color: #fff; 79 | padding: .5em; 80 | text-align: center; 81 | animation: .2s opac forwards; 82 | } -------------------------------------------------------------------------------- /frontend/src/css/_variables.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --blue: #2196f3; 3 | --dark-blue: #1E88E5; 4 | --red: #F44336; 5 | --dark-red: #D32F2F; 6 | --moon-grey: #f2f2f2; 7 | 8 | --icon-red: #da4453; 9 | --icon-orange: #f47750; 10 | --icon-yellow: #fdbc4b; 11 | --icon-green: #2ecc71; 12 | --icon-blue: #1d99f3; 13 | --icon-violet: #9b59b6; 14 | --primaryColor: var(--blue); 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/css/header.css: -------------------------------------------------------------------------------- 1 | /* Header */ 2 | header { 3 | z-index: 5; 4 | position: fixed; 5 | top: 0; 6 | left: 0; 7 | width: 100%; 8 | height: 4em; 9 | display: flex; 10 | align-items: center; 11 | justify-content: space-between; 12 | padding: 0.5em; 13 | background-color: #DDDDDD 14 | } 15 | 16 | @supports (backdrop-filter: none) { 17 | header { 18 | background-color: rgba(237, 237, 237, 0.33) !important; 19 | backdrop-filter: blur(16px) invert(0.1); 20 | } 21 | } 22 | 23 | header>* { 24 | flex: 0 0 auto; 25 | } 26 | 27 | header title { 28 | display: block; 29 | flex: 1 1 auto; 30 | padding: 0 1em; 31 | overflow: hidden; 32 | text-overflow: ellipsis; 33 | font-size: 1.2em; 34 | text-align: center; 35 | } 36 | 37 | header a, 38 | header a:hover { 39 | color: inherit; 40 | } 41 | 42 | header>div:first-child>.action, 43 | header img { 44 | margin-right: 1em; 45 | } 46 | 47 | header img { 48 | height: 2.5em; 49 | } 50 | 51 | header .action span { 52 | display: none; 53 | } 54 | 55 | /* Header with backdrop-filter support */ 56 | @supports (backdrop-filter: none) { 57 | header { 58 | background-color: rgba(237, 237, 237, 0.33) !important; 59 | backdrop-filter: blur(16px) invert(0.1); 60 | } 61 | } -------------------------------------------------------------------------------- /frontend/src/css/login.css: -------------------------------------------------------------------------------- 1 | #login { 2 | background: var(--background); 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | width: 100%; 7 | height: 100%; 8 | } 9 | 10 | #login img { 11 | width: 4em; 12 | height: 4em; 13 | margin: 0 auto; 14 | display: block; 15 | } 16 | 17 | #login h1 { 18 | text-align: center; 19 | font-size: 2.5em; 20 | margin: .4em 0 .67em; 21 | } 22 | 23 | #login form { 24 | position: fixed; 25 | top: 50%; 26 | left: 50%; 27 | transform: translate(-50%, -50%); 28 | max-width: 16em; 29 | width: 90%; 30 | } 31 | 32 | #login.recaptcha form { 33 | min-width: 304px; 34 | } 35 | 36 | #login #recaptcha { 37 | margin: .5em 0 0; 38 | } 39 | 40 | .wrong-login { 41 | background: var(--red); 42 | color: #fff; 43 | padding: .5em; 44 | text-align: center; 45 | animation: .2s opac forwards; 46 | margin-bottom: 0.5em; 47 | border-radius: 1em; 48 | } 49 | 50 | @keyframes opac { 51 | 0% { 52 | opacity: 0; 53 | } 54 | 100% { 55 | opacity: 1; 56 | } 57 | } 58 | 59 | #login p { 60 | cursor: pointer; 61 | text-align: right; 62 | color: var(--primaryColor); 63 | text-transform: lowercase; 64 | font-weight: 500; 65 | font-size: 0.9rem; 66 | margin: .5rem 0; 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/css/mobile.css: -------------------------------------------------------------------------------- 1 | @media (max-width: 800px) { 2 | #listingView.list .item div:last-of-type{ 3 | display:block; 4 | width:100%; 5 | } 6 | body { 7 | padding-bottom: 5em; 8 | } 9 | 10 | #listingView.list .item .name { 11 | width: 60%; 12 | } 13 | 14 | #more { 15 | display: inherit 16 | } 17 | 18 | body.rtl #dropdown { 19 | right: unset; 20 | left: 1em; 21 | transform-origin: top left; 22 | } 23 | 24 | header img { 25 | display: none; 26 | } 27 | 28 | #listingView { 29 | margin-bottom: 5em; 30 | } 31 | #listingView .item { 32 | width: 100% 33 | } 34 | body.rtl #listingView { 35 | margin-right: unset; 36 | } 37 | 38 | 39 | body.rtl #nav .wrapper { 40 | margin-right: unset; 41 | } 42 | 43 | body.rtl .dashboard .row { 44 | margin-right: unset; 45 | } 46 | #search { 47 | min-width: unset; 48 | max-width: 60%; 49 | } 50 | 51 | #search.active { 52 | display: block; 53 | position: fixed; 54 | top: 0; 55 | left: 50%; 56 | width: 100%; 57 | max-width: 100%; 58 | } 59 | #search #input { 60 | transition: 1s ease all; 61 | } 62 | #search.active #input { 63 | border-bottom: 3px solid rgba(0, 0, 0, 0.075); 64 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); 65 | backdrop-filter: blur(6px); 66 | height: 4em; 67 | } 68 | 69 | #search.active>div { 70 | border-radius: 0 !important; 71 | } 72 | 73 | #search.active #result { 74 | height: 100vh; 75 | padding-top: 0; 76 | } 77 | 78 | 79 | #search.active #result>p>i { 80 | text-align: center; 81 | margin: 0 auto; 82 | display: table; 83 | } 84 | 85 | #search.active #result ul li a { 86 | display: flex; 87 | align-items: center; 88 | padding: .3em 0; 89 | margin-right: .3em; 90 | } 91 | 92 | #search #input>.action, 93 | #search #input>i { 94 | margin-right: 0.3em; 95 | user-select: none; 96 | } 97 | 98 | #result-list { 99 | width:100vw !important; 100 | max-width: 100vw !important; 101 | left: 0; 102 | top: 4em; 103 | -webkit-box-direction: normal; 104 | -ms-flex-direction: column; 105 | overflow: scroll; 106 | display: flex; 107 | flex-direction: column; 108 | } 109 | } 110 | 111 | 112 | @media (max-width: 450px) { 113 | 114 | #listingView.list .item .name { 115 | width: 100%; 116 | } 117 | } 118 | 119 | /* Mobile Specific Styles */ 120 | .mobile-only { 121 | display: none !important; 122 | } -------------------------------------------------------------------------------- /frontend/src/global.d.ts: -------------------------------------------------------------------------------- 1 | // src/global.d.ts 2 | interface Window { 3 | grecaptcha?: any; // or use a more specific type if available 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import router from './router'; // Adjust the path as per your setup 3 | import App from './App.vue'; // Adjust the path as per your setup 4 | import { state } from '@/store'; // Adjust the path as per your setup 5 | import i18n from "@/i18n"; 6 | import VueLazyload from "vue-lazyload"; 7 | 8 | import './css/styles.css'; 9 | 10 | const app = createApp(App); 11 | 12 | // provide v-focus for components 13 | app.directive("focus", { 14 | mounted: async (el) => { 15 | // initiate focus for the element 16 | el.focus(); 17 | }, 18 | }); 19 | 20 | // Install additionals 21 | app.use(VueLazyload); 22 | app.use(i18n); 23 | app.use(router); 24 | 25 | // Provide state to the entire application 26 | app.provide('state', state); 27 | 28 | // provide v-focus for components 29 | app.directive("focus", { 30 | mounted: async (el) => { 31 | // initiate focus for the element 32 | el.focus(); 33 | }, 34 | }); 35 | 36 | app.mixin({ 37 | mounted() { 38 | // expose vue instance to components 39 | this.$el.__vue__ = this; 40 | }, 41 | }); 42 | 43 | router.isReady().then(() => app.mount("#app")); -------------------------------------------------------------------------------- /frontend/src/notify/index.ts: -------------------------------------------------------------------------------- 1 | import * as messageFunctions from "./message.js"; 2 | import * as loadingSpinnerFunctions from "./loadingSpinner.js"; 3 | import * as events from "./events.js"; 4 | 5 | const notify = { 6 | ...messageFunctions, 7 | ...loadingSpinnerFunctions 8 | }; 9 | 10 | export { notify, events }; -------------------------------------------------------------------------------- /frontend/src/notify/loadingSpinner.js: -------------------------------------------------------------------------------- 1 | export function startLoading (from, to) { 2 | if (from == to) { 3 | return 4 | } 5 | 6 | // Get the spinner canvas element 7 | let spinner = document.querySelector('.notification-spinner') 8 | if (!spinner) { 9 | console.error('Spinner canvas element not found') 10 | return 11 | } 12 | spinner.classList.remove('hidden') 13 | 14 | // Get the 2D context of the canvas 15 | let ctx = spinner.getContext('2d') 16 | if (!ctx) { 17 | console.error('Could not get 2D context') 18 | return 19 | } 20 | 21 | // Set canvas dimensions 22 | let width = spinner.width 23 | let height = spinner.height 24 | 25 | // Initialize variables 26 | let degrees = from * 3.6 // Convert percentage to degrees 27 | let new_degrees = to * 3.6 // Convert percentage to degrees 28 | let difference = new_degrees - degrees 29 | let color = spinner.style.color || '#fff' 30 | let bgcolor = '#666' 31 | let animation_loop 32 | 33 | // Clear any existing animation loop 34 | if (animation_loop !== undefined) clearInterval(animation_loop) 35 | 36 | // Calculate the increment per 10ms 37 | let duration = 300 // Duration of the animation in ms 38 | let increment = difference / (duration / 10) 39 | 40 | // Start the animation loop 41 | animation_loop = setInterval(function () { 42 | // Check if the animation should stop 43 | if ( 44 | (increment > 0 && degrees >= new_degrees) || 45 | (increment < 0 && degrees <= new_degrees) 46 | ) { 47 | clearInterval(animation_loop) 48 | return 49 | } 50 | 51 | // Update the degrees 52 | degrees += increment 53 | 54 | // Clear the canvas 55 | ctx.clearRect(0, 0, width, height) 56 | 57 | // Draw the background circle 58 | ctx.beginPath() 59 | ctx.strokeStyle = bgcolor 60 | ctx.lineWidth = 10 61 | ctx.arc(width / 2, height / 2, height / 3, 0, Math.PI * 2, false) 62 | ctx.stroke() 63 | 64 | // Draw the foreground circle 65 | let radians = (degrees * Math.PI) / 180 66 | ctx.beginPath() 67 | ctx.strokeStyle = color 68 | ctx.lineWidth = 10 69 | ctx.arc( 70 | width / 2, 71 | height / 2, 72 | height / 3, 73 | 0 - (90 * Math.PI) / 180, 74 | radians - (90 * Math.PI) / 180, 75 | false 76 | ) 77 | ctx.stroke() 78 | 79 | // Draw the text 80 | ctx.fillStyle = '#fff' 81 | ctx.font = '1.2em Roboto' 82 | let text = Math.floor((degrees / 360) * 100) + '%' 83 | let text_width = ctx.measureText(text).width 84 | ctx.fillText(text, width / 2 - text_width / 2, height / 2 + 8) 85 | }, 10) // Update every 10ms 86 | } 87 | -------------------------------------------------------------------------------- /frontend/src/notify/message.js: -------------------------------------------------------------------------------- 1 | import { mutations, state } from '@/store' 2 | 3 | let active = false 4 | let closeTimeout // Store timeout ID 5 | 6 | export function showPopup(type, message, autoclose = true) { 7 | if (active) { 8 | clearTimeout(closeTimeout) // Clear the existing timeout 9 | } 10 | 11 | const [popup, popupContent] = getElements() 12 | if (popup === undefined || popup == null) { 13 | return 14 | } 15 | popup.classList.remove('success', 'error') // Clear previous types 16 | popup.classList.add(type) 17 | active = true 18 | 19 | let apiMessage 20 | 21 | try { 22 | apiMessage = JSON.parse(message) 23 | if ( 24 | apiMessage && 25 | Object.prototype.hasOwnProperty.call(apiMessage, 'status') && 26 | Object.prototype.hasOwnProperty.call(apiMessage, 'message') 27 | ) { 28 | popupContent.textContent = 29 | 'Error ' + apiMessage.status + ': ' + apiMessage.message 30 | } 31 | } catch (error) { 32 | popupContent.textContent = message 33 | } 34 | 35 | popup.style.right = '0em' 36 | 37 | // Don't auto-hide for 'action' type popups 38 | if (type === 'action') { 39 | popup.classList.add('success') 40 | return 41 | } 42 | 43 | if (!autoclose || !active) { 44 | return 45 | } 46 | 47 | // Set a new timeout for closing 48 | closeTimeout = setTimeout(() => { 49 | if (active) { 50 | closePopUp() 51 | } 52 | }, 5000) 53 | } 54 | 55 | export function closePopUp() { 56 | active = false 57 | const [popup, popupContent] = getElements() 58 | if (popupContent == undefined) { 59 | return 60 | } 61 | if ( 62 | popupContent.textContent == 'Multiple Selection Enabled' && 63 | state.multiple 64 | ) { 65 | mutations.setMultiple(false) 66 | } 67 | popup.style.right = '-50em' // Slide out 68 | popupContent.textContent = 'no content' 69 | } 70 | 71 | function getElements() { 72 | const popup = document.getElementById('popup-notification') 73 | if (!popup) { 74 | return [null, null] 75 | } 76 | 77 | const popupContent = popup.querySelector('#popup-notification-content') 78 | if (!popupContent) { 79 | return [null, null] 80 | } 81 | 82 | return [popup, popupContent] 83 | } 84 | 85 | export function showSuccess(message) { 86 | showPopup('success', message) 87 | } 88 | 89 | export function showError(message) { 90 | showPopup('error', message) 91 | console.error(message) 92 | } 93 | 94 | export function showMultipleSelection() { 95 | showPopup('action', 'Multiple Selection Enabled') 96 | } 97 | -------------------------------------------------------------------------------- /frontend/src/store/eventBus.js: -------------------------------------------------------------------------------- 1 | // eventBus.ts 2 | class EventBus extends EventTarget { 3 | emit(event, data) { 4 | this.dispatchEvent(new CustomEvent(event, { detail: data })); 5 | } 6 | 7 | on(event, callback) { 8 | this.addEventListener(event, (e) => callback(e.detail)); 9 | } 10 | } 11 | 12 | export const eventBus = new EventBus(); 13 | 14 | export function emitStateChanged() { 15 | eventBus.emit('stateChanged'); 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/store/index.ts: -------------------------------------------------------------------------------- 1 | // store/index.js 2 | import { state } from "./state.js"; 3 | import { getters } from "./getters.js"; 4 | import { mutations } from "./mutations.js"; 5 | 6 | export { 7 | state, 8 | getters, 9 | mutations 10 | }; -------------------------------------------------------------------------------- /frontend/src/store/state.js: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue'; 2 | import { detectLocale } from "@/i18n"; 3 | 4 | export const state = reactive({ 5 | multiButtonState: "menu", 6 | multiButtonLastState: "menu", 7 | showOverflowMenu: false, 8 | sessionId: "", 9 | disableOnlyOfficeExt: "", 10 | isSafari: /^((?!chrome|android).)*safari/i.test(navigator.userAgent), 11 | activeSettingsView: "", 12 | isMobile: window.innerWidth <= 800, 13 | isSearchActive: false, 14 | showSidebar: false, 15 | usages: {}, 16 | editor: null, 17 | serverHasMultipleSources: false, 18 | realtimeActive: undefined, 19 | realtimeDownCount: 0, 20 | popupPreviewSource: "", 21 | sources: { 22 | current: "", 23 | count: 1, 24 | hasSourceInfo: false, 25 | info: {}, 26 | }, 27 | user: { 28 | preview: { 29 | video: true, 30 | image: true, 31 | popup: true, 32 | highQuality: true, 33 | }, 34 | loginType: "", 35 | username: "", 36 | quickDownloadEnabled: false, 37 | gallarySize: 0, 38 | singleClick: false, 39 | stickySidebar: stickyStartup(), 40 | locale: detectLocale(), // Default to the locale from moment 41 | viewMode: 'normal', // Default to mosaic view 42 | showHidden: false, // Default to false, assuming this is a boolean 43 | scopes: [], 44 | permissions: {}, // Default to an empty object for permissions 45 | darkMode: true, // Default to false, assuming this is a boolean 46 | profile: { // Example of additional user properties 47 | username: '', // Default to an empty string 48 | email: '', // Default to an empty string 49 | avatarUrl: '' // Default to an empty string 50 | } 51 | }, 52 | req: { 53 | sorting: { 54 | by: 'name', // Initial sorting field 55 | asc: true, // Initial sorting order 56 | }, 57 | items: [], 58 | numDirs: 0, 59 | numFiles: 0, 60 | }, 61 | listing: { 62 | category: "folders", 63 | letter: "A", 64 | scrolling: false, 65 | scrollRatio: 0, 66 | }, 67 | previewRaw: "", 68 | oldReq: {}, 69 | clipboard: { 70 | key: "", 71 | items: [], 72 | }, 73 | jwt: "", 74 | sharePassword: "", 75 | loading: [], 76 | reload: false, 77 | selected: [], 78 | multiple: false, 79 | upload: { 80 | uploads: {}, 81 | queue: [], 82 | progress: [], 83 | sizes: [], 84 | }, 85 | prompts: [], 86 | show: null, 87 | showConfirm: null, 88 | route: {}, 89 | settings: { 90 | signup: false, 91 | createUserDir: false, 92 | userHomeBasePath: "", 93 | rules: [], 94 | frontend: { 95 | disableExternal: false, 96 | name: "", 97 | files: "", 98 | }, 99 | }, 100 | }); 101 | 102 | function stickyStartup() { 103 | const stickyStatus = localStorage.getItem("stickySidebar"); 104 | return stickyStatus == "true" 105 | } -------------------------------------------------------------------------------- /frontend/src/utils/auth.js: -------------------------------------------------------------------------------- 1 | import { mutations, getters,state } from "@/store"; 2 | import router from "@/router"; 3 | import { usersApi } from "@/api"; 4 | import { getApiPath } from "@/utils/url.js"; 5 | import { recaptcha, loginPage } from "@/utils/constants"; 6 | 7 | export async function setNewToken(token) { 8 | document.cookie = `auth=${token}; path=/`; 9 | mutations.setJWT(token); 10 | } 11 | 12 | export async function validateLogin() { 13 | let userInfo = await usersApi.get("self"); 14 | mutations.setCurrentUser(userInfo); 15 | getters.isLoggedIn() 16 | if (state.user.loginMethod == "proxy") { 17 | let apiPath = getApiPath("api/auth/login") 18 | const res = await fetch(apiPath, { 19 | method: "POST", 20 | }); 21 | const body = await res.text(); 22 | if (res.status === 200) { 23 | await setNewToken(body); 24 | } else { 25 | throw new Error(body); 26 | } 27 | } 28 | return 29 | } 30 | 31 | export async function renew(jwt) { 32 | let apiPath = getApiPath("api/auth/renew") 33 | const res = await fetch(apiPath, { 34 | method: "POST", 35 | headers: { 36 | "X-Auth": jwt, 37 | }, 38 | }); 39 | const body = await res.text(); 40 | if (res.status === 200) { 41 | mutations.setSession(generateRandomCode(8)); 42 | await setNewToken(body); 43 | } else { 44 | throw new Error(body); 45 | } 46 | } 47 | 48 | export function generateRandomCode(length) { 49 | const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 50 | let code = ''; 51 | for (let i = 0; i < length; i++) { 52 | const randomIndex = Math.floor(Math.random() * charset.length); 53 | code += charset[randomIndex]; 54 | } 55 | 56 | return code; 57 | } 58 | 59 | export function logout() { 60 | if (state.user.loginMethod == "oidc") { 61 | document.cookie = "auth=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/"; 62 | mutations.setCurrentUser(null); 63 | let apiPath = getApiPath("api/auth/logout") 64 | window.location.href = apiPath; 65 | return 66 | } 67 | document.cookie = "auth=; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/"; 68 | mutations.setCurrentUser(null); 69 | router.push({ path: "/login" }); 70 | } 71 | 72 | // Helper function to retrieve the value of a specific cookie 73 | //function getCookie(name) { 74 | // return document.cookie 75 | // .split('; ') 76 | // .find(row => row.startsWith(name + '=')) 77 | // ?.split('=')[1]; 78 | //} 79 | 80 | export async function initAuth() { 81 | if (loginPage && !getters.isShare()) { 82 | console.log("validating login"); 83 | await validateLogin(); 84 | } 85 | if (recaptcha) { 86 | await new Promise((resolve) => { 87 | const check = () => { 88 | if (typeof window.grecaptcha === "undefined") { 89 | setTimeout(check, 100); 90 | } else { 91 | resolve(); 92 | } 93 | }; 94 | check(); 95 | }); 96 | } 97 | } -------------------------------------------------------------------------------- /frontend/src/utils/buttons.js: -------------------------------------------------------------------------------- 1 | function loading(button) { 2 | let el = document.querySelector(`#${button}-button > i`); 3 | 4 | if (el === undefined || el === null) { 5 | return; 6 | } 7 | 8 | if (el.innerHTML == "autorenew" || el.innerHTML == "done") { 9 | return; 10 | } 11 | 12 | el.dataset.icon = el.innerHTML; 13 | el.style.opacity = 0; 14 | 15 | setTimeout(() => { 16 | el.classList.add("spin"); 17 | el.innerHTML = "autorenew"; 18 | el.style.opacity = 1; 19 | }, 100); 20 | } 21 | 22 | function done(button) { 23 | let el = document.querySelector(`#${button}-button > i`); 24 | 25 | if (el === undefined || el === null) { 26 | return; 27 | } 28 | 29 | el.style.opacity = 0; 30 | 31 | setTimeout(() => { 32 | el.classList.remove("spin"); 33 | el.innerHTML = el.dataset.icon; 34 | el.style.opacity = 1; 35 | }, 100); 36 | } 37 | 38 | function success(button) { 39 | let el = document.querySelector(`#${button}-button > i`); 40 | 41 | if (el === undefined || el === null) { 42 | return; 43 | } 44 | 45 | el.style.opacity = 0; 46 | 47 | setTimeout(() => { 48 | el.classList.remove("spin"); 49 | el.innerHTML = "done"; 50 | el.style.opacity = 1; 51 | 52 | setTimeout(() => { 53 | el.style.opacity = 0; 54 | 55 | setTimeout(() => { 56 | el.innerHTML = el.dataset.icon; 57 | el.style.opacity = 1; 58 | }, 100); 59 | }, 500); 60 | }, 100); 61 | } 62 | 63 | export default { 64 | loading, 65 | done, 66 | success, 67 | }; 68 | -------------------------------------------------------------------------------- /frontend/src/utils/constants.js: -------------------------------------------------------------------------------- 1 | import i18n from '@/i18n'; // Import the default export (your i18n instance) 2 | 3 | const name = window.FileBrowser.Name; 4 | const disableExternal = window.FileBrowser.DisableExternal; 5 | const externalLinks = window.FileBrowser.ExternalLinks; 6 | const baseURL = window.FileBrowser.BaseURL; 7 | const staticURL = window.FileBrowser.StaticURL; 8 | const darkMode = window.FileBrowser.darkMode; 9 | const recaptcha = false; 10 | const recaptchaKey = ""; 11 | const signup = window.FileBrowser.Signup; 12 | const version = window.FileBrowser.Version; 13 | const commitSHA = window.FileBrowser.CommitSHA; 14 | const logoURL = `${staticURL}/img/logo.png`; 15 | const noAuth = window.FileBrowser.NoAuth; 16 | const loginPage = window.FileBrowser.LoginPage; 17 | const enableThumbs = window.FileBrowser.EnableThumbs; 18 | const externalUrl = window.FileBrowser.ExternalUrl 19 | const onlyOfficeUrl = window.FileBrowser.OnlyOfficeUrl 20 | const serverHasMultipleSources = window.FileBrowser.SourceCount > 1; 21 | const oidcAvailable = window.FileBrowser.OidcAvailable; 22 | const passwordAvailable = window.FileBrowser.PasswordAvailable; 23 | const mediaAvailable = window.FileBrowser.MediaAvailable; 24 | const origin = window.location.origin; 25 | 26 | const settings = [ 27 | { id: 'profile', label: i18n.global.t('settings.profileSettings'), component: 'ProfileSettings' }, 28 | { id: 'shares', label: i18n.global.t('settings.shareSettings'), component: 'SharesSettings', permissions: { share: true } }, 29 | { id: 'api', label: i18n.global.t('api.title'), component: 'ApiKeys', permissions: { api: true } }, 30 | //{ id: 'global', label: 'Global', component: 'GlobalSettings', permissions: { admin: true } }, 31 | { id: 'users', label: i18n.global.t('settings.userManagement'), component: 'UserManagement' }, 32 | ]; 33 | 34 | export { 35 | mediaAvailable, 36 | oidcAvailable, 37 | passwordAvailable, 38 | serverHasMultipleSources, 39 | name, 40 | externalUrl, 41 | disableExternal, 42 | externalLinks, 43 | baseURL, 44 | logoURL, 45 | recaptcha, 46 | recaptchaKey, 47 | signup, 48 | version, 49 | commitSHA, 50 | noAuth, 51 | loginPage, 52 | enableThumbs, 53 | origin, 54 | darkMode, 55 | settings, 56 | onlyOfficeUrl, 57 | }; 58 | -------------------------------------------------------------------------------- /frontend/src/utils/cookie.js: -------------------------------------------------------------------------------- 1 | export default function (name) { 2 | let re = new RegExp( 3 | "(?:(?:^|.*;\\s*)" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$" 4 | ); 5 | return document.cookie.replace(re, "$1"); 6 | } 7 | 8 | export function getCookie(name) { 9 | let cookie = document.cookie 10 | .split(";") 11 | .find((cookie) => cookie.includes(name + "=")); 12 | if (cookie != null) { 13 | return cookie.split("=")[1]; 14 | } 15 | return "" 16 | } -------------------------------------------------------------------------------- /frontend/src/utils/deepclone.ts: -------------------------------------------------------------------------------- 1 | type DeepCloneable = object | Array; 2 | 3 | export default function deepClone(obj: T): T { 4 | if (obj === null || typeof obj !== 'object') { 5 | return obj; 6 | } 7 | 8 | if (Array.isArray(obj)) { 9 | return obj.map(deepClone) as T; 10 | } 11 | 12 | const clone = {} as T; 13 | for (const key in obj) { 14 | clone[key] = deepClone(obj[key] as any); 15 | } 16 | return clone; 17 | } -------------------------------------------------------------------------------- /frontend/src/utils/download.js: -------------------------------------------------------------------------------- 1 | import { state, mutations, getters } from "@/store"; 2 | import { filesApi } from "@/api"; 3 | import { notify } from "@/notify"; 4 | import { removePrefix } from "@/utils/url.js"; 5 | import { publicApi } from "@/api"; 6 | 7 | export default function download() { 8 | if (getters.currentView() === "share") { 9 | let urlPath = getters.routePath("share"); 10 | let parts = urlPath.split("/"); 11 | const hash = parts[1]; 12 | const subPath = "/" + parts.slice(2).join("/"); 13 | let files = []; 14 | for (let i of state.selected) { 15 | const dlfile = removePrefix(state.req.items[i].url, "share/" + hash); 16 | files.push(dlfile); 17 | } 18 | const share = { 19 | path: subPath, 20 | hash: hash, 21 | token: "", 22 | format: files.length ? "zip" : null, 23 | }; 24 | // Perform download without opening a new window 25 | startDownload(share, files, true); 26 | return; 27 | } 28 | 29 | if (state.isSearchActive) { 30 | startDownload(null, [state.selected[0].url]); 31 | return; 32 | } 33 | 34 | if (getters.isSingleFileSelected()) { 35 | startDownload(null, [getters.selectedDownloadUrl()]); 36 | return; 37 | } 38 | 39 | // Multiple files download with user confirmation 40 | mutations.showHover({ 41 | name: "download", 42 | confirm: (format) => { 43 | mutations.closeHovers(); 44 | let files = []; 45 | if (state.selected.length > 0) { 46 | for (let i of state.selected) { 47 | files.push(state.req.items[i].url); 48 | } 49 | } else { 50 | files.push(state.route.path); 51 | } 52 | startDownload(format, files); 53 | }, 54 | }); 55 | } 56 | 57 | async function startDownload(config, files, isPublic) { 58 | try { 59 | if (isPublic) { 60 | publicApi.download(config, files); 61 | } else { 62 | filesApi.download(config, files); 63 | } 64 | notify.showSuccess("Downloading..."); 65 | } catch (e) { 66 | notify.showError(`Error downloading: ${e}`); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /frontend/src/utils/files.js: -------------------------------------------------------------------------------- 1 | 2 | export function getFileExtension(filename) { 3 | if (typeof filename !== 'string') { 4 | return '' 5 | } 6 | const firstDotIndex = filename.indexOf('.') 7 | 8 | // If no dot exists, return an empty string 9 | if (firstDotIndex === -1) { 10 | return '' 11 | } 12 | 13 | // Default: Get everything after the first dot 14 | const firstDotExtension = filename.substring(firstDotIndex) 15 | 16 | if (firstDotExtension === '.') { 17 | return "" 18 | } 19 | // If it's 7 or fewer characters (including the dot), return it 20 | if (firstDotExtension.length <= 7) { 21 | return firstDotExtension 22 | } 23 | 24 | // Otherwise, return everything after the last dot 25 | const lastDotIndex = filename.lastIndexOf('.') 26 | return filename.substring(lastDotIndex) 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/utils/files.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { getFileExtension } from './files.js'; 3 | 4 | describe('testSort', () => { 5 | 6 | it('get extension from file', () => { 7 | const tests = [ 8 | {input: "hi.txt", expected:".txt"}, 9 | {input: "hello world.exe", expected:".exe"}, 10 | {input: "Amazon.com - Order.pdf", expected:".pdf"}, 11 | {input: "file", expected:""}, 12 | {input: "file.", expected:""}, 13 | {input: "file.tar.gz", expected:".tar.gz"}, 14 | ] 15 | for (let i in tests) { 16 | expect(getFileExtension(tests[i].input)).toEqual(tests[i].expected); 17 | } 18 | }); 19 | 20 | }); 21 | -------------------------------------------------------------------------------- /frontend/src/utils/filesizes.js: -------------------------------------------------------------------------------- 1 | export function getHumanReadableFilesize(fileSizeBytes) { 2 | let size; // size in the specified unit 3 | let unit; // the unit name 4 | unit = 'bytes'; 5 | size = fileSizeBytes; 6 | 7 | switch (true) { 8 | case fileSizeBytes < 1024: 9 | break; 10 | case fileSizeBytes < 1024 ** 2: // 1 KB - 1 MB 11 | size = fileSizeBytes / 1024; 12 | unit = 'KB'; 13 | break; 14 | case fileSizeBytes < 1024 ** 3: // 1 MB - 1 GB 15 | size = fileSizeBytes / (1024 ** 2); 16 | unit = 'MB'; 17 | break; 18 | case fileSizeBytes < 1024 ** 4: // 1 GB - 1 TB 19 | size = fileSizeBytes / (1024 ** 3); 20 | unit = 'GB'; 21 | break; 22 | case fileSizeBytes < 1024 ** 5: // 1 TB - 1 PB 23 | size = fileSizeBytes / (1024 ** 4); 24 | unit = 'TB'; 25 | break; 26 | default: // >= 1 PB 27 | size = fileSizeBytes / (1024 ** 5); 28 | unit = 'PB'; 29 | break; 30 | } 31 | return `${size.toFixed(1)} ${unit}`; 32 | } -------------------------------------------------------------------------------- /frontend/src/utils/filesizes.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { getHumanReadableFilesize } from './filesizes.js'; 3 | 4 | describe('testSort', () => { 5 | 6 | it('validate human readable sizes', () => { 7 | const tests = [ 8 | {input: 1, expected:"1.0 bytes"}, 9 | {input: 1150, expected:"1.1 KB"}, 10 | {input: 5105650, expected:"4.9 MB"}, 11 | {input: 156518899684, expected:"145.8 GB"}, 12 | {input: 1020993183744, expected:"950.9 GB"}, 13 | {input: 4891498498488, expected:"4.4 TB"}, 14 | {input: 11991498498488488, expected:"10.7 PB"}, 15 | ] 16 | for (let i in tests) { 17 | expect(getHumanReadableFilesize(tests[i].input)).toEqual(tests[i].expected); 18 | } 19 | }); 20 | 21 | }); 22 | -------------------------------------------------------------------------------- /frontend/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import * as url from "./url.js"; 2 | 3 | export { 4 | url, 5 | }; -------------------------------------------------------------------------------- /frontend/src/utils/sort.js: -------------------------------------------------------------------------------- 1 | 2 | import { state } from "@/store"; 3 | 4 | export function sortedItems(items = [], sortby="name") { 5 | return items.sort((a, b) => { 6 | let valueA = a[sortby]; 7 | let valueB = b[sortby]; 8 | 9 | if (sortby === "name") { 10 | valueA = valueA.split(".")[0] 11 | valueB = valueB.split(".")[0] 12 | // Handle sorting for "name" field 13 | const isNumericA = !isNaN(valueA); 14 | const isNumericB = !isNaN(valueB); 15 | 16 | if (isNumericA && isNumericB) { 17 | // Compare numeric strings as numbers 18 | return state.user.sorting.asc 19 | ? parseFloat(valueA) - parseFloat(valueB) 20 | : parseFloat(valueB) - parseFloat(valueA); 21 | } 22 | // Compare non-numeric values as strings 23 | return state.user.sorting.asc 24 | ? valueA.localeCompare(valueB) 25 | : valueB.localeCompare(valueA); 26 | } 27 | 28 | // Default sorting for other fields 29 | if (state.user.sorting.asc) { 30 | return valueA > valueB ? 1 : -1; 31 | } else { 32 | return valueA < valueB ? 1 : -1; 33 | } 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/utils/sort.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { sortedItems } from './sort.js'; 3 | 4 | describe('testSort', () => { 5 | 6 | it('sort items by name correctly', () => { 7 | const input = [ 8 | { name: "zebra" }, 9 | { name: "1" }, 10 | { name: "10" }, 11 | { name: "Apple" }, 12 | { name: "2" }, 13 | ] 14 | const expected = [ 15 | { name: "1" }, 16 | { name: "2" }, 17 | { name: "10" }, 18 | { name: "Apple" }, 19 | { name: "zebra" } 20 | ] 21 | expect(sortedItems(input, "name")).toEqual(expected); 22 | }); 23 | 24 | it('sort items with extensions by name correctly', () => { 25 | const input = [ 26 | { name: "zebra.txt" }, 27 | { name: "1.txt" }, 28 | { name: "10.txt" }, 29 | { name: "Apple.txt" }, 30 | { name: "2.txt" }, 31 | { name: "0" } 32 | ] 33 | const expected = [ 34 | { name: "0" }, 35 | { name: "1.txt" }, 36 | { name: "2.txt" }, 37 | { name: "10.txt" }, 38 | { name: "Apple.txt" }, 39 | { name: "zebra.txt" } 40 | ] 41 | expect(sortedItems(input, "name")).toEqual(expected); 42 | }); 43 | 44 | it('sort items by size correctly', () => { 45 | const input = [ 46 | { size: "10" }, 47 | { size: "0" }, 48 | { size: "5000" }, 49 | ] 50 | const expected = [ 51 | { size: "0" }, 52 | { size: "10" }, 53 | { size: "5000" } 54 | ] 55 | expect(sortedItems(input, "size")).toEqual(expected); 56 | }); 57 | 58 | it('sort items by date correctly', () => { 59 | const now = new Date(); 60 | const tenMinutesAgo = new Date(now.getTime() - 10 * 60 * 1000); 61 | const tenMinutesFromNow = new Date(now.getTime() + 10 * 60 * 1000); 62 | 63 | const input = [ 64 | { date: now }, 65 | { date: tenMinutesAgo }, 66 | { date: tenMinutesFromNow }, 67 | ] 68 | const expected = [ 69 | { date: tenMinutesAgo }, 70 | { date: now }, 71 | { date: tenMinutesFromNow } 72 | ] 73 | expect(sortedItems(input, "date")).toEqual(expected); 74 | }); 75 | 76 | 77 | }); 78 | -------------------------------------------------------------------------------- /frontend/src/utils/throttle.js: -------------------------------------------------------------------------------- 1 | // Function to mimic lodash throttle 2 | export default function throttle(func, limit) { 3 | let inThrottle; 4 | return function (...args) { 5 | const context = this; 6 | if (!inThrottle) { 7 | func.apply(context, args); 8 | inThrottle = true; 9 | setTimeout(() => (inThrottle = false), limit); 10 | } 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/views/Errors.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 57 | -------------------------------------------------------------------------------- /frontend/src/views/files/Editor.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 81 | -------------------------------------------------------------------------------- /frontend/src/views/files/OnlyOfficeEditor.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 80 | 81 | 102 | -------------------------------------------------------------------------------- /frontend/src/views/settings/Global.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 75 | -------------------------------------------------------------------------------- /frontend/src/views/settings/Users.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 81 | -------------------------------------------------------------------------------- /frontend/tests-playwright/general/auth.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test("redirect to login from root", async ({ page, context }) => { 4 | await context.clearCookies(); 5 | await page.goto("/"); 6 | await expect(page).toHaveURL(/\/login/); 7 | 8 | }); 9 | 10 | test("redirect to login from files", async ({ page, context }) => { 11 | await context.clearCookies(); 12 | await page.goto("/files/"); 13 | await expect(page).toHaveURL(/\/login\?redirect=\/files\//); 14 | }); 15 | 16 | test("logout", async ({ page, context }) => { 17 | await page.goto('/'); 18 | await expect(page.locator("div.wrong")).toBeHidden(); 19 | await page.waitForURL("**/files/playwright-files", { timeout: 100 }); 20 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files"); 21 | let cookies = await context.cookies(); 22 | expect(cookies.find((c) => c.name == "auth")?.value).toBeDefined(); 23 | await page.locator('button[aria-label="logout-button"]').click(); 24 | await page.waitForURL("**/login", { timeout: 100 }); 25 | await expect(page).toHaveTitle("Graham's Filebrowser - Login"); 26 | cookies = await context.cookies(); 27 | expect(cookies.find((c) => c.name == "auth")?.value).toBeUndefined(); 28 | }); -------------------------------------------------------------------------------- /frontend/tests-playwright/general/navigation.spec.ts: -------------------------------------------------------------------------------- 1 | 2 | import { test, expect } from "../test-setup"; 3 | 4 | test("navigate with hash in file name", async({ page, checkForErrors, context }) => { 5 | await page.goto("/files/"); 6 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files"); 7 | await page.locator('a[aria-label="folder#hash"]').waitFor({ state: 'visible' }); 8 | await page.locator('a[aria-label="folder#hash"]').dblclick(); 9 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - folder#hash"); 10 | await page.locator('a[aria-label="file#.sh"]').waitFor({ state: 'visible' }); 11 | await page.locator('a[aria-label="file#.sh"]').dblclick(); 12 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - file#.sh"); 13 | await expect(page.locator('.topTitle')).toHaveText('file#.sh'); 14 | checkForErrors() 15 | }) 16 | 17 | test("breadcrumbs navigation checks", async({ page, checkForErrors, context }) => { 18 | await page.goto("/files/playwright-files/myfolder"); 19 | await page.waitForSelector('#breadcrumbs'); 20 | let spanChildrenCount = await page.locator('#breadcrumbs > ul > li.item').count(); 21 | spanChildrenCount = await page.locator('#breadcrumbs > ul > li.item').count(); 22 | expect(spanChildrenCount).toBe(1); 23 | let breadCrumbLink = page.locator('a[aria-label="breadcrumb-link-myfolder"]') 24 | await expect(breadCrumbLink).toHaveText("myfolder"); 25 | 26 | await page.goto("/files/playwright-files/myfolder/testdata"); 27 | await page.waitForSelector('#breadcrumbs'); 28 | spanChildrenCount = await page.locator('#breadcrumbs > ul > li.item').count(); 29 | expect(spanChildrenCount).toBe(2); 30 | breadCrumbLink = page.locator('a[aria-label="breadcrumb-link-testdata"]') 31 | await expect(breadCrumbLink).toHaveText("testdata"); 32 | 33 | await page.goto("/files/playwright-files/files"); 34 | await page.waitForSelector('#breadcrumbs'); 35 | spanChildrenCount = await page.locator('#breadcrumbs > ul > li.item').count(); 36 | expect(spanChildrenCount).toBe(1); 37 | breadCrumbLink = page.locator('a[aria-label="breadcrumb-link-files"]') 38 | await expect(breadCrumbLink).toHaveText("files"); 39 | checkForErrors(); 40 | }); 41 | 42 | test("navigate from search item", async({ page, checkForErrors, context }) => { 43 | await page.goto("/files/"); 44 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files"); 45 | await page.locator('#search').click() 46 | await page.locator('#main-input').fill('for testing'); 47 | await expect(page.locator('#result-list')).toHaveCount(1); 48 | await page.locator('li[aria-label="for testing.md"]').click(); 49 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - for testing.md"); 50 | await expect(page.locator('.topTitle')).toHaveText('for testing.md'); 51 | checkForErrors() 52 | }); 53 | -------------------------------------------------------------------------------- /frontend/tests-playwright/general/settings.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '../test-setup' 2 | 3 | test('create and delete testuser', async ({ 4 | page, 5 | checkForErrors, 6 | context 7 | }) => { 8 | await page.goto('/settings') 9 | await expect(page).toHaveTitle("Graham's Filebrowser - Settings") 10 | await page.locator('button[aria-label="Add New User"]').click() 11 | await page.locator('#username').fill('testuser') 12 | await page.locator('input[aria-label="Password1"]').fill('testpassword') 13 | await page.locator('input[aria-label="Password2"]').fill('testpass') 14 | // check that the invalid-field class is added properly 15 | await expect(page.locator('input[aria-label="Password2"]')).toHaveClass( 16 | 'input input--block form-form invalid-form' 17 | ) 18 | await page.locator('input[aria-label="Password2"]').fill('testpassword') 19 | await page.locator('input[aria-label="Save User"]').click() 20 | 21 | // click the edit button for testuser 22 | const userRow = page.locator('tr.item', { hasText: 'testuser' }) 23 | const editLink = await userRow 24 | .locator('td[aria-label="Edit User"] a') 25 | .getAttribute('href') 26 | await page.goto(editLink!) 27 | checkForErrors() 28 | }) 29 | -------------------------------------------------------------------------------- /frontend/tests-playwright/general/theme-branding.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../test-setup"; 2 | 3 | test("sidebar links", async({ page, checkForErrors, context }) => { 4 | await page.goto("/files/"); 5 | 6 | // Verify the page title 7 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files"); 8 | 9 | // Locate the credits container 10 | const credits = page.locator('.credits'); // Fix the selector to match the HTML structure 11 | 12 | // Assert that the

contains the text 'FileBrowser Quantum' 13 | await expect(credits.locator("h4")).toHaveText("Graham's Filebrowser"); 14 | 15 | // Assert that the contains the text 'A playwright test' 16 | await expect(credits.locator("span").locator("a")).toHaveText('A playwright test'); 17 | 18 | // Assert that the does not contain the text 'Help' 19 | await expect(credits.locator("span").locator("a")).not.toHaveText('Help'); 20 | // Check for console errors 21 | checkForErrors(); 22 | }); 23 | 24 | test("adjusting theme colors", async({ page, checkForErrors, context }) => { 25 | await page.goto("/files/"); 26 | const originalPrimaryColor = await page.evaluate(() => { 27 | return getComputedStyle(document.documentElement).getPropertyValue('--primaryColor').trim(); 28 | }); 29 | await expect(originalPrimaryColor).toBe('#2196f3'); 30 | 31 | // Verify the page title 32 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files"); 33 | await page.locator('i[aria-label="settings"]').click(); 34 | await expect(page).toHaveTitle("Graham's Filebrowser - Settings"); 35 | await page.locator('button', { hasText: 'violet' }).click(); 36 | const popup = page.locator('#popup-notification-content'); 37 | await popup.waitFor({ state: 'visible' }); 38 | await expect(popup).toHaveText('Settings updated!'); 39 | const newPrimaryColor = await page.evaluate(() => { 40 | return getComputedStyle(document.documentElement).getPropertyValue('--primaryColor').trim(); 41 | }); 42 | await expect(newPrimaryColor).toBe('#9b59b6'); 43 | // Check for console errors 44 | checkForErrors(); 45 | }); 46 | -------------------------------------------------------------------------------- /frontend/tests-playwright/noauth-setup.ts: -------------------------------------------------------------------------------- 1 | import { Browser, firefox, expect, Page } from "@playwright/test"; 2 | 3 | // Perform authentication and store auth state 4 | async function globalSetup() { 5 | const browser: Browser = await firefox.launch(); 6 | const context = await browser.newContext(); 7 | const page: Page = await context.newPage(); 8 | 9 | await page.goto("http://127.0.0.1/files", { timeout: 500 }); 10 | 11 | // Create a share of folder 12 | await page.locator('a[aria-label="myfolder"]').waitFor({ state: 'visible' }); 13 | await page.locator('a[aria-label="myfolder"]').click({ button: "right" }); 14 | await page.locator('.selected-count-header').waitFor({ state: 'visible' }); 15 | await expect(page.locator('.selected-count-header')).toHaveText('1 selected'); 16 | await page.locator('button[aria-label="Share"]').click(); 17 | await expect(page.locator('div[aria-label="share-path"]')).toHaveText('Path: /myfolder'); 18 | await page.locator('button[aria-label="Share-Confirm"]').click(); 19 | await expect(page.locator("#share .card-content table tbody tr:not(:has(th))")).toHaveCount(1); 20 | const shareHash = await page.locator("#share .card-content table tbody tr:not(:has(th)) td").first().textContent(); 21 | if (!shareHash) { 22 | throw new Error("Failed to retrieve shareHash"); 23 | } 24 | // Store shareHash in localStorage 25 | await page.evaluate((hash) => { 26 | localStorage.setItem('shareHash', hash); 27 | }, shareHash); 28 | 29 | await page.goto("http://127.0.0.1/files", { timeout: 500 }); 30 | // Create a share of file 31 | await page.locator('a[aria-label="1file1.txt"]').waitFor({ state: 'visible' }); 32 | await page.locator('a[aria-label="1file1.txt"]').click({ button: "right" }); 33 | await page.locator('.selected-count-header').waitFor({ state: 'visible' }); 34 | await expect(page.locator('.selected-count-header')).toHaveText('1 selected'); 35 | await page.locator('button[aria-label="Share"]').click(); 36 | await expect(page.locator('div[aria-label="share-path"]')).toHaveText('Path: /1file1.txt'); 37 | await page.locator('button[aria-label="Share-Confirm"]').click(); 38 | await expect(page.locator("#share .card-content table tbody tr:not(:has(th))")).toHaveCount(1); 39 | const shareHashFile = await page.locator("#share .card-content table tbody tr:not(:has(th)) td").first().textContent(); 40 | if (!shareHashFile) { 41 | throw new Error("Failed to retrieve shareHash"); 42 | } 43 | // Store shareHash in localStorage 44 | await page.evaluate((hash) => { 45 | localStorage.setItem('shareHashFile', hash); 46 | }, shareHashFile); 47 | 48 | 49 | await context.storageState({ path: "./noauth.json" }); 50 | await browser.close(); 51 | } 52 | 53 | export default globalSetup; -------------------------------------------------------------------------------- /frontend/tests-playwright/noauth/navigation.spec.ts: -------------------------------------------------------------------------------- 1 | 2 | import { test, expect } from "../test-setup"; 3 | 4 | test("navigate with hash in file name", async({ page, checkForErrors, context }) => { 5 | await page.goto("/files/"); 6 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files"); 7 | await page.locator('a[aria-label="folder#hash"]').waitFor({ state: 'visible' }); 8 | await page.locator('a[aria-label="folder#hash"]').dblclick(); 9 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - folder#hash"); 10 | await page.locator('a[aria-label="file#.sh"]').waitFor({ state: 'visible' }); 11 | await page.locator('a[aria-label="file#.sh"]').dblclick(); 12 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - file#.sh"); 13 | await expect(page.locator('.topTitle')).toHaveText('file#.sh'); 14 | checkForErrors() 15 | }) 16 | 17 | test("breadcrumbs navigation checks", async({ page, checkForErrors, context }) => { 18 | await page.goto("/files/myfolder"); 19 | await page.waitForSelector('#breadcrumbs'); 20 | let spanChildrenCount = await page.locator('#breadcrumbs > ul > li.item').count(); 21 | spanChildrenCount = await page.locator('#breadcrumbs > ul > li.item').count(); 22 | expect(spanChildrenCount).toBe(1); 23 | let breadCrumbLink = page.locator('a[aria-label="breadcrumb-link-myfolder"]') 24 | await expect(breadCrumbLink).toHaveText("myfolder"); 25 | 26 | await page.goto("/files/myfolder/testdata"); 27 | await page.waitForSelector('#breadcrumbs'); 28 | spanChildrenCount = await page.locator('#breadcrumbs > ul > li.item').count(); 29 | expect(spanChildrenCount).toBe(2); 30 | breadCrumbLink = page.locator('a[aria-label="breadcrumb-link-testdata"]') 31 | await expect(breadCrumbLink).toHaveText("testdata"); 32 | 33 | // TODO: fix this test.. router issue for /files path 34 | //await page.goto("/files/files"); 35 | //await page.waitForSelector('#breadcrumbs'); 36 | //spanChildrenCount = await page.locator('#breadcrumbs > ul > li.item').count(); 37 | //expect(spanChildrenCount).toBe(1); 38 | //breadCrumbLink = page.locator('a[aria-label="breadcrumb-link-files"]') 39 | //await expect(breadCrumbLink).toHaveText("files"); 40 | checkForErrors(); 41 | }); 42 | 43 | test("navigate from search item", async({ page, checkForErrors, context }) => { 44 | await page.goto("/files/"); 45 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files"); 46 | await page.locator('#search').click() 47 | await page.locator('#main-input').fill('for testing'); 48 | await expect(page.locator('#result-list')).toHaveCount(1); 49 | await page.locator('li[aria-label="for testing.md"]').click(); 50 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - for testing.md"); 51 | await expect(page.locator('.topTitle')).toHaveText('for testing.md'); 52 | checkForErrors() 53 | }); 54 | -------------------------------------------------------------------------------- /frontend/tests-playwright/noauth/preview.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../test-setup"; 2 | 3 | test("blob file preview", async({ page, checkForErrors, context }) => { 4 | await page.goto("/files/"); 5 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files"); 6 | await page.locator('a[aria-label="file.tar.gz"]').waitFor({ state: 'visible' }); 7 | await page.locator('a[aria-label="file.tar.gz"]').dblclick(); 8 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - file.tar.gz"); 9 | await page.locator('button[title="Close"]').click(); 10 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files"); 11 | // Check for console errors 12 | checkForErrors(); 13 | }); 14 | 15 | test("text file editor", async({ page, checkForErrors, context }) => { 16 | await page.goto("/files/"); 17 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files"); 18 | await page.locator('a[aria-label="copyme.txt"]').waitFor({ state: 'visible' }); 19 | await page.locator('a[aria-label="copyme.txt"]').dblclick(); 20 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - copyme.txt"); 21 | const firstLineText = await page.locator('.ace_text-layer .ace_line').first().textContent(); 22 | expect(firstLineText).toBe('test file for playwright'); 23 | await page.locator('button[title="Close"]').click(); 24 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files"); 25 | // Check for console errors 26 | checkForErrors(); 27 | }); 28 | 29 | test("navigate folders", async({ page, checkForErrors, context }) => { 30 | await page.goto("/files/"); 31 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files"); 32 | await page.locator('a[aria-label="myfolder"]').waitFor({ state: 'visible' }); 33 | await page.locator('a[aria-label="myfolder"]').dblclick(); 34 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - myfolder"); 35 | await page.locator('a[aria-label="testdata"]').waitFor({ state: 'visible' }); 36 | await page.locator('a[aria-label="testdata"]').dblclick(); 37 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - testdata"); 38 | await page.locator('a[aria-label="gray-sample.jpg"]').waitFor({ state: 'visible' }); 39 | await page.locator('a[aria-label="gray-sample.jpg"]').dblclick(); 40 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - gray-sample.jpg"); 41 | // Check for console errors 42 | checkForErrors(); 43 | }); -------------------------------------------------------------------------------- /frontend/tests-playwright/noauth/theme-branding.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../test-setup"; 2 | 3 | test("sidebar links", async({ page, checkForErrors, context }) => { 4 | await page.goto("/files/"); 5 | 6 | // Verify the page title 7 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files"); 8 | 9 | // Locate the credits container 10 | const credits = page.locator('.credits'); // Fix the selector to match the HTML structure 11 | 12 | // Assert that the

contains the text 'FileBrowser Quantum' 13 | await expect(credits.locator("h4")).toHaveText("Graham's Filebrowser"); 14 | 15 | // Assert that the contains the text 'A playwright test' 16 | await expect(credits.locator("span").locator("a")).toHaveText('A playwright test'); 17 | 18 | // Assert that the does not contain the text 'Help' 19 | await expect(credits.locator("span").locator("a")).not.toHaveText('Help'); 20 | // Check for console errors 21 | checkForErrors(); 22 | }); 23 | 24 | test("adjusting theme colors", async({ page, checkForErrors, context }) => { 25 | await page.goto("/files/"); 26 | const originalPrimaryColor = await page.evaluate(() => { 27 | return getComputedStyle(document.documentElement).getPropertyValue('--primaryColor').trim(); 28 | }); 29 | await expect(originalPrimaryColor).toBe('#2196f3'); 30 | 31 | // Verify the page title 32 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files"); 33 | await page.locator('i[aria-label="settings"]').click(); 34 | await expect(page).toHaveTitle("Graham's Filebrowser - Settings"); 35 | await page.locator('button', { hasText: 'violet' }).click(); 36 | const popup = page.locator('#popup-notification-content'); 37 | await popup.waitFor({ state: 'visible' }); 38 | await expect(popup).toHaveText('Settings updated!'); 39 | const newPrimaryColor = await page.evaluate(() => { 40 | return getComputedStyle(document.documentElement).getPropertyValue('--primaryColor').trim(); 41 | }); 42 | await expect(newPrimaryColor).toBe('#9b59b6'); 43 | // Check for console errors 44 | checkForErrors(); 45 | }); 46 | -------------------------------------------------------------------------------- /frontend/tests-playwright/proxy/preview.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "../test-setup"; 2 | 3 | test("Create first new file", async ({ page, checkForErrors, context }) => { 4 | await page.goto("/"); 5 | await expect(page.locator('#listingView .message > span')).toHaveText('It feels lonely here...'); 6 | await page.locator('#listingView').click({ button: "right" }); 7 | await page.locator('button[aria-label="New file"]').click(); 8 | await page.locator('input[aria-label="FileName Field"]').fill('test.txt'); 9 | await page.locator('button[aria-label="Create"]').click(); 10 | await expect(page).toHaveTitle("Graham's Filebrowser - Files - test.txt"); 11 | await page.locator('button[aria-label="Close"]').click(); 12 | await expect(page.locator('#listingView .file-items')).toHaveCount(1); 13 | checkForErrors(); 14 | }); 15 | 16 | -------------------------------------------------------------------------------- /frontend/tests-playwright/test-setup.ts: -------------------------------------------------------------------------------- 1 | import { test as base, expect, Page } from "@playwright/test"; 2 | 3 | export const test = base.extend<{ 4 | checkForErrors: (expectedConsoleErrors?: number, expectedApiErrors?: number) => void; 5 | openContextMenu: () => Promise; 6 | }>({ 7 | checkForErrors: async ({ page }, use) => { 8 | const { checkForErrors } = setupErrorTracking(page); 9 | await use(checkForErrors); 10 | }, 11 | openContextMenu: async ({ page }, use) => { 12 | await use(async () => { 13 | const listingView = await page.locator('#listingView'); 14 | const box = await listingView.boundingBox(); 15 | if (!box) throw new Error("Could not find listingView bounding box"); 16 | const x = box.x + box.width / 2; 17 | const y = box.y + box.height - 1; 18 | await page.mouse.click(x, y, { button: "right" }); 19 | }); 20 | } 21 | }); 22 | 23 | // Error tracking function 24 | export function setupErrorTracking(page: Page) { 25 | const consoleErrors: string[] = []; 26 | const failedResponses: { url: string; status: number }[] = []; 27 | 28 | // Track console errors 29 | page.on("console", (message) => { 30 | if (message.type() === "error") { 31 | consoleErrors.push(message.text()); 32 | } 33 | }); 34 | 35 | // Track failed API calls 36 | page.on("response", (response) => { 37 | if (!response.ok()) { 38 | failedResponses.push({ 39 | url: response.url(), 40 | status: response.status(), 41 | }); 42 | } 43 | }); 44 | 45 | return { 46 | checkForErrors: (expectedConsoleErrors = 0, expectedApiErrors = 0) => { 47 | if (consoleErrors.length !== expectedConsoleErrors) { 48 | console.error("Unexpected Console Errors:", consoleErrors); 49 | } 50 | 51 | if (failedResponses.length !== expectedApiErrors) { 52 | console.error("Unexpected Failed API Calls:", failedResponses); 53 | } 54 | 55 | expect(consoleErrors).toHaveLength(expectedConsoleErrors); 56 | expect(failedResponses).toHaveLength(expectedApiErrors); 57 | }, 58 | }; 59 | } 60 | 61 | 62 | 63 | export { expect }; 64 | -------------------------------------------------------------------------------- /frontend/tests/mocks/setup.js: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | vi.mock('@/store', () => { 4 | return { 5 | state: { 6 | activeSettingsView: "", 7 | isMobile: false, 8 | showSidebar: false, 9 | usage: { 10 | used: "0 B", 11 | total: "0 B", 12 | usedPercentage: 0, 13 | }, 14 | sources: { 15 | info: {default: {pathPrefix: "", used: "0 B", total: "0 B", usedPercentage: 0}}, 16 | current: "default", 17 | count: 1, 18 | }, 19 | editor: null, 20 | user: { 21 | gallarySize: 0, 22 | stickySidebar: false, 23 | locale: "en", 24 | viewMode: "normal", 25 | showHidden: false, 26 | perm: {}, 27 | rules: [], 28 | permissions: {}, 29 | darkMode: false, 30 | profile: { 31 | username: '', 32 | email: '', 33 | avatarUrl: '', 34 | }, 35 | sorting: { 36 | by: 'name', 37 | asc: true, 38 | }, 39 | }, 40 | req: { 41 | sorting: { 42 | by: 'name', 43 | asc: true, 44 | }, 45 | items: [], 46 | numDirs: 0, 47 | numFiles: 0, 48 | }, 49 | previewRaw: "", 50 | oldReq: {}, 51 | clipboard: { 52 | key: "", 53 | items: [], 54 | }, 55 | jwt: "", 56 | loading: [], 57 | reload: false, 58 | selected: [], 59 | multiple: false, 60 | upload: { 61 | uploads: {}, 62 | queue: [], 63 | progress: [], 64 | sizes: [], 65 | }, 66 | prompts: [], 67 | show: null, 68 | showConfirm: null, 69 | route: {}, 70 | settings: { 71 | signup: false, 72 | createUserDir: false, 73 | userHomeBasePath: "", 74 | rules: [], 75 | frontend: { 76 | disableExternal: false, 77 | disableUsedPercentage: false, 78 | name: "", 79 | files: "", 80 | }, 81 | }, 82 | }, 83 | }; 84 | }); 85 | 86 | vi.mock('@/utils/constants', () => { 87 | return { 88 | baseURL: "/files/", 89 | }; 90 | }); 91 | 92 | vi.mock('@/notify', () => ({ 93 | events: { 94 | startSSE: vi.fn(), 95 | }, 96 | notify: { 97 | closePopUp: vi.fn(), 98 | }, 99 | })); -------------------------------------------------------------------------------- /frontend/tests/playwright-files/1file1.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/tests/playwright-files/1file1.txt -------------------------------------------------------------------------------- /frontend/tests/playwright-files/copyme.txt: -------------------------------------------------------------------------------- 1 | test file for playwright -------------------------------------------------------------------------------- /frontend/tests/playwright-files/deleteme.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/tests/playwright-files/deleteme.txt -------------------------------------------------------------------------------- /frontend/tests/playwright-files/file.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/tests/playwright-files/file.tar.gz -------------------------------------------------------------------------------- /frontend/tests/playwright-files/files/for testing.md: -------------------------------------------------------------------------------- 1 | # this is a test -------------------------------------------------------------------------------- /frontend/tests/playwright-files/files/nested/binary.dat: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/tests/playwright-files/files/nested/graham.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/tests/playwright-files/files/nested/graham.xlsx -------------------------------------------------------------------------------- /frontend/tests/playwright-files/folder#hash/file#.sh: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/tests/playwright-files/folder#hash/file#.sh -------------------------------------------------------------------------------- /frontend/tests/playwright-files/myfolder/testdata/20130612_142406.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/tests/playwright-files/myfolder/testdata/20130612_142406.jpg -------------------------------------------------------------------------------- /frontend/tests/playwright-files/myfolder/testdata/IMG_2578.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/tests/playwright-files/myfolder/testdata/IMG_2578.JPG -------------------------------------------------------------------------------- /frontend/tests/playwright-files/myfolder/testdata/gray-sample.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtsteffaniak/filebrowser/40374752a3dcb56aa0254abdfb48ae36f2feaf4f/frontend/tests/playwright-files/myfolder/testdata/gray-sample.jpg -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "allowJs": true, 5 | "target": "ESNext", 6 | "useDefineForClassFields": true, 7 | "module": "ESNext", 8 | "moduleResolution": "Node10", 9 | "strict": true, 10 | "sourceMap": true, 11 | "noImplicitReturns": true, 12 | "isolatedModules": true, 13 | "esModuleInterop": true, 14 | "lib": ["ESNext", "DOM"], 15 | "skipLibCheck": true, 16 | "resolveJsonModule": true, 17 | "types": ["vite/client", "@intlify/unplugin-vue-i18n/messages"], 18 | "paths": { 19 | "@/*": ["./src/*"] 20 | }, 21 | "checkJs": false, // check later 22 | }, 23 | "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.vue", "tests-playwright/general/auth.spec.ts"], 24 | "exclude": ["node_modules", "dist"] 25 | } -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { defineConfig } from "vite"; 3 | import vue from "@vitejs/plugin-vue"; 4 | import VueI18nPlugin from "@intlify/unplugin-vue-i18n/vite"; 5 | import { compression } from "vite-plugin-compression2"; 6 | 7 | const plugins = [ 8 | vue(), 9 | VueI18nPlugin({ 10 | include: [path.resolve(__dirname, "./src/i18n/**/*.json")], 11 | }), 12 | compression({ 13 | include: /\.(js|woff2|woff)(\?.*)?$/i, 14 | deleteOriginalAssets: true, 15 | }), 16 | ]; 17 | 18 | const resolve = { 19 | alias: { 20 | "@": path.resolve(__dirname, "src"), 21 | }, 22 | }; 23 | 24 | // https://vitejs.dev/config/ 25 | export default defineConfig(({ command }) => { 26 | return { 27 | plugins, 28 | resolve, 29 | base: "", 30 | build: { 31 | rollupOptions: { 32 | input: { 33 | index: path.resolve(__dirname, "./public/index.html"), 34 | }, 35 | output: { 36 | manualChunks: (id) => { 37 | if (id.includes("i18n/")) { 38 | return "i18n"; 39 | } 40 | }, 41 | }, 42 | }, 43 | }, 44 | experimental: { 45 | renderBuiltUrl(filename, { hostType }) { 46 | if (hostType === "js") { 47 | return { runtime: `window.__prependStaticUrl("${filename}")` }; 48 | } else if (hostType === "html") { 49 | return `{{ .StaticURL }}/${filename}`; 50 | } else { 51 | return { relative: true }; 52 | } 53 | }, 54 | }, 55 | test: { 56 | globals: true, 57 | include: ["src/**/*.test.js"], 58 | exclude: ["src/**/*.vue"], 59 | environment: "jsdom", 60 | setupFiles: "tests/mocks/setup.js", 61 | }, 62 | }; 63 | }); 64 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | 3 | .SILENT: 4 | setup: 5 | echo "creating ./backend/test_config.yaml for local testing..." 6 | if [ ! -f backend/test_config.yaml ]; then \ 7 | cp backend/config.yaml backend/test_config.yaml; \ 8 | fi 9 | echo "installing backend tooling..." 10 | cd backend && go get tool 11 | echo "installing npm requirements for frontend..." 12 | cd frontend && npm i 13 | 14 | update: 15 | cd backend && go get -u ./... && go mod tidy 16 | cd frontend && npm update 17 | 18 | build: 19 | docker build --build-arg="VERSION=testing" --build-arg="REVISION=n/a" -t gtstef/filebrowser -f _docker/Dockerfile . 20 | 21 | build-backend: 22 | cd backend && go build -o filebrowser --ldflags="-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA=testingCommit' -X 'github.com/gtsteffaniak/filebrowser/backend/version.Version=testing'" 23 | 24 | run: build-frontend 25 | cd backend && go tool swag init --output swagger/docs && \ 26 | if [ "$(shell uname)" = "Darwin" ]; then \ 27 | sed -i '' '/func init/,+3d' ./swagger/docs/docs.go; \ 28 | else \ 29 | sed -i '/func init/,+3d' ./swagger/docs/docs.go; \ 30 | fi && \ 31 | FILEBROWSER_NO_EMBEDED=true go run \ 32 | --ldflags="-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA=testingCommit' -X 'github.com/gtsteffaniak/filebrowser/backend/version.Version=testing'" . -c test_config.yaml 33 | 34 | build-frontend: 35 | cd backend && rm -rf http/dist http/embed/* && \ 36 | FILEBROWSER_GENERATE_CONFIG=true go run . && cp generated.yaml ../frontend/public/config.generated.yaml 37 | cd backend/http/ && ln -s ../../frontend/dist 38 | if [ "$(OS)" = "Windows_NT" ]; then \ 39 | cd frontend && npm run build-windows; \ 40 | else \ 41 | cd frontend && npm run build; \ 42 | fi 43 | 44 | lint-frontend: 45 | cd frontend && npm run lint 46 | 47 | lint-backend: 48 | cd backend && go tool golangci-lint run --path-prefix=backend 49 | 50 | lint: lint-backend lint-frontend 51 | 52 | test: test-backend test-frontend 53 | 54 | check-all: lint test 55 | 56 | test-backend: 57 | cd backend && go test -race -timeout=10s ./... 58 | 59 | test-frontend: 60 | cd frontend && npm run test 61 | 62 | test-playwright: build-frontend 63 | cd backend && GOOS=linux go build -o filebrowser . 64 | docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-general . 65 | docker run --rm --name filebrowser-playwright-tests filebrowser-playwright-tests 66 | docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-noauth . 67 | docker run --rm --name filebrowser-playwright-tests filebrowser-playwright-tests 68 | docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-proxy . 69 | docker run --rm --name filebrowser-playwright-tests filebrowser-playwright-tests 70 | 71 | run-proxy: build-frontend 72 | cd _docker && docker compose up -d --build --------------------------------------------------------------------------------