Compare commits

...
48 Commits
Author SHA1 Message Date
Graham Steffaniak a8d6043c99 updated workflow 2025-05-03 09:43:21 -05:00
Graham Steffaniak afbf4ca4c5 Merge branch 'beta/v0.7.0' of github.com:gtsteffaniak/filebrowser into beta/v0.7.0 2025-05-02 19:04:47 -05:00
Graham SteffaniakandAaron Ky-Riesenbach 8136731f81 Merged changes (#573)
Co-authored-by: Aaron Ky-Riesenbach <aaron@kyriesenba.ch>
2025-05-02 18:52:25 -05:00
Graham Steffaniak 384a4a301b Merge branch 'main' into beta/v0.7.0 2025-05-02 18:46:12 -05:00
Graham Steffaniak 40c4cb925e updated with a few more fixes 2025-05-02 18:11:48 -05:00
Graham Steffaniak 58c89cf561 updated 2025-05-02 16:03:32 -05:00
Graham Steffaniak 3bb68760a9 updated conversions 2025-05-02 16:00:53 -05:00
Graham Steffaniak f19ec9a221 Final adjustments (#571) 2025-05-02 14:07:42 -05:00
Graham Steffaniak 492b477b7d Update video.go 2025-05-01 23:02:14 -05:00
Graham Steffaniak 43cbba7d89 Update preview.go preview size 2025-05-01 22:48:58 -05:00
Graham Steffaniak e66cdfde43 Update video.go 2025-05-01 20:25:32 -05:00
Graham Steffaniak a0130f6ae4 Update Dockerfile.media 2025-05-01 20:13:37 -05:00
Graham Steffaniak 69e410e992 Update Dockerfile.media 2025-05-01 20:13:21 -05:00
Graham Steffaniak 2f66dd3442 Update Dockerfile.media 2025-05-01 20:07:34 -05:00
Graham Steffaniak a07853763d Update README.md 2025-05-01 20:04:19 -05:00
Graham Steffaniak d3390ee585 More final changes (#569) 2025-05-01 18:59:41 -05:00
Graham Steffaniak 813762caba Lots of small changes (#568) 2025-05-01 12:46:12 -05:00
Graham Steffaniak 16793c3851 updated with more tweaks (#566) 2025-04-30 11:57:15 -05:00
Graham Steffaniak 6715479758 Fix search scope (#565) 2025-04-29 18:34:24 -05:00
Graham Steffaniak 7ae2a8c851 updated with more fixes (#563) 2025-04-29 11:23:05 -05:00
Graham Steffaniak 5ef01021a4 Add multi icon animated (#562) 2025-04-28 17:07:23 -05:00
Graham Steffaniak acbd976a5c updated with overflow (#556) 2025-04-23 17:07:59 -05:00
Graham Steffaniak 0c7d35d8cd updated tooling (#554) 2025-04-22 17:19:59 -05:00
Graham SteffaniakandAaron Ky-Riesenbach f249e38dd9 Cleaunup test code (#553)
Co-authored-by: Aaron Ky-Riesenbach <aaron@kyriesenba.ch>
2025-04-22 16:05:07 -05:00
Graham SteffaniakandAaron Ky-Riesenbach b6ebd91775 Update breadcrumbs style (#552)
Co-authored-by: Aaron Ky-Riesenbach <aaron@kyriesenba.ch>
2025-04-21 16:39:30 -05:00
Graham SteffaniakandAaron Ky-Riesenbach db816c1fe8 Add toggle switches (#544)
Co-authored-by: Aaron Ky-Riesenbach <aaron@kyriesenba.ch>
2025-04-16 09:29:15 -05:00
Graham SteffaniakandAaron Ky-Riesenbach ceb8eda39f Update styles 0.7.0 (#540)
Co-authored-by: Aaron Ky-Riesenbach <aaron@kyriesenba.ch>
2025-04-15 16:44:47 -05:00
Graham SteffaniakandAaron Ky-Riesenbach 99198fbe19 Update main 0.7.0 (#534)
Co-authored-by: Aaron Ky-Riesenbach <aaron@kyriesenba.ch>
2025-04-14 18:11:25 -05:00
Graham Steffaniak 36e547bb56 Update CHANGELOG.md 2025-04-11 17:52:24 -05:00
Graham Steffaniak cb6ad047f8 updated with oidc login (#523) 2025-04-11 17:00:04 -05:00
Graham Steffaniak a5559ec3e2 updated with autobackup (#514) 2025-04-09 14:01:35 -05:00
Graham Steffaniak 698a3e126c updated workflow 2025-04-09 10:14:51 -05:00
Graham Steffaniak f6702414b2 updated readme 2025-04-09 10:12:00 -05:00
Graham Steffaniak 5ea3813d81 Merge branch 'dev/v0.7.0' of github.com:gtsteffaniak/filebrowser into dev/v0.7.0 2025-04-09 09:46:36 -05:00
Graham Steffaniak 86e73abfab office and video file previews (#512) 2025-04-09 09:45:31 -05:00
Graham Steffaniak 9230169d0f office file previews (#510) 2025-04-08 17:05:26 -05:00
Graham Steffaniak 147615e627 Merge branch 'dev/v0.7.0' of github.com:gtsteffaniak/filebrowser into dev/v0.7.0 2025-04-08 11:13:51 -05:00
Graham Steffaniak 8d82792618 Merge from main (#509) 2025-04-08 11:13:40 -05:00
Graham Steffaniak 06cb471fe3 updated 2025-04-08 10:30:33 -05:00
Graham Steffaniak 40e76c2a14 Merge branch 'main' into dev/v0.7.0 2025-04-08 10:23:23 -05:00
Graham Steffaniak b531100208 adding ffmpeg and general preview service plumbing (#491) 2025-04-06 18:59:31 -05:00
Graham Steffaniak 3161ded372 Add simple validation (#490) 2025-04-04 11:16:07 -05:00
Graham Steffaniak c750b6c4e2 Merge branch 'main' into dev/v0.7.0 2025-04-04 10:34:26 -05:00
Graham Steffaniak 9daf88c5dd updated 2025-03-29 16:15:20 -07:00
Graham Steffaniak cecdf7ae09 Merge branch 'dev/v0.7.0' of github.com:gtsteffaniak/filebrowser into dev/v0.7.0 2025-03-29 16:14:30 -07:00
Graham Steffaniak d4e5b52dc2 Merge from main (#466) 2025-03-29 16:13:58 -07:00
Graham Steffaniak 85266f09d0 Merge branch 'main' into dev/v0.7.0 2025-03-29 15:35:36 -07:00
Graham Steffaniak bafdff14d4 add realtime events and source info (#458) 2025-03-28 18:14:52 -05:00
212 changed files with 8503 additions and 4162 deletions

No files matched your search

+2
View File
@@ -13,6 +13,7 @@ rice-box.go
/frontend/dist
/frontend/pkg
/frontend/test-results
/frontend/tests/playwright-files/demo*
/frontend/loginAuth.json
/frontend/package-lock.json
/backend/vendor
@@ -44,3 +45,4 @@ yarn-error.log*
*.sw*
bin/
build/
*__debug*
+48
View File
@@ -2,6 +2,54 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v0.7.0-beta
**New Features**:
- New authentication method: OIDC (OpenID Connect)
- UI refresh
- Refreshed icons and styles to provide more contrast https://github.com/gtsteffaniak/filebrowser/issues/493
- New scrollbar which includes information about the listing https://github.com/gtsteffaniak/filebrowser/issues/304
- User-configurable popup previewer and user can control preview size of images.
- Enhanced user settings page with more toggle options.
- Replaced checkboxes with toggles switches https://github.com/gtsteffaniak/filebrowser/issues/461
- Refreshed Breadcrumbs style.
- Main navbar icon is multipurpose menu, close, back and animates
- Enhanced source info on the UI
- User must have permission `realtime: true` property to get realtime events.
- Sources shows status of the directory `ready`, `indexing`, and `unavailable`
- Top-right overflow menu for deleting / editing files in peview https://github.com/gtsteffaniak/filebrowser/issues/456
- Helpful UI animation for drag and drop files, to get feedback where the drop target is.
- More consistent theme color https://github.com/gtsteffaniak/filebrowser/issues/538
- New file preview types:
- Video thumbnails available via new media integration (see configuration wiki for help) https://github.com/gtsteffaniak/filebrowser/issues/351
- Office file previews if you have office integration enabled. https://github.com/gtsteffaniak/filebrowser/issues/460
**Notes**:
- sesssionId is now unique per window. Previously it was shared accross browser tabs.
- DisableUsedPercentage is a backend property now, so users can't "hack" the information to be shown.
- Updated documentation for resources api https://github.com/gtsteffaniak/filebrowser/issues/560
- Updated placeholder for scopes https://github.com/gtsteffaniak/filebrowser/issues/475
- When user's API permissions are removed, any api keys the user had will be revoked.
- `server.enableThumbnails` moved to `server.disablePreviews` defaulting to false.
- `server.resizePreview` moved to `server.resizePreviews` (with an "s" at the end)
**Bug Fixes**:
- Nil pointer error when source media is disconnected while running.
- Source selection buggy https://github.com/gtsteffaniak/filebrowser/issues/537
- Upload folder structure https://github.com/gtsteffaniak/filebrowser/issues/539
- Editing files on multiple sources https://github.com/gtsteffaniak/filebrowser/issues/535
- Prevent the user from changing the password https://github.com/gtsteffaniak/filebrowser/issues/550
- Links in setting page does not navigate to correct location https://github.com/gtsteffaniak/filebrowser/issues/474
- Url encoding issue https://github.com/gtsteffaniak/filebrowser/issues/530
- Certain file types being treated as folders https://github.com/gtsteffaniak/filebrowser/issues/555
- Source name with special characters https://github.com/gtsteffaniak/filebrowser/issues/557
- Onlyoffice support on proxy auth https://github.com/gtsteffaniak/filebrowser/issues/559
- Downloading with user scope https://github.com/gtsteffaniak/filebrowser/issues/564
- User disableSettings property to be respected.
- Non admin users updating admin settings.
- Right click context issue on safari desktop.
- office save file issue.
## v0.6.8-beta
**New Features**
+37 -44
View File
@@ -6,41 +6,36 @@
[![DockerHub Pulls](https://img.shields.io/docker/pulls/gtstef/filebrowser?label=latest%20Docker%20pulls)](https://hub.docker.com/r/gtstef/filebrowser)
[![Apache-2.0 License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
[![Poll](https://img.shields.io/badge/poll-vote_most_important_features-purple)](https://github.com/gtsteffaniak/filebrowser/discussions/368)
[![Donate with PayPal](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://www.paypal.com/donate/?business=W5XKNXHJM2WPE&no_recurring=0&currency_code=USD)
[![Donate](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://www.paypal.com/donate/?business=W5XKNXHJM2WPE&no_recurring=0&currency_code=USD)
<img width="150" src="https://github.com/user-attachments/assets/59986a2a-f960-4536-aa35-4a9a7c98ad48" title="Logo">
<h3>FileBrowser Quantum</h3>
A modern web-based file manager
The best free self-hosted web-based file manager.
<br/><br/>
<img width="800" src="https://github.com/user-attachments/assets/c35a8455-c32d-47f5-853d-f285e160b022" title="Main Screenshot">
<img width="800" src="https://github.com/user-attachments/assets/95af22fa-7760-4f7b-9c20-51fdcfe8f4ea" title="Main Screenshot">
</div>
> [!WARNING]
> There is no stable version -- planned 2025.
> There is no stable version -- planned for 2025.
> [!NOTE]
> [Read about upcoming 0.7.0 release](https://github.com/gtsteffaniak/filebrowser/discussions/459)
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
FileBrowser Quantum is a fork of the file browser open-source project with the following changes:
1. ✅ Indexes files (See [indexing Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.)
- Real-time search results as you type
- Search supports file/folder sizes and many file type filters.
- Enhanced interactive results that show file/folder sizes.
2. ✅ Multiple sources support
1. ✅ Multiple sources support
2. ✅ Login support for OIDC, password, and proxy.
3. ✅ Revamped UI
- Additional compact view mode as well as refreshed view mode styles.
- Many graphical and user experience improvements.
- right-click context menu
4. ✅ Revamped and simplified configuration via `config.yaml` config file.
5. ✅ Better listing browsing
- Instantly Switches view modes and sort order without reloading data.
- Folder sizes are displayed
- Navigating remembers the scroll position, navigating back keeps the last scroll position.
6. ✅ Developer API support
- Ability to create long-live API Tokens.
- Helpful Swagger page available at `/swagger` endpoint.
4. ✅ Simplified configuration via `config.yaml` config file.
5. ✅ Ultra-efficient [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) and real-time updates
- Real-time search results as you type.
- Real-time monitoring and updates in the UI.
- Search supports file and folder sizes, along with various filters.
6. ✅ Better listing browsing
- More file type previews, such as **office** and **video** file previews
- Instantly switches view modes and sort order without reloading data.
- Folder sizes are displayed.
- Navigating remembers the last scroll position.
7. ✅ Developer API support
- Ability to create long-lived API Tokens.
- A helpful Swagger page is available at `/swagger` endpoint for API enabled users.
Notable features that this fork *does not* have (removed):
@@ -50,29 +45,28 @@ Notable features that this fork *does not* have (removed):
## About
FileBrowser Quantum differs significantly from the original.
There are hundreds of thousands of lines changed and they are generally
no longer compatible with each other. This has been intentional -- the
focus of this fork is on a few key principles:
- Simplicity and improved user experience
- Improving performance and faster feedback when making changes.
- Minimize external dependencies and standard library usage.
- Of course -- adding much-needed features.
FileBrowser Quantum differs significantly from the original version. Many of these changes required a significant overhaul. Creating a fork was a necessary process to make the program better. There have been many growing pains, but a stable release is planned and coming soon.
This version is called "Quantum" because it packs tons of advanced features in a tiny executable file. Unlike the majority of alternative options, FileBrowser Quantum is simple to install and easy to configure.
The goal for this repo is to become the best open-source self-hosted file browsing application that exists -- **all for free**.
This repo will always be free and open-source.
For more, see the [Q&A Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A)
## Look
The UI has a simple three-component navigation system :
The UI has a simple three-component navigation system:
1. (Left) The slide-out action panel button
1. (Left) Multi-action button with slide-out panel.
2. (Middle) The powerful search bar.
3. (Right) The view change toggle.
All other functions are moved either into the action menu or popup menus.
All other functions are moved either into the action menu or pop-up menus.
If the action does not depend on context, it will exist in the slide-out
action panel. If the action is available based on context, it will show up as
a popup menu.
a pop-up menu.
<p align="center">
<img width="1000" src="https://github.com/user-attachments/assets/aa32b05c-f917-47bb-b07f-857edc5e47f7" title="Search GIF">
@@ -122,27 +116,26 @@ Min. Memory Requirements | 128 MB | 128 MB | 128 MB (main image) | 128 MB |
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
price | free | free | free | free tier | free tier | $99+ |
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Advanced Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Indexed Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Content-aware search? | ❌ | ❌ | ❌ | configurable | ✅ | ✅ |
Custom job support | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ |
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Single sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
2FA sign-on support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Single sign-on support | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Mobile App | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ |
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
tags support | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
sharable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Event-based notifications | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Metrics | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
file space quotas | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
Branding support | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
activity log | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
+35
View File
@@ -0,0 +1,35 @@
FROM mwader/static-ffmpeg AS ffmpeg
FROM golang:1.24-alpine AS base
ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
#RUN swag init --output swagger/docs
RUN ln -s swagger /usr/local/go/src/
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM node:slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
RUN npm i --maxsockets 1
COPY ./frontend/ ./
RUN npm run build-docker
FROM alpine:latest
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_NO_EMBEDED="true"
RUN apk --no-cache add ca-certificates mailcap
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
WORKDIR /home/filebrowser
COPY --from=base --chown=filebrowser:1000 /app/filebrowser* ./
COPY --from=base --chown=filebrowser:1000 /app/config.media.yaml ./config.yaml
COPY --from=nbuild --chown=filebrowser:1000 /app/dist/ ./http/dist/
USER root
# exposing default port for auto discovery.
EXPOSE 80
ENTRYPOINT [ "./filebrowser" ]
+1
View File
@@ -7,4 +7,5 @@ WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+3
View File
@@ -15,3 +15,6 @@ frontend:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
userDefaults:
permissions:
realtime: true
+1 -1
View File
@@ -10,7 +10,7 @@ server {
location / {
proxy_set_header X-Username $uuid;
add_header X-Username $uuid;
proxy_pass http://localhost:8080;
proxy_pass http://filebrowser:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
File renamed without changes.
File renamed without changes.
@@ -31,9 +31,10 @@ type FileCache struct {
// NewFileCache creates a new FileCache
func NewFileCache(dir string) (*FileCache, error) {
if err := os.MkdirAll(dir, 0700); err != nil {
if err := os.MkdirAll(filepath.Join(dir, "thumbnails", "video"), 0700); err != nil {
return nil, fmt.Errorf("can't make directory %s: %v", dir, err)
}
return &FileCache{dir: dir}, nil
}
File renamed without changes.
@@ -7,7 +7,9 @@ import (
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func Test_GetRealPath(t *testing.T) {
@@ -64,7 +66,7 @@ func Test_GetRealPath(t *testing.T) {
},
},
}
idx := Index{
idx := indexing.Index{
Source: settings.Source{
Path: trimPrefix,
},
@@ -84,33 +86,33 @@ func Test_GetRealPath(t *testing.T) {
func TestSortItems(t *testing.T) {
tests := []struct {
name string
input FileInfo
expected FileInfo
input iteminfo.FileInfo
expected iteminfo.FileInfo
}{
{
name: "Numeric and Lexicographical Sorting",
input: FileInfo{
Folders: []ItemInfo{
input: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "10.txt"},
{Name: "2.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "File2.txt"},
{Name: "File10.txt"},
{Name: "File1"},
{Name: "banana"},
},
},
expected: FileInfo{
Folders: []ItemInfo{
expected: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "2.txt"},
{Name: "10.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "banana"},
{Name: "File1"},
{Name: "File10.txt"},
@@ -120,25 +122,25 @@ func TestSortItems(t *testing.T) {
},
{
name: "Only Lexicographical Sorting",
input: FileInfo{
Folders: []ItemInfo{
input: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "dog.txt"},
{Name: "Cat.txt"},
{Name: "apple"},
},
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "Zebra"},
{Name: "apple"},
{Name: "cat"},
},
},
expected: FileInfo{
Folders: []ItemInfo{
expected: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "apple"},
{Name: "Cat.txt"},
{Name: "dog.txt"},
},
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "apple"},
{Name: "cat"},
{Name: "Zebra"},
@@ -151,7 +153,7 @@ func TestSortItems(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
test.input.SortItems()
getNames := func(items []ItemInfo) []string {
getNames := func(items []iteminfo.ItemInfo) []string {
names := []string{}
for _, folder := range items {
names = append(names, folder.Name)
+304
View File
@@ -0,0 +1,304 @@
package files
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"fmt"
"hash"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/cache"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error) {
response := iteminfo.ExtendedFileInfo{}
if opts.Source == "" {
opts.Source = settings.Config.Server.DefaultSource.Name
}
index := indexing.GetIndex(opts.Source)
if index == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
}
realPath, isDir, err := index.GetRealPath(opts.Path)
if err != nil {
return response, err
}
opts.IsDir = isDir
// TODO: whats the best way to save trips to disk here?
// disabled using cache because its not clear if this is helping or hurting
// check if the file exists in the index
//info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
//if exists {
// err := RefreshFileInfo(opts)
// if err != nil {
// return info, err
// }
// if opts.Content {
// content := ""
// content, err = getContent(opts.Path)
// if err != nil {
// return info, err
// }
// info.Content = content
// }
// return info, nil
//}
err = index.RefreshFileInfo(opts)
if err != nil {
return response, err
}
info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
if !exists {
return response, fmt.Errorf("could not get metadata for path: %v", opts.Path)
}
if opts.Content && strings.HasPrefix(info.Type, "text") {
// Check file size
if info.Size > 50*1024*1024 { // 50 megabytes in bytes
logger.Debug(fmt.Sprintf("Reading large text file contents: "+info.Path, info.Name))
}
content, err := getContent(realPath)
if err != nil {
return response, err
}
response.Content = content
}
response.FileInfo = *info
response.RealPath = realPath
response.Source = opts.Source
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
response.OnlyOfficeId = generateOfficeId(realPath)
}
if strings.HasPrefix(info.Type, "video") {
parentInfo, exists := index.GetReducedMetadata(filepath.Dir(info.Path), true)
if exists {
response.DetectSubtitles(parentInfo)
}
}
return response, nil
}
func generateOfficeId(realPath string) string {
key, ok := cache.OnlyOffice.Get(realPath).(string)
if !ok {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
documentKey := utils.HashSHA256(realPath + timestamp)
cache.OnlyOffice.Set(realPath, documentKey)
return documentKey
}
return key
}
// Checksum checksums a given File for a given User, using a specific
// algorithm. The checksums data is saved on File object.
func GetChecksum(fullPath, algo string) (map[string]string, error) {
subs := map[string]string{}
reader, err := os.Open(fullPath)
if err != nil {
return subs, err
}
defer reader.Close()
hashFuncs := map[string]hash.Hash{
"md5": md5.New(),
"sha1": sha1.New(),
"sha256": sha256.New(),
"sha512": sha512.New(),
}
h, ok := hashFuncs[algo]
if !ok {
return subs, errors.ErrInvalidOption
}
_, err = io.Copy(h, reader)
if err != nil {
return subs, err
}
subs[algo] = hex.EncodeToString(h.Sum(nil))
return subs, nil
}
func DeleteFiles(source, absPath string, absDirPath string) error {
err := os.RemoveAll(absPath)
if err != nil {
return err
}
index := indexing.GetIndex(source)
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
}
refreshConfig := iteminfo.FileOptions{Path: index.MakeIndexPath(absDirPath), IsDir: true}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return err
}
return nil
}
func MoveResource(sourceIndex, destIndex, realsrc, realdst string) error {
err := fileutils.MoveFile(realsrc, realdst)
if err != nil {
return err
}
idxSrc := indexing.GetIndex(sourceIndex)
if idxSrc == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
idxDst := indexing.GetIndex(destIndex)
if idxDst == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshSourceDir := idxSrc.MakeIndexPath(filepath.Dir(realsrc))
refreshDestDir := idxDst.MakeIndexPath(filepath.Dir(realdst))
// refresh info for source and dest
err = idxSrc.RefreshFileInfo(iteminfo.FileOptions{
Path: refreshSourceDir,
IsDir: true,
})
if err != nil {
return fmt.Errorf("could not refresh index for source: %v", err)
}
if refreshSourceDir == refreshDestDir {
return nil
}
refreshConfig := iteminfo.FileOptions{Path: refreshDestDir, IsDir: true}
err = idxDst.RefreshFileInfo(refreshConfig)
if err != nil {
return fmt.Errorf("could not refresh index for dest: %v", err)
}
return nil
}
func CopyResource(sourceIndex, destIndex, realsrc, realdst string) error {
err := fileutils.CopyFile(realsrc, realdst)
if err != nil {
return err
}
idxSrc := indexing.GetIndex(sourceIndex)
if idxSrc == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
idxDst := indexing.GetIndex(destIndex)
if idxDst == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshSourceDir := idxSrc.MakeIndexPath(filepath.Dir(realsrc))
refreshDestDir := idxDst.MakeIndexPath(filepath.Dir(realdst))
index := indexing.GetIndex(sourceIndex)
if index == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshConfig := iteminfo.FileOptions{Path: refreshSourceDir, IsDir: true}
// refresh info for source and dest
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return fmt.Errorf("could not refresh index for source: %v", err)
}
refreshConfig.Path = refreshDestDir
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return errors.ErrEmptyKey
}
return nil
}
func WriteDirectory(opts iteminfo.FileOptions) error {
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
realPath, _, _ := idx.GetRealPath(opts.Path)
// Ensure the parent directories exist
err := os.MkdirAll(realPath, 0775)
if err != nil {
return err
}
err = idx.RefreshFileInfo(opts)
if err != nil {
return errors.ErrEmptyKey
}
return nil
}
func WriteFile(opts iteminfo.FileOptions, in io.Reader) error {
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
dst, _, _ := idx.GetRealPath(opts.Path)
parentDir := filepath.Dir(dst)
// Create the directory and all necessary parents
err := os.MkdirAll(parentDir, 0775)
if err != nil {
return err
}
// Open the file for writing (create if it doesn't exist, truncate if it does)
file, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775)
if err != nil {
return err
}
defer file.Close()
// Copy the contents from the reader to the file
_, err = io.Copy(file, in)
if err != nil {
return err
}
opts.Path = idx.MakeIndexPath(parentDir)
opts.IsDir = true
return idx.RefreshFileInfo(opts)
}
// addContent reads and sets content based on the file type.
func getContent(realPath string) (string, error) {
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
}
stringContent := string(content)
if !utf8.ValidString(stringContent) {
return "", nil
}
if stringContent == "" {
return "empty-file-x6OlSil", nil
}
return stringContent, nil
}
func IsNamedPipe(mode os.FileMode) bool {
return mode&os.ModeNamedPipe != 0
}
func IsSymlink(mode os.FileMode) bool {
return mode&os.ModeSymlink != 0
}
func Exists(path string) bool {
_, err := os.Stat(path)
if err == nil {
return true
}
if os.IsNotExist(err) {
return false
}
return false
}
File renamed without changes.
@@ -5,12 +5,11 @@ import (
"os"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// todo fix this!!
// MakeUserDir makes the user directory according to settings.
func MakeUserDir(fullPath string) error {
if err := os.MkdirAll(fullPath, os.ModePerm); err != nil {
@@ -31,7 +30,7 @@ func MakeUserDirs(u *users.User) error {
continue
}
fullPath := ""
if filepath.Base(scope.Scope) != cleanedUserName && !u.Perm.Admin {
if filepath.Base(scope.Scope) != cleanedUserName && !u.Permissions.Admin {
scope.Scope = filepath.Join(scope.Scope, cleanedUserName)
fullPath = filepath.Join(source.Path, scope.Scope)
} else {
File renamed without changes.
File renamed without changes.
@@ -7,8 +7,8 @@ import (
"path"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
// MoveFile moves a file from src to dst.
File renamed without changes.
+18 -1
View File
@@ -2,8 +2,14 @@ package auth
import (
"net/http"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
var (
revokedApiKeyList map[string]bool
revokeMu sync.Mutex
)
// Auther is the authentication interface.
@@ -13,3 +19,14 @@ type Auther interface {
// LoginPage indicates if this auther needs a login page.
LoginPage() bool
}
func IsRevokedApiKey(key string) bool {
_, exists := revokedApiKeyList[key]
return exists
}
func RevokeAPIKey(key string) {
revokeMu.Lock()
delete(revokedApiKeyList, key)
revokeMu.Unlock()
}
+11 -11
View File
@@ -8,10 +8,10 @@ import (
"os/exec"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
type hookCred struct {
@@ -162,8 +162,8 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
SingleClick: a.Settings.UserDefaults.SingleClick,
ShowHidden: a.Settings.UserDefaults.ShowHidden,
},
Username: a.Cred.Username,
Perm: a.Settings.UserDefaults.Permissions,
Username: a.Cred.Username,
Permissions: a.Settings.UserDefaults.Permissions,
}
u = a.GetUser(d)
@@ -181,7 +181,7 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
if len(a.Fields.Values) > 1 {
u = a.GetUser(u)
// update user with provided fields
err := a.Users.Update(u, true)
err := a.Users.Update(u, u.Permissions.Admin)
if err != nil {
return nil, err
}
@@ -193,11 +193,11 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
// GetUser returns a User filled with hook values or provided defaults
func (a *HookAuth) GetUser(d *users.User) *users.User {
// adds all permissions when user is admin
isAdmin := d.Perm.Admin
isAdmin := d.Permissions.Admin
perms := users.Permissions{
Admin: isAdmin,
Modify: isAdmin || d.Perm.Modify,
Share: isAdmin || d.Perm.Share,
Modify: isAdmin || d.Permissions.Modify,
Share: isAdmin || d.Permissions.Share,
}
user := users.User{
NonAdminEditable: users.NonAdminEditable{
@@ -210,7 +210,7 @@ func (a *HookAuth) GetUser(d *users.User) *users.User {
ID: d.ID,
Username: d.Username,
Scopes: d.Scopes,
Perm: perms,
Permissions: perms,
LockPassword: true,
}
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
type jsonCred struct {
+1 -1
View File
@@ -3,7 +3,7 @@ package auth
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// MethodNoAuth is used to identify no auth.
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"net/http"
"os"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// MethodProxyAuth is used to identify no auth.
+1 -1
View File
@@ -1,7 +1,7 @@
package auth
import (
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// StorageBackend is a storage backend for auth storage.
+13 -13
View File
@@ -5,8 +5,8 @@
? github.com/gtsteffaniak/filebrowser/backend/auth [no test files]
? github.com/gtsteffaniak/filebrowser/backend/cmd [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/diskcache 0.005s
? github.com/gtsteffaniak/filebrowser/backend/errors [no test files]
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache 0.005s
? github.com/gtsteffaniak/filebrowser/backend/common/errors [no test files]
/home/graham/git/filebrowser /home/graham/git/filebrowser
/home/graham/git/filebrowser/files/file.go /home/graham/git/filebrowser
/home/graham/git/filebrowser/mnt/doesnt/exist /home/graham/git/filebrowser
@@ -22,7 +22,7 @@ ok github.com/gtsteffaniak/filebrowser/backend/diskcache 0.005s
2025/01/04 14:04:56 Next scan in 1m0s
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/backend/files
pkg: github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
BenchmarkFillIndex-8 2025/01/04 14:04:57 Initializing index and assessing file system complexity
2025/01/04 14:04:57 Starting full scan
@@ -44,23 +44,23 @@ BenchmarkSearchAllIndexes-8 2025/01/04 14:04:59 Initializing index
2025/01/04 14:04:59 Next scan in 1m0s
10 766822 ns/op 34230 B/op 900 allocs/op
PASS
ok github.com/gtsteffaniak/filebrowser/backend/files 5.094s
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files 5.094s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/fileutils 0.002s
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils 0.002s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/http 0.184s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/img 0.123s
ok github.com/gtsteffaniak/filebrowser/backend/preview/img 0.123s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/runner 0.004s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/settings 0.005s
? github.com/gtsteffaniak/filebrowser/backend/share [no test files]
? github.com/gtsteffaniak/filebrowser/backend/storage [no test files]
? github.com/gtsteffaniak/filebrowser/backend/storage/bolt [no test files]
ok github.com/gtsteffaniak/filebrowser/backend/common/settings 0.005s
? github.com/gtsteffaniak/filebrowser/backend/database/share [no test files]
? github.com/gtsteffaniak/filebrowser/backend/database/storage [no test files]
? github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt [no test files]
? github.com/gtsteffaniak/filebrowser/backend/swagger/docs [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/users 0.003s
ok github.com/gtsteffaniak/filebrowser/backend/database/users 0.003s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/utils 0.002s
? github.com/gtsteffaniak/filebrowser/backend/version [no test files]
ok github.com/gtsteffaniak/filebrowser/backend/common/utils 0.002s
? github.com/gtsteffaniak/filebrowser/backend/common/version [no test files]
+6 -6
View File
@@ -6,11 +6,11 @@ import (
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/version"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
var (
@@ -105,7 +105,7 @@ func runCLI() bool {
}
user.Password = password
if asAdmin {
user.Perm.Admin = true
user.Permissions.Admin = true
}
err = store.Users.Save(user, true)
if err != nil {
+21
View File
@@ -0,0 +1,21 @@
package cmd
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
// healthcheck attempt to save test file against configured url
func validateOfficeIntegration() {
if settings.Config.Integrations.OnlyOffice.Url != "" {
// get url health
// get request against the url
_, err := http.NewRequest("GET", settings.Config.Integrations.OnlyOffice.Url, nil)
if err != nil {
logger.Warning("Could not create request to only office Url, make sure its valid and running")
return
}
}
}
+16 -28
View File
@@ -7,18 +7,17 @@ import (
"os/signal"
"syscall"
"github.com/gtsteffaniak/filebrowser/backend/diskcache"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/img"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
"github.com/swaggo/swag"
"github.com/gtsteffaniak/filebrowser/backend/version"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
)
var store *storage.Storage
@@ -84,9 +83,10 @@ func StartFilebrowser() {
logger.Fatal("No sources configured, exiting...")
}
for _, source := range settings.Config.Server.SourceMap {
go files.Initialize(source)
go indexing.Initialize(source, false)
}
validateUserInfo()
validateOfficeIntegration()
// Start the rootCMD in a goroutine
go func() {
if err := rootCMD(ctx, store, &serverConfig, shutdownComplete); err != nil {
@@ -113,26 +113,14 @@ func rootCMD(ctx context.Context, store *storage.Storage, serverConfig *settings
if serverConfig.NumImageProcessors < 1 {
logger.Fatal("Image resize workers count could not be < 1")
}
imgSvc := img.New(serverConfig.NumImageProcessors)
cacheDir := settings.Config.Server.CacheDir
var fileCache diskcache.Interface
// Use file cache if cacheDir is specified
if cacheDir != "" {
var err error
fileCache, err = diskcache.NewFileCache(cacheDir)
if err != nil {
if cacheDir == "tmp" {
logger.Error("The cache dir could not be created. Make sure the user that you executed the program with has access to create directories in the local path. filebrowser is trying to use the default `server.cacheDir: tmp` , but you can change this location if you need to. Please see configuration wiki for more information about this error. https://github.com/gtsteffaniak/filebrowser/wiki/Configuration")
}
logger.Fatal(fmt.Sprintf("failed to create file cache path, which is now require to run the server: %v", err))
}
} else {
// No-op cache if no cacheDir is specified
fileCache = diskcache.NewNoOp()
numWorkers := settings.Config.Server.NumImageProcessors
ffpmpegPath := settings.Config.Integrations.Media.FfmpegPath
// setup disk cache
err := preview.Start(numWorkers, ffpmpegPath, cacheDir)
if err != nil {
logger.Fatal(fmt.Sprintf("Error starting preview service: %v", err))
}
fbhttp.StartHttp(ctx, imgSvc, store, fileCache, shutdownComplete)
fbhttp.StartHttp(ctx, store, shutdownComplete)
return nil
}
+139 -27
View File
@@ -3,10 +3,10 @@ package cmd
import (
"fmt"
"github.com/gtsteffaniak/filebrowser/backend/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
var createBackup = []bool{}
@@ -26,6 +26,15 @@ func validateUserInfo() {
if updateUserScopes(user) {
updateUser = true
}
if updatePermissions(user) {
updateUser = true
}
if updatePreviewSettings(user) {
updateUser = true
}
if updateLoginType(user) {
updateUser = true
}
if updateUser {
if len(createBackup) == 1 {
logger.Warning("Incompatible user settings detected, creating backup of database before converting.")
@@ -44,40 +53,143 @@ func validateUserInfo() {
func updateUserScopes(user *users.User) bool {
updateUser := false
newScopes := []users.SourceScope{}
finalScopes := []users.SourceScope{}
seenNames := make(map[string]bool)
// Step 1: Start by including all existing scopes, updating Name if source.Path matches
for _, existingScope := range user.Scopes {
updated := false
for _, source := range settings.Config.Server.SourceMap {
if existingScope.Name == source.Path || existingScope.Scope == source.Path {
// Update Name to match source.Path
existingScope.Name = source.Path
updated = true
break
}
}
if existingScope.Scope == "" {
existingScope.Scope = "/"
updateUser = true
}
if !seenNames[existingScope.Name] {
finalScopes = append(finalScopes, existingScope)
seenNames[existingScope.Name] = true
if updated {
updateUser = true
}
}
}
// Step 2: Add missing scopes from SourceMap if admin or DefaultEnabled
for _, source := range settings.Config.Server.SourceMap {
if seenNames[source.Path] {
continue
}
// Check if scope exists already via path
scopePath, err := settings.GetScopeFromSourcePath(user.Scopes, source.Path)
// apply default scope if it doesn't exist
if !user.Perm.Admin && scopePath == "" {
// If user has no access, skip unless admin or default-enabled
if err != nil && !(user.Permissions.Admin || source.Config.DefaultEnabled) {
continue
}
// Determine scope path
if scopePath == "" {
scopePath = source.Config.DefaultUserScope
}
if scopePath == "" {
scopePath = "/"
}
if source.Config.CreateUserDir && !user.Perm.Admin {
if source.Config.CreateUserDir && !user.Permissions.Admin {
scopePath = fmt.Sprintf("%s%s", scopePath, users.CleanUsername(user.Username))
}
// if user doesn't yet have scope for source, add it for admins and default sources
if err != nil {
if user.Perm.Admin || source.Config.DefaultEnabled {
newScopes = append(newScopes, users.SourceScope{Scope: scopePath, Name: source.Path}) // backend name is path
updateUser = true
}
} else {
newScopes = append(newScopes, users.SourceScope{Scope: scopePath, Name: source.Path}) // backend name is path
}
}
// maintain backwards compatibility, update user scope from scopes
if len(newScopes) == 0 {
newScopes = []users.SourceScope{
{
Scope: settings.Config.Server.DefaultSource.Config.DefaultUserScope,
Name: settings.Config.Server.DefaultSource.Path, // backend name is path
},
}
// Add new scope
finalScopes = append(finalScopes, users.SourceScope{
Scope: scopePath,
Name: source.Path,
})
seenNames[source.Path] = true
updateUser = true
}
user.Scopes = newScopes
// Step 3: If no scopes, apply default
if len(finalScopes) == 0 {
defaultScope := users.SourceScope{
Scope: settings.Config.Server.DefaultSource.Config.DefaultUserScope,
Name: settings.Config.Server.DefaultSource.Path,
}
finalScopes = append(finalScopes, defaultScope)
updateUser = true
}
// Step 4: Assign only if different
if !scopesEqual(user.Scopes, finalScopes) {
user.Scopes = finalScopes
updateUser = true
}
return updateUser
}
// scopesEqual does a deep comparison to avoid unnecessary writes
func scopesEqual(a, b []users.SourceScope) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].Scope != b[i].Scope || a[i].Name != b[i].Name {
return false
}
}
return true
}
// func to convert legacy user with perm key to permissions
func updatePermissions(user *users.User) bool {
updateUser := false
// if any keys are true, set the permissions to true
if user.Perm.Api {
user.Permissions.Api = true
user.Perm.Api = false
updateUser = true
}
if user.Perm.Admin {
user.Permissions.Admin = true
user.Perm.Admin = false
updateUser = true
}
if user.Perm.Modify {
user.Permissions.Modify = true
user.Perm.Modify = false
updateUser = true
}
if user.Perm.Share {
user.Permissions.Share = true
user.Perm.Share = false
updateUser = true
}
if updateUser {
createBackup = append(createBackup, true)
}
return updateUser
}
func updateLoginType(user *users.User) bool {
if user.LoginMethod == "" {
user.LoginMethod = users.LoginMethodPassword
return true
}
return false
}
func updatePreviewSettings(user *users.User) bool {
// if user hasn't been updated yet
if user.LoginMethod == "" {
user.Preview.Image = true
user.Preview.PopUp = true
return true
}
return false
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
+49
View File
@@ -0,0 +1,49 @@
package settings
type Auth struct {
TokenExpirationHours int `json:"tokenExpirationHours"` // the number of hours until the token expires. Default is 2 hours.
Methods LoginMethods `json:"methods"`
Key string `json:"key"` // the key used to sign the JWT tokens. If not set, a random key will be generated.
AdminUsername string `json:"adminUsername"` // the username of the admin user. If not set, the default is "admin".
AdminPassword string `json:"adminPassword"` // the password of the admin user. If not set, the default is "admin".
AuthMethods []string `json:"-"`
}
type LoginMethods struct {
ProxyAuth ProxyAuthConfig `json:"proxy" validate:"omitempty"`
NoAuth bool `json:"noauth"` // if set to true, overrides all other auth methods and disables authentication
PasswordAuth PasswordAuthConfig `json:"password" validate:"omitempty"`
OidcAuth OidcConfig `json:"oidc" validate:"omitempty"`
}
type PasswordAuthConfig struct {
Enabled bool `json:"enabled"`
MinLength int `json:"minLength" validate:"omitempty,min=5"`
Signup bool `json:"signup" validate:"omitempty"` // currently not used by filebrowser
Recaptcha Recaptcha `json:"recaptcha" validate:"omitempty"` // recaptcha config, only used if signup is enabled
}
type ProxyAuthConfig struct {
Enabled bool `json:"enabled"`
CreateUser bool `json:"createUser"` // create user if not exists
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
}
type Recaptcha struct {
Host string `json:"host" validate:"required"`
Key string `json:"key" validate:"required"`
Secret string `json:"secret" validate:"required"`
}
// OpenID OAuth2.0
type OidcConfig struct {
Enabled bool `json:"enabled"` // whether to enable OIDC authentication
ClientID string `json:"clientId" validate:"required"` // client id of the OIDC application
ClientSecret string `json:"clientSecret" validate:"required"` // client secret of the OIDC application
AuthorizationUrl string `json:"authorizationUrl" validate:"required"` // authorization URL of the OIDC provider
TokenUrl string `json:"tokenUrl" validate:"required"` // token URL of the OIDC provider
UserInfoUrl string `json:"userInfoUrl" validate:"required"` // user info URL of the OIDC provider
Scopes string `json:"scopes" validate:"required"` // space separated list of scopes to request from the OIDC provider
UserIdentifier string `json:"userIdentifier"` // optional: which attribute should be used as the username? options: email, username, name, phone_number, sub
JwksUrl string `json:"jwksUrl"` // currently not used by filebrowser
}
@@ -9,10 +9,10 @@ import (
"strings"
"github.com/go-playground/validator/v10"
yaml "github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/version"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
var Config Settings
@@ -22,11 +22,20 @@ func Initialize(configFile string) {
if err != nil {
logger.Fatal(err.Error())
}
loadEnvConfig()
err = ValidateConfig(Config)
if err != nil {
errmsg := "the provided config file failed validation. "
errmsg += "If you are seeing this on a config that worked previeously, "
errmsg += "this is because v0.6.8 requires a fully validated config to run. "
errmsg += "Please review your config for typos and invalid keys which are no longer supported. "
errmsg += "visit https://github.com/gtsteffaniak/filebrowser/wiki/Full-Config-Example for more information."
logger.Error(errmsg)
logger.Fatal(err.Error())
}
setupLogging()
setupAuth()
setupSources()
setupBaseURL()
setupUrls()
setupFrontend()
}
@@ -115,13 +124,17 @@ func setupSources() {
Config.Server.Sources = sourceList
}
func setupBaseURL() {
func setupUrls() {
baseurl := strings.Trim(Config.Server.BaseURL, "/")
if baseurl == "" {
Config.Server.BaseURL = "/"
} else {
Config.Server.BaseURL = "/" + baseurl + "/"
}
Config.Server.InternalUrl = strings.Trim(Config.Server.InternalUrl, "/")
Config.Server.ExternalUrl = strings.Trim(Config.Server.ExternalUrl, "/")
Config.Integrations.OnlyOffice.Url = strings.Trim(Config.Integrations.OnlyOffice.Url, "/")
Config.Integrations.OnlyOffice.InternalUrl = strings.Trim(Config.Integrations.OnlyOffice.InternalUrl, "/")
}
func setupAuth() {
@@ -131,6 +144,9 @@ func setupAuth() {
if Config.Auth.Methods.ProxyAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "Proxy")
}
if Config.Auth.Methods.OidcAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "OIDC")
}
if Config.Auth.Methods.NoAuth {
logger.Warning("Configured with no authentication, this is not recommended.")
Config.Auth.AuthMethods = []string{"Disabled"}
@@ -164,13 +180,13 @@ func setupLogging() {
}
func loadConfigWithDefaults(configFile string) error {
Config = setDefaults()
// Open and read the YAML file
yamlFile, err := os.Open(configFile)
if err != nil {
return err
}
defer yamlFile.Close()
stat, err := yamlFile.Stat()
if err != nil {
return err
@@ -183,35 +199,26 @@ func loadConfigWithDefaults(configFile string) error {
if err != nil {
logger.Warning(fmt.Sprintf("Could not load config file '%v', using default settings: %v", configFile, err))
}
err = ValidateConfig(yamlData)
if err != nil {
errmsg := "the provided config file failed validation. "
errmsg += "If you are seeing this on a config that worked previeously, "
errmsg += "this is because v0.6.8 requires a fully validated config to run. "
errmsg += "Please review your config for typos and invalid keys which are no longer supported. "
errmsg += "visit https://github.com/gtsteffaniak/filebrowser/wiki/Full-Config-Example for more information."
logger.Error(errmsg)
}
return err
}
func ValidateConfig(yamlData []byte) error {
var err error
Config = setDefaults()
err = yaml.NewDecoder(strings.NewReader(string(yamlData)), yaml.DisallowUnknownField()).Decode(&Config)
if err != nil {
return fmt.Errorf("error unmarshaling YAML data: %v", err)
}
loadEnvConfig()
return nil
}
func ValidateConfig(config Settings) error {
validate := validator.New()
err = validate.Struct(Config)
err := validate.Struct(Config)
if err != nil {
return fmt.Errorf("could not validate %v", err)
return fmt.Errorf("could not validate config: %v", err)
}
return nil
}
func loadEnvConfig() {
adminPassword, ok := os.LookupEnv("FILEBROWSER_ADMIN_PASSWORD")
if ok {
logger.Info("Using admin password from FILEBROWSER_ADMIN_PASSWORD environment variable")
@@ -222,13 +229,17 @@ func loadEnvConfig() {
logger.Info("Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable")
Config.Integrations.OnlyOffice.Secret = officeSecret
}
ffmpegPath, ok := os.LookupEnv("FILEBROWSER_FFMPEG_PATH")
if ok {
Config.Integrations.Media.FfmpegPath = ffmpegPath
}
}
func setDefaults() Settings {
return Settings{
Server: Server{
EnableThumbnails: true,
ResizePreview: false,
Port: 80,
NumImageProcessors: 4,
BaseURL: "",
@@ -302,7 +313,8 @@ func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, er
// check if its the name of a source and convert it to a path
source, ok = Config.Server.NameToSource[scope.Name]
if !ok {
return newScopes, fmt.Errorf("invalid scope for source %v", scope.Name)
// source might no longer be configured
continue
}
newScopes = append(newScopes, users.SourceScope{
Name: source.Path, // backend name is path
@@ -358,3 +370,15 @@ func GetScopeFromSourcePath(scopes []users.SourceScope, sourcePath string) (stri
}
return "", fmt.Errorf("scope not found for source %v", sourcePath)
}
// assumes backend style scopes
func GetSources(u *users.User) []string {
sources := []string{}
for _, scope := range u.Scopes {
source, ok := Config.Server.SourceMap[scope.Name]
if ok {
sources = append(sources, source.Name)
}
}
return sources
}
+101
View File
@@ -0,0 +1,101 @@
package settings
import (
"os"
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestInitialize(t *testing.T) {
type args struct {
configFile string
}
tests := []struct {
name string
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Initialize(tt.args.configFile)
})
}
}
func Test_setDefaults(t *testing.T) {
tests := []struct {
name string
want Settings
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := setDefaults(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("setDefaults() = %v, want %v", got, tt.want)
}
})
}
}
func TestConfigLoadChanged(t *testing.T) {
defaultConfig := setDefaults()
err := loadConfigWithDefaults("./validConfig.yaml")
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
// Use go-cmp to compare the two structs
if diff := cmp.Diff(defaultConfig, Config); diff == "" {
t.Errorf("No change when there should have been (-want +got):\n%s", diff)
}
}
func TestConfigLoadEnvVars(t *testing.T) {
defaultConfig := setDefaults()
expectedKey := "MYKEY"
// mock environment variables
os.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", expectedKey)
err := loadConfigWithDefaults("./validConfig.yaml")
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
if Config.Integrations.OnlyOffice.Secret != expectedKey {
t.Errorf("Expected OnlyOffice.Secret to be '%v', got '%s'", expectedKey, Config.Integrations.OnlyOffice.Secret)
}
// Use go-cmp to compare the two structs
if diff := cmp.Diff(defaultConfig, Config); diff == "" {
t.Errorf("No change when there should have been (-want +got):\n%s", diff)
}
}
func TestConfigLoadSpecificValues(t *testing.T) {
defaultConfig := setDefaults()
err := loadConfigWithDefaults("./validConfig.yaml")
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
testCases := []struct {
fieldName string
globalVal interface{}
newVal interface{}
}{
{"Server.Database", Config.Server.Database, defaultConfig.Server.Database},
}
for _, tc := range testCases {
if tc.globalVal == tc.newVal {
t.Errorf("Differences should have been found:\nConfig.%s: %v \nSetConfig: %v \n", tc.fieldName, tc.globalVal, tc.newVal)
}
}
}
func TestInvalidConfig(t *testing.T) {
configFile := "./invalidConfig.yaml"
err := loadConfigWithDefaults(configFile)
if err == nil {
t.Fatalf("expected error loading config file %s, got nil", configFile)
}
}
@@ -1,9 +1,10 @@
//go:generate go run ./tools/yaml.go -input=common/settings/settings.go -output=config.generated.yaml
package settings
import (
"crypto/rand"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
const DefaultUsersHomeBasePath = "/users"
@@ -44,7 +45,7 @@ func ApplyUserDefaults(u *users.User) {
u.Locale = Config.UserDefaults.Locale
u.ViewMode = Config.UserDefaults.ViewMode
u.SingleClick = Config.UserDefaults.SingleClick
u.Perm = Config.UserDefaults.Permissions
u.Permissions = Config.UserDefaults.Permissions
u.ShowHidden = Config.UserDefaults.ShowHidden
u.DateFormat = Config.UserDefaults.DateFormat
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
@@ -1,7 +1,7 @@
package settings
import (
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
// StorageBackend is a settings storage backend.
+129
View File
@@ -0,0 +1,129 @@
package settings
import (
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
type AllowedMethods string
const (
ProxyAuth AllowedMethods = "proxyAuth"
NoAuth AllowedMethods = "noAuth"
PasswordAuth AllowedMethods = "passwordAuth"
)
type Settings struct {
Server Server `json:"server"`
Auth Auth `json:"auth"`
Frontend Frontend `json:"frontend"`
UserDefaults UserDefaults `json:"userDefaults"`
Integrations Integrations `json:"integrations"`
}
type Server struct {
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs
Socket string `json:"socket"` // socket to listen on
TLSKey string `json:"tlsKey"` // path to TLS key
TLSCert string `json:"tlsCert"` // path to TLS cert
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
Port int `json:"port"` // port to listen on
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
Logging []LogConfig `json:"logging"`
Database string `json:"database"` // path to the database file
Sources []Source `json:"sources" validate:"required,dive"`
ExternalUrl string `json:"externalUrl"` // used by share links if set
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the url that an integration service will use to communicate with filebrowser
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
// not exposed to config
SourceMap map[string]Source `json:"-" validate:"omitempty"` // uses realpath as key
NameToSource map[string]Source `json:"-" validate:"omitempty"` // uses name as key
DefaultSource Source `json:"-" validate:"omitempty"`
}
type Integrations struct {
OnlyOffice OnlyOffice `json:"office" validate:"omitempty"`
Media Media `json:"media" validate:"omitempty"`
}
// onlyoffice secret is stored in the local.json file
// docker exec <containerID> /var/www/onlyoffice/documentserver/npm/json -f /etc/onlyoffice/documentserver/local.json 'services.CoAuthoring.secret.session.string'
type OnlyOffice struct {
Url string `json:"url" validate:"required"` // The URL to the OnlyOffice Document Server, needs to be accessible to the user.
InternalUrl string `json:"internalUrl"` // An optional internal address that the filebrowser server can use to communicate with the OnlyOffice Document Server, could be useful to bypass proxy.
Secret string `json:"secret" validate:"required"`
}
type Media struct {
FfmpegPath string `json:"ffmpegPath"` // path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
}
type LogConfig struct {
Levels string `json:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
ApiLevels string `json:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
Output string `json:"output"` // output location. (eg. "stdout" or "path/to/file.log")
NoColors bool `json:"noColors"` // disable colors in the output
Json bool `json:"json"` // output in json format, currently not supported
}
type Source struct {
Path string `json:"path" validate:"required"` // file system path. (Can be relative)
Name string `json:"name"` // display name
Config SourceConfig `json:"config"`
}
type SourceConfig struct {
IndexingInterval uint32 `json:"indexingInterval"` // optional manual overide interval in seconds to re-index the source
Disabled bool `json:"disabled"` // disable the indexing of this source
MaxWatchers int `json:"maxWatchers"` // number of concurrent watchers to use for this source, currently not supported
NeverWatch []string `json:"neverWatchPaths"` // paths to never watch, relative to the source path (eg. "/folder/file.txt")
IgnoreHidden bool `json:"ignoreHidden"` // ignore hidden files and folders.
IgnoreZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // ignore folders with 0 size
Exclude IndexFilter `json:"exclude"` // exclude files and folders from indexing, if include is not set
Include IndexFilter `json:"include"` // include files and folders from indexing, if exclude is not set
DefaultUserScope string `json:"defaultUserScope"` // default "/" should match folders under path
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user
}
type IndexFilter struct {
Files []string `json:"files"` // array of file names to include/exclude
Folders []string `json:"folders"` // array of folder names to include/exclude
FileEndsWith []string `json:"fileEndsWith"` // array of file names to include/exclude (eg "a.jpg")
}
type Frontend struct {
Name string `json:"name"` // display name
DisableDefaultLinks bool `json:"disableDefaultLinks"` // disable default links in the sidebar
DisableUsedPercentage bool `json:"disableUsedPercentage"` // disable used percentage for the sources in the sidebar
ExternalLinks []ExternalLink `json:"externalLinks"`
}
type ExternalLink struct {
Text string `json:"text" validate:"required"` // the text to display on the link
Title string `json:"title"` // the title to display on hover
Url string `json:"url" validate:"required"` // the url to link to
}
// UserDefaults is a type that holds the default values
// for some fields on User.
type UserDefaults struct {
PreviewOptions users.PreviewOptions `json:"preview"`
StickySidebar bool `json:"stickySidebar"`
DarkMode bool `json:"darkMode"`
LockPassword bool `json:"lockPassword"`
DisableSettings bool `json:"disableSettings,omitempty"`
Locale string `json:"locale"`
ViewMode string `json:"viewMode"`
GallerySize int `json:"gallerySize"`
SingleClick bool `json:"singleClick"`
Permissions users.Permissions `json:"permissions"`
ShowHidden bool `json:"showHidden"`
DateFormat bool `json:"dateFormat"`
ThemeColor string `json:"themeColor"`
QuickDownload bool `json:"quickDownload"`
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"`
DefaultScopes []users.SourceScope `json:"-"`
}
@@ -3,8 +3,8 @@ server:
socket: ""
tlsKey: ""
tlsCert: ""
enableThumbnails: false
resizePreview: true
disablePreviews: false
disablePreviewResize: false
port: 80
baseURL: "/"
database: "mydb.db"
@@ -32,4 +32,4 @@ userDefaults:
modify: true
share: true
api: true
dateFormat: false
dateFormat: false
@@ -6,13 +6,15 @@ import (
"encoding/hex"
"fmt"
math "math/rand"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func CheckErr(source string, err error) {
@@ -114,3 +116,31 @@ func JoinPathAsUnix(parts ...string) string {
}
return joinedPath
}
// ResolveSymlinks resolves symlinks in the given path and returns
// the final resolved path, whether it's a directory (considering bundle logic), and any error.
func ResolveSymlinks(path string) (string, bool, error) {
for {
// Get the file info using os.Lstat to handle symlinks
info, err := os.Lstat(path)
if err != nil {
return path, false, fmt.Errorf("could not stat path: %s, %v", path, err)
}
// Check if the path is a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Read the symlink target
target, err := os.Readlink(path)
if err != nil {
return path, false, fmt.Errorf("could not read symlink: %s, %v", path, err)
}
// Resolve the symlink's target relative to its directory
path = filepath.Join(filepath.Dir(path), target)
} else {
// Not a symlink, check with bundle-aware directory logic
isDir := iteminfo.IsDirectory(info)
return path, isDir, nil
}
}
}
File renamed without changes.
@@ -1,17 +1,19 @@
package files
package utils
import (
"time"
"math/rand"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func CreateMockData(numDirs, numFilesPerDir int) FileInfo {
dir := FileInfo{}
func CreateMockData(numDirs, numFilesPerDir int) iteminfo.FileInfo {
dir := iteminfo.FileInfo{}
dir.Path = "/here/is/your/mock/dir"
for i := 0; i < numDirs; i++ {
newFile := ItemInfo{
Name: "file-" + getRandomTerm() + getRandomExtension(),
newFile := iteminfo.ItemInfo{
Name: "file-" + GetRandomTerm() + GetRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
Type: "blob",
@@ -20,8 +22,8 @@ func CreateMockData(numDirs, numFilesPerDir int) FileInfo {
}
// Simulating files and directories with FileInfo
for j := 0; j < numFilesPerDir; j++ {
newFile := ItemInfo{
Name: "file-" + getRandomTerm() + getRandomExtension(),
newFile := iteminfo.ItemInfo{
Name: "file-" + GetRandomTerm() + GetRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
Type: "blob",
@@ -31,16 +33,16 @@ func CreateMockData(numDirs, numFilesPerDir int) FileInfo {
return dir
}
func generateRandomPath(levels int) string {
func GenerateRandomPath(levels int) string {
rand.New(rand.NewSource(time.Now().UnixNano()))
dirName := "srv"
for i := 0; i < levels; i++ {
dirName += "/" + getRandomTerm()
dirName += "/" + GetRandomTerm()
}
return dirName
}
func getRandomTerm() string {
func GetRandomTerm() string {
wordbank := []string{
"hi", "test", "other", "name",
"cool", "things", "more", "items",
@@ -51,7 +53,7 @@ func getRandomTerm() string {
return wordbank[index]
}
func getRandomExtension() string {
func GetRandomExtension() string {
wordbank := []string{
".txt", ".mp3", ".mov", ".doc",
".mp4", ".bak", ".zip", ".jpg",
@@ -61,11 +63,11 @@ func getRandomExtension() string {
return wordbank[index]
}
func generateRandomSearchTerms(numTerms int) []string {
func GenerateRandomSearchTerms(numTerms int) []string {
// Generate random search terms
searchTerms := make([]string, numTerms)
for i := 0; i < numTerms; i++ {
searchTerms[i] = getRandomTerm()
searchTerms[i] = GetRandomTerm()
}
return searchTerms
}
File renamed without changes.
+11
View File
@@ -0,0 +1,11 @@
server:
logging:
- levels: "info|warning|error"
sources:
- path: "/srv"
userDefaults:
preview:
image: true
popup: true
video: true
highQuality: true
+7 -1
View File
@@ -2,10 +2,16 @@ server:
port: 80
baseURL: "/"
logging:
- levels: "info|debug|warning|error"
- levels: "info|warning|error"
sources:
- path: "/srv"
userDefaults:
preview:
image: true
popup: true
video: false
office: false
highQuality: false
darkMode: true
disableSettings: false
singleClick: false
File renamed without changes.
@@ -3,7 +3,7 @@ package share
import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
// StorageBackend is the interface to implement for a share storage.
@@ -90,8 +90,8 @@ func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
}
// Gets wraps a StorageBackend.Gets
func (s *Storage) Gets(path, source string, id uint) ([]*Link, error) {
links, err := s.back.Gets(path, source, id)
func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
links, err := s.back.Gets(sourcePath, source, id)
if err != nil {
return nil, err
@@ -3,7 +3,7 @@ package bolt
import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
type authBackend struct {
@@ -4,9 +4,9 @@ import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// NewStorage creates a storage.Storage based on Bolt DB.
@@ -2,7 +2,7 @@ package bolt
import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
type settingsBackend struct {
@@ -7,9 +7,9 @@ import (
storm "github.com/asdine/storm/v3"
"github.com/asdine/storm/v3/q"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
)
type shareBackend struct {
@@ -48,19 +48,14 @@ func (s shareBackend) GetByHash(hash string) (*share.Link, error) {
func (s shareBackend) GetPermanent(path, source string, id uint) (*share.Link, error) {
var v share.Link
var legacy []*share.Link
// todo remove legacy and return notfound errors
// TODO remove legacy and return notfound errors
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", source), q.Eq("Expire", 0), q.Eq("UserID", id)).First(&v)
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", ""), q.Eq("UserID", id)).Find(&legacy)
return &v, nil
}
func (s shareBackend) Gets(path, source string, id uint) ([]*share.Link, error) {
func (s shareBackend) Gets(path, sourcePath string, id uint) ([]*share.Link, error) {
var v []*share.Link
var legacy []*share.Link
// todo remove legacy and return notfound errors
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", source), q.Eq("UserID", id)).Find(&v)
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", ""), q.Eq("UserID", id)).Find(&legacy)
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", sourcePath), q.Eq("UserID", id)).Find(&v)
filteredList := []*share.Link{}
var err error
// through and filter out expired share
@@ -74,20 +69,6 @@ func (s shareBackend) Gets(path, source string, id uint) ([]*share.Link, error)
filteredList = append(filteredList, v[i])
}
}
// automatically delete and clear expired shares
// todo remove after some time.
for i := range legacy {
if legacy[i].Expire < time.Now().Unix() && legacy[i].Expire != 0 {
err = s.Delete(legacy[i].PasswordHash)
if err != nil {
logger.Error(fmt.Sprintf("expired share could not be deleted: %v", err.Error()))
}
} else {
filteredList = append(filteredList, legacy[i])
}
}
return filteredList, err
}
@@ -8,12 +8,13 @@ import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
type usersBackend struct {
@@ -68,29 +69,42 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
return err
}
fields, err = parseFields(user, fields)
fields, err = parseFields(user, fields, actorIsAdmin)
if err != nil {
return err
}
if !slices.Contains(fields, "Password") {
user.Password = existingUser.Password
} else {
if existingUser.LockPassword {
return fmt.Errorf("password cannot be changed when lock password is enabled")
}
}
if !actorIsAdmin {
fmt.Println("User is not admin, checking restricted fields")
err := checkRestrictedFields(existingUser, fields)
if err != nil {
return err
}
fmt.Println("existingUser, fields", existingUser, fields)
}
if len(fields) == 0 {
return fmt.Errorf("no fields to update")
}
// converting scopes to map of paths intead of names (names can change)
if slices.Contains(fields, "Scopes") {
adjustedScopes, err := settings.ConvertToBackendScopes(user.Scopes)
if err != nil {
return err
}
user.Scopes = adjustedScopes
}
// Use reflection to access struct fields
userFields := reflect.ValueOf(user).Elem() // Get struct value
for _, field := range fields {
// Get the corresponding field using reflection
fieldValue := userFields.FieldByName(field)
@@ -110,11 +124,22 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
return fmt.Errorf("failed to update user field: %s, error: %v", field, err)
}
}
// last revoke api keys if needed.
if existingUser.Permissions.Api && !user.Permissions.Api && slices.Contains(fields, "Permissions") {
for _, key := range existingUser.ApiKeys {
auth.RevokeAPIKey(key.Key) // add to blacklist
}
}
return nil
}
func (st usersBackend) Save(user *users.User, changePass bool) error {
if settings.Config.Auth.Methods.PasswordAuth.Enabled && changePass {
if user.LoginMethod == "" {
user.LoginMethod = users.LoginMethodPassword
}
logger.Debug(fmt.Sprintf("Saving user [%s] changepass: %v", user.Username, changePass))
if user.LoginMethod == users.LoginMethodPassword && changePass {
err := checkPassword(user.Password)
if err != nil {
return err
@@ -159,25 +184,29 @@ func (st usersBackend) DeleteByUsername(username string) error {
// Define a function to check for restricted fields
func checkRestrictedFields(existingUser *users.User, fields []string) error {
// Check if 'password' field is locked
if existingUser.LockPassword && slices.Contains(fields, "password") {
return fmt.Errorf("password is locked")
}
// Use reflection to get the field names of NonAdminEditable
editableFields := reflect.ValueOf(existingUser)
if editableFields.Kind() == reflect.Struct {
for i := 0; i < editableFields.NumField(); i++ {
fieldName := editableFields.Type().Field(i).Name
if slices.Contains(fields, fieldName) {
return fmt.Errorf("non-admins cannot modify field: %s", fieldName)
}
// Get a list of allowed fields from NonAdminEditable
allowed := getNonAdminEditableFieldNames()
for _, field := range fields {
if !slices.Contains(allowed, field) {
return fmt.Errorf("non-admins cannot modify field: %s", field)
}
}
// No restricted fields found, return nil (no error)
return nil
}
func parseFields(user *users.User, fields []string) ([]string, error) {
// Helper to return list of field names from NonAdminEditable struct
func getNonAdminEditableFieldNames() []string {
var names []string
t := reflect.TypeOf(users.NonAdminEditable{})
for i := 0; i < t.NumField(); i++ {
names = append(names, t.Field(i).Name)
}
return names
}
func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string, error) {
// If `Which` is not specified, default to updating all fields
if len(fields) == 0 || fields[0] == "all" {
fields = []string{}
@@ -200,6 +229,9 @@ func parseFields(user *users.User, fields []string) ([]string, error) {
for _, field := range fields {
capitalField := utils.CapitalizeFirst(field)
if capitalField == "Scopes" {
if !actorIsAdmin {
continue
}
newScopes, err := settings.ConvertToBackendScopes(user.Scopes)
if err == nil {
user.Scopes = newScopes
@@ -210,6 +242,13 @@ func parseFields(user *users.User, fields []string) ([]string, error) {
}
}
if capitalField == "Password" {
if user.LoginMethod != users.LoginMethodPassword {
return nil, fmt.Errorf("password cannot be changed when login method is not password")
}
err := checkPassword(user.Password)
if err != nil {
return nil, fmt.Errorf("password does not meet complexity requirements")
}
value, err := users.HashPwd(user.Password)
if err != nil {
logger.Error(err.Error())
@@ -3,7 +3,7 @@ package bolt
import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
func get(db *storm.DB, name string, to interface{}) error {
@@ -7,13 +7,13 @@ import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// Storage is a storage powered by a Backend which makes the necessary
@@ -87,7 +87,7 @@ func quickSetup(store *Storage) {
settings.ApplyUserDefaults(user)
user.Username = settings.Config.Auth.AdminUsername
user.Password = settings.Config.Auth.AdminPassword
user.Perm.Admin = true
user.Permissions.Admin = true
user.Scopes = []users.SourceScope{}
for _, val := range settings.Config.Server.Sources {
user.Scopes = append(user.Scopes, users.SourceScope{
@@ -96,7 +96,7 @@ func quickSetup(store *Storage) {
})
}
user.LockPassword = false
user.Perm = settings.AdminPerms()
user.Permissions = settings.AdminPerms()
logger.Debug(fmt.Sprintf("Creating user as admin: %v %v", user.Username, user.Password))
err = store.Users.Save(user, true)
utils.CheckErr("store.Users.Save", err)
@@ -112,7 +112,7 @@ func CreateUser(userInfo users.User, asAdmin bool) error {
logger.Debug(fmt.Sprintf("Creating user: %v %v", userInfo.Username, userInfo.Scopes))
settings.ApplyUserDefaults(newUser)
if asAdmin {
userInfo.Perm = settings.AdminPerms()
userInfo.Permissions = settings.AdminPerms()
}
if len(userInfo.Scopes) == 0 {
userInfo.Scopes = settings.Config.UserDefaults.DefaultScopes
File renamed without changes.
@@ -4,7 +4,7 @@ import (
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
// StorageBackend is the interface to implement for a users storage.
@@ -87,7 +87,7 @@ func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
user.ApiKeys = make(map[string]AuthToken)
}
user.ApiKeys[name] = key
err = s.Update(user, false, "ApiKeys")
err = s.Update(user, true, "ApiKeys")
if err != nil {
return err
}
@@ -105,7 +105,7 @@ func (s *Storage) DeleteApiKey(userID uint, name string) error {
user.ApiKeys = make(map[string]AuthToken)
}
delete(user.ApiKeys, name)
err = s.Update(user, false, "ApiKeys")
err = s.Update(user, true, "ApiKeys")
if err != nil {
return err
}
File renamed without changes.
@@ -12,6 +12,16 @@ var (
dashes = regexp.MustCompile(`[\-]+`)
)
const ()
type LoginMethod string
const (
LoginMethodPassword LoginMethod = "password"
LoginMethodProxy LoginMethod = "proxy"
LoginMethodOidc LoginMethod = "oidc"
)
type AuthToken struct {
Key string `json:"key"`
Name string `json:"name"`
@@ -23,10 +33,11 @@ type AuthToken struct {
}
type Permissions struct {
Api bool `json:"api"`
Admin bool `json:"admin"`
Modify bool `json:"modify"`
Share bool `json:"share"`
Api bool `json:"api"`
Admin bool `json:"admin"`
Modify bool `json:"modify"`
Share bool `json:"share"`
Realtime bool `json:"realtime"`
}
// SortingSettings represents the sorting settings.
@@ -35,18 +46,28 @@ type Sorting struct {
Asc bool `json:"asc"`
}
type PreviewOptions struct {
HighQuality bool `json:"highQuality"`
Image bool `json:"image"`
Video bool `json:"video"`
Office bool `json:"office"`
PopUp bool `json:"popup"`
}
// User describes a user.
type User struct {
NonAdminEditable
StickySidebar bool `json:"stickySidebar"`
DisableSettings bool `json:"disableSettings"`
ID uint `storm:"id,increment" json:"id"`
Username string `storm:"unique" json:"username"`
Scopes []SourceScope `json:"scopes"`
Scope string `json:"scope,omitempty"`
LockPassword bool `json:"lockPassword"`
Perm Permissions `json:"perm"`
Permissions Permissions `json:"permissions"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"`
LoginMethod LoginMethod `json:"loginMethod"`
// legacy for migration purposes... og filebrowser has perm attribute
Perm Permissions `json:"perm,omitzero"`
}
type SourceScope struct {
@@ -55,18 +76,20 @@ type SourceScope struct {
}
type NonAdminEditable struct {
DarkMode bool `json:"darkMode"`
Password string `json:"password,omitempty"`
Locale string `json:"locale"`
ViewMode string `json:"viewMode"`
SingleClick bool `json:"singleClick"`
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"`
DateFormat bool `json:"dateFormat"`
GallerySize int `json:"gallerySize"`
ThemeColor string `json:"themeColor"`
QuickDownload bool `json:"quickDownload"`
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"`
Preview PreviewOptions `json:"preview"`
StickySidebar bool `json:"stickySidebar"`
DarkMode bool `json:"darkMode"`
Password string `json:"password,omitempty"`
Locale string `json:"locale"`
ViewMode string `json:"viewMode"`
SingleClick bool `json:"singleClick"`
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"`
DateFormat bool `json:"dateFormat"`
GallerySize int `json:"gallerySize"`
ThemeColor string `json:"themeColor"`
QuickDownload bool `json:"quickDownload"`
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"`
}
var PublicUser = User{
@@ -76,12 +99,7 @@ var PublicUser = User{
},
Username: "publicUser", // temp user not registered
LockPassword: true,
Perm: Permissions{
Modify: false,
Share: false,
Admin: false,
Api: false,
},
Permissions: Permissions{},
}
func CleanUsername(s string) string {
+140
View File
@@ -0,0 +1,140 @@
package events
import (
"sync"
)
type EventMessage struct {
EventType string `json:"eventType"`
Message string `json:"message"`
}
type sourceEvent struct {
source string
event EventMessage
}
type userEvent struct {
event EventMessage
users []string
}
var (
BroadcastChan = make(chan EventMessage, 100)
userEventChan = make(chan userEvent, 100)
debounceInputChan = make(chan EventMessage, 100)
sourceUpdateChan = make(chan sourceEvent, 100)
userClientsMu sync.RWMutex
userClients = make(map[string][]chan EventMessage)
sourceClientsMu sync.RWMutex
sourceClients = make(map[string]map[chan EventMessage]struct{})
)
func init() {
go handleUserEvents()
go handleSourceUpdates() // Add this
}
func handleUserEvents() {
for ue := range userEventChan {
for _, user := range ue.users {
userClientsMu.RLock()
conns := userClients[user]
userClientsMu.RUnlock()
for _, ch := range conns {
select {
case ch <- ue.event:
default:
}
}
}
}
}
func Register(username string, sources []string) chan EventMessage {
ch := make(chan EventMessage, 10)
userClientsMu.Lock()
userClients[username] = append(userClients[username], ch)
userClientsMu.Unlock()
sourceClientsMu.Lock()
for _, source := range sources {
if sourceClients[source] == nil {
sourceClients[source] = make(map[chan EventMessage]struct{})
}
sourceClients[source][ch] = struct{}{}
}
sourceClientsMu.Unlock()
return ch
}
func Unregister(username string, ch chan EventMessage) {
userClientsMu.Lock()
conns := userClients[username]
for i, c := range conns {
if c == ch {
userClients[username] = append(conns[:i], conns[i+1:]...)
break
}
}
if len(userClients[username]) == 0 {
delete(userClients, username)
}
userClientsMu.Unlock()
sourceClientsMu.Lock()
for source, clients := range sourceClients {
delete(clients, ch)
if len(clients) == 0 {
delete(sourceClients, source)
}
}
sourceClientsMu.Unlock()
close(ch)
}
func SendToUsers(eventType, message string, users []string) {
userEventChan <- userEvent{
event: EventMessage{EventType: eventType, Message: message},
users: users,
}
}
func SendSourceUpdate(source string, message string) {
sourceUpdateChan <- sourceEvent{
source: source,
event: EventMessage{
EventType: "sourceUpdate",
Message: message,
},
}
}
func DebouncedBroadcast(eventType, message string) {
debounceInputChan <- EventMessage{
EventType: eventType,
Message: message,
}
}
func handleSourceUpdates() {
for update := range sourceUpdateChan {
sourceClientsMu.RLock()
clients := sourceClients[update.source]
sourceClientsMu.RUnlock()
for ch := range clients {
select {
case ch <- update.event:
default:
// Optional: log dropped message
}
}
}
}
-280
View File
@@ -1,280 +0,0 @@
package files
import (
"mime"
"path/filepath"
"regexp"
"strconv"
"strings"
)
var typeRegexp = regexp.MustCompile(`type:(\S+)`)
var AllFiletypeOptions = []string{
"image",
"audio",
"archive",
"video",
"doc",
"text",
}
// Document file extensions
var documentTypes = []string{
// Common Document Formats
".doc", ".docx", // Microsoft Word
".pdf", // Portable Document Format
".odt", // OpenDocument Text
".rtf", // Rich Text Format
".conf",
".bash_history",
".gitignore",
".htpasswd",
".profile",
".dockerignore",
".editorconfig",
// Presentation Formats
".ppt", ".pptx", // Microsoft PowerPoint
".odp", // OpenDocument Presentation
// google docs
".gdoc",
// google sheet
".gsheet",
// Spreadsheet Formats
".xls", ".xlsx", // Microsoft Excel
".ods", // OpenDocument Spreadsheet
// Other Document Formats
".epub", // Electronic Publication
".mobi", // Amazon Kindle
".fb2", // FictionBook
}
var onlyOfficeSupported = []string{
".doc", ".docm", ".docx", ".dot", ".dotm", ".dotx", ".epub",
".fb2", ".fodt", ".htm", ".html", ".mht", ".mhtml", ".odt",
".ott", ".rtf", ".stw", ".sxw", ".txt", ".wps", ".wpt", ".xml",
".csv", ".et", ".ett", ".fods", ".ods", ".ots", ".sxc", ".xls",
".xlsb", ".xlsm", ".xlsx", ".xlt", ".xltm", ".xltx", ".dps",
".dpt", ".fodp", ".odp", ".otp", ".pot", ".potm", ".potx",
".pps", ".ppsm", ".ppsx", ".ppt", ".pptm", ".pptx", ".sxi",
".djvu", ".docxf", ".oform", ".oxps", ".pdf", ".xps",
}
// Text-based file extensions
var textTypes = []string{
// Common Text Formats
".txt",
".md", // Markdown
// Scripting and Programming Languages
".sh", // Bash script
".py", // Python
".js", // JavaScript
".ts", // TypeScript
".php", // PHP
".rb", // Ruby
".go", // Go
".java", // Java
".c", ".cpp", // C/C++
".cs", // C#
".swift", // Swift
// Configuration Files
".yaml", ".yml", // YAML
".json", // JSON
".xml", // XML
".ini", // INI
".toml", // TOML
".cfg", // Configuration file
// Other Text-Based Formats
".css", // Cascading Style Sheets
".html", ".htm", // HyperText Markup Language
".sql", // SQL
".csv", // Comma-Separated Values
".tsv", // Tab-Separated Values
".log", // Log file
".bat", // Batch file
".ps1", // PowerShell script
".tex", // LaTeX
".bib", // BibTeX
}
// Compressed file extensions
var compressedFile = []string{
".7z",
".rar",
".zip",
".tar",
".gz",
".xz",
".bz2",
".tgz", // tar.gz
".tbz2", // tar.bz2
".lzma",
".lz4",
".zstd",
}
type SearchOptions struct {
Conditions map[string]bool
LargerThan int
SmallerThan int
Terms []string
}
func extendedMimeTypeCheck(extension string) string {
if isDoc(extension) {
return "application/document"
}
if isText(extension) {
return "text/plain"
}
return "blob"
}
func ParseSearch(value string) SearchOptions {
opts := SearchOptions{
Conditions: map[string]bool{
"exact": strings.Contains(value, "case:exact"),
},
Terms: []string{},
}
// removes the options from the value
value = strings.Replace(value, "case:exact", "", -1)
value = strings.TrimSpace(value)
types := typeRegexp.FindAllStringSubmatch(value, -1)
for _, filterType := range types {
if len(filterType) == 1 {
continue
}
filter := filterType[1]
switch filter {
case "image":
opts.Conditions["image"] = true
case "audio", "music":
opts.Conditions["audio"] = true
case "video":
opts.Conditions["video"] = true
case "doc":
opts.Conditions["doc"] = true
case "archive":
opts.Conditions["archive"] = true
case "folder":
opts.Conditions["dir"] = true
case "file":
opts.Conditions["dir"] = false
}
if len(filter) < 8 {
continue
}
if strings.HasPrefix(filter, "largerThan=") {
opts.Conditions["larger"] = true
size := strings.TrimPrefix(filter, "largerThan=")
opts.LargerThan = updateSize(size)
}
if strings.HasPrefix(filter, "smallerThan=") {
opts.Conditions["smaller"] = true
size := strings.TrimPrefix(filter, "smallerThan=")
opts.SmallerThan = updateSize(size)
}
}
if len(types) > 0 {
// Remove the fields from the search value
value = typeRegexp.ReplaceAllString(value, "")
}
if value == "" {
return opts
}
// if the value starts with " and finishes what that character, we will
// only search for that term
if value[0] == '"' && value[len(value)-1] == '"' {
unique := strings.TrimPrefix(value, "\"")
unique = strings.TrimSuffix(unique, "\"")
opts.Terms = []string{unique}
return opts
}
value = strings.TrimSpace(value)
opts.Terms = strings.Split(value, "|")
return opts
}
func toInt(str string) int {
val, err := strconv.Atoi(str)
if err != nil {
return 0
}
return val
}
func updateSize(given string) int {
size := toInt(given)
if size == 0 {
return 100
} else {
return size
}
}
func IsMatchingType(extension string, matchType string) bool {
mimetype := mime.TypeByExtension(extension)
if strings.HasPrefix(mimetype, matchType) {
return true
}
switch matchType {
case "doc":
return isDoc(extension)
case "text":
return isText(extension)
case "archive":
return isArchive(extension)
}
return false
}
func isText(extension string) bool {
for _, typefile := range textTypes {
if extension == typefile {
return true
}
}
return false
}
func isDoc(extension string) bool {
for _, typefile := range documentTypes {
if extension == typefile {
return true
}
}
return false
}
func isArchive(extension string) bool {
for _, typefile := range compressedFile {
if extension == typefile {
return true
}
}
return false
}
func isOnlyOffice(name string) bool {
extention := filepath.Ext(name)
for _, typefile := range onlyOfficeSupported {
if extention == typefile {
return true
}
}
return false
}
-462
View File
@@ -1,462 +0,0 @@
package files
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"fmt"
"hash"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
type ItemInfo struct {
Name string `json:"name"` // name of the file
Size int64 `json:"size"` // length in bytes for regular files
ModTime time.Time `json:"modified"` // modification time
Type string `json:"type"` // type of the file, either "directory" or a file mimetype
Hidden bool `json:"hidden"` // whether the file is hidden
}
// FileInfo describes a file.
// reduced item is non-recursive reduced "Items", used to pass flat items array
type FileInfo struct {
ItemInfo
Files []ItemInfo `json:"files"` // files in the directory
Folders []ItemInfo `json:"folders"` // folders in the directory
Path string `json:"path"` // path scoped to the associated index
}
// for efficiency, a response will be a pointer to the data
// extra calculated fields can be added here
type ExtendedFileInfo struct {
FileInfo
Content string `json:"content,omitempty"` // text content of a file, if requested
Subtitles []string `json:"subtitles,omitempty"` // subtitles for video files
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
Token string `json:"token,omitempty"` // token for the file -- used for sharing
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source"` // associated index source for the file
RealPath string `json:"-"`
}
// FileOptions are the options when getting a file info.
type FileOptions struct {
Path string // realpath
Source string
IsDir bool
Modify bool
Expand bool
ReadHeader bool
Content bool
}
func (f FileOptions) Components() (string, string) {
return filepath.Dir(f.Path), filepath.Base(f.Path)
}
func FileInfoFaster(opts FileOptions) (ExtendedFileInfo, error) {
response := ExtendedFileInfo{}
if opts.Source == "" {
opts.Source = settings.Config.Server.DefaultSource.Name
}
index := GetIndex(opts.Source)
if index == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
}
realPath, isDir, err := index.GetRealPath(opts.Path)
if err != nil {
return response, err
}
opts.IsDir = isDir
// TODO : whats the best way to save trips to disk here?
// disabled using cache because its not clear if this is helping or hurting
// check if the file exists in the index
//info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
//if exists {
// err := RefreshFileInfo(opts)
// if err != nil {
// return info, err
// }
// if opts.Content {
// content := ""
// content, err = getContent(opts.Path)
// if err != nil {
// return info, err
// }
// info.Content = content
// }
// return info, nil
//}
err = index.RefreshFileInfo(opts)
if err != nil {
return response, err
}
info, exists := index.GetReducedMetadata(opts.Path, opts.IsDir)
if !exists {
return response, fmt.Errorf("could not get metadata for path: %v", opts.Path)
}
if opts.Content && strings.HasPrefix(info.Type, "text") {
// Check file size
if info.Size > 50*1024*1024 { // 50 megabytes in bytes
logger.Debug(fmt.Sprintf("Reading large text file contents: "+info.Path, info.Name))
}
content, err := getContent(realPath)
if err != nil {
return response, err
}
response.Content = content
}
response.FileInfo = *info
response.RealPath = realPath
response.Source = opts.Source
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && isOnlyOffice(info.Name) {
response.OnlyOfficeId = generateOfficeId(realPath)
}
if strings.HasPrefix(info.Type, "video") {
response.detectSubtitles(realPath)
}
return response, nil
}
func generateOfficeId(realPath string) string {
key, ok := cache.OnlyOffice.Get(realPath).(string)
if !ok {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
documentKey := utils.HashSHA256(realPath + timestamp)
cache.OnlyOffice.Set(realPath, documentKey)
return documentKey
}
return key
}
// Checksum checksums a given File for a given User, using a specific
// algorithm. The checksums data is saved on File object.
func GetChecksum(fullPath, algo string) (map[string]string, error) {
subs := map[string]string{}
reader, err := os.Open(fullPath)
if err != nil {
return subs, err
}
defer reader.Close()
hashFuncs := map[string]hash.Hash{
"md5": md5.New(),
"sha1": sha1.New(),
"sha256": sha256.New(),
"sha512": sha512.New(),
}
h, ok := hashFuncs[algo]
if !ok {
return subs, errors.ErrInvalidOption
}
_, err = io.Copy(h, reader)
if err != nil {
return subs, err
}
subs[algo] = hex.EncodeToString(h.Sum(nil))
return subs, nil
}
func DeleteFiles(source, absPath string, absDirPath string) error {
err := os.RemoveAll(absPath)
if err != nil {
return err
}
index := GetIndex(source)
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
}
refreshConfig := FileOptions{Path: index.MakeIndexPath(absDirPath), IsDir: true}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return err
}
return nil
}
func MoveResource(sourceIndex, destIndex, realsrc, realdst string) error {
err := fileutils.MoveFile(realsrc, realdst)
if err != nil {
return err
}
idxSrc := GetIndex(sourceIndex)
if idxSrc == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
idxDst := GetIndex(destIndex)
if idxDst == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshSourceDir := idxSrc.MakeIndexPath(filepath.Dir(realsrc))
refreshDestDir := idxDst.MakeIndexPath(filepath.Dir(realdst))
// refresh info for source and dest
err = idxSrc.RefreshFileInfo(FileOptions{
Path: refreshSourceDir,
IsDir: true,
})
if err != nil {
return fmt.Errorf("could not refresh index for source: %v", err)
}
if refreshSourceDir == refreshDestDir {
return nil
}
refreshConfig := FileOptions{Path: refreshDestDir, IsDir: true}
err = idxDst.RefreshFileInfo(refreshConfig)
if err != nil {
return fmt.Errorf("could not refresh index for dest: %v", err)
}
return nil
}
func CopyResource(sourceIndex, destIndex, realsrc, realdst string) error {
err := fileutils.CopyFile(realsrc, realdst)
if err != nil {
return err
}
idxSrc := GetIndex(sourceIndex)
if idxSrc == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
idxDst := GetIndex(destIndex)
if idxDst == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshSourceDir := idxSrc.MakeIndexPath(filepath.Dir(realsrc))
refreshDestDir := idxDst.MakeIndexPath(filepath.Dir(realdst))
index := GetIndex(sourceIndex)
if index == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
}
refreshConfig := FileOptions{Path: refreshSourceDir, IsDir: true}
// refresh info for source and dest
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return fmt.Errorf("could not refresh index for source: %v", err)
}
refreshConfig.Path = refreshDestDir
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return errors.ErrEmptyKey
}
return nil
}
func WriteDirectory(opts FileOptions) error {
idx := GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
realPath, _, _ := idx.GetRealPath(opts.Path)
// Ensure the parent directories exist
err := os.MkdirAll(realPath, 0775)
if err != nil {
return err
}
err = idx.RefreshFileInfo(opts)
if err != nil {
return errors.ErrEmptyKey
}
return nil
}
func WriteFile(opts FileOptions, in io.Reader) error {
idx := GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
dst, _, _ := idx.GetRealPath(opts.Path)
parentDir := filepath.Dir(dst)
// Create the directory and all necessary parents
err := os.MkdirAll(parentDir, 0775)
if err != nil {
return err
}
// Open the file for writing (create if it doesn't exist, truncate if it does)
file, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775)
if err != nil {
return err
}
defer file.Close()
// Copy the contents from the reader to the file
_, err = io.Copy(file, in)
if err != nil {
return err
}
opts.Path = idx.MakeIndexPath(parentDir)
opts.IsDir = true
return idx.RefreshFileInfo(opts)
}
func resolveSymlinks(path string) (string, bool, error) {
const maxSymlinks = 25
for i := 0; i < maxSymlinks; i++ {
info, err := os.Lstat(path)
if err != nil {
return path, false, fmt.Errorf("could not stat path: %s, %v", path, err)
}
if info.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(path)
if err != nil {
return path, false, fmt.Errorf("could not read symlink: %s, %v", path, err)
}
path = filepath.Clean(filepath.Join(filepath.Dir(path), target))
if !filepath.IsAbs(path) {
path, err = filepath.Abs(path)
if err != nil {
return path, false, fmt.Errorf("could not resolve absolute path: %s, %v", path, err)
}
}
continue
}
return path, info.IsDir(), nil
}
return path, false, fmt.Errorf("too many symlink resolutions for path: %s", path)
}
// addContent reads and sets content based on the file type.
func getContent(realPath string) (string, error) {
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
}
stringContent := string(content)
if !utf8.ValidString(stringContent) {
return "", nil
}
if stringContent == "" {
return "empty-file-x6OlSil", nil
}
return stringContent, nil
}
// DetectType detects the MIME type of a file and updates the ItemInfo struct.
func (i *ItemInfo) DetectType(realPath string, saveContent bool) {
name := i.Name
ext := filepath.Ext(name)
// Attempt MIME detection by file extension
if ext == ".md" {
i.Type = "text/markdown"
return
}
i.Type = strings.Split(mime.TypeByExtension(ext), ";")[0]
if i.Type == "" {
i.Type = extendedMimeTypeCheck(ext)
}
if i.Type == "blob" {
// Read only the first 512 bytes for efficient MIME detection
file, err := os.Open(realPath)
if err != nil {
} else {
defer file.Close()
buffer := make([]byte, 512)
n, _ := file.Read(buffer) // Ignore errors from Read
i.Type = strings.Split(http.DetectContentType(buffer[:n]), ";")[0]
}
}
}
// TODO add subtitles back
// detectSubtitles detects subtitles for video files.
func (i *ExtendedFileInfo) detectSubtitles(path string) {
if !strings.HasPrefix(i.Type, "video") {
logger.Debug("subtitles are not supported for this file : " + path)
return
}
idx := GetIndex(i.Source)
if idx == nil {
return
}
parentInfo, exists := idx.GetReducedMetadata(filepath.Dir(i.Path), true)
if !exists {
return
}
base := strings.Split(i.Name, ".")[0]
for _, f := range parentInfo.Files {
baseName := strings.Split(f.Name, ".")[0]
if baseName != base {
continue
}
for _, subtitleExt := range []string{".vtt", ".srt", ".lrc", ".sbv", ".ass", ".ssa", ".sub", ".smi"} {
if strings.HasSuffix(f.Name, subtitleExt) {
fullPathBase := strings.Split(i.Path, ".")[0]
i.Subtitles = append(i.Subtitles, fullPathBase+subtitleExt)
}
}
}
}
func IsNamedPipe(mode os.FileMode) bool {
return mode&os.ModeNamedPipe != 0
}
func IsSymlink(mode os.FileMode) bool {
return mode&os.ModeSymlink != 0
}
func Exists(path string) bool {
_, err := os.Stat(path)
if err == nil {
return true
}
if os.IsNotExist(err) {
return false
}
return false
}
func (info *FileInfo) SortItems() {
sort.Slice(info.Folders, func(i, j int) bool {
nameWithoutExt := strings.Split(info.Folders[i].Name, ".")[0]
nameWithoutExt2 := strings.Split(info.Folders[j].Name, ".")[0]
// Convert strings to integers for numeric sorting if both are numeric
numI, errI := strconv.Atoi(nameWithoutExt)
numJ, errJ := strconv.Atoi(nameWithoutExt2)
if errI == nil && errJ == nil {
return numI < numJ
}
// Fallback to case-insensitive lexicographical sorting
return strings.ToLower(info.Folders[i].Name) < strings.ToLower(info.Folders[j].Name)
})
sort.Slice(info.Files, func(i, j int) bool {
nameWithoutExt := strings.Split(info.Files[i].Name, ".")[0]
nameWithoutExt2 := strings.Split(info.Files[j].Name, ".")[0]
// Convert strings to integers for numeric sorting if both are numeric
numI, errI := strconv.Atoi(nameWithoutExt)
numJ, errJ := strconv.Atoi(nameWithoutExt2)
if errI == nil && errJ == nil {
return numI < numJ
}
// Fallback to case-insensitive lexicographical sorting
return strings.ToLower(info.Files[i].Name) < strings.ToLower(info.Files[j].Name)
})
}
-127
View File
@@ -1,127 +0,0 @@
package files
import (
"fmt"
"time"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
// schedule in minutes
var scanSchedule = []time.Duration{
5 * time.Minute, // 5 minute quick scan & 25 minutes for a full scan
10 * time.Minute,
20 * time.Minute,
40 * time.Minute, // [3] element is 40 minutes, reset anchor for full scan
1 * time.Hour,
2 * time.Hour,
3 * time.Hour,
4 * time.Hour, // 4 hours for quick scan & 20 hours for a full scan
}
func (idx *Index) newScanner(origin string) {
fullScanAnchor := 3
fullScanCounter := 0 // every 5th scan is a full scan
for {
// Determine sleep time with modifiers
fullScanCounter++
sleepTime := scanSchedule[idx.currentSchedule] + idx.SmartModifier
if idx.assessment == "simple" {
sleepTime = scanSchedule[idx.currentSchedule] - idx.SmartModifier
}
if idx.Source.Config.IndexingInterval > 0 {
sleepTime = time.Duration(idx.Source.Config.IndexingInterval) * time.Minute
}
// Log and sleep before indexing
logger.Debug(fmt.Sprintf("Next scan in %v", sleepTime))
time.Sleep(sleepTime)
idx.scannerMu.Lock()
if fullScanCounter == 5 {
idx.RunIndexing(origin, false) // Full scan
fullScanCounter = 0
} else {
idx.RunIndexing(origin, true) // Quick scan
}
idx.scannerMu.Unlock()
// Adjust schedule based on file changes
if idx.FilesChangedDuringIndexing {
logger.Debug(fmt.Sprintf("Files changed during indexing [%v], adjusting schedule.", idx.Name))
// Move to at least the full-scan anchor or reduce interval
if idx.currentSchedule > fullScanAnchor {
idx.currentSchedule = fullScanAnchor
} else if idx.currentSchedule > 0 {
idx.currentSchedule--
}
} else {
// Increment toward the longest interval if no changes
if idx.currentSchedule < len(scanSchedule)-1 {
idx.currentSchedule++
}
}
if idx.assessment == "simple" && idx.currentSchedule > 3 {
idx.currentSchedule = 3
}
// Ensure `currentSchedule` stays within bounds
if idx.currentSchedule < 0 {
idx.currentSchedule = 0
} else if idx.currentSchedule >= len(scanSchedule) {
idx.currentSchedule = len(scanSchedule) - 1
}
}
}
func (idx *Index) RunIndexing(origin string, quick bool) {
prevNumDirs := idx.NumDirs
prevNumFiles := idx.NumFiles
if quick {
logger.Debug(fmt.Sprintf("Starting quick scan for [%v]", idx.Source.Name))
} else {
logger.Debug(fmt.Sprintf("Starting full scan for [%v]", idx.Source.Name))
idx.NumDirs = 0
idx.NumFiles = 0
}
startTime := time.Now()
idx.FilesChangedDuringIndexing = false
// Perform the indexing operation
err := idx.indexDirectory("/", quick, true)
if err != nil {
logger.Error(fmt.Sprintf("Error during indexing: %v", err))
}
firstRun := idx.LastIndexed == time.Time{}
// Update the LastIndexed time
idx.LastIndexed = time.Now()
idx.indexingTime = int(time.Since(startTime).Seconds())
if !quick {
// update smart indexing
if idx.indexingTime < 3 || idx.NumDirs < 10000 {
idx.assessment = "simple"
idx.SmartModifier = 4 * time.Minute
} else if idx.indexingTime > 120 || idx.NumDirs > 500000 {
idx.assessment = "complex"
modifier := idx.indexingTime / 10 // seconds
idx.SmartModifier = time.Duration(modifier) * time.Minute
} else {
idx.assessment = "normal"
}
if firstRun {
logger.Info(fmt.Sprintf("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Source.Name, idx.assessment, idx.NumDirs, idx.NumFiles))
} else {
logger.Debug(fmt.Sprintf("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Source.Name, idx.assessment, idx.NumDirs, idx.NumFiles))
}
if idx.NumDirs != prevNumDirs || idx.NumFiles != prevNumFiles {
idx.FilesChangedDuringIndexing = true
}
}
if firstRun {
logger.Info(fmt.Sprintf("Time spent indexing [%v]: %v seconds", idx.Source.Name, idx.indexingTime))
} else {
logger.Debug(fmt.Sprintf("Time spent indexing [%v]: %v seconds", idx.Source.Name, idx.indexingTime))
}
}
func (idx *Index) setupIndexingScanners() {
go idx.newScanner("/")
}
+189 -8
View File
@@ -1,8 +1,6 @@
module github.com/gtsteffaniak/filebrowser/backend
go 1.23.0
toolchain go1.24.1
go 1.24.0
require (
github.com/asdine/storm/v3 v3.2.1
@@ -13,21 +11,68 @@ require (
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/afero v1.11.0
github.com/spf13/afero v1.14.0
github.com/stretchr/testify v1.10.0
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.37.0
golang.org/x/image v0.26.0
golang.org/x/sys v0.32.0
gopkg.in/yaml.v3 v3.0.1
)
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect
github.com/Antonboom/errname v1.0.0 // indirect
github.com/Antonboom/nilnil v1.0.1 // indirect
github.com/Antonboom/testifylint v1.5.2 // indirect
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
github.com/Crocmagnon/fatcontext v0.7.1 // indirect
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.5 // indirect
github.com/alexkohler/prealloc v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.1.2 // indirect
github.com/ashanbrown/forbidigo v1.6.0 // indirect
github.com/ashanbrown/makezero v1.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.5.0 // indirect
github.com/breml/bidichk v0.3.2 // indirect
github.com/breml/errchkjson v0.4.0 // indirect
github.com/butuzov/ireturn v0.3.1 // indirect
github.com/butuzov/mirror v1.3.0 // indirect
github.com/catenacyber/perfsprint v0.8.2 // indirect
github.com/ccojocar/zxcvbn-go v1.0.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.10 // indirect
github.com/chavacava/garif v0.1.0 // indirect
github.com/ckaznocha/intrange v0.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.5 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/ghostiam/protogetter v0.3.9 // indirect
github.com/go-critic/go-critic v0.12.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
@@ -36,19 +81,155 @@ require (
github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang/geo v0.0.0-20250410091149-9ff723126794 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect
github.com/go-toolsmith/astequal v1.2.0 // indirect
github.com/go-toolsmith/astfmt v1.1.0 // indirect
github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang/geo v0.0.0-20250502184219-cc00c7679ab0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.0 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint v1.64.8 // indirect
github.com/golangci/misspell v0.6.0 // indirect
github.com/golangci/plugin-module-register v0.1.1 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect
github.com/gordonklaus/ineffassign v0.1.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.5.0 // indirect
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
github.com/gostaticanalysis/nilerr v0.1.1 // indirect
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jgautheron/goconst v1.7.1 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
github.com/jjti/go-spancheck v0.6.4 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kulti/thelper v0.6.3 // indirect
github.com/kunwardeep/paralleltest v1.0.10 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.2 // indirect
github.com/ldez/gomoddirectives v0.6.1 // indirect
github.com/ldez/grignotin v0.9.0 // indirect
github.com/ldez/tagliatelle v0.7.1 // indirect
github.com/ldez/usetesting v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/macabu/inamedparam v0.1.3 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/maratori/testableexamples v1.0.0 // indirect
github.com/maratori/testpackage v1.1.1 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.7.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.19.1 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/polyfloyd/go-errorlint v1.7.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/raeperd/recvcheck v0.2.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/ryancurrah/gomodguard v1.3.5 // indirect
github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect
github.com/securego/gosec/v2 v2.22.2 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sivchari/tenv v1.12.1 // indirect
github.com/sonatard/noctx v0.1.0 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/tdakkota/asciicheck v0.4.1 // indirect
github.com/tetafro/godot v1.5.0 // indirect
github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3 // indirect
github.com/timonwong/loggercheck v0.10.1 // indirect
github.com/tomarrell/wrapcheck/v2 v2.10.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/uudashr/gocognit v1.2.0 // indirect
github.com/uudashr/iface v1.3.1 // indirect
github.com/xen0n/gosmopolitan v1.2.2 // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.13.0 // indirect
go-simpler.org/sloglint v0.9.0 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/tools v0.32.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.6.1 // indirect
mvdan.cc/gofumpt v0.7.0 // indirect
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
tool (
github.com/golangci/golangci-lint/cmd/golangci-lint
github.com/swaggo/swag/cmd/swag
)
+915 -12
View File
File diff suppressed because it is too large. Load diff
+10 -9
View File
@@ -7,7 +7,8 @@ import (
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// createApiKeyHandler creates an API key for the user.
@@ -30,7 +31,7 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
durationStr := r.URL.Query().Get("days")
permissionsStr := r.URL.Query().Get("permissions")
if !d.user.Perm.Api {
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to create api keys")
}
@@ -45,10 +46,10 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
// Parse permissions from the query parameter
permissions := users.Permissions{
Api: strings.Contains(permissionsStr, "api") && d.user.Perm.Api,
Admin: strings.Contains(permissionsStr, "admin") && d.user.Perm.Admin,
Modify: strings.Contains(permissionsStr, "modify") && d.user.Perm.Modify,
Share: strings.Contains(permissionsStr, "share") && d.user.Perm.Share,
Api: strings.Contains(permissionsStr, "api") && d.user.Permissions.Api,
Admin: strings.Contains(permissionsStr, "admin") && d.user.Permissions.Admin,
Modify: strings.Contains(permissionsStr, "modify") && d.user.Permissions.Modify,
Share: strings.Contains(permissionsStr, "share") && d.user.Permissions.Share,
}
// Convert the duration string to an int64
@@ -87,7 +88,7 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// @Router /api/deleteApiKey [delete]
func deleteApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
name := r.URL.Query().Get("name")
if !d.user.Perm.Api {
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to delete api keys")
}
@@ -101,7 +102,7 @@ func deleteApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusNotFound, err
}
revokeAPIKey(keyInfo.Key) // add to blacklist
auth.RevokeAPIKey(keyInfo.Key) // add to blacklist
response := HttpResponse{
Message: "successfully deleted api key from user",
}
@@ -129,7 +130,7 @@ type AuthTokenMin struct {
// @Router /api/listApiKeys [get]
func listApiKeysHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
key := r.URL.Query().Get("name")
if !d.user.Perm.Api {
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api keys")
}
+207 -24
View File
@@ -4,26 +4,23 @@ import (
"encoding/json"
libError "errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v4/request"
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
)
var (
revokedApiKeyList map[string]bool
revokeMu sync.Mutex
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// first checks for cookie
@@ -71,6 +68,40 @@ func extractToken(r *http.Request) (string, error) {
return "", request.ErrNoTokenInRequest
}
func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*users.User, error) {
var err error
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(proxyUser)
if err != nil {
if err.Error() != "the resource does not exist" {
return nil, err
}
if config.Auth.Methods.ProxyAuth.CreateUser {
hashpass, err := users.HashPwd(proxyUser)
if err != nil {
return nil, err
}
err = storage.CreateUser(users.User{
LoginMethod: users.LoginMethodProxy,
Username: proxyUser,
NonAdminEditable: users.NonAdminEditable{
Password: hashpass, // hashed password that can't actually be used
},
}, false)
if err != nil {
return nil, err
}
data.user, err = store.Users.Get(proxyUser)
if err != nil {
return nil, err
}
} else {
return nil, fmt.Errorf("proxy authentication failed - no user found")
}
}
return data.user, nil
}
// loginHandler handles user authentication via password.
// @Summary User login
// @Description Authenticate a user with a username and password.
@@ -82,6 +113,19 @@ func extractToken(r *http.Request) (string, error) {
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/login [post]
func loginHandler(w http.ResponseWriter, r *http.Request) {
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
user, err := setupProxyUser(r, &requestContext{}, proxyUser)
if err != nil {
http.Error(w, err.Error(), 403)
return
}
status, err := printToken(w, r, user) // Pass the data object
if err != nil {
http.Error(w, http.StatusText(status), status)
}
return
}
if !config.Auth.Methods.PasswordAuth.Enabled {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
@@ -93,12 +137,15 @@ func loginHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
// Authenticate the user based on the request
user, err := auther.Auth(r, store.Users)
if err != nil {
logger.Debug(err.Error())
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
status, err := printToken(w, r, user) // Pass the data object
if err != nil {
http.Error(w, http.StatusText(status), status)
@@ -110,6 +157,14 @@ type signupBody struct {
Password string `json:"password"`
}
type userInfo struct {
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Sub string `json:"sub"`
Phone string `json:"phone_number"`
}
// signupHandler registers a new user account.
// @Summary User signup
// @Description Register a new user account with a username and password.
@@ -176,8 +231,7 @@ func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
}
func printToken(w http.ResponseWriter, _ *http.Request, user *users.User) (int, error) {
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Perm)
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Permissions)
if err != nil {
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, err
@@ -191,17 +245,6 @@ func printToken(w http.ResponseWriter, _ *http.Request, user *users.User) (int,
return 0, nil
}
func isRevokedApiKey(key string) bool {
_, exists := revokedApiKeyList[key]
return exists
}
func revokeAPIKey(key string) {
revokeMu.Lock()
delete(revokedApiKeyList, key)
revokeMu.Unlock()
}
func makeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions) (users.AuthToken, error) {
_, ok := user.ApiKeys[name]
if ok {
@@ -264,3 +307,143 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
}
return 200, nil
}
func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
code := r.URL.Query().Get("code")
//state := r.URL.Query().Get("state")
//
//fmt.Println("State:", state)
origin := r.Header.Get("Origin")
if origin == "" {
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
}
redirectURI := fmt.Sprintf("%s/api/auth/oidc/callback", origin)
// Step 1: Exchange the code for tokens
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("code", code)
data.Set("client_id", config.Auth.Methods.OidcAuth.ClientID)
data.Set("client_secret", config.Auth.Methods.OidcAuth.ClientSecret)
data.Set("redirect_uri", redirectURI)
req, _ := http.NewRequest("POST", config.Auth.Methods.OidcAuth.TokenUrl, strings.NewReader(data.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return 500, fmt.Errorf("token request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
// print body in response
body, _ := io.ReadAll(resp.Body)
return 500, fmt.Errorf("failed to fetch token: %v %v", resp.StatusCode, string(body))
}
var tokenResp struct {
AccessToken string `json:"access_token"`
}
if err = json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return 500, fmt.Errorf("failed to parse token response: %v", err)
}
// Step 2: Use the access token to fetch user info from the UserInfo URL
userInfoURL := config.Auth.Methods.OidcAuth.UserInfoUrl
req, _ = http.NewRequest("GET", userInfoURL, nil)
req.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
resp, err = client.Do(req)
if err != nil || resp.StatusCode != 200 {
return 500, fmt.Errorf("failed to fetch user info: %v %v", resp.StatusCode, err)
}
defer resp.Body.Close()
var userdata userInfo
if err = json.NewDecoder(resp.Body).Decode(&userdata); err != nil {
return 500, fmt.Errorf("failed to parse user info: %v", err)
}
return loginWithOidcUser(w, r, userdata)
}
func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Auth.Methods.OidcAuth.Enabled {
origin := r.Header.Get("Origin")
if origin == "" {
origin = fmt.Sprintf("%s://%s", getScheme(r), r.Host)
}
nonce := utils.InsecureRandomIdentifier(16)
redirectURI := fmt.Sprintf("%s/api/auth/oidc/callback", origin)
authURL := fmt.Sprintf("%s?client_id=%s&response_type=code&scope=%s&redirect_uri=%s&state=%s&fb_redirect=%s",
config.Auth.Methods.OidcAuth.AuthorizationUrl,
url.QueryEscape(config.Auth.Methods.OidcAuth.ClientID),
url.QueryEscape(config.Auth.Methods.OidcAuth.Scopes),
url.QueryEscape(redirectURI),
nonce,
r.URL.Query().Get("fb_redirect"),
)
http.Redirect(w, r, authURL, http.StatusFound)
return 0, nil
}
return http.StatusForbidden, fmt.Errorf("oidc authentication is not enabled")
}
func loginWithOidcUser(w http.ResponseWriter, r *http.Request, userInfo userInfo) (int, error) {
username := userInfo.PreferredUsername
switch config.Auth.Methods.OidcAuth.UserIdentifier {
case "email":
username = userInfo.Email
case "username":
username = userInfo.PreferredUsername
}
// Retrieve the user from the store and store it in the context
user, err := store.Users.Get(username)
if err != nil {
if err.Error() != "the resource does not exist" {
return http.StatusInternalServerError, err
}
hashpass := ""
hashpass, err = users.HashPwd(username) // hashed password that can't actually be used, gets double hashed
if err != nil {
return http.StatusInternalServerError, err
}
err = storage.CreateUser(users.User{
LoginMethod: users.LoginMethodOidc,
Username: username,
NonAdminEditable: users.NonAdminEditable{
Password: hashpass, // hashed password that can't actually be used
},
}, false)
if err != nil {
return http.StatusInternalServerError, err
}
user, err = store.Users.Get(username)
if err != nil {
return http.StatusInternalServerError, err
}
}
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Permissions)
if err != nil {
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, err
}
return http.StatusInternalServerError, err
}
// set signed.Key in the cookie as auth
cookie := &http.Cookie{
Name: "auth",
Value: signed.Key,
Domain: strings.Split(r.Host, ":")[0],
Path: "/",
}
http.SetCookie(w, cookie)
setUserInResponseWriter(w, user)
http.Redirect(w, r, "/", http.StatusFound)
return 200, nil
}
+80
View File
@@ -0,0 +1,80 @@
package http
import (
"fmt"
"io"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/events"
)
type messenger struct {
flusher http.Flusher
writer io.Writer
}
// expects message with double quotes around string
func (msgr messenger) sendEvent(eventType, message string) error {
_, err := fmt.Fprintf(msgr.writer, "data: {\"eventType\":\"%s\",\"message\":%s}\n\n", eventType, message)
if err != nil {
return err
}
msgr.flusher.Flush()
return nil
}
func sseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Realtime {
return http.StatusForbidden, fmt.Errorf("realtime is disabled for this user")
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
sessionId := r.URL.Query().Get("sessionId")
username := d.user.Username
f, ok := w.(http.Flusher)
if !ok {
logger.Debug(fmt.Sprintf("error: ResponseWriter does not support Flusher. User: %s, SessionId: %s", username, sessionId))
return http.StatusInternalServerError, fmt.Errorf("streaming not supported")
}
msgr := messenger{flusher: f, writer: w}
clientGone := r.Context().Done()
// Initial ack
if err := msgr.sendEvent("acknowledge", "\"connection established\""); err != nil {
return http.StatusInternalServerError, fmt.Errorf("error sending message: %v, user: %s, SessionId: %s", err, username, sessionId)
}
// Register this client with the events system
sendChan := events.Register(username, settings.GetSources(d.user))
defer events.Unregister(username, sendChan)
for {
select {
case <-d.ctx.Done():
_ = msgr.sendEvent("notification", "\"the server is shutting down\"")
return http.StatusOK, nil
case <-clientGone:
logger.Debug(fmt.Sprintf("client disconnected. user: %s, SessionId: %s", username, sessionId))
return http.StatusOK, nil
case msg := <-events.BroadcastChan:
if err := msgr.sendEvent(msg.EventType, msg.Message); err != nil {
return http.StatusInternalServerError, fmt.Errorf("error sending broadcast: %v, user: %s", err, username)
}
case msg := <-sendChan:
if err := msgr.sendEvent(msg.EventType, msg.Message); err != nil {
return http.StatusInternalServerError, fmt.Errorf("error sending targeted message: %v, user: %s", err, username)
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
package http
import (
"fmt"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
func getJobsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sources := settings.GetSources(d.user)
reducedIndexes := map[string]indexing.ReducedIndex{}
for _, source := range sources {
reducedIndex, err := indexing.GetIndexInfo(source)
if err != nil {
logger.Debug(fmt.Sprintf("error getting index info: %v", err))
continue
}
reducedIndexes[source] = reducedIndex
}
return renderJSON(w, r, reducedIndexes)
}
@@ -11,12 +11,11 @@ import (
"text/template"
"time"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/version"
httpSwagger "github.com/swaggo/http-swagger" // http-swagger middleware
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
// http-swagger middleware
)
// Embed the files in the frontend/dist directory
@@ -38,20 +37,14 @@ func (d dirFS) Open(name string) (fs.File, error) {
}
var (
store *storage.Storage
config *settings.Settings
fileCache FileCache
imgSvc ImgService
assetFs fs.FS
store *storage.Storage
config *settings.Settings
assetFs fs.FS
)
func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage, cache FileCache, shutdownComplete chan struct{}) {
func StartHttp(ctx context.Context, storage *storage.Storage, shutdownComplete chan struct{}) {
store = storage
fileCache = cache
imgSvc = Service
config = &settings.Config
var err error
if embeddedFS {
@@ -86,6 +79,8 @@ func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage
api.HandleFunc("GET /auth/token", withUser(createApiKeyHandler))
api.HandleFunc("DELETE /auth/token", withUser(deleteApiKeyHandler))
api.HandleFunc("GET /auth/tokens", withUser(listApiKeysHandler))
api.HandleFunc("GET /auth/oidc/callback", wrapHandler(oidcCallbackHandler))
api.HandleFunc("GET /auth/oidc/login", wrapHandler(oidcLoginHandler))
// Resources routes
api.HandleFunc("GET /resources", withUser(resourceGetHandler))
@@ -93,7 +88,6 @@ func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage
api.HandleFunc("POST /resources", withUser(resourcePostHandler))
api.HandleFunc("PUT /resources", withUser(resourcePutHandler))
api.HandleFunc("PATCH /resources", withUser(resourcePatchHandler))
api.HandleFunc("GET /usage", withUser(diskUsage))
api.HandleFunc("GET /raw", withUser(rawHandler))
api.HandleFunc("GET /preview", withUser(previewHandler))
if version.Version == "testing" || version.Version == "untracked" {
@@ -111,12 +105,23 @@ func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage
api.HandleFunc("GET /public/publicUser", publicUserGetHandler)
api.HandleFunc("GET /public/dl", withHashFile(publicRawHandler))
api.HandleFunc("GET /public/share", withHashFile(publicShareHandler))
api.HandleFunc("GET /public/preview", withHashFile(publicPreviewHandler))
// Settings routes
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
// Events routes
api.HandleFunc("GET /events", withUser(func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
d.ctx = ctx // Pass the parent context to ensure proper shutdown
return sseHandler(w, r, d)
}))
// Job routes
api.HandleFunc("GET /jobs/{action}/{target}", withUser(getJobsHandler))
api.HandleFunc("GET /onlyoffice/config", withUser(onlyofficeClientConfigGetHandler))
api.HandleFunc("POST /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
api.HandleFunc("GET /onlyoffice/getToken", withUser(onlyofficeGetTokenHandler))
api.HandleFunc("GET /search", withUser(searchHandler))
apiPath := config.Server.BaseURL + "api"
@@ -130,14 +135,7 @@ func StartHttp(ctx context.Context, Service ImgService, storage *storage.Storage
router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler)
// Swagger
router.Handle(fmt.Sprintf("%vswagger/", config.Server.BaseURL),
httpSwagger.Handler(
httpSwagger.URL(config.Server.BaseURL+"swagger/doc.json"), //The url pointing to API definition
httpSwagger.DeepLinking(true),
httpSwagger.DocExpansion("none"),
httpSwagger.DomID("swagger-ui"),
),
)
router.Handle(fmt.Sprintf("%vswagger/", config.Server.BaseURL), withUser(swaggerHandler))
var scheme string
port := ""
+44 -43
View File
@@ -2,6 +2,7 @@ package http
import (
"compress/gzip"
"context"
"encoding/json"
"fmt"
"net/http"
@@ -9,19 +10,22 @@ import (
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
type requestContext struct {
user *users.User
raw interface{}
path string
token string
share *share.Link
user *users.User
raw interface{}
fileInfo iteminfo.ExtendedFileInfo
path string
token string
share *share.Link
ctx context.Context
}
type HttpResponse struct {
@@ -60,10 +64,15 @@ func withHashFileHelper(fn handleFunc) handleFunc {
if path == "" || path == "/" {
data.path = link.Path
}
source, ok := config.Server.SourceMap[link.Source]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
// Get file information with options
file, err := FileInfoFasterFunc(files.FileOptions{
file, err := FileInfoFasterFunc(iteminfo.FileOptions{
Path: data.path,
Source: link.Source,
Source: source.Name,
Modify: false,
Expand: true,
})
@@ -83,7 +92,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
func withAdminHelper(fn handleFunc) handleFunc {
return withUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
// Ensure the user has admin permissions
if !data.user.Perm.Admin {
if !data.user.Permissions.Admin {
return http.StatusForbidden, nil
}
return fn(w, r, data)
@@ -105,35 +114,11 @@ func withUserHelper(fn handleFunc) handleFunc {
}
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
var err error
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(proxyUser)
user, err := setupProxyUser(r, data, proxyUser)
if err != nil {
if err.Error() != "the resource does not exist" {
return http.StatusInternalServerError, err
}
if config.Auth.Methods.ProxyAuth.CreateUser {
hashpass, err := users.HashPwd(proxyUser)
if err != nil {
return http.StatusInternalServerError, err
}
err = storage.CreateUser(users.User{
Username: proxyUser,
NonAdminEditable: users.NonAdminEditable{
Password: hashpass, // hashed password that can't actually be used
},
}, false)
if err != nil {
return http.StatusInternalServerError, err
}
data.user, err = store.Users.Get(proxyUser)
if err != nil {
return http.StatusInternalServerError, err
}
} else {
return http.StatusUnauthorized, fmt.Errorf("proxy authentication failed - no user found")
}
return http.StatusForbidden, err
}
data.user = user
setUserInResponseWriter(w, data.user)
return fn(w, r, data)
}
@@ -153,7 +138,7 @@ func withUserHelper(fn handleFunc) handleFunc {
if !token.Valid {
return http.StatusUnauthorized, fmt.Errorf("invalid token")
}
if isRevokedApiKey(tk.Key) || tk.Expires < time.Now().Unix() {
if auth.IsRevokedApiKey(tk.Key) || tk.Expires < time.Now().Unix() {
return http.StatusUnauthorized, fmt.Errorf("token expired or revoked")
}
// Check if the token is about to expire and send a header to renew it
@@ -178,7 +163,7 @@ func withUserHelper(fn handleFunc) handleFunc {
func withSelfOrAdminHelper(fn handleFunc) handleFunc {
return withUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
// Check if the current user is the same as the requested user or if they are an admin
if !data.user.Perm.Admin {
if !data.user.Permissions.Admin {
return http.StatusForbidden, nil
}
// Call the actual handler function with the updated context
@@ -228,7 +213,7 @@ func wrapHandler(fn handleFunc) http.HandlerFunc {
func withPermShareHelper(fn handleFunc) handleFunc {
return withUserHelper(func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Perm.Share {
if !d.user.Permissions.Share {
return http.StatusForbidden, nil
}
return fn(w, r, d)
@@ -366,3 +351,19 @@ func acceptsGzip(r *http.Request) bool {
ae := r.Header.Get("Accept-Encoding")
return ae != "" && strings.Contains(ae, "gzip")
}
func (w *ResponseWriterWrapper) Flush() {
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
func getScheme(r *http.Request) string {
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
return proto
}
if r.TLS != nil {
return "https"
}
return "http"
}
+28 -24
View File
@@ -8,15 +8,13 @@ import (
"time"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/diskcache"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/img"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func setupTestEnv(t *testing.T) {
@@ -35,10 +33,14 @@ func setupTestEnv(t *testing.T) {
Share: shareStore,
Settings: settingsStore,
}
fileCache = diskcache.NewNoOp() // mocked
imgSvc = img.New(1) // mocked
config = &settings.Config // mocked
mockFileInfoFaster(t) // Mock FileInfoFasterFunc for this test
config = &settings.Config // mocked
config.Server.SourceMap = map[string]settings.Source{
"/srv": settings.Source{
Path: "/srv",
Name: "srv",
},
}
mockFileInfoFaster(t) // Mock FileInfoFasterFunc for this test
}
func mockFileInfoFaster(t *testing.T) {
@@ -48,11 +50,11 @@ func mockFileInfoFaster(t *testing.T) {
t.Cleanup(func() { FileInfoFasterFunc = originalFileInfoFaster })
// Mock the function to skip execution
FileInfoFasterFunc = func(opts files.FileOptions) (files.ExtendedFileInfo, error) {
return files.ExtendedFileInfo{
FileInfo: files.FileInfo{
FileInfoFasterFunc = func(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error) {
return iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
Path: opts.Path,
ItemInfo: files.ItemInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "mocked_file",
Size: 12345,
},
@@ -65,14 +67,14 @@ func TestWithAdminHelper(t *testing.T) {
setupTestEnv(t)
// Mock a user who has admin permissions
adminUser := &users.User{
ID: 1,
Username: "admin",
Perm: users.Permissions{Admin: true}, // Ensure the user is an admin
ID: 1,
Username: "admin",
Permissions: users.Permissions{Admin: true}, // Ensure the user is an admin
}
nonAdminUser := &users.User{
ID: 2,
Username: "non-admin",
Perm: users.Permissions{Admin: false}, // Non-admin user
ID: 2,
Username: "non-admin",
Permissions: users.Permissions{Admin: false}, // Non-admin user
}
// Save the users to the mock database
if err := store.Users.Save(adminUser, true); err != nil {
@@ -155,7 +157,8 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
{
name: "Public share, no auth required",
share: &share.Link{
Hash: "public_hash",
Hash: "public_hash",
Source: "/srv",
},
expectedStatusCode: 0, // zero means 200 on helpers
},
@@ -176,6 +179,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
UserID: 1,
PasswordHash: passwordBcrypt,
Token: "123",
Source: "/srv",
},
token: "123",
expectedStatusCode: 0, // zero means 200 on helpers
+50 -20
View File
@@ -11,11 +11,13 @@ import (
"strings"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/cache"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
const (
@@ -45,6 +47,8 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
}
// get path from url
pathParts := strings.Split(url, "/api/raw?files=")
origPathParts := strings.Split(encodedUrl, "/api/raw?files=")
encodedPath := origPathParts[len(origPathParts)-1]
sourceFile := pathParts[len(pathParts)-1]
sourceSplit := strings.Split(sourceFile, "::")
if len(sourceSplit) != 2 {
@@ -54,7 +58,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
path := sourceSplit[1]
urlFirst := pathParts[0]
if settings.Config.Server.InternalUrl != "" {
urlFirst = strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
urlFirst = settings.Config.Server.InternalUrl
replacement := strings.Split(url, "/api/raw")[0]
url = strings.Replace(url, replacement, settings.Config.Server.InternalUrl, 1)
}
@@ -62,29 +66,26 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Modify: d.user.Perm.Modify,
Modify: d.user.Permissions.Modify,
Source: source,
Expand: false,
})
if err != nil {
return errToStatus(err), err
}
id, err := getOnlyOfficeId(source, fileInfo.Path)
if err != nil {
return http.StatusNotFound, err
}
split := strings.Split(fileInfo.Name, ".")
fileType := split[len(split)-1]
theme := "light"
if d.user.DarkMode {
theme = "dark"
}
callbackURL := fmt.Sprintf("%v/api/onlyoffice/callback?path=%v&auth=%v", urlFirst, path, d.token)
callbackURL := fmt.Sprintf("%v/api/onlyoffice/callback?path=%v&auth=%v", urlFirst, encodedPath, d.token)
clientConfig := map[string]interface{}{
"document": map[string]interface{}{
"fileType": fileType,
@@ -92,7 +93,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
"title": fileInfo.Name,
"url": url + "&auth=" + d.token,
"permissions": map[string]interface{}{
"edit": d.user.Perm.Modify,
"edit": d.user.Permissions.Modify,
"download": true,
"print": true,
},
@@ -128,7 +129,6 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
if err != nil {
return http.StatusInternalServerError, err
}
var data OnlyOfficeCallback
err = json.Unmarshal(body, &data)
if err != nil {
@@ -136,12 +136,16 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
}
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
pathParts := strings.Split(encodedPath, "::")
if len(pathParts) < 2 {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
source := pathParts[0]
if source == "" {
source = settings.Config.Server.DefaultSource.Name
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
path, err := url.QueryUnescape(pathParts[1])
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
@@ -158,7 +162,7 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
data.Status == onlyOfficeStatusForceSaveWhileDocumentStillOpen {
if !d.user.Perm.Modify {
if !d.user.Permissions.Modify {
return http.StatusForbidden, nil
}
@@ -168,7 +172,7 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
}
defer doc.Body.Close()
fileOpts := files.FileOptions{
fileOpts := iteminfo.FileOptions{
Path: path,
Source: source,
}
@@ -185,7 +189,7 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
}
func getOnlyOfficeId(source, path string) (string, error) {
idx := files.GetIndex(source)
idx := indexing.GetIndex(source)
if idx == nil {
return "", fmt.Errorf("source not found")
}
@@ -200,7 +204,7 @@ func getOnlyOfficeId(source, path string) (string, error) {
}
func deleteOfficeId(source, path string) {
idx := files.GetIndex(source)
idx := indexing.GetIndex(source)
if idx == nil {
logger.Error(fmt.Sprintf("deleteOfficeId: failed to find source index for user home dir creation: %s", source))
return
@@ -208,3 +212,29 @@ func deleteOfficeId(source, path string) {
realpath, _, _ := idx.GetRealPath(path)
cache.OnlyOffice.Delete(realpath)
}
func onlyofficeGetTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// get config from body
body, err := io.ReadAll(r.Body)
if err != nil {
return http.StatusInternalServerError, err
}
defer r.Body.Close()
var payload map[string]interface{}
// marshall to struct
err = json.Unmarshal(body, &payload)
if err != nil {
return http.StatusInternalServerError, err
}
if settings.Config.Integrations.OnlyOffice.Secret != "" {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(payload))
ss, err := token.SignedString([]byte(settings.Config.Integrations.OnlyOffice.Secret))
if err != nil {
return 500, errors.New("could not generate a new jwt")
}
return renderJSON(w, r, map[string]string{"token": ss})
}
return 400, fmt.Errorf("bad request")
}
+56 -103
View File
@@ -4,25 +4,18 @@ import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/img"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/preview"
)
type ImgService interface {
FormatFromExtension(ext string) (img.Format, error)
Resize(ctx context.Context, in io.Reader, width, height int, out io.Writer, options ...img.Option) error
}
type FileCache interface {
Store(ctx context.Context, key string, value []byte) error
Load(ctx context.Context, key string) ([]byte, bool, error)
@@ -43,17 +36,18 @@ type FileCache interface {
// @Failure 404 {object} map[string]string "File not found"
// @Failure 415 {object} map[string]string "Unsupported file type for preview"
// @Failure 500 {object} map[string]string "Internal server error"
// @Failure 501 {object} map[string]string "Preview generation not implemented"
// @Router /api/preview [get]
func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Server.DisablePreviews {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
}
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
}
previewSize := r.URL.Query().Get("size")
if previewSize != "small" {
previewSize = "large"
}
if path == "" {
return http.StatusBadRequest, fmt.Errorf("invalid request path")
}
@@ -61,101 +55,21 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Modify: d.user.Perm.Modify,
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
})
if err != nil {
return errToStatus(err), err
}
if fileInfo.Type == "directory" {
return http.StatusBadRequest, fmt.Errorf("can't create preview for directory")
}
setContentDisposition(w, r, fileInfo.Name)
if !strings.HasPrefix(fileInfo.Type, "image") {
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", fileInfo.Type)
}
if (previewSize == "large" && !config.Server.ResizePreview) ||
(previewSize == "small" && !config.Server.EnableThumbnails) {
return rawFileHandler(w, r, fileInfo)
}
format, err := imgSvc.FormatFromExtension(filepath.Ext(fileInfo.Name))
// Unsupported extensions directly return the raw data
if err == img.ErrUnsupportedFormat || format == img.FormatGif {
return rawFileHandler(w, r, fileInfo)
}
if err != nil {
return errToStatus(err), err
}
cacheKey := previewCacheKey(fileInfo.RealPath, previewSize, fileInfo.ModTime)
resizedImage, ok, err := fileCache.Load(r.Context(), cacheKey)
if err != nil {
return errToStatus(err), err
}
if !ok {
resizedImage, err = createPreview(imgSvc, fileCache, fileInfo, previewSize)
if err != nil {
return errToStatus(err), err
}
}
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, fileInfo.RealPath, fileInfo.ModTime, bytes.NewReader(resizedImage))
return 0, nil
d.fileInfo = fileInfo
return previewHelperFunc(w, r, d)
}
func createPreview(imgSvc ImgService, fileCache FileCache, file files.ExtendedFileInfo, previewSize string) ([]byte, error) {
fd, err := os.Open(file.RealPath)
if err != nil {
return nil, err
}
defer fd.Close()
var (
width int
height int
options []img.Option
)
switch {
case previewSize == "large":
width = 1080
height = 1080
options = append(options, img.WithMode(img.ResizeModeFit), img.WithQuality(img.QualityMedium))
case previewSize == "small":
width = 256
height = 256
options = append(options, img.WithMode(img.ResizeModeFill), img.WithQuality(img.QualityLow), img.WithFormat(img.FormatJpeg))
default:
return nil, img.ErrUnsupportedFormat
}
buf := &bytes.Buffer{}
if err := imgSvc.Resize(context.Background(), fd, width, height, buf, options...); err != nil {
return nil, err
}
go func() {
cacheKey := previewCacheKey(file.RealPath, previewSize, file.FileInfo.ModTime)
if err := fileCache.Store(context.Background(), cacheKey, buf.Bytes()); err != nil {
logger.Error(fmt.Sprintf("failed to cache resized image: %v", err))
}
}()
return buf.Bytes(), nil
}
// Generates a cache key for the preview image
func previewCacheKey(realPath, previewSize string, modTime time.Time) string {
return fmt.Sprintf("%x%x%x", realPath, modTime.Unix(), previewSize)
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file files.ExtendedFileInfo) (int, error) {
idx := files.GetIndex(file.Source)
func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.ExtendedFileInfo) (int, error) {
idx := indexing.GetIndex(file.Source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source not found: %s", file.Source)
}
@@ -172,3 +86,42 @@ func rawFileHandler(w http.ResponseWriter, r *http.Request, file files.ExtendedF
http.ServeContent(w, r, file.Name, file.ModTime, fd)
return 0, nil
}
func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
previewSize := r.URL.Query().Get("size")
if previewSize != "small" {
previewSize = "large"
}
if d.fileInfo.Type == "directory" {
return http.StatusBadRequest, fmt.Errorf("can't create preview for directory")
}
setContentDisposition(w, r, d.fileInfo.Name)
isImage := strings.HasPrefix(d.fileInfo.Type, "image")
if config.Server.DisableResize && isImage {
return rawFileHandler(w, r, d.fileInfo)
}
if !preview.AvailablePreview(d.fileInfo) {
if isImage {
return rawFileHandler(w, r, d.fileInfo)
}
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", d.fileInfo.Type)
}
pathUrl := fmt.Sprintf("/api/raw?files=%s::%s", d.fileInfo.Source, d.fileInfo.Path)
pathUrl = pathUrl + "&auth=" + d.token
if settings.Config.Server.InternalUrl != "" {
pathUrl = config.Server.InternalUrl + pathUrl
} else {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
pathUrl = scheme + "://" + r.Host + pathUrl
}
previewImg, err := preview.GetPreviewForFile(d.fileInfo, previewSize, pathUrl)
if err != nil {
return http.StatusInternalServerError, err
}
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, d.fileInfo.RealPath, d.fileInfo.ModTime, bytes.NewReader(previewImg))
return 0, nil
}
+55 -7
View File
@@ -7,9 +7,12 @@ import (
"net/url"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
)
@@ -37,7 +40,7 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
fileInfo, ok := d.raw.(files.ExtendedFileInfo)
fileInfo, ok := d.raw.(iteminfo.ExtendedFileInfo)
if !ok {
logger.Error("public share handler: failed to assert type files.FileInfo")
return http.StatusInternalServerError, fmt.Errorf("failed to assert type files.FileInfo")
@@ -56,11 +59,11 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
func publicShareHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
file, ok := d.raw.(files.ExtendedFileInfo)
file, ok := d.raw.(iteminfo.ExtendedFileInfo)
if !ok {
return http.StatusInternalServerError, fmt.Errorf("failed to assert type files.FileInfo")
return http.StatusInternalServerError, fmt.Errorf("failed to assert type iteminfo.FileInfo")
}
file.Path = strings.TrimPrefix(file.Path, file.Source)
file.Path = strings.TrimPrefix(file.Path, d.share.Path)
return renderJSON(w, r, file)
}
@@ -90,3 +93,48 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
// publicPreviewHandler handles the preview request for images from shares.
// @Summary Get image preview
// @Description Returns a preview image based on the requested path and size.
// @Tags Resources
// @Accept json
// @Produce json
// @Param hash query string true "source hash"
// @Param path query string true "File path of the image to preview"
// @Param size query string false "Preview size ('small' or 'large'). Default is based on server config."
// @Success 200 {file} file "Preview image content"
// @Failure 202 {object} map[string]string "Download permissions required"
// @Failure 400 {object} map[string]string "Invalid request path"
// @Failure 404 {object} map[string]string "File not found"
// @Failure 415 {object} map[string]string "Unsupported file type for preview"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/preview [get]
func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Server.DisablePreviews {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
}
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
}
if path == "" {
return http.StatusBadRequest, fmt.Errorf("invalid request path")
}
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(d.share.Path, path),
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
})
if err != nil {
logger.Debug(fmt.Sprintf("public preview handler: error getting file info: %v", err))
return 400, fmt.Errorf("file not found")
}
if fileInfo.Type == "directory" {
return http.StatusBadRequest, fmt.Errorf("can't create preview for directory")
}
d.fileInfo = fileInfo
return previewHelperFunc(w, r, d)
}
+15 -16
View File
@@ -13,10 +13,10 @@ import (
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
@@ -70,7 +70,7 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
}
}
idx := files.GetIndex(source)
idx := indexing.GetIndex(source)
if idx == nil {
return fmt.Errorf("source %s is not available", source)
}
@@ -197,7 +197,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
return http.StatusForbidden, err
}
}
idx := files.GetIndex(firstFileSource)
idx := indexing.GetIndex(firstFileSource)
if idx == nil {
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", firstFileSource)
}
@@ -230,15 +230,13 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
sizeInMB := estimatedSize / 1024 / 1024
// if larger than 500 megabytes, log it
// if larger than 500 MB, log it
if sizeInMB > 500 {
logger.Debug(fmt.Sprintf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName))
}
// Stream file to response
_, err2 = io.Copy(w, fd)
if err2 != nil {
return http.StatusInternalServerError, err2
}
// serve content allows for range requests.
// video scrubbing, etc.
http.ServeContent(w, r, fileName, fileInfo.ModTime(), fd)
return 200, nil
}
@@ -296,7 +294,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
sizeInMB := fileInfo.Size() / 1024 / 1024
if sizeInMB > 100 {
if sizeInMB > 500 {
logger.Debug(fmt.Sprintf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName))
}
@@ -325,7 +323,7 @@ func computeArchiveSize(fileList []string, d *requestContext) (int64, error) {
}
source := splitFile[0]
path := splitFile[1]
idx := files.GetIndex(source)
idx := indexing.GetIndex(source)
if idx == nil {
return 0, fmt.Errorf("source %s is not available", source)
}
@@ -337,11 +335,12 @@ func computeArchiveSize(fileList []string, d *requestContext) (int64, error) {
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
}
}
_, isDir, err := idx.GetRealPath(userScope, path)
realPath, isDir, err := idx.GetRealPath(userScope, path)
if err != nil {
return http.StatusInternalServerError, err
}
info, ok := idx.GetReducedMetadata(path, isDir)
indexPath := idx.MakeIndexPath(realPath)
info, ok := idx.GetReducedMetadata(indexPath, isDir)
if !ok {
return 0, fmt.Errorf("failed to get metadata info for %s", path)
}
+48 -104
View File
@@ -11,14 +11,14 @@ import (
"strconv"
"strings"
"github.com/shirou/gopsutil/v3/disk"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/preview"
)
// resourceGetHandler retrieves information about a resource.
@@ -32,7 +32,7 @@ import (
// @Param source query string false "Name for the desired source, default is used if not provided"
// @Param content query string false "Include file content if true"
// @Param checksum query string false "Optional checksum validation"
// @Success 200 {object} files.FileInfo "Resource metadata"
// @Success 200 {object} iteminfo.FileInfo "Resource metadata"
// @Failure 404 {object} map[string]string "Resource not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [get]
@@ -51,10 +51,10 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Modify: d.user.Perm.Modify,
scopePath := utils.JoinPathAsUnix(userscope, path)
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: scopePath,
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
Content: r.URL.Query().Get("content") == "true",
@@ -62,11 +62,17 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
if err != nil {
return errToStatus(err), err
}
if userscope != "/" {
fileInfo.Path = strings.TrimPrefix(fileInfo.Path, userscope)
}
if fileInfo.Path == "" {
fileInfo.Path = "/"
}
if fileInfo.Type == "directory" {
return renderJSON(w, r, fileInfo)
}
if algo := r.URL.Query().Get("checksum"); algo != "" {
idx := files.GetIndex(source)
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
@@ -116,10 +122,10 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(files.FileOptions{
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Perm.Modify,
Modify: d.user.Permissions.Modify,
Expand: false,
})
if err != nil {
@@ -127,7 +133,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
}
// delete thumbnails
delThumbs(r.Context(), fileCache, fileInfo)
preview.DelThumbs(r.Context(), fileInfo)
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath))
if err != nil {
@@ -143,9 +149,8 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "Path to the resource"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Param source query string false "Name for the desired source, default is used if not provided"
// @Param path query string true "Destination path where to place the files inside the destination source, a directory must end in / to create a directory"
// @Param source query string false "Name for the desired filebrowser destination source name, default is used if not provided"
// @Param override query bool false "Override existing file if true"
// @Success 200 "Resource created successfully"
// @Failure 403 {object} map[string]string "Forbidden"
@@ -154,29 +159,22 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [post]
func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = config.Server.DefaultSource.Name
}
if !d.user.Perm.Modify {
if !d.user.Permissions.Modify {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
fileOpts := files.FileOptions{
fileOpts := iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Perm.Modify,
Modify: d.user.Permissions.Modify,
Expand: false,
}
// Directories creation on POST.
@@ -195,11 +193,11 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
// Permission for overwriting the file
if !d.user.Perm.Modify {
if !d.user.Permissions.Modify {
return http.StatusForbidden, nil
}
delThumbs(r.Context(), fileCache, fileInfo)
preview.DelThumbs(r.Context(), fileInfo)
}
err = files.WriteFile(fileOpts, r.Body)
if err != nil {
@@ -215,7 +213,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "Path to the resource"
// @Param path query string true "Destination path where to place the files inside the destination source"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Param source query string false "Name for the desired source, default is used if not provided"
// @Success 200 "Resource updated successfully"
@@ -229,7 +227,7 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
if source == "" {
source = config.Server.DefaultSource.Name
}
if !d.user.Perm.Modify {
if !d.user.Permissions.Modify {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
encodedPath := r.URL.Query().Get("path")
@@ -247,10 +245,10 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
if err != nil {
return http.StatusForbidden, err
}
fileOpts := files.FileOptions{
fileOpts := iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Perm.Modify,
Modify: d.user.Permissions.Modify,
Expand: false,
}
err = files.WriteFile(fileOpts, r.Body)
@@ -263,8 +261,7 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// @Tags Resources
// @Accept json
// @Produce json
// @Param from query string true "Path from resource"
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Param from query string true "Path from resource in <source_name>::<index_path> format"
// @Param destination query string true "Destination path for the resource"
// @Param action query string true "Action to perform (copy, rename)"
// @Param overwrite query bool false "Overwrite if destination exists"
@@ -276,9 +273,8 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [patch]
func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// TODO source := r.URL.Query().Get("source")
action := r.URL.Query().Get("action")
if !d.user.Perm.Modify {
if !d.user.Permissions.Modify {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
@@ -321,7 +317,7 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
return http.StatusForbidden, err
}
idx := files.GetIndex(dstIndex)
idx := indexing.GetIndex(dstIndex)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", dstIndex)
}
@@ -333,7 +329,7 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
}
realDest := parentDir + "/" + filepath.Base(dst)
idx2 := files.GetIndex(srcIndex)
idx2 := indexing.GetIndex(srcIndex)
if idx2 == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", srcIndex)
}
@@ -348,10 +344,10 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
realDest = addVersionSuffix(realDest)
}
// Permission for overwriting the file
if overwrite && !d.user.Perm.Modify {
if overwrite && !d.user.Permissions.Modify {
return http.StatusForbidden, fmt.Errorf("forbidden: user does not have permission to overwrite file")
}
err = patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir, srcIndex, dstIndex)
err = patchAction(r.Context(), action, realSrc, realDest, d, isSrcDir, srcIndex, dstIndex)
if err != nil {
logger.Debug(fmt.Sprintf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err))
}
@@ -374,26 +370,19 @@ func addVersionSuffix(source string) string {
return source
}
func delThumbs(ctx context.Context, fileCache FileCache, file files.ExtendedFileInfo) {
err := fileCache.Delete(ctx, previewCacheKey(file.RealPath, "small", file.FileInfo.ModTime))
if err != nil {
logger.Debug(fmt.Sprintf("Could not delete small thumbnail: %v", err))
}
}
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, fileCache FileCache, isSrcDir bool, srcIndex, destIndex string) error {
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, isSrcDir bool, srcIndex, destIndex string) error {
switch action {
case "copy":
err := files.CopyResource(srcIndex, destIndex, src, dst)
return err
case "rename", "move":
idx := files.GetIndex(srcIndex)
idx := indexing.GetIndex(srcIndex)
srcPath := idx.MakeIndexPath(src)
fileInfo, err := files.FileInfoFaster(files.FileOptions{
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Path: srcPath,
Source: srcIndex,
IsDir: isSrcDir,
Modify: d.user.Perm.Modify,
Modify: d.user.Permissions.Modify,
Expand: false,
ReadHeader: false,
})
@@ -403,58 +392,13 @@ func patchAction(ctx context.Context, action, src, dst string, d *requestContext
}
// delete thumbnails
delThumbs(ctx, fileCache, fileInfo)
preview.DelThumbs(ctx, fileInfo)
return files.MoveResource(srcIndex, destIndex, src, dst)
default:
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
}
}
type DiskUsageResponse struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
}
// diskUsage returns the disk usage information for a given directory.
// @Summary Get disk usage
// @Description Returns the total and used disk space for a specified directory.
// @Tags Resources
// @Accept json
// @Produce json
// @Param source query string false "Source name for the desired source, default is used if not provided"
// @Success 200 {object} DiskUsageResponse "Disk usage details"
// @Failure 404 {object} map[string]string "Directory not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/usage [get]
func diskUsage(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sourceName := r.URL.Query().Get("source")
if sourceName == "" {
sourceName = config.Server.DefaultSource.Name
}
value, ok := cache.DiskUsage.Get(sourceName).(DiskUsageResponse)
if ok {
return renderJSON(w, r, &value)
}
source, ok := config.Server.NameToSource[sourceName]
if !ok {
return 403, fmt.Errorf("source '%s' either does not exist or user does not have permission to it", sourceName)
}
_, err := settings.GetScopeFromSourceName(d.user.Scopes, sourceName)
if err != nil {
return 403, fmt.Errorf("user doesn't have access to '%s', contact your administrator for access", sourceName)
}
usage, err := disk.UsageWithContext(r.Context(), source.Path)
if err != nil {
return errToStatus(err), err
}
latestUsage := DiskUsageResponse{
Total: usage.Total,
Used: usage.Used,
}
cache.DiskUsage.Set(sourceName, latestUsage)
return renderJSON(w, r, &latestUsage)
}
func inspectIndex(w http.ResponseWriter, r *http.Request) {
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
@@ -464,7 +408,7 @@ func inspectIndex(w http.ResponseWriter, r *http.Request) {
// Decode the URL-encoded path
path, _ := url.QueryUnescape(encodedPath)
isNotDir := r.URL.Query().Get("isDir") == "false" // default to isDir true
index := files.GetIndex(source)
index := indexing.GetIndex(source)
if index == nil {
http.Error(w, "source not found", http.StatusNotFound)
return
@@ -481,6 +425,6 @@ func mockData(w http.ResponseWriter, r *http.Request) {
if err != nil || err2 != nil {
return
}
mockDir := files.CreateMockData(NumDirs, numFiles)
mockDir := utils.CreateMockData(NumDirs, numFiles)
renderJSON(w, r, mockDir) // nolint:errcheck
}
+12 -4
View File
@@ -7,8 +7,8 @@ import (
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
// searchHandler handles search requests for files based on the provided query.
@@ -52,7 +52,7 @@ import (
// @Param query query string true "Search query"
// @Param scope query string false "path within user scope to search, for example '/first/second' to search within the second directory only"
// @Param SessionId header string false "User session ID, add unique value to prevent collisions"
// @Success 200 {array} files.SearchResult "List of search results"
// @Success 200 {array} indexing.SearchResult "List of search results"
// @Failure 400 {object} map[string]string "Bad Request"
// @Router /api/search [get]
func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
@@ -69,7 +69,7 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
searchScope := strings.TrimPrefix(unencodedScope, ".")
// Retrieve the User-Agent and X-Auth headers from the request
sessionId := r.Header.Get("SessionId")
index := files.GetIndex(source)
index := indexing.GetIndex(source)
if index == nil {
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", source)
}
@@ -80,6 +80,14 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScope))
// Perform the search using the provided query and user scope
response := index.Search(query, combinedPath, sessionId)
for i := range response {
// Remove the user scope from the path
response[i].Path = strings.TrimPrefix(response[i].Path, userscope)
if response[i].Path == "" {
response[i].Path = "/"
}
}
// trim user scope from each result path
// Set the Content-Type header to application/json
w.Header().Set("Content-Type", "application/json")
return renderJSON(w, r, response)
+24 -8
View File
@@ -6,15 +6,16 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/share"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
)
// shareListHandler returns a list of all share links.
@@ -31,7 +32,7 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
s []*share.Link
err error
)
if d.user.Perm.Admin {
if d.user.Permissions.Admin {
s, err = store.Share.All()
} else {
s, err = store.Share.FindByUserID(d.user.ID)
@@ -64,12 +65,22 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/share [get]
func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if source == "" {
source = settings.Config.Server.DefaultSource.Name
}
s, err := store.Share.Gets(path, source, d.user.ID)
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
sourcePath, ok := config.Server.NameToSource[source]
if !ok {
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", source)
}
s, err := store.Share.Gets(path, sourcePath.Path, d.user.ID)
if err == errors.ErrNotExist {
return renderJSON(w, r, []*share.Link{})
}
@@ -172,7 +183,12 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
token = base64.URLEncoding.EncodeToString(tokenBuffer)
stringHash = string(hash)
}
path := r.URL.Query().Get("path")
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
sourceName := r.URL.Query().Get("source")
if sourceName == "" {
sourceName = config.Server.DefaultSource.Name
@@ -182,7 +198,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
s = &share.Link{
Path: path,
Hash: secure_hash,
Source: source.Path,
Source: source.Path, // path instead to persist accoss name change
Expire: expire,
UserID: d.user.ID,
PasswordHash: stringHash,
+22 -21
View File
@@ -8,8 +8,8 @@ import (
"strings"
"text/template"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/version"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
)
var templateRenderer *TemplateRenderer
@@ -39,25 +39,26 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, file, contentT
}
data := map[string]interface{}{
"Name": config.Frontend.Name,
"DisableExternal": config.Frontend.DisableDefaultLinks,
"DisableUsedPercentage": config.Frontend.DisableUsedPercentage,
"darkMode": settings.Config.UserDefaults.DarkMode,
"BaseURL": config.Server.BaseURL,
"Version": version.Version,
"CommitSHA": version.CommitSHA,
"StaticURL": config.Server.BaseURL + "static",
"Signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
"NoAuth": config.Auth.Methods.NoAuth,
"PasswordAuth": config.Auth.Methods.PasswordAuth,
"LoginPage": auther.LoginPage(),
"CSS": false,
"EnableThumbs": config.Server.EnableThumbnails,
"ResizePreview": config.Server.ResizePreview,
"ExternalLinks": config.Frontend.ExternalLinks,
"ExternalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"OnlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
"SourceCount": len(config.Server.Sources),
"Name": config.Frontend.Name,
"DisableExternal": config.Frontend.DisableDefaultLinks,
"darkMode": settings.Config.UserDefaults.DarkMode,
"BaseURL": config.Server.BaseURL,
"Version": version.Version,
"CommitSHA": version.CommitSHA,
"StaticURL": config.Server.BaseURL + "static",
"Signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
"NoAuth": config.Auth.Methods.NoAuth,
"PasswordAuth": config.Auth.Methods.PasswordAuth,
"LoginPage": auther.LoginPage(),
"CSS": false,
"EnableThumbs": !config.Server.DisablePreviews,
"ExternalLinks": config.Frontend.ExternalLinks,
"ExternalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"OnlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
"SourceCount": len(config.Server.Sources),
"OidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
"PasswordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
"MediaAvailable": config.Integrations.Media.FfmpegPath != "",
}
b, err := json.Marshal(data)
+20
View File
@@ -0,0 +1,20 @@
package http
import (
"net/http"
httpSwagger "github.com/swaggo/http-swagger"
)
func swaggerHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Api {
return http.StatusForbidden, nil
}
httpSwagger.Handler(
httpSwagger.URL(config.Server.BaseURL+"swagger/doc.json"),
httpSwagger.DeepLinking(true),
httpSwagger.DocExpansion("none"),
httpSwagger.DomID("swagger-ui"),
).ServeHTTP(w, r)
return http.StatusOK, nil
}
+13 -13
View File
@@ -8,10 +8,10 @@ import (
"sort"
"strconv"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/storage"
"github.com/gtsteffaniak/filebrowser/backend/users"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
type UserRequest struct {
@@ -60,7 +60,7 @@ func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
return userList[i].ID < userList[j].ID
})
if !d.user.Perm.Admin {
if !d.user.Permissions.Admin {
userList = selfUserList
}
return renderJSON(w, r, userList)
@@ -69,7 +69,7 @@ func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
givenUserId = uint(num)
}
if givenUserId != d.user.ID && !d.user.Perm.Admin {
if givenUserId != d.user.ID && !d.user.Permissions.Admin {
return http.StatusForbidden, nil
}
@@ -111,7 +111,7 @@ func userDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext
return http.StatusForbidden, fmt.Errorf("cannot delete your own user")
}
if !d.user.Perm.Admin {
if !d.user.Permissions.Admin {
return http.StatusForbidden, fmt.Errorf("cannot delete users without admin permissions")
}
@@ -139,7 +139,7 @@ func userDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/users [post]
func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Perm.Admin {
if !d.user.Permissions.Admin {
return http.StatusForbidden, nil
}
// Read the JSON body
@@ -162,7 +162,7 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusBadRequest, errors.ErrEmptyPassword
}
err = storage.CreateUser(*req.Data, req.Data.Perm.Admin)
err = storage.CreateUser(*req.Data, req.Data.Permissions.Admin)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -189,14 +189,14 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
num, _ := strconv.ParseUint(givenUserIdString, 10, 32)
givenUserId := uint(num)
if givenUserId != d.user.ID && !d.user.Perm.Admin {
if givenUserId != d.user.ID && !d.user.Permissions.Admin {
return http.StatusForbidden, nil
}
// Read the JSON body
body, err := io.ReadAll(r.Body)
if err != nil {
return http.StatusInternalServerError, err
return http.StatusBadRequest, err
}
defer r.Body.Close()
@@ -207,9 +207,9 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
}
// Perform the user update
err = store.Users.Update(req.Data, d.user.Perm.Admin, req.Which...)
err = store.Users.Update(req.Data, d.user.Permissions.Admin, req.Which...)
if err != nil {
return http.StatusInternalServerError, err
return http.StatusBadRequest, err
}
// Return the updated user (with the password hidden) as JSON response
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http"
"os"
libErrors "github.com/gtsteffaniak/filebrowser/backend/errors"
libErrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
func errToStatus(err error) int {
@@ -1,9 +1,9 @@
//go:build !windows
// +build !windows
package files
package indexing
func checkWindowsHidden(realpath string) bool {
func CheckWindowsHidden(realpath string) bool {
// Non-Windows platforms don't support hidden attributes in the same way
return false
}
@@ -1,13 +1,13 @@
//go:build windows
// +build windows
package files
package indexing
import (
"golang.org/x/sys/windows"
)
func checkWindowsHidden(realpath string) bool {
func CheckWindowsHidden(realpath string) bool {
// Convert the realpath to a UTF-16 pointer
pointer, err := windows.UTF16PtrFromString(realpath)
if err != nil {
@@ -1,4 +1,4 @@
package files
package indexing
import (
"fmt"
@@ -10,26 +10,39 @@ import (
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
// reduced index is json exposed to the client
type ReducedIndex struct {
IdxName string `json:"name"`
DiskUsed int64 `json:"used"`
DiskTotal int64 `json:"total"`
Status IndexStatus `json:"status"`
NumDirs uint64 `json:"numDirs"`
NumFiles uint64 `json:"numFiles"`
NumDeleted uint64 `json:"numDeleted"`
LastIndexed time.Time `json:"-"`
LastIndexedUnix int64 `json:"lastIndexedUnixTime"`
QuickScanTime int `json:"quickScanDurationSeconds"`
FullScanTime int `json:"fullScanDurationSeconds"`
Assessment string `json:"assessment"`
}
type Index struct {
settings.Source
Directories map[string]*FileInfo
NumDirs uint64
NumFiles uint64
NumDeleted uint64
FilesChangedDuringIndexing bool
currentSchedule int
assessment string
indexingTime int
LastIndexed time.Time
SmartModifier time.Duration
ReducedIndex
CurrentSchedule int `json:"-"`
settings.Source `json:"-"`
Directories map[string]*iteminfo.FileInfo `json:"-"`
runningScannerCount int `json:"-"`
SmartModifier time.Duration `json:"-"`
FilesChangedDuringIndexing bool `json:"-"`
mock bool
mu sync.RWMutex
scannerMu sync.Mutex
}
var (
@@ -37,15 +50,29 @@ var (
indexesMutex sync.RWMutex
)
type IndexStatus string
const (
READY IndexStatus = "ready"
INDEXING IndexStatus = "indexing"
UNAVAILABLE IndexStatus = "unavailable"
)
func init() {
indexes = make(map[string]*Index)
}
func Initialize(source settings.Source) {
func Initialize(source settings.Source, mock bool) {
indexesMutex.Lock()
newIndex := Index{
mock: mock,
Source: source,
Directories: make(map[string]*FileInfo),
Directories: make(map[string]*iteminfo.FileInfo),
}
newIndex.ReducedIndex = ReducedIndex{
Status: "indexing",
IdxName: source.Name,
Assessment: "unknown",
}
indexes[newIndex.Source.Name] = &newIndex
indexesMutex.Unlock()
@@ -80,7 +107,7 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
}
// get whats currently in cache
idx.mu.RLock()
cacheDirItems := []ItemInfo{}
cacheDirItems := []iteminfo.ItemInfo{}
modChange := true // default to true
cachedDir, exists := idx.Directories[adjustedPath]
if exists && quick {
@@ -114,27 +141,23 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
}
var totalSize int64
fileInfos := []ItemInfo{}
dirInfos := []ItemInfo{}
fileInfos := []iteminfo.ItemInfo{}
dirInfos := []iteminfo.ItemInfo{}
// Process each file and directory in the current directory
for _, file := range files {
isHidden := isHidden(file, idx.Source.Path+combinedPath)
isDir := file.IsDir()
isDir := iteminfo.IsDirectory(file)
fullCombined := combinedPath + file.Name()
if idx.shouldSkip(isDir, isHidden, fullCombined) {
continue
}
itemInfo := &ItemInfo{
itemInfo := &iteminfo.ItemInfo{
Name: file.Name(),
ModTime: file.ModTime(),
Hidden: isHidden,
}
// fix for .app files on macos which are technically directories, but we don't want to treat them as such
if isDir && strings.HasSuffix(file.Name(), ".app") {
isDir = false
}
if isDir {
// skip non-indexable dirs.
@@ -171,12 +194,12 @@ func (idx *Index) indexDirectory(adjustedPath string, quick, recursive bool) err
return nil
}
// Create FileInfo for the current directory
dirFileInfo := &FileInfo{
dirFileInfo := &iteminfo.FileInfo{
Path: adjustedPath,
Files: fileInfos,
Folders: dirInfos,
}
dirFileInfo.ItemInfo = ItemInfo{
dirFileInfo.ItemInfo = iteminfo.ItemInfo{
Name: dirInfo.Name(),
Type: "directory",
Size: totalSize,
@@ -210,7 +233,7 @@ func (idx *Index) MakeIndexPath(subPath string) string {
return adjustedPath
}
func (idx *Index) recursiveUpdateDirSizes(childInfo *FileInfo, previousSize int64) {
func (idx *Index) recursiveUpdateDirSizes(childInfo *iteminfo.FileInfo, previousSize int64) {
parentDir := utils.GetParentDirectoryPath(childInfo.Path)
parentInfo, exists := idx.GetMetadataInfo(parentDir, true)
if !exists || parentDir == "" {
@@ -236,7 +259,7 @@ func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err)
}
// Resolve symlinks and get the real path
realPath, isDir, err := resolveSymlinks(absolutePath)
realPath, isDir, err := utils.ResolveSymlinks(absolutePath)
if err == nil {
cache.RealPath.Set(joinedPath, realPath)
cache.RealPath.Set(joinedPath+":isdir", isDir)
@@ -244,8 +267,8 @@ func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
return realPath, isDir, err
}
func (idx *Index) RefreshFileInfo(opts FileOptions) error {
refreshOptions := FileOptions{
func (idx *Index) RefreshFileInfo(opts iteminfo.FileOptions) error {
refreshOptions := iteminfo.FileOptions{
Path: opts.Path,
IsDir: opts.IsDir,
}
@@ -285,7 +308,7 @@ func isHidden(file os.FileInfo, srcPath string) bool {
}
if runtime.GOOS == "windows" {
return checkWindowsHidden(filepath.Join(srcPath, file.Name()))
return CheckWindowsHidden(filepath.Join(srcPath, file.Name()))
}
// Default behavior for non-Windows systems
@@ -342,3 +365,31 @@ func (idx *Index) shouldSkip(isDir bool, isHidden bool, fullCombined string) boo
return false
}
type DiskUsage struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
}
func (idx *Index) SetUsage(usage DiskUsage) {
if settings.Config.Frontend.DisableUsedPercentage {
return
}
idx.mu.Lock()
defer idx.mu.Unlock()
idx.ReducedIndex.DiskUsed = int64(usage.Used)
idx.ReducedIndex.DiskTotal = int64(usage.Total)
}
func (idx *Index) SetStatus(status IndexStatus) {
idx.mu.Lock()
idx.ReducedIndex.Status = status
switch status {
case INDEXING:
idx.runningScannerCount++
case READY, UNAVAILABLE:
idx.runningScannerCount = 0
}
idx.mu.Unlock()
idx.SendSourceUpdateEvent()
}
+166
View File
@@ -0,0 +1,166 @@
package indexing
import (
"encoding/json"
"fmt"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/events"
)
// schedule in minutes
var scanSchedule = []time.Duration{
5 * time.Minute, // 5 minute quick scan & 25 minutes for a full scan
10 * time.Minute,
20 * time.Minute,
40 * time.Minute, // [3] element is 40 minutes, reset anchor for full scan
1 * time.Hour,
2 * time.Hour,
3 * time.Hour,
4 * time.Hour, // 4 hours for quick scan & 20 hours for a full scan
}
var fullScanAnchor = 3 // index of the schedule for a full scan
func (idx *Index) newScanner(origin string) {
fullScanCounter := 0 // every 5th scan is a full scan
for {
// Determine sleep time with modifiers
fullScanCounter++
sleepTime := scanSchedule[idx.CurrentSchedule] + idx.SmartModifier
if idx.Assessment == "simple" {
sleepTime = scanSchedule[idx.CurrentSchedule] - idx.SmartModifier
}
if idx.Source.Config.IndexingInterval > 0 {
sleepTime = time.Duration(idx.Source.Config.IndexingInterval) * time.Minute
}
// Log and sleep before indexing
logger.Debug(fmt.Sprintf("Next scan in %v", sleepTime))
time.Sleep(sleepTime)
if fullScanCounter == 5 {
idx.RunIndexing(origin, false) // Full scan
fullScanCounter = 0
} else {
idx.RunIndexing(origin, true) // Quick scan
}
idx.UpdateSchedule()
}
}
func (idx *Index) PreScan() {
idx.SetStatus(INDEXING)
}
func (idx *Index) PostScan() {
idx.mu.Lock()
idx.runningScannerCount--
idx.mu.Unlock()
if idx.runningScannerCount == 0 {
idx.SetStatus(READY)
}
}
func (idx *Index) UpdateSchedule() {
// Adjust schedule based on file changes
if idx.FilesChangedDuringIndexing {
logger.Debug(fmt.Sprintf("Files changed during indexing [%v], adjusting schedule.", idx.Name))
// Move to at least the full-scan anchor or reduce interval
if idx.CurrentSchedule > fullScanAnchor {
idx.CurrentSchedule = fullScanAnchor
} else if idx.CurrentSchedule > 0 {
idx.CurrentSchedule--
}
} else {
// Increment toward the longest interval if no changes
if idx.CurrentSchedule < len(scanSchedule)-1 {
idx.CurrentSchedule++
}
}
if idx.Assessment == "simple" && idx.CurrentSchedule > 3 {
idx.CurrentSchedule = 3
}
// Ensure `currentSchedule` stays within bounds
if idx.CurrentSchedule < 0 {
idx.CurrentSchedule = 0
} else if idx.CurrentSchedule >= len(scanSchedule) {
idx.CurrentSchedule = len(scanSchedule) - 1
}
}
func (idx *Index) SendSourceUpdateEvent() {
if idx.mock {
logger.Debug("Skipping source update event for mock index.")
return
}
reducedIndex, err := GetIndexInfo(idx.Name)
if err != nil {
logger.Error(fmt.Sprintf("Error getting index info: %v", err))
return
}
sourceAsMap := map[string]ReducedIndex{
idx.Name: reducedIndex,
}
message, err := json.Marshal(sourceAsMap)
if err != nil {
logger.Error(fmt.Sprintf("Error marshalling source update message: %v", err))
return
}
events.SendSourceUpdate(idx.Name, string(message))
}
func (idx *Index) RunIndexing(origin string, quick bool) {
idx.PreScan()
prevNumDirs := idx.NumDirs
prevNumFiles := idx.NumFiles
if quick {
logger.Debug(fmt.Sprintf("Starting quick scan for [%v]", idx.Source.Name))
} else {
logger.Debug(fmt.Sprintf("Starting full scan for [%v]", idx.Source.Name))
idx.NumDirs = 0
idx.NumFiles = 0
}
startTime := time.Now()
idx.FilesChangedDuringIndexing = false
// Perform the indexing operation
err := idx.indexDirectory("/", quick, true)
if err != nil {
logger.Error(fmt.Sprintf("Error during indexing: %v", err))
}
firstRun := idx.LastIndexed == time.Time{}
// Update the LastIndexed time
idx.LastIndexed = time.Now()
idx.LastIndexedUnix = idx.LastIndexed.Unix()
if quick {
idx.QuickScanTime = int(time.Since(startTime).Seconds())
logger.Debug(fmt.Sprintf("Time spent indexing [%v]: %v seconds", idx.Source.Name, idx.QuickScanTime))
} else {
idx.FullScanTime = int(time.Since(startTime).Seconds())
// update smart indexing
if idx.FullScanTime < 3 || idx.NumDirs < 10000 {
idx.Assessment = "simple"
idx.SmartModifier = 4 * time.Minute
} else if idx.FullScanTime > 120 || idx.NumDirs > 500000 {
idx.Assessment = "complex"
modifier := idx.FullScanTime / 10 // seconds
idx.SmartModifier = time.Duration(modifier) * time.Minute
} else {
idx.Assessment = "normal"
}
if firstRun {
logger.Info(fmt.Sprintf("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Source.Name, idx.Assessment, idx.NumDirs, idx.NumFiles))
} else {
logger.Debug(fmt.Sprintf("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Source.Name, idx.Assessment, idx.NumDirs, idx.NumFiles))
}
if idx.NumDirs != prevNumDirs || idx.NumFiles != prevNumFiles {
idx.FilesChangedDuringIndexing = true
}
logger.Debug(fmt.Sprintf("Time spent indexing [%v]: %v seconds", idx.Source.Name, idx.FullScanTime))
}
idx.PostScan()
}
func (idx *Index) setupIndexingScanners() {
go idx.newScanner("/")
}
@@ -1,20 +1,18 @@
package files
package indexing
import (
"encoding/json"
"math/rand"
"reflect"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
func BenchmarkFillIndex(b *testing.B) {
Initialize(settings.Source{
Name: "test",
Path: "/srv",
})
}, true)
idx := GetIndex("test")
if idx == nil {
b.Fatal("index is nil")
@@ -22,31 +20,7 @@ func BenchmarkFillIndex(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
idx.createMockData(50, 3) // 1000 dirs, 3 files per dir
}
}
func (idx *Index) createMockData(numDirs, numFilesPerDir int) {
for i := 0; i < numDirs; i++ {
dirPath := generateRandomPath(rand.Intn(3) + 1)
files := []ItemInfo{} // Slice of FileInfo
// Simulating files and directories with FileInfo
for j := 0; j < numFilesPerDir; j++ {
newFile := ItemInfo{
Name: "file-" + getRandomTerm() + getRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
Type: "blob",
}
files = append(files, newFile)
}
dirInfo := &FileInfo{
Path: dirPath,
Files: files,
}
idx.UpdateMetadata(dirInfo)
idx.CreateMockData(50, 3) // 1000 dirs, 3 files per dir
}
}
+357
View File
@@ -0,0 +1,357 @@
package iteminfo
import (
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
var AllFiletypeOptions = []string{
"image",
"audio",
"archive",
"video",
"doc",
"text",
}
// Known bundle-style extensions that are technically directories but treated as files
var BundleExtensions = []string{
".app", // macOS application bundle
".bundle", // macOS plugin bundle
".framework", // macOS framework
".plugin", // macOS plugin
".kext", // macOS kernel extension
".pkg", // macOS installer package
".mpkg", // macOS multi-package
".apk", // Android package
".aab", // Android App Bundle
".appx", // Windows application package
".msix", // Windows modern app package
".deb", // Debian package
".snap", // Snap package
".flatpak", // Flatpak application
".dmg", // macOS disk image
".iso", // ISO disk image
}
// Document file extensions
var documentTypes = []string{
// Common Document Formats
".doc", ".docx", // Microsoft Word
".pdf", // Portable Document Format
".odt", // OpenDocument Text
".rtf", // Rich Text Format
".conf",
".bash_history",
".gitignore",
".htpasswd",
".profile",
".dockerignore",
".editorconfig",
// Presentation Formats
".ppt", ".pptx", // Microsoft PowerPoint
".odp", // OpenDocument Presentation
// google docs
".gdoc",
// google sheet
".gsheet",
// Spreadsheet Formats
".xls", ".xlsx", // Microsoft Excel
".ods", // OpenDocument Spreadsheet
// Other Document Formats
".epub", // Electronic Publication
".mobi", // Amazon Kindle
".fb2", // FictionBook
}
var onlyOfficeSupported = []string{
".doc", ".docm", ".docx", ".dot", ".dotm", ".dotx", ".epub",
".fb2", ".fodt", ".htm", ".html", ".mht", ".mhtml", ".odt",
".ott", ".rtf", ".stw", ".sxw", ".txt", ".wps", ".wpt", ".xml",
".csv", ".et", ".ett", ".fods", ".ods", ".ots", ".sxc", ".xls",
".xlsb", ".xlsm", ".xlsx", ".xlt", ".xltm", ".xltx", ".dps",
".dpt", ".fodp", ".odp", ".otp", ".pot", ".potm", ".potx",
".pps", ".ppsm", ".ppsx", ".ppt", ".pptm", ".pptx", ".sxi",
".djvu", ".docxf", ".oform", ".oxps", ".pdf", ".xps",
}
// Text-based file extensions
var textTypes = []string{
// Common Text Formats
".txt",
".md", // Markdown
// Scripting and Programming Languages
".sh", // Bash script
".py", // Python
".js", // JavaScript
".ts", // TypeScript
".php", // PHP
".rb", // Ruby
".go", // Go
".java", // Java
".c", ".cpp", // C/C++
".cs", // C#
".swift", // Swift
// Configuration Files
".yaml", ".yml", // YAML
".json", // JSON
".xml", // XML
".ini", // INI
".toml", // TOML
".cfg", // Configuration file
// Other Text-Based Formats
".css", // Cascading Style Sheets
".html", ".htm", // HyperText Markup Language
".sql", // SQL
".csv", // Comma-Separated Values
".tsv", // Tab-Separated Values
".log", // Log file
".bat", // Batch file
".ps1", // PowerShell script
".tex", // LaTeX
".bib", // BibTeX
}
// Compressed file extensions
var compressedFile = []string{
".7z",
".rar",
".zip",
".tar",
".gz",
".xz",
".bz2",
".tgz", // tar.gz
".tbz2", // tar.bz2
".lzma",
".lz4",
".zstd",
}
func ExtendedMimeTypeCheck(extension string) string {
if IsDoc(extension) {
return "application/document"
}
if IsText(extension) {
return "text/plain"
}
return "blob"
}
func ToInt(str string) int {
val, err := strconv.Atoi(str)
if err != nil {
return 0
}
return val
}
func UpdateSize(given string) int {
size := ToInt(given)
if size == 0 {
return 100
} else {
return size
}
}
func IsText(extension string) bool {
for _, typefile := range textTypes {
if extension == typefile {
return true
}
}
return false
}
func IsDoc(extension string) bool {
for _, typefile := range documentTypes {
if extension == typefile {
return true
}
}
return false
}
func IsArchive(extension string) bool {
for _, typefile := range compressedFile {
if extension == typefile {
return true
}
}
return false
}
func IsOnlyOffice(name string) bool {
extention := filepath.Ext(name)
for _, typefile := range onlyOfficeSupported {
if extention == typefile {
return true
}
}
return false
}
func IsMatchingType(extension string, matchType string) bool {
mimetype := mime.TypeByExtension(extension)
if strings.HasPrefix(mimetype, matchType) {
return true
}
switch matchType {
case "doc":
return IsDoc(extension)
case "text":
return IsText(extension)
case "archive":
return IsArchive(extension)
}
return false
}
// DetectType detects the MIME type of a file and updates the ItemInfo struct.
func (i *ItemInfo) DetectType(realPath string, saveContent bool) {
name := i.Name
ext := filepath.Ext(name)
// Attempt MIME detection by file extension
if ext == ".md" {
i.Type = "text/markdown"
return
}
i.Type = strings.Split(mime.TypeByExtension(ext), ";")[0]
if i.Type == "" {
i.Type = ExtendedMimeTypeCheck(ext)
}
// do header detection for certain files to ensure the type is correct for undetected or ambiguous files
if !settings.Config.Server.DisableTypeDetectionByHeader {
switch ext {
case ".ts", ".xcf":
i.Type = DetectTypeByHeader(realPath)
return
}
if i.Type == "blob" {
i.Type = DetectTypeByHeader(realPath)
}
}
}
// DetectTypeByHeader detects the MIME type of a file based on its header.
func DetectTypeByHeader(realPath string) string {
file, err := os.Open(realPath)
if err != nil {
return "blob"
}
defer file.Close()
buffer := make([]byte, 512)
n, err := file.Read(buffer)
if err != nil {
return "blob"
}
return http.DetectContentType(buffer[:n])
}
// returns true if the file name contains the search term
// returns file type if the file name contains the search term
// returns size of file/dir if the file name contains the search term
func (fi ItemInfo) ContainsSearchTerm(searchTerm string, options SearchOptions) bool {
fileTypes := map[string]bool{}
largerThan := int64(options.LargerThan) * 1024 * 1024
smallerThan := int64(options.SmallerThan) * 1024 * 1024
conditions := options.Conditions
lowerFileName := strings.ToLower(fi.Name)
// Convert to lowercase if not exact match
if !conditions["exact"] {
searchTerm = strings.ToLower(searchTerm)
}
// Check if the file name contains the search term
if !strings.Contains(lowerFileName, searchTerm) {
return false
}
// Initialize file size and fileTypes map
var fileSize int64
extension := filepath.Ext(lowerFileName)
// Collect file types
for _, k := range AllFiletypeOptions {
if IsMatchingType(extension, k) {
fileTypes[k] = true
}
}
isDir := fi.Type == "directory"
fileTypes["dir"] = isDir
fileSize = fi.Size
// Evaluate all conditions
for t, v := range conditions {
if t == "exact" {
continue
}
switch t {
case "larger":
if largerThan > 0 {
if fileSize <= largerThan {
return false
}
}
case "smaller":
if smallerThan > 0 {
if fileSize >= smallerThan {
return false
}
}
default:
// Handle other file type conditions
notMatchType := v != fileTypes[t]
if notMatchType {
return false
}
}
}
return true
}
// IsDirectory determines if a path should be treated as a directory.
// It treats known bundle-style directories as files instead.
func IsDirectory(fileInfo os.FileInfo) bool {
if !fileInfo.IsDir() {
return false
}
if !hasBundleExtension(fileInfo.Name()) {
return true
}
// For bundle-type dirs, treat them as files
return false
}
// hasBundleExtension checks if a file has a known bundle-style extension.
func hasBundleExtension(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
for _, bundleExt := range BundleExtensions {
if ext == bundleExt {
return true
}
}
return false
}
@@ -1,4 +1,4 @@
package files
package iteminfo
import (
"fmt"
@@ -67,7 +67,7 @@ func TestUpdateSize(t *testing.T) {
}
for _, tc := range testCases {
actual := updateSize(tc.input)
actual := UpdateSize(tc.input)
assert.Equal(t, tc.expected, actual, sizeErrorMsg(tc.input, tc.expected, actual))
}
}
+51
View File
@@ -0,0 +1,51 @@
package iteminfo
import (
"path/filepath"
"time"
)
type ItemInfo struct {
Name string `json:"name"` // name of the file
Size int64 `json:"size"` // length in bytes for regular files
ModTime time.Time `json:"modified"` // modification time
Type string `json:"type"` // type of the file, either "directory" or a file mimetype
Hidden bool `json:"hidden"` // whether the file is hidden
}
// FileInfo describes a file.
// reduced item is non-recursive reduced "Items", used to pass flat items array
type FileInfo struct {
ItemInfo
Files []ItemInfo `json:"files"` // files in the directory
Folders []ItemInfo `json:"folders"` // folders in the directory
Path string `json:"path"` // path scoped to the associated index
}
// for efficiency, a response will be a pointer to the data
// extra calculated fields can be added here
type ExtendedFileInfo struct {
FileInfo
Content string `json:"content,omitempty"` // text content of a file, if requested
Subtitles []string `json:"subtitles,omitempty"` // subtitles for video files
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
Token string `json:"token,omitempty"` // token for the file -- used for sharing
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source"` // associated index source for the file
RealPath string `json:"-"`
}
// FileOptions are the options when getting a file info.
type FileOptions struct {
Path string // realpath
Source string
IsDir bool
Modify bool
Expand bool
ReadHeader bool
Content bool
}
func (f FileOptions) Components() (string, string) {
return filepath.Dir(f.Path), filepath.Base(f.Path)
}
+87
View File
@@ -0,0 +1,87 @@
package iteminfo
import (
"regexp"
"strings"
)
var typeRegexp = regexp.MustCompile(`type:(\S+)`)
type SearchOptions struct {
Conditions map[string]bool
LargerThan int
SmallerThan int
Terms []string
}
func ParseSearch(value string) SearchOptions {
opts := SearchOptions{
Conditions: map[string]bool{
"exact": strings.Contains(value, "case:exact"),
},
Terms: []string{},
}
// removes the options from the value
value = strings.Replace(value, "case:exact", "", -1)
value = strings.TrimSpace(value)
types := typeRegexp.FindAllStringSubmatch(value, -1)
for _, filterType := range types {
if len(filterType) == 1 {
continue
}
filter := filterType[1]
switch filter {
case "image":
opts.Conditions["image"] = true
case "audio", "music":
opts.Conditions["audio"] = true
case "video":
opts.Conditions["video"] = true
case "doc":
opts.Conditions["doc"] = true
case "archive":
opts.Conditions["archive"] = true
case "folder":
opts.Conditions["dir"] = true
case "file":
opts.Conditions["dir"] = false
}
if len(filter) < 8 {
continue
}
if strings.HasPrefix(filter, "largerThan=") {
opts.Conditions["larger"] = true
size := strings.TrimPrefix(filter, "largerThan=")
opts.LargerThan = UpdateSize(size)
}
if strings.HasPrefix(filter, "smallerThan=") {
opts.Conditions["smaller"] = true
size := strings.TrimPrefix(filter, "smallerThan=")
opts.SmallerThan = UpdateSize(size)
}
}
if len(types) > 0 {
// Remove the fields from the search value
value = typeRegexp.ReplaceAllString(value, "")
}
if value == "" {
return opts
}
// if the value starts with " and finishes what that character, we will
// only search for that term
if value[0] == '"' && value[len(value)-1] == '"' {
unique := strings.TrimPrefix(value, "\"")
unique = strings.TrimSuffix(unique, "\"")
opts.Terms = []string{unique}
return opts
}
value = strings.TrimSpace(value)
opts.Terms = strings.Split(value, "|")
return opts
}
+59
View File
@@ -0,0 +1,59 @@
package iteminfo
import (
"sort"
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
)
// detects subtitles for video files.
func (i *ExtendedFileInfo) DetectSubtitles(parentInfo *FileInfo) {
if !strings.HasPrefix(i.Type, "video") {
logger.Debug("subtitles are not supported for this file : " + i.Name)
return
}
base := strings.Split(i.Name, ".")[0]
for _, f := range parentInfo.Files {
baseName := strings.Split(f.Name, ".")[0]
if baseName != base {
continue
}
for _, subtitleExt := range []string{".vtt", ".srt", ".lrc", ".sbv", ".ass", ".ssa", ".sub", ".smi"} {
if strings.HasSuffix(f.Name, subtitleExt) {
fullPathBase := strings.Split(i.Path, ".")[0]
i.Subtitles = append(i.Subtitles, fullPathBase+subtitleExt)
}
}
}
}
func (info *FileInfo) SortItems() {
sort.Slice(info.Folders, func(i, j int) bool {
nameWithoutExt := strings.Split(info.Folders[i].Name, ".")[0]
nameWithoutExt2 := strings.Split(info.Folders[j].Name, ".")[0]
// Convert strings to integers for numeric sorting if both are numeric
numI, errI := strconv.Atoi(nameWithoutExt)
numJ, errJ := strconv.Atoi(nameWithoutExt2)
if errI == nil && errJ == nil {
return numI < numJ
}
// Fallback to case-insensitive lexicographical sorting
return strings.ToLower(info.Folders[i].Name) < strings.ToLower(info.Folders[j].Name)
})
sort.Slice(info.Files, func(i, j int) bool {
nameWithoutExt := strings.Split(info.Files[i].Name, ".")[0]
nameWithoutExt2 := strings.Split(info.Files[j].Name, ".")[0]
// Convert strings to integers for numeric sorting if both are numeric
numI, errI := strconv.Atoi(nameWithoutExt)
numJ, errJ := strconv.Atoi(nameWithoutExt2)
if errI == nil && errJ == nil {
return numI < numJ
}
// Fallback to case-insensitive lexicographical sorting
return strings.ToLower(info.Files[i].Name) < strings.ToLower(info.Files[j].Name)
})
}
+33
View File
@@ -0,0 +1,33 @@
package indexing
import (
"math/rand"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func (idx *Index) CreateMockData(numDirs, numFilesPerDir int) {
for i := 0; i < numDirs; i++ {
dirPath := utils.GenerateRandomPath(rand.Intn(3) + 1)
files := []iteminfo.ItemInfo{} // Slice of FileInfo
// Simulating files and directories with FileInfo
for j := 0; j < numFilesPerDir; j++ {
newFile := iteminfo.ItemInfo{
Name: "file-" + utils.GetRandomTerm() + utils.GetRandomExtension(),
Size: rand.Int63n(1000), // Random size
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
Type: "blob",
}
files = append(files, newFile)
}
dirInfo := &iteminfo.FileInfo{
Path: dirPath,
Files: files,
}
idx.UpdateMetadata(dirInfo)
}
}
@@ -1,15 +1,19 @@
package files
package indexing
import (
"fmt"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/logger"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/shirou/gopsutil/v3/disk"
)
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
func (idx *Index) UpdateMetadata(info *FileInfo) bool {
func (idx *Index) UpdateMetadata(info *iteminfo.FileInfo) bool {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.Directories[info.Path] = info
@@ -17,7 +21,7 @@ func (idx *Index) UpdateMetadata(info *FileInfo) bool {
}
// GetMetadataInfo retrieves the FileInfo from the specified file or directory in the index.
func (idx *Index) GetReducedMetadata(target string, isDir bool) (*FileInfo, bool) {
func (idx *Index) GetReducedMetadata(target string, isDir bool) (*iteminfo.FileInfo, bool) {
idx.mu.Lock()
defer idx.mu.Unlock()
checkDir := idx.MakeIndexPath(target)
@@ -42,7 +46,7 @@ func (idx *Index) GetReducedMetadata(target string, isDir bool) (*FileInfo, bool
baseName := filepath.Base(target)
for _, item := range dir.Files {
if item.Name == baseName {
return &FileInfo{
return &iteminfo.FileInfo{
Path: checkDir + "/" + item.Name,
ItemInfo: item,
}, true
@@ -53,7 +57,7 @@ func (idx *Index) GetReducedMetadata(target string, isDir bool) (*FileInfo, bool
}
// raw directory info retrieval -- does not work on files, only returns a directory
func (idx *Index) GetMetadataInfo(target string, isDir bool) (*FileInfo, bool) {
func (idx *Index) GetMetadataInfo(target string, isDir bool) (*iteminfo.FileInfo, bool) {
idx.mu.RLock()
defer idx.mu.RUnlock()
checkDir := idx.MakeIndexPath(target)
@@ -93,3 +97,28 @@ func GetIndex(name string) *Index {
}
return index
}
func GetIndexInfo(sourceName string) (ReducedIndex, error) {
idx, ok := indexes[sourceName]
if !ok {
return ReducedIndex{}, fmt.Errorf("index %s not found", sourceName)
}
sourcePath := idx.Path
cacheKey := "usageCache-" + sourceName
_, ok = cache.DiskUsage.Get(cacheKey).(bool)
if !ok {
usage, err := disk.Usage(sourcePath)
if err != nil {
logger.Error(fmt.Sprintf("error getting disk usage for %s: %v", sourcePath, err))
idx.SetStatus(UNAVAILABLE)
return ReducedIndex{}, fmt.Errorf("error getting disk usage for %s: %v", sourcePath, err)
}
latestUsage := DiskUsage{
Total: usage.Total,
Used: usage.Used,
}
idx.SetUsage(latestUsage)
cache.DiskUsage.Set(cacheKey, true)
}
return idx.ReducedIndex, nil
}
@@ -1,9 +1,10 @@
package files
package indexing
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/stretchr/testify/assert"
)
@@ -34,7 +35,7 @@ func TestGetFileMetadataSize(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fileInfo, _ := testIndex.GetReducedMetadata(tt.adjustedPath, true)
// Iterate over fileInfo.Items to look for expectedName
// Iterate over iteminfo.Items to look for expectedName
for _, item := range fileInfo.Files {
// Assert the existence and the name
if item.Name == tt.expectedName {
@@ -98,7 +99,7 @@ func TestGetFileMetadata(t *testing.T) {
}
found := false
if tt.isDir {
// Iterate over fileInfo.Items to look for expectedName
// Iterate over iteminfo.Items to look for expectedName
for _, item := range fileInfo.Files {
// Assert the existence and the name
if item.Name == tt.expectedName {
@@ -119,16 +120,16 @@ func TestGetFileMetadata(t *testing.T) {
// Test for UpdateFileMetadata
func TestUpdateFileMetadata(t *testing.T) {
info := &FileInfo{
info := &iteminfo.FileInfo{
Path: "/testpath",
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "testfile.txt"},
{Name: "anotherfile.txt"},
},
}
index := &Index{
Directories: map[string]*FileInfo{
Directories: map[string]*iteminfo.FileInfo{
"/testpath": info,
},
}
@@ -161,27 +162,27 @@ func TestGetDirMetadata(t *testing.T) {
// Test for SetDirectoryInfo
func TestSetDirectoryInfo(t *testing.T) {
index := &Index{
Directories: map[string]*FileInfo{
Directories: map[string]*iteminfo.FileInfo{
"/testpath": {
Path: "/testpath",
ItemInfo: ItemInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "testpath",
Type: "directory",
},
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "testfile.txt"},
{Name: "anotherfile.txt"},
},
},
},
}
dir := &FileInfo{
dir := &iteminfo.FileInfo{
Path: "/newPath",
ItemInfo: ItemInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "newPath",
Type: "directory",
},
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "testfile.txt"},
},
}
@@ -195,7 +196,7 @@ func TestSetDirectoryInfo(t *testing.T) {
// Test for RemoveDirectory
func TestRemoveDirectory(t *testing.T) {
index := &Index{
Directories: map[string]*FileInfo{
Directories: map[string]*iteminfo.FileInfo{
"/testpath": {},
},
}
@@ -208,34 +209,37 @@ func TestRemoveDirectory(t *testing.T) {
func init() {
testIndex = Index{
ReducedIndex: ReducedIndex{
NumFiles: 10,
NumDirs: 5,
},
Source: settings.Source{
Path: "/",
Name: "test",
},
NumFiles: 10,
NumDirs: 5,
Directories: map[string]*FileInfo{
Directories: map[string]*iteminfo.FileInfo{
"/testpath": {
Path: "/testpath",
ItemInfo: ItemInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "testpath",
Type: "directory",
},
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "testfile.txt", Size: 100},
{Name: "anotherfile.txt", Size: 100},
},
},
"/anotherpath": {
Path: "/anotherpath",
ItemInfo: ItemInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "anotherpath",
Type: "directory",
},
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "afile.txt", Size: 100},
},
Folders: []ItemInfo{
Folders: []iteminfo.ItemInfo{
{Name: "directory", Type: "directory", Size: 100},
},
},
@@ -1,4 +1,4 @@
package files
package indexing
import (
"path/filepath"
@@ -6,8 +6,9 @@ import (
"strings"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/utils"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/cache"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
var (
@@ -29,7 +30,7 @@ func (idx *Index) Search(search string, scope string, sourceSession string) []Se
// Remove slashes
runningHash := utils.InsecureRandomIdentifier(4)
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
searchOptions := ParseSearch(search)
searchOptions := iteminfo.ParseSearch(search)
results := make(map[string]SearchResult, 0)
count := 0
var directories []string
@@ -58,12 +59,12 @@ func (idx *Index) Search(search string, scope string, sourceSession string) []Se
if count > maxSearchResults {
break
}
reducedDir := ItemInfo{
reducedDir := iteminfo.ItemInfo{
Name: filepath.Base(dirName),
Type: "directory",
Size: dir.Size,
}
matches := reducedDir.containsSearchTerm(searchTerm, searchOptions)
matches := reducedDir.ContainsSearchTerm(searchTerm, searchOptions)
if matches {
results[dirName+"/"] = SearchResult{Path: dirName + "/", Type: "directory", Size: dir.Size}
count++
@@ -82,7 +83,7 @@ func (idx *Index) Search(search string, scope string, sourceSession string) []Se
if count > maxSearchResults {
break
}
matches := item.containsSearchTerm(searchTerm, searchOptions)
matches := item.ContainsSearchTerm(searchTerm, searchOptions)
if matches {
results[fullPath] = SearchResult{Path: fullPath, Type: item.Type, Size: item.Size}
count++
@@ -106,71 +107,6 @@ func (idx *Index) Search(search string, scope string, sourceSession string) []Se
return sortedKeys
}
// returns true if the file name contains the search term
// returns file type if the file name contains the search term
// returns size of file/dir if the file name contains the search term
func (fi ItemInfo) containsSearchTerm(searchTerm string, options SearchOptions) bool {
fileTypes := map[string]bool{}
largerThan := int64(options.LargerThan) * 1024 * 1024
smallerThan := int64(options.SmallerThan) * 1024 * 1024
conditions := options.Conditions
lowerFileName := strings.ToLower(fi.Name)
// Convert to lowercase if not exact match
if !conditions["exact"] {
searchTerm = strings.ToLower(searchTerm)
}
// Check if the file name contains the search term
if !strings.Contains(lowerFileName, searchTerm) {
return false
}
// Initialize file size and fileTypes map
var fileSize int64
extension := filepath.Ext(lowerFileName)
// Collect file types
for _, k := range AllFiletypeOptions {
if IsMatchingType(extension, k) {
fileTypes[k] = true
}
}
isDir := fi.Type == "directory"
fileTypes["dir"] = isDir
fileSize = fi.Size
// Evaluate all conditions
for t, v := range conditions {
if t == "exact" {
continue
}
switch t {
case "larger":
if largerThan > 0 {
if fileSize <= largerThan {
return false
}
}
case "smaller":
if smallerThan > 0 {
if fileSize >= smallerThan {
return false
}
}
default:
// Handle other file type conditions
notMatchType := v != fileTypes[t]
if notMatchType {
return false
}
}
}
return true
}
func (idx *Index) getDirsInScope(scope string) []string {
newList := []string{}
idx.mu.Lock()
@@ -1,21 +1,23 @@
package files
package indexing
import (
"reflect"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/stretchr/testify/assert"
)
func BenchmarkSearchAllIndexes(b *testing.B) {
Initialize(settings.Source{Name: "test", Path: "/srv"})
Initialize(settings.Source{Name: "test", Path: "/srv"}, true)
idx := GetIndex("test")
idx.createMockData(50, 3) // 50 dirs, 3 files per dir
idx.CreateMockData(50, 3) // 50 dirs, 3 files per dir
// Generate 100 random search terms
searchTerms := generateRandomSearchTerms(100)
searchTerms := utils.GenerateRandomSearchTerms(100)
b.ResetTimer()
b.ReportAllocs()
@@ -30,25 +32,25 @@ func BenchmarkSearchAllIndexes(b *testing.B) {
func TestParseSearch(t *testing.T) {
tests := []struct {
input string
want SearchOptions
want iteminfo.SearchOptions
}{
{
input: "my test search",
want: SearchOptions{
want: iteminfo.SearchOptions{
Conditions: map[string]bool{"exact": false},
Terms: []string{"my test search"},
},
},
{
input: "case:exact my|test|search",
want: SearchOptions{
want: iteminfo.SearchOptions{
Conditions: map[string]bool{"exact": true},
Terms: []string{"my", "test", "search"},
},
},
{
input: "type:largerThan=100 type:smallerThan=1000 test",
want: SearchOptions{
want: iteminfo.SearchOptions{
Conditions: map[string]bool{"exact": false, "larger": true, "smaller": true},
Terms: []string{"test"},
LargerThan: 100,
@@ -57,7 +59,7 @@ func TestParseSearch(t *testing.T) {
},
{
input: "type:audio thisfile",
want: SearchOptions{
want: iteminfo.SearchOptions{
Conditions: map[string]bool{"exact": false, "audio": true},
Terms: []string{"thisfile"},
},
@@ -66,7 +68,7 @@ func TestParseSearch(t *testing.T) {
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
value := ParseSearch(tt.input)
value := iteminfo.ParseSearch(tt.input)
if !reflect.DeepEqual(value, tt.want) {
t.Fatalf("\n got: %+v\n want: %+v", value, tt.want)
}
@@ -75,12 +77,12 @@ func TestParseSearch(t *testing.T) {
}
func TestSearchWhileIndexing(t *testing.T) {
Initialize(settings.Source{Name: "test", Path: "/srv"})
Initialize(settings.Source{Name: "test", Path: "/srv"}, true)
idx := GetIndex("test")
searchTerms := generateRandomSearchTerms(10)
searchTerms := utils.GenerateRandomSearchTerms(10)
for i := 0; i < 5; i++ {
go idx.createMockData(100, 100) // Creating mock data concurrently
go idx.CreateMockData(100, 100) // Creating mock data concurrently
for _, term := range searchTerms {
go idx.Search(term, "/", "test") // Search concurrently
}
@@ -89,34 +91,34 @@ func TestSearchWhileIndexing(t *testing.T) {
func TestSearchIndexes(t *testing.T) {
index := Index{
Directories: map[string]*FileInfo{
"/": {Files: []ItemInfo{{Name: "audio-one.wav", Type: "audio"}}},
"/test": {Files: []ItemInfo{{Name: "audio-one.wav", Type: "audio"}}},
"/test/path": {Files: []ItemInfo{{Name: "file.txt", Type: "text"}}},
"/new/test": {Files: []ItemInfo{
Directories: map[string]*iteminfo.FileInfo{
"/": {Files: []iteminfo.ItemInfo{{Name: "audio-one.wav", Type: "audio"}}},
"/test": {Files: []iteminfo.ItemInfo{{Name: "audio-one.wav", Type: "audio"}}},
"/test/path": {Files: []iteminfo.ItemInfo{{Name: "file.txt", Type: "text"}}},
"/new/test": {Files: []iteminfo.ItemInfo{
{Name: "audio.wav", Type: "audio"},
{Name: "video.mp4", Type: "video"},
{Name: "video.MP4", Type: "video"},
}},
"/first Dir": {
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "space jam.zip", Size: 100, Type: "archive"},
},
},
"/new/test/path": {Files: []ItemInfo{{Name: "archive.zip", Type: "archive"}}},
"/new/test/path": {Files: []iteminfo.ItemInfo{{Name: "archive.zip", Type: "archive"}}},
"/firstDir": {
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "archive.zip", Size: 100, Type: "archive"},
},
Folders: []ItemInfo{
Folders: []iteminfo.ItemInfo{
{Name: "thisIsDir", Type: "directory", Size: 2 * 1024 * 1024},
},
},
"/firstDir/thisIsDir": {
Files: []ItemInfo{
Files: []iteminfo.ItemInfo{
{Name: "hi.txt", Type: "text"},
},
ItemInfo: ItemInfo{
ItemInfo: iteminfo.ItemInfo{
Size: 2 * 1024 * 1024,
},
},
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff. Show more