Compare commits
48
Commits
main
...
v0.7.0-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8d6043c99 | ||
|
|
afbf4ca4c5 | ||
|
|
8136731f81 | ||
|
|
384a4a301b | ||
|
|
40c4cb925e | ||
|
|
58c89cf561 | ||
|
|
3bb68760a9 | ||
|
|
f19ec9a221 | ||
|
|
492b477b7d | ||
|
|
43cbba7d89 | ||
|
|
e66cdfde43 | ||
|
|
a0130f6ae4 | ||
|
|
69e410e992 | ||
|
|
2f66dd3442 | ||
|
|
a07853763d | ||
|
|
d3390ee585 | ||
|
|
813762caba | ||
|
|
16793c3851 | ||
|
|
6715479758 | ||
|
|
7ae2a8c851 | ||
|
|
5ef01021a4 | ||
|
|
acbd976a5c | ||
|
|
0c7d35d8cd | ||
|
|
f249e38dd9 | ||
|
|
b6ebd91775 | ||
|
|
db816c1fe8 | ||
|
|
ceb8eda39f | ||
|
|
99198fbe19 | ||
|
|
36e547bb56 | ||
|
|
cb6ad047f8 | ||
|
|
a5559ec3e2 | ||
|
|
698a3e126c | ||
|
|
f6702414b2 | ||
|
|
5ea3813d81 | ||
|
|
86e73abfab | ||
|
|
9230169d0f | ||
|
|
147615e627 | ||
|
|
8d82792618 | ||
|
|
06cb471fe3 | ||
|
|
40e76c2a14 | ||
|
|
b531100208 | ||
|
|
3161ded372 | ||
|
|
c750b6c4e2 | ||
|
|
9daf88c5dd | ||
|
|
cecdf7ae09 | ||
|
|
d4e5b52dc2 | ||
|
|
85266f09d0 | ||
|
|
bafdff14d4 |
No files matched your search
@@ -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*
|
||||
@@ -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**
|
||||
|
||||
@@ -6,41 +6,36 @@
|
||||
[](https://hub.docker.com/r/gtstef/filebrowser)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
|
||||
[](https://github.com/gtsteffaniak/filebrowser/discussions/368)
|
||||
[](https://www.paypal.com/donate/?business=W5XKNXHJM2WPE&no_recurring=0¤cy_code=USD)
|
||||
[](https://www.paypal.com/donate/?business=W5XKNXHJM2WPE&no_recurring=0¤cy_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 | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
|
||||
@@ -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" ]
|
||||
@@ -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
|
||||
@@ -15,3 +15,6 @@ frontend:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
userDefaults:
|
||||
permissions:
|
||||
realtime: true
|
||||
@@ -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.
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)
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -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.
|
||||
@@ -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.
@@ -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
@@ -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 {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+10
-9
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user