mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-15 13:35:55 -05:00
Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4907225a1 | ||
|
|
cc136043f9 | ||
|
|
611dd8ea71 | ||
|
|
41e9ff27fa | ||
|
|
f11d8df7e8 | ||
|
|
3fd20bbee1 | ||
|
|
86a031be1d | ||
|
|
d1b5a18c83 | ||
|
|
d9fc8c0275 | ||
|
|
6cad10afb5 | ||
|
|
3a53b13b3d | ||
|
|
3fca886136 | ||
|
|
16994cae79 | ||
|
|
7eb9f1b0fa | ||
|
|
d81be14d12 | ||
|
|
884a5fa221 | ||
|
|
bbcf472fe9 | ||
|
|
49075d85df | ||
|
|
b95311dff8 | ||
|
|
7d3aa0201f | ||
|
|
1048b528eb | ||
|
|
6d4c74b883 | ||
|
|
e4d6f4fa51 | ||
|
|
2aa766000b | ||
|
|
315af7aa6e | ||
|
|
9399392956 | ||
|
|
2a17ccb2a9 | ||
|
|
c56b8cd6dc | ||
|
|
28ce27397f | ||
|
|
312e434bb5 | ||
|
|
cfa1a8c120 | ||
|
|
57487f7523 | ||
|
|
7a80433b2c | ||
|
|
30a00d59ef | ||
|
|
1b08b968db | ||
|
|
fcce47203f | ||
|
|
4de432e8ce | ||
|
|
fc42b6fbc0 | ||
|
|
5c2e2f6af5 | ||
|
|
5121a6fa82 | ||
|
|
dc8d71ddb0 | ||
|
|
961bdd475f | ||
|
|
34b957be0f | ||
|
|
86c41db9c5 | ||
|
|
89d99090ec | ||
|
|
0dde7cbe59 | ||
|
|
dfe217d020 | ||
|
|
1d2ac2ab06 | ||
|
|
87923add8a | ||
|
|
be7df9000a | ||
|
|
1126fe7d95 | ||
|
|
4476d97f6e | ||
|
|
888cad356e | ||
|
|
18bd22ee95 | ||
|
|
ae2fabfae6 | ||
|
|
1e891452bc | ||
|
|
dc177b634d | ||
|
|
1b0f53dead | ||
|
|
19c19f70d7 | ||
|
|
bf11fc43f3 | ||
|
|
7b39fcc9ac | ||
|
|
683c837d0c | ||
|
|
b95ce79d50 | ||
|
|
a0c6b123de | ||
|
|
5832a2ebfd | ||
|
|
a9d738d419 | ||
|
|
952484cd3d |
No files matched your search
@@ -14,7 +14,7 @@ jobs:
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go test -race -v ./... -timeout 30s
|
||||
run: go test -race -v ./...
|
||||
lint-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -56,13 +56,4 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run i18n:check
|
||||
check-docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- run: make generate-docs
|
||||
- run: git diff --exit-code
|
||||
run: npm i && npm run i18n:check
|
||||
-119
@@ -2,109 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## v1.3.4
|
||||
|
||||
**New Features**:
|
||||
- Epub placementg url anchors to bookmark a specific location on the doc.
|
||||
- Add Requirement for Current Password When Changing Account Password (#2112)
|
||||
|
||||
**Notes**:
|
||||
- user scope editing has path picker and filesystem validation
|
||||
|
||||
**BugFixes**:
|
||||
- Swiping media files doesn't respect Playback Queue (#2243)
|
||||
- Audio metadata and album thumbnails doesn't work (#2236)
|
||||
- Search spinner wouldn't show up sometimes while searching
|
||||
- The default landing page source should always be the first source in the sidebar, that wasn't always respected.
|
||||
|
||||
## v1.3.3
|
||||
|
||||
**New Features**:
|
||||
- Copy/paste files and folders (`CTRL+C`/`CTRL+V`) from other apps (win explorer, thunar, finder, etc) to upload them directly (#2197)
|
||||
- Caption font size can be adjusted in video settings
|
||||
- Ability to adjust the startup check method for sql database via `server.startupIntegrityCheck` (#2221)
|
||||
- Status bar for editor and markdown viewer (#2226)
|
||||
|
||||
**Notes**:
|
||||
- `.fbx` 3d model files added to supported previews.
|
||||
- removed upx compression on docker image (#2193)
|
||||
- videos double tap to fast-forward and rewind added.
|
||||
- Adjust Image album swipe behavior (#2068)
|
||||
- swipe animation for next/previous
|
||||
- swipe down on image to close and go to parent folder.
|
||||
- supports videos
|
||||
- chunked uploads will save to a temporary file at the destination and renamed on completion. Better upload pause handling (#2129)
|
||||
- The Icons in the UI were updated! (#2203)
|
||||
- More supported icons in the Icon Picker tool.
|
||||
- More file types have new icons across all the listings (such as `.md`, `.apk`, etc).
|
||||
- `.fbx` 3d model files added to supported previews.
|
||||
- removed upx compression on docker image (#2193)
|
||||
- chunked uploads will save to a temporary file at the destination and renamed on completion. Better upload pause handling (#2129)
|
||||
- deprecated `source.config.CreateUserDir`, now its always `true`. If a user directory doesn't exist it will get created empty.
|
||||
- `CTRL`+`Mouse Wheel` shortcut to change listing size. Also for change font size in editor (#2227)
|
||||
|
||||
**BugFixes**:
|
||||
- Archives don't preserve file metadata (#2063)
|
||||
- Chunked upload setting fails: "0" does not disable chunking as expected (#2202)
|
||||
- Incorrect Chinese labels for Upload controls in Quantum UI (#2191)
|
||||
- Chunked download stops after first chunk, add message explaining (#2074)
|
||||
- Esc to Cancel and Enter to confirm popup (#2079)
|
||||
- Language-tagged subtitle files not detected for videos (#2199)
|
||||
- Unable to use proxy auth since 1.3.0-beta (#2173)
|
||||
- Updating setting on a LDAP user turns the "Login Method" to password. Thus preventing further logins. (#2179)
|
||||
|
||||
## v1.3.2-beta
|
||||
|
||||
**Security**:
|
||||
- Patched Username Enumeration via Authentication Timing Side-Channel GHSA-7789-65hx-f26w
|
||||
|
||||
**New Features**:
|
||||
- Option in settings `userDefaults.preferEditorForMarkdown` to prefer editor first for Markdown files (#2136)
|
||||
- Copy to clipboard button for code blocks in Markdown Viewer (#2160)
|
||||
- Add "Last modified" filter in search dialog (#2157)
|
||||
|
||||
**Notes**:
|
||||
- docs preview for text and pdf has a 2 second timeout. If it hangs for whatever reason, the maximum time would be 2 seconds. (#2105) (#2114)
|
||||
- Downloading multiple files streams the archive creation rather than using cacheDir -- thanks @janakoram (#2125) (#2130)
|
||||
- `server.maxArchiveSizeGB` now defaults to 20 (GB) and only applies to archive/unarchive actions (not downloads).
|
||||
- browser download progress bar will no longer show for archive downloads. this is the main drawback to the streaming approach.
|
||||
- should allow for much higher parallel download support and lower cleanup maintenenance.
|
||||
- [docker] ffmpeg version upgraded to 8.1
|
||||
- remote ip in logs now prefers `X-Forwarded-For` if it exists, then `X-Real-IP`, then lastly the standard RemoteAddr. Useful when running behind a proxy to log the public IP of each request. (#2110)
|
||||
- changed loading spinner style to be more compatible with safari browsers.
|
||||
|
||||
**BugFixes**:
|
||||
- Wrong username in share settings (#2147) (#2148)
|
||||
- [OnlyOffice] Error when saving a file under a user scope #2133
|
||||
- Cannot edit shared file in OnlyOffice #2143
|
||||
- PWA install button disappeared (#2086)
|
||||
- Deleting a root folder was possible #2128
|
||||
- PUT resource api errors if action against a folder (#2153)
|
||||
- LDAP authentication issue if a password caontains @ symbol (#2154)
|
||||
- Share banner seems to be not working for custom urls (#2120)
|
||||
|
||||
## v1.3.1-beta
|
||||
|
||||
**Security**:
|
||||
- Patched Stored XSS in public share page via unsanitized share metadata (text/template misuse) GHSA-r633-fcgp-m532
|
||||
- Patched Incomplete Remediation of CVE-2026-27611: Password-Protected Share Bypass via /public/api/share/info GHSA-525j-95gf-766f
|
||||
|
||||
**New Features**:
|
||||
- More user options for settings (#2072) (#2067):
|
||||
- Option to disable thumbnails `userDefaults.preview.audio` and `userDefaults.preview.models` for Audio and 3D Models.
|
||||
- Option to disable files in the Tree navigation `userDefaults.hideFilesInTree`
|
||||
- Option to disable source files deletion when creating/extracting archives. `userDefaults.deleteAfterArchive`
|
||||
|
||||
**Notes**:
|
||||
- Share icon does not show in share listing or for shares for other users.
|
||||
- File Size Analyzer tool max items increased from 100 to 200.
|
||||
- changed symlink detection logic.
|
||||
|
||||
**BugFixes**:
|
||||
- 405 response code error on Webdav in 1.3.0-beta (#2054)
|
||||
- Motion Preview setting not saving when changed in profile settings.
|
||||
- Context menu on tools not working.
|
||||
|
||||
## v1.3.0-beta
|
||||
|
||||
Note: a potentially breaking change for docker users: \the default user is now "filebrowser" 1000:1000 instead of root.
|
||||
@@ -162,22 +59,6 @@ Note: a potentially breaking change for docker users: \the default user is now "
|
||||
**BugFixes**:
|
||||
- Long folder names get cut off at top navigation bar (#1934)
|
||||
|
||||
## v1.2.2-stable
|
||||
|
||||
**Security**:
|
||||
- Patched Stored XSS in public share page via unsanitized share metadata (text/template misuse)
|
||||
|
||||
**New Features**:
|
||||
- "Divider" option in sidebar links to add a text or divider between links ()
|
||||
- shares offer a "go to source Location" sidebar link and button when editing a share.
|
||||
|
||||
**Notes**:
|
||||
- Share edit/delete permissions are scoped to the user's shares rather than global (#2050)
|
||||
|
||||
**BugFixes**:
|
||||
- fixed the requirement that the database path needed to be set in the config file, now it loads `FILEBROWSER_DATABASE` value by default, fallback to config file property.
|
||||
- Error downloading zipped directory: no such file or directory (users with scope) #2015
|
||||
|
||||
## v1.2.1-stable
|
||||
|
||||
**BugFixes**:
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
## Pinned
|
||||
|
||||
:loudspeaker: [New Announcement](https://github.com/gtsteffaniak/filebrowser/discussions/2048)
|
||||
:loudspeaker: [What's Coming Soon](https://github.com/gtsteffaniak/filebrowser/discussions/1622)
|
||||
|
||||
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
FileBrowser Quantum provides an easy way to access and manage your files from the web. It has a modern responsive interface that has many advanced features to manage users, access, sharing, and file preview and editing.
|
||||
|
||||
This version is called "Quantum" because it packs tons of advanced features into a tiny and easy-to-run file. Unlike the majority of alternative options, FileBrowser Quantum is simple to install and easy to configure.
|
||||
This version is called "Quantum" because it packs tons of advanced features into a tiny easy to run 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.
|
||||
|
||||
@@ -35,20 +35,20 @@ Ready to try it out? See [Getting Started Docs](https://filebrowserquantum.com/e
|
||||
|
||||
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
|
||||
|
||||
1. ✅ Better source configuration - multiple sources, include/exclude rules, and [more](https://filebrowserquantum.com/en/docs/configuration/sources/)
|
||||
2. ✅ Login support for OIDC, LDAP, JWT, password + 2FA, and proxy.
|
||||
1. ✅ Add and configure multiple sources
|
||||
2. ✅ Login support for OIDC, password + 2FA, and proxy.
|
||||
3. ✅ Beautiful, Responsive, and Customizable user interface.
|
||||
4. ✅ Streamlined configuration via `config.yaml` config file.
|
||||
5. ✅ Ultra-efficient Sqlite [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing)
|
||||
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
|
||||
- Better thumbnail support including **office**, **video**, and **album artwork**, and **3D models**.
|
||||
- Better thumbnail support including **office**, **video**, and **album artwork**
|
||||
- Faster and more responsive views with animations.
|
||||
- Folder sizes are displayed and support thumbnails
|
||||
- Folder sizes are displayed and support for thumbnails
|
||||
- Navigating remembers the last scroll position.
|
||||
7. ✅ Highly configurable and [customizable sharing options](https://filebrowserquantum.com/en/docs/shares/options/)
|
||||
7. ✅ Highly configurable and customizable sharing options
|
||||
- share expiration time
|
||||
- users who can access share (including anonymous)
|
||||
- styling and themes
|
||||
@@ -59,10 +59,20 @@ FileBrowser Quantum is a massive fork of the file browser open-source project wi
|
||||
- A helpful Swagger page is available at `/swagger` endpoint for API enabled users.
|
||||
|
||||
Notable features that this fork *does not* have (removed):
|
||||
|
||||
- :construction: jobs are not supported yet.
|
||||
- ❌ shell commands are completely removed and will not be returned.
|
||||
|
||||
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.
|
||||
|
||||
## System Requirements
|
||||
|
||||
> [!WARNING]
|
||||
> Every file and directory in the source gets indexed (by default). This enables powerful features such as instant search, but large source filesystems can increase your system requirements. [See indexing wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.
|
||||
|
||||
- **Memory**: depends on configured source complexity. See [how much RAM does it require?](https://github.com/gtsteffaniak/filebrowser/discussions/787)
|
||||
- **GPU**: Not currently used (planned)
|
||||
|
||||
## The UI
|
||||
|
||||
The UI has a simple three-component navigation system:
|
||||
@@ -94,45 +104,47 @@ Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
webdav support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
webdav support | :warning:* | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Docker image size | 180 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
|
||||
Min. Memory Requirements | 512 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
|
||||
has standalone binary | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
price | free | free | free | free tier | free tier | $99+ |
|
||||
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Advanced Search? | ✅ | ❌ | ✅ | configurable | ✅ | ✅ |
|
||||
Indexed Search? | ✅ | ❌ | ✅ | configurable | ✅ | ✅ |
|
||||
Content-aware search? | ❌ | ❌ | ✅ | configurable | ✅ | ✅ |
|
||||
Custom job support | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Advanced Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
|
||||
Indexed Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
|
||||
Content-aware search? | ❌ | ❌ | ❌ | configurable | ✅ | ✅ |
|
||||
Custom job support | :construction: | ✅ | ❌ | ✅ | ❌ | ✅ |
|
||||
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Single sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Single sign-on support | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
LDAP sign-on support | :construction: | ❌ | ❌ | ✅ | ❌ | ✅ |
|
||||
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Mobile App | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
|
||||
Mobile App | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
|
||||
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
tags support | :construction: | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
tags support | :construction: | ❌ | ❌ | ✅ | ❌ | ✅ |
|
||||
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Event-based notifications | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
Metrics | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
file space quotas | :construction: | ❌ | ✅ | ❌ | ✅ | ✅ |
|
||||
Event-based notifications | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Metrics | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
file space quotas | :construction: | ❌ | ❌ | ❌ | ✅ | ✅ |
|
||||
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
Themes | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Branding support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
activity log | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Comments support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
trash support | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Starred/pinned files | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ |
|
||||
Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
Branding support | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
activity log | :construction: | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
Comments support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
trash support | :construction: | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
Starred/pinned files | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
|
||||
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
Share collections of files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can archive selected files | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can browse archive files | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
Can convert documents | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
Can convert videos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
Can convert photos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
Can archive selected files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can browse archive files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can convert documents | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can convert videos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
Can convert photos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
*Accessing files from FileBrowser via WebDav is a newly introduced and experimental feature.
|
||||
+3
-2
@@ -1,15 +1,16 @@
|
||||
FROM gtstef/ffmpeg:8.1-decode AS ffmpeg
|
||||
FROM gtstef/ffmpeg:8.0.1-decode AS ffmpeg
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add --no-cache gcc musl-dev
|
||||
RUN apk update && apk add --no-cache gcc musl-dev upx
|
||||
ENV CGO_ENABLED=1
|
||||
RUN go build -tags mupdf,musl -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
|
||||
-o filebrowser .
|
||||
RUN upx filebrowser
|
||||
|
||||
FROM node:jod-slim AS nbuild
|
||||
WORKDIR /app
|
||||
|
||||
@@ -14,9 +14,6 @@ COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# generate duplicate 1.1MB files
|
||||
RUN dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1MB.bin bs=1024 count=1126 && dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1mb.bin bs=1024 count=1126
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser set -u testuser1,testuser1
|
||||
|
||||
@@ -3,11 +3,12 @@ ARG VERSION
|
||||
ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add
|
||||
RUN apk update && apk add upx
|
||||
RUN go build -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
|
||||
-o filebrowser .
|
||||
RUN upx filebrowser
|
||||
|
||||
FROM node:jod-slim AS nbuild
|
||||
WORKDIR /app
|
||||
|
||||
@@ -8,10 +8,8 @@ userDefaults:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
audio: true
|
||||
models: true
|
||||
office: true
|
||||
highQuality: true
|
||||
permissions:
|
||||
download: true
|
||||
create: true
|
||||
create: true
|
||||
@@ -26,10 +26,8 @@ userDefaults:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
audio: true
|
||||
office: true
|
||||
models: true
|
||||
highQuality: true
|
||||
permissions:
|
||||
download: true
|
||||
create: true
|
||||
create: true
|
||||
@@ -11,14 +11,14 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/proxy-setup.ts",
|
||||
timeout: 10000,
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/proxy",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 3,
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1, // required for now! todo parallel some tests
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -411,218 +409,3 @@ func TestOverrideFileToDirectory(t *testing.T) {
|
||||
t.Error("Directory 'Test Object' should have been added to Folders")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteFilesRootProtection tests that DeleteFiles refuses to delete
|
||||
// the source root directory itself, preventing catastrophic data loss.
|
||||
// This is a regression test for the safety guard added to prevent accidental
|
||||
// deletion of the entire source directory.
|
||||
func TestDeleteFilesRootProtection(t *testing.T) {
|
||||
// Use a temporary directory for cache
|
||||
tmpDir := t.TempDir()
|
||||
originalCacheDir := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = tmpDir
|
||||
defer func() {
|
||||
settings.Config.Server.CacheDir = originalCacheDir
|
||||
}()
|
||||
|
||||
// Ensure fileutils permissions are set
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
}
|
||||
|
||||
// Initialize the database
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_root_protection", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
// Create a real temporary directory to use as source root
|
||||
realTmpDir := t.TempDir()
|
||||
|
||||
// Initialize the index with a real path
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: "test_root_protection",
|
||||
Path: realTmpDir,
|
||||
}, false, false) // false for mock mode (real path), false for isNewDb
|
||||
|
||||
// Wait for scanner to be ready
|
||||
idx := indexing.GetIndex("test_root_protection")
|
||||
if idx == nil {
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
|
||||
// Wait for scanner to finish
|
||||
for i := 0; i < 50; i++ {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
status := idx.GetScannerStatus()
|
||||
if status["status"] == "ready" || status["status"] == "unavailable" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Attempting to delete the root directory should return an error
|
||||
err := DeleteFiles("test_root_protection", realTmpDir, true)
|
||||
if err == nil {
|
||||
t.Error("DeleteFiles should return an error when trying to delete root directory")
|
||||
}
|
||||
|
||||
// Verify the error message contains expected text
|
||||
expectedErrMsg := "refusing to delete source root directory"
|
||||
if err != nil && !strings.Contains(err.Error(), expectedErrMsg) {
|
||||
t.Errorf("Expected error message to contain '%s', got: %v", expectedErrMsg, err.Error())
|
||||
}
|
||||
|
||||
// Test with trailing slash - should still be blocked
|
||||
err = DeleteFiles("test_root_protection", realTmpDir+"/", true)
|
||||
if err == nil {
|
||||
t.Error("DeleteFiles should return an error for root directory with trailing slash")
|
||||
}
|
||||
|
||||
// Test with cleaned path variations
|
||||
err = DeleteFiles("test_root_protection", realTmpDir+"/.", true)
|
||||
if err == nil {
|
||||
t.Error("DeleteFiles should return an error for root directory with /. suffix")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteFilesSubfolderWithRootName tests that deleting a subfolder
|
||||
// with the same name as the root directory works correctly and doesn't
|
||||
// accidentally delete the root.
|
||||
// Regression test for path handling bug where /srv/srv could be mistaken for /srv.
|
||||
func TestDeleteFilesSubfolderWithRootName(t *testing.T) {
|
||||
for iter := 1; iter <= raceStressIterations; iter++ {
|
||||
iter := iter
|
||||
t.Run(fmt.Sprintf("stress-iter-%d", iter), func(t *testing.T) {
|
||||
runDeleteFilesSubfolderWithRaceStress(t, iter)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
raceStressIterations = 3
|
||||
raceStatusPollInterval = 5 * time.Millisecond
|
||||
waitStatusMaxTries = 100
|
||||
waitStatusSleep = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
|
||||
t.Helper()
|
||||
start := time.Now()
|
||||
t.Logf("iteration %d: start", iter)
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
originalCache := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = cacheDir
|
||||
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
|
||||
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
}
|
||||
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_subfolder_rootname", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
realTmpDir := t.TempDir()
|
||||
subfolderName := filepath.Base(realTmpDir)
|
||||
subfolderPath := filepath.Join(realTmpDir, subfolderName)
|
||||
|
||||
if err := os.Mkdir(subfolderPath, 0755); err != nil {
|
||||
t.Fatalf("Failed to create subfolder: %v", err)
|
||||
}
|
||||
|
||||
testFile := filepath.Join(subfolderPath, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
sourceName := fmt.Sprintf("test_subfolder_rootname-%d", iter)
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: sourceName,
|
||||
Path: realTmpDir,
|
||||
}, false, false)
|
||||
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil {
|
||||
t.Fatalf("Failed to get test index for %s", sourceName)
|
||||
}
|
||||
|
||||
stopPolling := startScannerStatusPoll(t, idx, iter)
|
||||
defer stopPolling()
|
||||
|
||||
waitForScannerReady(t, idx)
|
||||
|
||||
err := DeleteFiles(sourceName, subfolderPath, true)
|
||||
stopPolling()
|
||||
|
||||
t.Logf("iteration %d: DeleteFiles finished in %s (err=%v)", iter, time.Since(start), err)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteFiles should succeed for subfolder with root-like name, got error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(subfolderPath); !os.IsNotExist(err) {
|
||||
t.Fatal("Subfolder should have been deleted")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(realTmpDir); os.IsNotExist(err) {
|
||||
t.Fatal("Root directory should NOT have been deleted")
|
||||
}
|
||||
|
||||
indexing.StopAllScanners()
|
||||
// Give scanners time to finish their cleanup (defer blocks in tryAcquireAndScan)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
indexing.ClearTestIndices()
|
||||
t.Logf("iteration %d: cleaned scanners and indices", iter)
|
||||
}
|
||||
|
||||
func waitForScannerReady(t *testing.T, idx *indexing.Index) {
|
||||
t.Helper()
|
||||
// Give the scanners a moment to initialize, but don't wait for full "ready" status
|
||||
// The initial scan can take longer than the test timeout, but DeleteFiles will work
|
||||
// as long as the scanners are initialized and running
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Verify scanners exist
|
||||
status := idx.GetScannerStatus()
|
||||
if totalScanners, ok := status["totalScanners"].(int); !ok || totalScanners == 0 {
|
||||
t.Fatal("No scanners were created")
|
||||
}
|
||||
t.Log("Scanners initialized, proceeding with delete operation")
|
||||
}
|
||||
|
||||
func startScannerStatusPoll(t *testing.T, idx *indexing.Index, iter int) func() {
|
||||
t.Helper()
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
var once sync.Once
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(raceStatusPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
close(done)
|
||||
return
|
||||
case <-ticker.C:
|
||||
idx.GetScannerStatus()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
close(stop)
|
||||
<-done
|
||||
t.Logf("iteration %d: poll watcher stopped", iter)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -25,39 +25,6 @@ import (
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
|
||||
var CheckPermissionsFunc = checkPermissionsImpl
|
||||
|
||||
// addMetadataToChildren runs concurrent ffmpeg/tag extraction for audio/video children.
|
||||
// Each goroutine is registered on wg. Caller must invoke wg.Wait() after scheduling.
|
||||
func addMetadataToChildren(response *iteminfo.ExtendedFileInfo, opts utils.FileOptions, wg *sync.WaitGroup) {
|
||||
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
|
||||
if sharedFFmpegService == nil {
|
||||
return
|
||||
}
|
||||
for i := range response.Files {
|
||||
fileItem := &response.Files[i]
|
||||
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
|
||||
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
|
||||
if !isItemAudio && !isItemVideo {
|
||||
continue
|
||||
}
|
||||
isAudio := isItemAudio
|
||||
wg.Go(func() {
|
||||
fullPath := response.RealPath + "/" + fileItem.Name
|
||||
if isAudio {
|
||||
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, sharedFFmpegService); err != nil {
|
||||
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
}
|
||||
} else {
|
||||
if err := extractVideoMetadata(context.Background(), fileItem, fullPath, sharedFFmpegService); err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// processDirectoryMetadata extracts metadata for audio/video files in directories
|
||||
func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts utils.FileOptions) {
|
||||
metadataCount := 0
|
||||
@@ -69,8 +36,11 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
|
||||
metadataCount++
|
||||
}
|
||||
}
|
||||
// Process files concurrently using goroutines
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex // Protects processedCount
|
||||
|
||||
// Refresh directory to update folder sizes
|
||||
wg.Go(func() {
|
||||
indexPath := idx.MakeIndexPath(response.Path, true)
|
||||
err := idx.RefreshDirectory(indexPath, false)
|
||||
@@ -79,9 +49,54 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
|
||||
}
|
||||
})
|
||||
|
||||
if opts.Metadata && metadataCount > 0 {
|
||||
addMetadataToChildren(response, opts, &wg)
|
||||
// Set hasMetadata flag if there are files with potential metadata
|
||||
if metadataCount > 0 {
|
||||
response.HasMetadata = true
|
||||
}
|
||||
|
||||
// Only process metadata if explicitly requested
|
||||
if opts.Metadata && metadataCount > 0 {
|
||||
processedCount := 0
|
||||
|
||||
// Create a single shared FFmpegService instance for all files to coordinate concurrency
|
||||
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
|
||||
if sharedFFmpegService != nil {
|
||||
|
||||
for i := range response.Files {
|
||||
fileItem := &response.Files[i]
|
||||
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
|
||||
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
|
||||
|
||||
if isItemAudio || isItemVideo {
|
||||
wg.Go(func() {
|
||||
// Extract metadata for audio files (without album art for performance)
|
||||
if isItemAudio {
|
||||
err := extractAudioMetadata(context.Background(), fileItem, response.RealPath+"/"+fileItem.Name, opts.AlbumArt, opts.Metadata, sharedFFmpegService)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
} else {
|
||||
mu.Lock()
|
||||
processedCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
} else {
|
||||
// Extract duration for video files
|
||||
err := extractVideoMetadata(context.Background(), fileItem, response.RealPath+"/"+fileItem.Name, sharedFFmpegService)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
} else {
|
||||
mu.Lock()
|
||||
processedCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
// Wait for all goroutines to complete
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -101,13 +116,7 @@ func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileIn
|
||||
}
|
||||
}
|
||||
|
||||
// CheckPermissions validates user access and returns the resolved index path and user scope
|
||||
func CheckPermissions(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
|
||||
return CheckPermissionsFunc(opts, access, user)
|
||||
}
|
||||
|
||||
// checkPermissionsImpl is the actual implementation of CheckPermissions
|
||||
func checkPermissionsImpl(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
|
||||
if access == nil {
|
||||
return "", "", fmt.Errorf("access not provided")
|
||||
}
|
||||
@@ -194,15 +203,7 @@ func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.Use
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// FileInfoFasterFunc is a variable that can be mocked in tests
|
||||
var FileInfoFasterFunc = fileInfoFasterImpl
|
||||
|
||||
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return FileInfoFasterFunc(opts, access, user, share)
|
||||
}
|
||||
|
||||
// fileInfoFasterImpl is the actual implementation of FileInfoFaster
|
||||
func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
response := &iteminfo.ExtendedFileInfo{}
|
||||
indexPath, userScope, err := CheckPermissions(opts, access, user)
|
||||
if err != nil {
|
||||
@@ -238,21 +239,22 @@ func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *us
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
if share != nil && user.Permissions.Share && opts.ShowSharedAttr {
|
||||
for i := range response.Files {
|
||||
file := &response.Files[i]
|
||||
file.IsShared = share.IsShared(response.Path+file.Name, idx.Path, user.ID)
|
||||
}
|
||||
for i := range response.Folders {
|
||||
folder := &response.Folders[i]
|
||||
folder.IsShared = share.IsShared(response.Path+folder.Name, idx.Path, user.ID)
|
||||
}
|
||||
response.IsShared = share.IsShared(response.Path, idx.Path, user.ID)
|
||||
}
|
||||
|
||||
defer finalizeResponse(response, info, response.RealPath, user, userScope)
|
||||
if opts.SkipExtendedAttrs {
|
||||
return response, nil
|
||||
}
|
||||
if share != nil && user.Permissions.Share {
|
||||
for i := range response.Files {
|
||||
file := &response.Files[i]
|
||||
file.IsShared = share.IsShared(response.Path+file.Name, idx.Path)
|
||||
}
|
||||
for i := range response.Folders {
|
||||
folder := &response.Folders[i]
|
||||
folder.IsShared = share.IsShared(response.Path+folder.Name, idx.Path)
|
||||
}
|
||||
response.IsShared = share.IsShared(response.Path, idx.Path)
|
||||
}
|
||||
// Process directory metadata if requested
|
||||
if info.Type == "directory" {
|
||||
processDirectoryMetadata(response, idx, opts)
|
||||
@@ -272,13 +274,9 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
if isFolder {
|
||||
return
|
||||
}
|
||||
if isVideo && !opts.Metadata {
|
||||
return
|
||||
}
|
||||
if isAudio && !(opts.Metadata || opts.AlbumArt) {
|
||||
return
|
||||
}
|
||||
|
||||
if isVideo {
|
||||
// Extract duration for video
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
@@ -289,6 +287,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
info.Metadata = extItem.Metadata
|
||||
}
|
||||
|
||||
// Handle subtitles if requested
|
||||
parentPath := filepath.Dir(info.Path)
|
||||
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
|
||||
if exists {
|
||||
@@ -298,12 +297,12 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
|
||||
info.Subtitles = append(info.Subtitles, subtitles...)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Audio: run tag/album-art extraction when full metadata or preview (AlbumArt) is requested.
|
||||
// Preview uses AlbumArt without Metadata so we do not load arbitrary files as text content.
|
||||
if isAudio {
|
||||
// Create an ExtendedItemInfo to hold the metadata
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
@@ -311,7 +310,9 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
|
||||
} else {
|
||||
// Copy metadata to ExtendedFileInfo
|
||||
info.Metadata = extItem.Metadata
|
||||
info.HasMetadata = true
|
||||
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
|
||||
}
|
||||
return
|
||||
@@ -440,11 +441,6 @@ func DeleteFiles(source, absPath string, isDir bool) error {
|
||||
if index == nil {
|
||||
return fmt.Errorf("could not get index: %v ", source)
|
||||
}
|
||||
// Safety guard: never allow deletion of the source root directory itself
|
||||
cleanAbs := filepath.Clean(absPath)
|
||||
if cleanAbs == filepath.Clean(index.Path) {
|
||||
return fmt.Errorf("refusing to delete source root directory: %s", absPath)
|
||||
}
|
||||
|
||||
if !index.Config.DisableIndexing {
|
||||
indexPath := index.MakeIndexPath(absPath, isDir)
|
||||
|
||||
@@ -108,6 +108,7 @@ var types = map[string]string{
|
||||
".chrt": "application/vnd.kde.kchart",
|
||||
".class": "application/java",
|
||||
".com": ContentTextHeaderValue,
|
||||
".conf": ContentTextHeaderValue,
|
||||
".cpio": "application/x-cpio",
|
||||
".cpp": "text/x-c",
|
||||
".cpt": "application/mac-compactpro",
|
||||
@@ -148,11 +149,7 @@ var types = map[string]string{
|
||||
".es": "application/x-esrehber",
|
||||
".etx": "text/x-setext",
|
||||
".evy": "application/envoy",
|
||||
".exe": "application/x-msdownload",
|
||||
".dll": "application/x-msdownload",
|
||||
".bat": "application/x-msdos-program",
|
||||
".cmd": "application/x-msdos-program",
|
||||
".msi": "application/x-msdownload",
|
||||
".exe": "application/octet-stream",
|
||||
".f77": "text/x-fortran",
|
||||
".f90": "text/x-fortran",
|
||||
".f": "text/x-fortran",
|
||||
@@ -228,6 +225,7 @@ var types = map[string]string{
|
||||
".js": ContentJavascriptHeaderValue,
|
||||
".mjs": ContentJavascriptHeaderValue,
|
||||
".json": ContentJSONHeaderValue,
|
||||
".vue": ContentJavascriptHeaderValue,
|
||||
".jut": "image/jutvision",
|
||||
".kar": "audio/midi",
|
||||
".karbon": "application/vnd.kde.karbon",
|
||||
@@ -245,8 +243,8 @@ var types = map[string]string{
|
||||
".la": "audio/nspaudio",
|
||||
".lam": "audio/x-liveaudio",
|
||||
".latex": "application/x-latex",
|
||||
".lha": "application/x-lzh-compressed",
|
||||
".lhx": "application/x-lzh-compressed",
|
||||
".lha": "application/lha",
|
||||
".lhx": "application/octet-stream",
|
||||
".list": ContentTextHeaderValue,
|
||||
".lma": "audio/nspaudio",
|
||||
".log": ContentTextHeaderValue,
|
||||
@@ -259,6 +257,7 @@ var types = map[string]string{
|
||||
".m1v": "video/mpeg",
|
||||
".m2a": "audio/mpeg",
|
||||
".m2v": "video/mpeg",
|
||||
".m3u": "audio/x-mpegurl",
|
||||
".m": "text/x-m",
|
||||
".man": "application/x-troff-man",
|
||||
".manifest": "text/cache-manifest",
|
||||
@@ -308,6 +307,7 @@ var types = map[string]string{
|
||||
".ncm": "application/vndnokiaconfiguration-message",
|
||||
".nif": "image/x-niff",
|
||||
".niff": "image/x-niff",
|
||||
".nix": "application/x-mix-transfer",
|
||||
".nsc": "application/x-conference",
|
||||
".nvd": "application/x-navidoc",
|
||||
".o": "application/octet-stream",
|
||||
@@ -343,14 +343,15 @@ var types = map[string]string{
|
||||
".p7m": "application/pkcs7-mime",
|
||||
".p7r": "application/x-pkcs7-certreqresp",
|
||||
".p7s": "application/pkcs7-signature",
|
||||
".p": "text/x-pascal",
|
||||
".part": "application/pro_eng",
|
||||
".pas": "text/pascal",
|
||||
".pbm": "image/x-portable-bitmap",
|
||||
".pcl": "application/vndhp-pcl",
|
||||
".pct": "image/x-pict",
|
||||
".pcx": "image/x-pcx",
|
||||
".pdb": "chemical/x-pdb",
|
||||
".pdf": "application/pdf",
|
||||
".pdfa": "application/pdf",
|
||||
".pfunk": "audio/make",
|
||||
".pgm": "image/x-portable-graymap",
|
||||
".pic": "image/pict",
|
||||
@@ -374,7 +375,7 @@ var types = map[string]string{
|
||||
".pre": "application/x-freelance",
|
||||
".prt": "application/pro_eng",
|
||||
".ps": "application/postscript",
|
||||
".psd": "image/vnd.adobe.photoshop",
|
||||
".psd": "application/octet-stream",
|
||||
".pvu": "paleovu/x-pv",
|
||||
".pwz": "application/vndms-powerpoint",
|
||||
".py": "text/x-scriptphyton",
|
||||
@@ -442,11 +443,9 @@ var types = map[string]string{
|
||||
".sprite": "application/x-sprite",
|
||||
".spx": "audio/ogg",
|
||||
".src": "application/x-wais-source",
|
||||
".srt": "text/subtitle-srt",
|
||||
".ass": "text/subtitle-ass",
|
||||
".vtt": "text/subtitle-vtt",
|
||||
".ssa": "text/subtitle-ssa",
|
||||
".srt": "text/plain",
|
||||
".sbv": "text/plain",
|
||||
".ssa": "text/plain",
|
||||
".ssi": "text/x-server-parsed-html",
|
||||
".ssm": "application/streamingmedia",
|
||||
".sst": "application/vndms-pkicertstore",
|
||||
@@ -598,105 +597,11 @@ var types = map[string]string{
|
||||
".ogx": "application/ogg",
|
||||
".m4v": "video/mp4",
|
||||
".mkv": "video/x-matroska",
|
||||
".webm": "video/webm",
|
||||
".weba": "audio/webm",
|
||||
".caf": "audio/x-caf",
|
||||
".m3u": "text/x-mpegurl", // .m3u and .m3u8 are playlist files - They contain paths, we set it as text to allow edition.
|
||||
".m3u8": "text/x-mpegURL",
|
||||
".pls": "text/x-scpls",
|
||||
".xspf": "application/xspf+xml",
|
||||
".m3u8": "application/x-mpegURL",
|
||||
".mpd": "application/dash+xml",
|
||||
".webp": "image/webp",
|
||||
".epub": "application/epub+zip",
|
||||
".db": "application/x-db",
|
||||
".db2": "application/x-db",
|
||||
".db3": "application/x-db",
|
||||
".sql": "application/sql",
|
||||
".sqlite": "application/vnd.sqlite3",
|
||||
".sqlite3": "application/vnd.sqlite3",
|
||||
".yaml": "application/yaml",
|
||||
".app": "application/x-application",
|
||||
".geojson": "application/geo+json",
|
||||
".sketch": "application/x-sketch",
|
||||
".bak": "application/backup",
|
||||
".backup": "application/backup",
|
||||
".xfc": "application/x-xcf",
|
||||
".vue": "text/vue",
|
||||
".conf": "text/config-file",
|
||||
".ini": "text/ini",
|
||||
".ps1": "application/x-powershell",
|
||||
".ps2": "application/x-powershell",
|
||||
".ps3": "application/x-powershell",
|
||||
".dart": "application/vnd.dart",
|
||||
".flutter": "text/flutter",
|
||||
".tsx": "text/x-typescript",
|
||||
".ts": "text/x-typescript",
|
||||
".php": "application/x-php",
|
||||
".php2": "application/x-php",
|
||||
".php3": "application/x-php",
|
||||
".phtml": "application/x-php",
|
||||
".p": "text/x-pascal",
|
||||
".pas": "text/x-pascal",
|
||||
".zig": "text/x-zig",
|
||||
".ex": "text/x-elixir",
|
||||
".exs": "text/x-elixir",
|
||||
".json5": "application/json5",
|
||||
".pmx": "model/x-pmx",
|
||||
".nix": "text/nix-lang",
|
||||
".cjs": "text/javascript",
|
||||
".torrent": "application/x-bittorrent",
|
||||
".tmp": "text/tmp",
|
||||
".temp": "text/tmp",
|
||||
// the ones below are some from: https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types
|
||||
".adp": "audio/adpcm",
|
||||
".mp4a": "audio/mp4",
|
||||
".mp2a": "audio/mpeg",
|
||||
".m3a": "audio/mpeg",
|
||||
".sil": "audio/silk",
|
||||
".uva": "audio/vnd.dece.audio",
|
||||
".uvva": "audio/vnd.dece.audio",
|
||||
".eol": "audio/vnd.digital-winds",
|
||||
".dra": "audio/vnd.dra",
|
||||
".dts": "audio/vnd.dts",
|
||||
".dtshd": "audio/vnd.dts.hd",
|
||||
".lvp": "audio/vnd.lucent.voice",
|
||||
".pya": "audio/vnd.ms-playready.media.pya",
|
||||
".rip": "audio/vnd.rip",
|
||||
".mka": "audio/x-matroska",
|
||||
".h261": "video/h261",
|
||||
".h263": "video/h263",
|
||||
".h264": "video/h264",
|
||||
".jpgv": "video/jpeg",
|
||||
".jpm": "video/jpm",
|
||||
".jpgm": "video/jpm",
|
||||
".mj2": "video/mj2",
|
||||
".mjp2": "video/mj2",
|
||||
".m2t": "video/mp2t",
|
||||
".m2ts": "video/mp2t",
|
||||
".mts": "video/mp2t",
|
||||
".mp4v": "video/mp4",
|
||||
".mpg4": "video/mp4",
|
||||
".uvh": "video/vnd.dece.hd",
|
||||
".uvvh": "video/vnd.dece.hd",
|
||||
".uvm": "video/vnd.dece.mobile",
|
||||
".uvvm": "video/vnd.dece.mobile",
|
||||
".uvp": "video/vnd.dece.pd",
|
||||
".uvvp": "video/vnd.dece.pd",
|
||||
".uvs": "video/vnd.dece.sd",
|
||||
".uvvs": "video/vnd.dece.sd",
|
||||
".uvv": "video/vnd.dece.video",
|
||||
".uvvv": "video/vnd.dece.video",
|
||||
".dvb": "video/vnd.dvb.file",
|
||||
".fvt": "video/vnd.fvt",
|
||||
".mxu": "video/vnd.mpegurl",
|
||||
".pyv": "video/vnd.ms-playready.media.pyv",
|
||||
".uvu": "video/vnd.uvvu.mp4",
|
||||
".uvvu": "video/vnd.uvvu.mp4",
|
||||
".f4v": "video/x-f4v",
|
||||
".mk3d": "video/x-matroska",
|
||||
".mks": "video/x-matroska",
|
||||
".vob": "video/x-ms-vob",
|
||||
".smv": "video/x-smv",
|
||||
}
|
||||
|
||||
//nolint:gochecknoinits
|
||||
|
||||
@@ -19,7 +19,7 @@ func MakeUserDir(fullPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func MakeUserDirs(u *users.User, createDir bool) error {
|
||||
func MakeUserDirs(u *users.User, disableScopeChange bool) error {
|
||||
cleanedUserName := users.CleanUsername(u.Username)
|
||||
if cleanedUserName == "" || cleanedUserName == "-" || cleanedUserName == "." {
|
||||
return fmt.Errorf("MakeUserDirs: invalid user for home dir creation: [%s]", u.Username)
|
||||
@@ -27,31 +27,48 @@ func MakeUserDirs(u *users.User, createDir bool) error {
|
||||
for i, scope := range u.Scopes {
|
||||
source, ok := settings.Config.Server.SourceMap[scope.Name]
|
||||
if !ok {
|
||||
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
logger.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
continue
|
||||
}
|
||||
fullPath := filepath.Join(source.Path, scope.Scope)
|
||||
parentDir := filepath.Dir(fullPath)
|
||||
if createDir {
|
||||
if source.Config.CreateUserDir && filepath.Base(scope.Scope) != cleanedUserName {
|
||||
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
|
||||
fullPath = filepath.Join(fullPath, cleanedUserName)
|
||||
}
|
||||
// create directory and append user name
|
||||
if filepath.Base(scope.Scope) != cleanedUserName && source.Config.CreateUserDir && !disableScopeChange {
|
||||
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
|
||||
parentDir := filepath.Join(source.Path, scope.Scope)
|
||||
// If parent directory doesn't exist and createUserDir is enabled, create it
|
||||
if !Exists(parentDir) {
|
||||
if err := MakeUserDir(parentDir); err != nil {
|
||||
logger.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Use JoinPathAsUnix to ensure scope remains in Unix format (forward slashes)
|
||||
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
|
||||
err := MakeUserDir(fullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
|
||||
logger.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
|
||||
// Continue to next scope even if this one failed
|
||||
}
|
||||
} else if filepath.Base(scope.Scope) == cleanedUserName && source.Config.CreateUserDir {
|
||||
// create directory exactly as specified
|
||||
fullPath := filepath.Join(source.Path, scope.Scope)
|
||||
parentDir := filepath.Dir(fullPath)
|
||||
if !Exists(parentDir) {
|
||||
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
|
||||
continue
|
||||
}
|
||||
err := MakeUserDir(fullPath)
|
||||
if err != nil {
|
||||
logger.Errorf("create user: failed to create user home dir: %s", err)
|
||||
// Continue to next scope even if this one failed
|
||||
}
|
||||
} else {
|
||||
if !Exists(fullPath) {
|
||||
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", fullPath)
|
||||
// just assigning scope to path provided, so just check that it exists
|
||||
path := filepath.Join(source.Path, scope.Scope)
|
||||
if !Exists(path) {
|
||||
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
u.Scopes[i] = scope
|
||||
}
|
||||
return nil
|
||||
|
||||
+5
-12
@@ -9,7 +9,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
@@ -43,21 +42,15 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
|
||||
}
|
||||
}
|
||||
|
||||
user, getErr := userStore.Get(username)
|
||||
var passwordHash string
|
||||
if getErr != nil {
|
||||
passwordHash = utils.InvalidPasswordHash
|
||||
} else {
|
||||
passwordHash = user.Password
|
||||
user, err := userStore.Get(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get user from store: %v", err)
|
||||
}
|
||||
// always run checkPwd to prevent timing attacks
|
||||
err = utils.CheckPwd(password, passwordHash)
|
||||
err = users.CheckPwd(password, user.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if getErr != nil {
|
||||
return nil, fmt.Errorf("unable to get user from store: %v", err)
|
||||
}
|
||||
|
||||
// check for OTP for password
|
||||
if user.TOTPSecret != "" {
|
||||
if totpCode == "" {
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
// Package auth_test contains timing attack tests for authentication.
|
||||
//
|
||||
// These tests verify that the JSON authentication implementation is protected
|
||||
// against timing attacks that could leak information about valid usernames.
|
||||
//
|
||||
// Timing Attack Background:
|
||||
// A timing attack is a side-channel attack where an attacker measures how long
|
||||
// authentication operations take to determine if a username exists in the system.
|
||||
// If authentication fails faster for non-existent users than for valid users
|
||||
// with wrong passwords, an attacker can enumerate valid usernames.
|
||||
//
|
||||
// Protection Mechanism:
|
||||
// The json.go Auth() implementation protects against timing attacks by:
|
||||
// 1. Always running the bcrypt password comparison, even for non-existent users
|
||||
// 2. Using utils.InvalidPasswordHash (a pre-computed bcrypt hash) when the user doesn't exist
|
||||
// 3. Ensuring both valid and invalid user authentication paths take similar time
|
||||
//
|
||||
// Test Coverage:
|
||||
// - TestJSONAuth_NoTimingAttack: Statistical analysis of timing differences
|
||||
// - TestJSONAuth_InvalidPasswordHashInitialized: Verifies protection is in place
|
||||
// - TestJSONAuth_ValidUserVsInvalidUserDirectComparison: Direct comparison test
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// mockUserBackend is a simple in-memory user storage for testing.
|
||||
type mockUserBackend struct {
|
||||
usersByUsername map[string]*users.User
|
||||
usersByID map[uint]*users.User
|
||||
}
|
||||
|
||||
func newMockUserBackend() *mockUserBackend {
|
||||
return &mockUserBackend{
|
||||
usersByUsername: make(map[string]*users.User),
|
||||
usersByID: make(map[uint]*users.User),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) GetBy(id interface{}) (*users.User, error) {
|
||||
switch v := id.(type) {
|
||||
case string:
|
||||
if user, ok := m.usersByUsername[v]; ok {
|
||||
return user, nil
|
||||
}
|
||||
return nil, fmt.Errorf("user not found: %s", v)
|
||||
case uint:
|
||||
if user, ok := m.usersByID[v]; ok {
|
||||
return user, nil
|
||||
}
|
||||
return nil, fmt.Errorf("user not found: %d", v)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid id type")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) Gets() ([]*users.User, error) {
|
||||
result := make([]*users.User, 0, len(m.usersByID))
|
||||
for _, user := range m.usersByID {
|
||||
result = append(result, user)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) Save(u *users.User, changePass bool, disableScopeChange bool) error {
|
||||
m.usersByUsername[u.Username] = u
|
||||
m.usersByID[u.ID] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) Update(u *users.User, adminActor bool, fields ...string) error {
|
||||
m.usersByUsername[u.Username] = u
|
||||
m.usersByID[u.ID] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) DeleteByID(id uint) error {
|
||||
if user, ok := m.usersByID[id]; ok {
|
||||
delete(m.usersByUsername, user.Username)
|
||||
delete(m.usersByID, id)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("user not found: %d", id)
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) DeleteByUsername(username string) error {
|
||||
if user, ok := m.usersByUsername[username]; ok {
|
||||
delete(m.usersByID, user.ID)
|
||||
delete(m.usersByUsername, username)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("user not found: %s", username)
|
||||
}
|
||||
|
||||
// setupTestUsers creates a user storage with 2 valid test users
|
||||
func setupTestUsers(t *testing.T) *users.Storage {
|
||||
t.Helper()
|
||||
|
||||
// Reduce bcrypt cost for faster tests (default is 10, min is 4)
|
||||
// This significantly speeds up password hashing during tests
|
||||
originalCost := utils.BcryptCost
|
||||
utils.BcryptCost = 4 // Minimum bcrypt cost for testing
|
||||
t.Cleanup(func() {
|
||||
utils.BcryptCost = originalCost // Restore original cost after test
|
||||
})
|
||||
|
||||
// Initialize InvalidPasswordHash to prevent timing attacks
|
||||
// This simulates the application startup initialization
|
||||
err := utils.SetInvalidPasswordHash()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set invalid password hash: %v", err)
|
||||
}
|
||||
|
||||
backend := newMockUserBackend()
|
||||
storage := users.NewStorage(backend)
|
||||
|
||||
// Create two valid users with hashed passwords
|
||||
password1Hash, err := utils.HashPwd("password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
password2Hash, err := utils.HashPwd("securepass456")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
user1 := &users.User{
|
||||
ID: 1,
|
||||
Username: "admin",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: password1Hash,
|
||||
},
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
}
|
||||
|
||||
user2 := &users.User{
|
||||
ID: 2,
|
||||
Username: "testuser",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: password2Hash,
|
||||
},
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
}
|
||||
|
||||
err = storage.Save(user1, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save user1: %v", err)
|
||||
}
|
||||
|
||||
err = storage.Save(user2, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save user2: %v", err)
|
||||
}
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
// measureAuthTime measures the time taken to authenticate with given credentials
|
||||
func measureAuthTime(t *testing.T, auther auth.JSONAuth, userStore *users.Storage, username, password string) time.Duration {
|
||||
t.Helper()
|
||||
|
||||
// Create a mock HTTP request
|
||||
req, err := http.NewRequest("POST", "/api/auth/login?username="+url.QueryEscape(username), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Password", url.QueryEscape(password))
|
||||
|
||||
start := time.Now()
|
||||
_, _ = auther.Auth(req, userStore)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
return elapsed
|
||||
}
|
||||
|
||||
// TestJSONAuth_NoTimingAttack tests that authentication timing does not leak user existence
|
||||
func TestJSONAuth_NoTimingAttack(t *testing.T) {
|
||||
storage := setupTestUsers(t)
|
||||
auther := auth.JSONAuth{}
|
||||
|
||||
// Test cases: mix of valid and invalid usernames
|
||||
testCases := []struct {
|
||||
username string
|
||||
isValid bool
|
||||
}{
|
||||
{"admin", true},
|
||||
{"testuser", true},
|
||||
{"nonexistent1", false},
|
||||
{"invaliduser", false},
|
||||
{"notauser", false},
|
||||
{"fakeadmin", false},
|
||||
}
|
||||
|
||||
// Number of samples per test case for statistical analysis
|
||||
samplesPerCase := 5
|
||||
|
||||
// Collect timing measurements
|
||||
measurements := make(map[string][]time.Duration)
|
||||
for _, tc := range testCases {
|
||||
measurements[tc.username] = make([]time.Duration, 0, samplesPerCase)
|
||||
}
|
||||
|
||||
// Run multiple iterations to gather statistical data
|
||||
for i := 0; i < samplesPerCase; i++ {
|
||||
// Randomize order to avoid cache effects
|
||||
for _, tc := range testCases {
|
||||
elapsed := measureAuthTime(t, auther, storage, tc.username, "wrongpassword123")
|
||||
measurements[tc.username] = append(measurements[tc.username], elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate statistics for each test case
|
||||
type stats struct {
|
||||
mean time.Duration
|
||||
stddev time.Duration
|
||||
min time.Duration
|
||||
max time.Duration
|
||||
}
|
||||
|
||||
calculateStats := func(durations []time.Duration) stats {
|
||||
if len(durations) == 0 {
|
||||
return stats{}
|
||||
}
|
||||
|
||||
// Calculate mean
|
||||
var sum time.Duration
|
||||
for _, d := range durations {
|
||||
sum += d
|
||||
}
|
||||
mean := sum / time.Duration(len(durations))
|
||||
|
||||
// Calculate standard deviation
|
||||
var variance float64
|
||||
for _, d := range durations {
|
||||
diff := float64(d - mean)
|
||||
variance += diff * diff
|
||||
}
|
||||
variance /= float64(len(durations))
|
||||
stddev := time.Duration(math.Sqrt(variance))
|
||||
|
||||
// Find min and max
|
||||
min := durations[0]
|
||||
max := durations[0]
|
||||
for _, d := range durations {
|
||||
if d < min {
|
||||
min = d
|
||||
}
|
||||
if d > max {
|
||||
max = d
|
||||
}
|
||||
}
|
||||
|
||||
return stats{mean: mean, stddev: stddev, min: min, max: max}
|
||||
}
|
||||
|
||||
// Analyze results
|
||||
validUserStats := make([]stats, 0)
|
||||
invalidUserStats := make([]stats, 0)
|
||||
|
||||
t.Log("\n=== Timing Analysis Results ===")
|
||||
for _, tc := range testCases {
|
||||
s := calculateStats(measurements[tc.username])
|
||||
t.Logf("%s (valid=%v): mean=%.4fms, stddev=%.4fms, min=%.4fms, max=%.4fms",
|
||||
tc.username, tc.isValid,
|
||||
float64(s.mean.Microseconds())/1000.0,
|
||||
float64(s.stddev.Microseconds())/1000.0,
|
||||
float64(s.min.Microseconds())/1000.0,
|
||||
float64(s.max.Microseconds())/1000.0)
|
||||
|
||||
if tc.isValid {
|
||||
validUserStats = append(validUserStats, s)
|
||||
} else {
|
||||
invalidUserStats = append(invalidUserStats, s)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate overall statistics for valid vs invalid users
|
||||
var validMeanSum, invalidMeanSum time.Duration
|
||||
for _, s := range validUserStats {
|
||||
validMeanSum += s.mean
|
||||
}
|
||||
for _, s := range invalidUserStats {
|
||||
invalidMeanSum += s.mean
|
||||
}
|
||||
|
||||
validAvgMean := validMeanSum / time.Duration(len(validUserStats))
|
||||
invalidAvgMean := invalidMeanSum / time.Duration(len(invalidUserStats))
|
||||
|
||||
t.Logf("\n=== Summary ===")
|
||||
t.Logf("Valid users average: %.4fms", float64(validAvgMean.Microseconds())/1000.0)
|
||||
t.Logf("Invalid users average: %.4fms", float64(invalidAvgMean.Microseconds())/1000.0)
|
||||
|
||||
// Calculate the percentage difference
|
||||
diff := float64(validAvgMean - invalidAvgMean)
|
||||
percentDiff := math.Abs(diff) / float64(validAvgMean) * 100.0
|
||||
t.Logf("Difference: %.4fms (%.2f%%)", diff/1e6, percentDiff)
|
||||
|
||||
// Assert: The timing difference should be less than 20%
|
||||
// Bcrypt's constant-time comparison should make both cases take similar time
|
||||
// We allow 20% threshold to account for system noise and variability
|
||||
threshold := 20.0 // percent
|
||||
if percentDiff > threshold {
|
||||
t.Errorf("TIMING ATTACK VULNERABILITY DETECTED: Valid vs invalid user timing differs by %.2f%% (threshold: %.2f%%)",
|
||||
percentDiff, threshold)
|
||||
t.Errorf("This difference could allow an attacker to enumerate valid usernames!")
|
||||
} else {
|
||||
t.Logf("✓ No timing attack vulnerability detected (difference: %.2f%% < threshold: %.2f%%)",
|
||||
percentDiff, threshold)
|
||||
}
|
||||
|
||||
// Additional check: Ensure no individual case is an obvious outlier
|
||||
// Check if any single user's timing is significantly faster than bcrypt operations
|
||||
allStats := append(validUserStats, invalidUserStats...)
|
||||
var allMeansSum time.Duration
|
||||
for _, s := range allStats {
|
||||
allMeansSum += s.mean
|
||||
}
|
||||
overallMean := allMeansSum / time.Duration(len(allStats))
|
||||
|
||||
for _, tc := range testCases {
|
||||
s := calculateStats(measurements[tc.username])
|
||||
deviation := math.Abs(float64(s.mean-overallMean)) / float64(overallMean) * 100.0
|
||||
|
||||
// If any user is more than 30% faster/slower than average, it's suspicious
|
||||
if deviation > 30.0 {
|
||||
t.Errorf("OUTLIER DETECTED: %s deviates by %.2f%% from overall mean (%.4fms vs %.4fms)",
|
||||
tc.username, deviation,
|
||||
float64(s.mean.Microseconds())/1000.0,
|
||||
float64(overallMean.Microseconds())/1000.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJSONAuth_InvalidPasswordHashInitialized verifies that InvalidPasswordHash is properly set
|
||||
func TestJSONAuth_InvalidPasswordHashInitialized(t *testing.T) {
|
||||
// This test ensures that the InvalidPasswordHash is initialized
|
||||
// If it's empty, timing attacks would be possible because invalid users
|
||||
// would skip bcrypt comparison entirely
|
||||
|
||||
// Use lower bcrypt cost for faster testing
|
||||
originalCost := utils.BcryptCost
|
||||
utils.BcryptCost = 4
|
||||
t.Cleanup(func() {
|
||||
utils.BcryptCost = originalCost
|
||||
})
|
||||
|
||||
err := utils.SetInvalidPasswordHash()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set invalid password hash: %v", err)
|
||||
}
|
||||
|
||||
if utils.InvalidPasswordHash == "" {
|
||||
t.Fatal("InvalidPasswordHash is not initialized - timing attack vulnerability exists!")
|
||||
}
|
||||
|
||||
// Verify that the invalid hash can be used for password comparison
|
||||
// This should fail (wrong password) but take the same time as a real bcrypt comparison
|
||||
err = utils.CheckPwd("anypassword", utils.InvalidPasswordHash)
|
||||
if err == nil {
|
||||
t.Fatal("CheckPwd should fail with InvalidPasswordHash")
|
||||
}
|
||||
|
||||
t.Log("✓ InvalidPasswordHash is properly initialized")
|
||||
}
|
||||
@@ -89,11 +89,6 @@ func StartFilebrowser() {
|
||||
settings.Env.EmbeddedFs = os.IsNotExist(err)
|
||||
}
|
||||
|
||||
err = utils.SetInvalidPasswordHash()
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to set security hash: %v", err)
|
||||
}
|
||||
|
||||
sourceList := []string{}
|
||||
for path, source := range settings.Config.Server.SourceMap {
|
||||
sourceList = append(sourceList, fmt.Sprintf("%v: %v", source.Name, path))
|
||||
|
||||
@@ -156,6 +156,7 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
|
||||
sourceA = settings.Source{Path: "/pathA", Name: "sourceA", Config: settings.SourceConfig{
|
||||
DefaultEnabled: true,
|
||||
DefaultUserScope: "/defaultA",
|
||||
CreateUserDir: true,
|
||||
}}
|
||||
sourceB = settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
|
||||
settings.Config.Server.Sources = []*settings.Source{&sourceA, &sourceB}
|
||||
|
||||
@@ -22,7 +22,6 @@ var (
|
||||
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not yet configured")
|
||||
ErrUnauthorized = errors.New("user unauthorized")
|
||||
ErrNotIndexed = errors.New("directory or item excluded from indexing")
|
||||
ErrNotViewable = errors.New("directory or item is not viewable")
|
||||
ErrWrongLoginMethod = errors.New("user attempted to login with wrong login method")
|
||||
ErrDownloadNotAllowed = errors.New("downloads are not allowed for this share")
|
||||
ErrUploadNotAllowed = errors.New("upload permission not allowed for this share")
|
||||
|
||||
@@ -667,9 +667,14 @@ func setDefaults(generate bool) Settings {
|
||||
database := os.Getenv("FILEBROWSER_DATABASE")
|
||||
if database == "" {
|
||||
database = "database.db"
|
||||
} else {
|
||||
// check if database file exists
|
||||
if _, err := os.Stat(database); os.IsNotExist(err) {
|
||||
database = "database.db"
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(database); os.IsNotExist(err) {
|
||||
logger.Warning("database file could not be found. If this is unexpected, please set the FILEBROWSER_DATABASE environment variable to the correct path.")
|
||||
logger.Warning("database file could not be found. If this is unexpected, the default path is `./database.db`, but it can be configured in the config file under `server.database`.")
|
||||
}
|
||||
s := Settings{
|
||||
Server: Server{
|
||||
@@ -679,14 +684,13 @@ func setDefaults(generate bool) Settings {
|
||||
Database: database,
|
||||
SourceMap: map[string]*Source{},
|
||||
NameToSource: map[string]*Source{},
|
||||
MaxArchiveSizeGB: 50,
|
||||
CacheDir: "tmp",
|
||||
MaxArchiveSizeGB: 20,
|
||||
IndexSqlConfig: IndexSqlConfig{
|
||||
WalMode: false,
|
||||
BatchSize: 1000,
|
||||
CacheSizeMB: 32,
|
||||
DisableReuse: false,
|
||||
StartupIntegrityCheck: IndexStartupIntegrityQuickCheck,
|
||||
WalMode: false,
|
||||
BatchSize: 1000,
|
||||
CacheSizeMB: 32,
|
||||
DisableReuse: false,
|
||||
},
|
||||
Filesystem: Filesystem{
|
||||
CreateFilePermission: "644",
|
||||
@@ -708,19 +712,16 @@ func setDefaults(generate bool) Settings {
|
||||
Name: "FileBrowser Quantum",
|
||||
},
|
||||
UserDefaults: UserDefaults{
|
||||
DeleteAfterArchive: true,
|
||||
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
|
||||
StickySidebar: true,
|
||||
HideFilesInTree: false,
|
||||
PreferEditorForMarkdown: false,
|
||||
LockPassword: false,
|
||||
ShowHidden: false,
|
||||
DarkMode: boolPtr(true),
|
||||
DisableSettings: false,
|
||||
ViewMode: "normal",
|
||||
Locale: "en",
|
||||
GallerySize: 3,
|
||||
ThemeColor: "var(--blue)",
|
||||
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
|
||||
StickySidebar: true,
|
||||
LockPassword: false,
|
||||
ShowHidden: false,
|
||||
DarkMode: boolPtr(true),
|
||||
DisableSettings: false,
|
||||
ViewMode: "normal",
|
||||
Locale: "en",
|
||||
GallerySize: 3,
|
||||
ThemeColor: "var(--blue)",
|
||||
Permissions: UserDefaultsPermissions{
|
||||
Modify: false,
|
||||
Share: false,
|
||||
@@ -731,13 +732,11 @@ func setDefaults(generate bool) Settings {
|
||||
Preview: UserDefaultsPreview{
|
||||
Image: boolPtr(true),
|
||||
Video: boolPtr(true),
|
||||
Audio: boolPtr(true),
|
||||
MotionVideoPreview: boolPtr(true),
|
||||
Office: boolPtr(true),
|
||||
PopUp: boolPtr(true),
|
||||
AutoplayMedia: boolPtr(true),
|
||||
Folder: boolPtr(true),
|
||||
Models: boolPtr(true),
|
||||
},
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 10,
|
||||
@@ -870,7 +869,26 @@ func setConditionals(config *Source) {
|
||||
IgnoreAllZeroSizeFolders: false,
|
||||
IgnoreAllSymlinks: false,
|
||||
IndexingDisabled: false,
|
||||
NoRules: len(rules) == 0,
|
||||
}
|
||||
|
||||
// backwards compatibility
|
||||
if config.Config.Conditionals.Hidden {
|
||||
logger.Warning("source.conditionals.hidden is deprecated, use source.rules instead")
|
||||
resolved.IgnoreAllHidden = true
|
||||
}
|
||||
// Backwards compatibility: if old format fields are set, treat as global rules
|
||||
if config.Config.Conditionals.IgnoreHidden {
|
||||
logger.Warning("source.conditionals.ignoreHidden is deprecated, use source.rules instead")
|
||||
resolved.IgnoreAllHidden = true
|
||||
}
|
||||
if config.Config.DisableIndexing {
|
||||
logger.Warning("source.disableIndexing is deprecated, use source.rules instead")
|
||||
resolved.IndexingDisabled = true
|
||||
}
|
||||
|
||||
if config.Config.Conditionals.ZeroSizeFolders {
|
||||
logger.Warning("source.conditionals.zeroSizeFolders is deprecated, use source.rules instead")
|
||||
resolved.IgnoreAllZeroSizeFolders = true
|
||||
}
|
||||
|
||||
// Process all rules and infer global flags from root-level rules
|
||||
|
||||
@@ -68,8 +68,6 @@ func AdminPerms() users.Permissions {
|
||||
func ApplyUserDefaults(u *users.User) {
|
||||
u.StickySidebar = Config.UserDefaults.StickySidebar
|
||||
u.DisableSettings = Config.UserDefaults.DisableSettings
|
||||
u.HideFilesInTree = Config.UserDefaults.HideFilesInTree
|
||||
u.DeleteAfterArchive = Config.UserDefaults.DeleteAfterArchive
|
||||
|
||||
// Handle DarkMode with default - dereference pointer from config
|
||||
u.DarkMode = boolValueOrDefault(Config.UserDefaults.DarkMode, true)
|
||||
@@ -92,14 +90,12 @@ func ApplyUserDefaults(u *users.User) {
|
||||
u.Preview.DisableHideSidebar = Config.UserDefaults.Preview.DisableHideSidebar
|
||||
u.Preview.Image = boolValueOrDefault(Config.UserDefaults.Preview.Image, true)
|
||||
u.Preview.Video = boolValueOrDefault(Config.UserDefaults.Preview.Video, true)
|
||||
u.Preview.Audio = boolValueOrDefault(Config.UserDefaults.Preview.Audio, true)
|
||||
u.Preview.MotionVideoPreview = boolValueOrDefault(Config.UserDefaults.Preview.MotionVideoPreview, true)
|
||||
u.Preview.Office = boolValueOrDefault(Config.UserDefaults.Preview.Office, true)
|
||||
u.Preview.PopUp = boolValueOrDefault(Config.UserDefaults.Preview.PopUp, true)
|
||||
u.Preview.AutoplayMedia = boolValueOrDefault(Config.UserDefaults.Preview.AutoplayMedia, true)
|
||||
u.Preview.DefaultMediaPlayer = Config.UserDefaults.Preview.DefaultMediaPlayer
|
||||
u.Preview.Folder = boolValueOrDefault(Config.UserDefaults.Preview.Folder, true)
|
||||
u.Preview.Models = boolValueOrDefault(Config.UserDefaults.Preview.Models, true)
|
||||
|
||||
u.ShowHidden = Config.UserDefaults.ShowHidden
|
||||
u.DateFormat = Config.UserDefaults.DateFormat
|
||||
@@ -111,8 +107,7 @@ func ApplyUserDefaults(u *users.User) {
|
||||
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
|
||||
u.FileLoading = Config.UserDefaults.FileLoading
|
||||
u.DisableOfficePreviewExt = Config.UserDefaults.DisableOfficePreviewExt
|
||||
u.PreferEditorForMarkdown = Config.UserDefaults.PreferEditorForMarkdown
|
||||
if len(u.Scopes) == 0 && u.Username != "anonymous" {
|
||||
if len(u.Scopes) == 0 {
|
||||
for _, source := range Config.Server.Sources {
|
||||
if source.Config.DefaultEnabled {
|
||||
u.Scopes = append(u.Scopes, users.SourceScope{
|
||||
|
||||
@@ -62,7 +62,7 @@ type Server struct {
|
||||
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
|
||||
CacheDirCleanup bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
|
||||
Filesystem Filesystem `json:"filesystem"` // filesystem settings
|
||||
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
|
||||
DisableWebDAV bool `json:"disableWebDAV"` // disable webdav support (default: false)
|
||||
@@ -76,22 +76,11 @@ type Filesystem struct {
|
||||
CreateDirectoryPermission string `json:"createDirectoryPermission" validate:"required,file_permission"` // Unix permissions like 755, 2755, 1777 (default: 755)
|
||||
}
|
||||
|
||||
// Index SQL startup integrity modes (IndexSqlConfig.StartupIntegrityCheck).
|
||||
const (
|
||||
// IndexStartupIntegrityQuickCheck runs PRAGMA quick_check (default). Slower on very large DBs but thorough.
|
||||
IndexStartupIntegrityQuickCheck = "quickCheck"
|
||||
// IndexStartupIntegrityProbe verifies sqlite_master and optionally one row read only; fast for large indexes.
|
||||
IndexStartupIntegrityProbe = "probe"
|
||||
// IndexStartupIntegrityOff skips startup checks beyond sql.Open Ping; least safe, fastest boot.
|
||||
IndexStartupIntegrityOff = "off"
|
||||
)
|
||||
|
||||
type IndexSqlConfig struct {
|
||||
BatchSize int `json:"batchSize"` // number of items to batch in a single transaction, typically 500-5000. higher = faster but could use more memory.
|
||||
CacheSizeMB int `json:"cacheSizeMB"` // size of the SQLite cache in MB
|
||||
WalMode bool `json:"walMode"` // enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
|
||||
DisableReuse bool `json:"disableReuse"` // enable to always create a new indexing database on startup.
|
||||
StartupIntegrityCheck string `json:"startupIntegrityCheck,omitempty" validate:"omitempty,oneof=quickCheck probe off"` // the method used to check the integrity of the index database on startup (default: quickCheck)
|
||||
BatchSize int `json:"batchSize"` // number of items to batch in a single transaction, typically 500-5000. higher = faster but could use more memory.
|
||||
CacheSizeMB int `json:"cacheSizeMB"` // size of the SQLite cache in MB
|
||||
WalMode bool `json:"walMode"` // enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
|
||||
DisableReuse bool `json:"disableReuse"` // enable to always create a new indexing database on startup.
|
||||
}
|
||||
|
||||
type Integrations struct {
|
||||
@@ -214,7 +203,7 @@ type SourceConfig struct {
|
||||
Rules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
|
||||
DefaultUserScope string `json:"defaultUserScope"` // defaults to root of index "/" should match folders under path
|
||||
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
|
||||
CreateUserDir bool `json:"createUserDir"` // deprecated: create a user directory for each user under defaultUserScope + username (always true now)
|
||||
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user under defaultUserScope + username
|
||||
UseLogicalSize bool `json:"useLogicalSize"` // calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
|
||||
// hidden but used internally - optimized map lookups for conditional rules
|
||||
ResolvedRules ResolvedRulesConfig `json:"-"`
|
||||
@@ -272,7 +261,6 @@ type ResolvedRulesConfig struct {
|
||||
IgnoreAllZeroSizeFolders bool // Excludes all folders with 0 size
|
||||
IgnoreAllSymlinks bool // Excludes all symbolic links
|
||||
IndexingDisabled bool // Excludes all files and folders from indexing
|
||||
NoRules bool // No rules are configured
|
||||
}
|
||||
|
||||
type Frontend struct {
|
||||
@@ -318,14 +306,12 @@ type UserDefaultsPreview struct {
|
||||
HighQuality *bool `json:"highQuality"` // deprecated: always true in v1.3.0+
|
||||
Image *bool `json:"image"` // show thumbnails for image files
|
||||
Video *bool `json:"video"` // show thumbnails for video files
|
||||
Audio *bool `json:"audio"` // show thumbnails for audio files
|
||||
MotionVideoPreview *bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
|
||||
Office *bool `json:"office"` // show thumbnails for office files
|
||||
PopUp *bool `json:"popup"` // show larger popup preview when hovering over thumbnail
|
||||
AutoplayMedia *bool `json:"autoplayMedia"` // autoplay media files in preview
|
||||
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable the styled feature-rich media player for browser default
|
||||
Folder *bool `json:"folder"` // show thumbnails for folders that have previewable contents
|
||||
Models *bool `json:"models"` // show live thumbnails for 3D models files
|
||||
}
|
||||
|
||||
// UserDefaultsPermissions holds permission settings with pointer types for defaults
|
||||
@@ -348,7 +334,6 @@ type UserDefaults struct {
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
|
||||
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
@@ -368,12 +353,10 @@ type UserDefaults struct {
|
||||
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
|
||||
FileLoading users.FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer.
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var BcryptCost = bcrypt.DefaultCost
|
||||
var InvalidPasswordHash = ""
|
||||
|
||||
// HashPwd hashes a password.
|
||||
func HashPwd(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPwd checks if a password is correct.
|
||||
func CheckPwd(password, hash string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
}
|
||||
|
||||
func SetInvalidPasswordHash() error {
|
||||
passwordHash, err := HashPwd(InsecureRandomIdentifier(16))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
InvalidPasswordHash = passwordHash
|
||||
return nil
|
||||
}
|
||||
|
||||
func HashSHA256(data string) string {
|
||||
bytes := sha256.Sum256([]byte(data))
|
||||
return hex.EncodeToString(bytes[:])
|
||||
}
|
||||
|
||||
func GenerateKey() string {
|
||||
b := make([]byte, 64)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -60,7 +60,6 @@ type FileOptions struct {
|
||||
FollowSymlinks bool // whether to follow symlinks
|
||||
Only string // whether to only get files or folders
|
||||
SkipExtendedAttrs bool // whether to skip extended attributes
|
||||
ShowSharedAttr bool // whether to show share attributes
|
||||
}
|
||||
|
||||
// SanitizeUserPath prevents path traversal attacks by cleaning and validating user input.
|
||||
@@ -80,9 +79,5 @@ func SanitizeUserPath(userPath string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if clean == "." {
|
||||
return "", fmt.Errorf("invalid path: path must standard index path")
|
||||
}
|
||||
|
||||
return clean, nil
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package utils
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
math "math/rand"
|
||||
"os"
|
||||
@@ -20,6 +23,15 @@ func CheckErr(source string, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateKey() string {
|
||||
b := make([]byte, 64)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// CapitalizeFirst returns the input string with the first letter capitalized.
|
||||
func CapitalizeFirst(s string) string {
|
||||
if len(s) == 0 {
|
||||
@@ -80,6 +92,11 @@ func GetParentDirectoryPath(path string) string {
|
||||
return path[:lastSlash]
|
||||
}
|
||||
|
||||
func HashSHA256(data string) string {
|
||||
bytes := sha256.Sum256([]byte(data))
|
||||
return hex.EncodeToString(bytes[:])
|
||||
}
|
||||
|
||||
func GetLastComponent(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
|
||||
@@ -8,4 +8,3 @@ userDefaults:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
audio: true
|
||||
+1
-6
@@ -11,7 +11,6 @@ userDefaults:
|
||||
disableQuickToggles: false # disable the quick toggles in the sidebar
|
||||
disableSearchOptions: false # disable the search options in the search bar
|
||||
stickySidebar: true # keep sidebar open when navigating
|
||||
hideFilesInTree: false # hide files in the sidebar tree navigation, when true, will show only directories.
|
||||
darkMode: true # should dark mode be enabled (defaults to true if not set)
|
||||
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
|
||||
singleClick: false # open directory on single click, also enables middle click to open in new tab
|
||||
@@ -28,14 +27,12 @@ userDefaults:
|
||||
disableHideSidebar: false # disable the hide sidebar preview for previews and editors
|
||||
image: true # show thumbnail preview image for image files (defaults to true if not set)
|
||||
video: true # show thumbnail preview image for video files (defaults to true if not set)
|
||||
audio: true # show thumbnail preview image for audio files (defaults to true if not set)
|
||||
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering (defaults to true if not set)
|
||||
office: true # show thumbnail preview image for office files (defaults to true if not set)
|
||||
popup: true # show larger popup preview when hovering over thumbnail (defaults to true if not set)
|
||||
autoplayMedia: true # autoplay media files in preview (defaults to true if not set)
|
||||
defaultMediaPlayer: false # disable html5 media player and use the default media player
|
||||
folder: true # show thumbnail preview image for folder files (defaults to true if not set)
|
||||
models: true # show live thumbnail preview for 3D Models files (defaults to true if not set)
|
||||
permissions:
|
||||
api: false # allow api access
|
||||
admin: false # allow admin access
|
||||
@@ -47,9 +44,7 @@ userDefaults:
|
||||
download: true # allow downloading files (defaults to true if not set)
|
||||
disableUpdateNotifications: false # disable update notifications banner for admin users
|
||||
deleteWithoutConfirming: false # delete files without confirmation
|
||||
deleteAfterArchive: true # delete source files after successful creation/extraction of archives
|
||||
disableOnlyOfficeExt: ".md .txt .pdf .html .xml" # list of file extensions to disable onlyoffice editor for
|
||||
customTheme: "" # Name of theme to use chosen from custom themes config.
|
||||
showSelectMultiple: false # show select multiple files on desktop
|
||||
debugOffice: false # debug onlyoffice editor
|
||||
preferEditorForMarkdown: false # prefer editor first for markdown files instead of the Markdown Viewer
|
||||
debugOffice: false # debug onlyoffice editor
|
||||
@@ -2,13 +2,10 @@ package share
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// IsSingleFileShare determines if this share is for a single file (not a directory).
|
||||
@@ -147,73 +144,10 @@ func (l *Link) HasReachedUserLimit(username string) bool {
|
||||
return count >= l.DownloadsLimit
|
||||
}
|
||||
|
||||
func (l *Link) GetSourceName() string {
|
||||
func (l *Link) GetSourceName() (string, error) {
|
||||
sourceInfo, ok := settings.Config.Server.SourceMap[l.Source]
|
||||
if !ok {
|
||||
return ""
|
||||
return "", fmt.Errorf("source not found")
|
||||
}
|
||||
return sourceInfo.Name
|
||||
}
|
||||
|
||||
func (l *Link) UserCanEdit(user *users.User) bool {
|
||||
return l.UserID == user.ID || user.Permissions.Admin
|
||||
}
|
||||
|
||||
func (l *Link) SourceURL(user *users.User) string {
|
||||
sourceName := l.GetSourceName()
|
||||
// get user scope path from share
|
||||
userScope, err := user.GetScopeForSourceName(sourceName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(l.Path, userScope) {
|
||||
return ""
|
||||
}
|
||||
scopedPath := strings.TrimPrefix(l.Path, userScope)
|
||||
return "/" + filepath.Join("files", sourceName, scopedPath)
|
||||
}
|
||||
|
||||
func (l *Link) BannerURL() string {
|
||||
_, _, err := l.GetShareImagePartsHelper(true)
|
||||
if err == nil {
|
||||
return fmt.Sprintf("%s%spublic/api/share/image?banner=true&hash=%s", settings.Config.Server.ExternalUrl, settings.Config.Server.BaseURL, l.Hash)
|
||||
}
|
||||
return l.Banner
|
||||
}
|
||||
|
||||
func (l *Link) FaviconURL() string {
|
||||
_, _, err := l.GetShareImagePartsHelper(false)
|
||||
if err == nil {
|
||||
return fmt.Sprintf("%s%spublic/api/share/image?favicon=true&hash=%s", settings.Config.Server.ExternalUrl, settings.Config.Server.BaseURL, l.Hash)
|
||||
}
|
||||
return l.Favicon
|
||||
}
|
||||
|
||||
func (l *Link) GetShareImagePartsHelper(isBanner bool) (string, string, error) {
|
||||
// Get the asset query string from share
|
||||
var assetQueryString string
|
||||
if isBanner {
|
||||
assetQueryString = l.Banner
|
||||
} else {
|
||||
assetQueryString = l.Favicon
|
||||
}
|
||||
|
||||
if assetQueryString == "" {
|
||||
return "", "", fmt.Errorf("asset not configured for this share")
|
||||
}
|
||||
|
||||
// Parse the query string to extract source and path
|
||||
assetParams, err := url.ParseQuery(assetQueryString)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid asset configuration: %v", err)
|
||||
}
|
||||
|
||||
sourceName := assetParams.Get("source")
|
||||
assetPath := assetParams.Get("path")
|
||||
|
||||
if sourceName == "" || assetPath == "" {
|
||||
return "", "", fmt.Errorf("source or path not found in asset configuration")
|
||||
}
|
||||
|
||||
return sourceName, assetPath, nil
|
||||
return sourceInfo.Name, nil
|
||||
}
|
||||
@@ -45,8 +45,6 @@ type CommonShare struct {
|
||||
HasPassword bool `json:"hasPassword,omitempty"`
|
||||
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
|
||||
DisableLoginOption bool `json:"disableLoginOption"` // disable login option in share (true = hide, false = show)
|
||||
SourceURL string `json:"sourceURL,omitempty"`
|
||||
CanEditShare bool `json:"canEditShare,omitempty"`
|
||||
}
|
||||
type CreateBody struct {
|
||||
CommonShare
|
||||
|
||||
@@ -309,13 +309,8 @@ func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
|
||||
}
|
||||
|
||||
// IsShared returns whether the given path and source have any shares in the cache.
|
||||
func (s *Storage) IsShared(path, source string, id uint) bool {
|
||||
func (s *Storage) IsShared(path, source string) bool {
|
||||
links, _ := s.GetBySourcePath(path, source)
|
||||
for _, l := range links {
|
||||
if l.UserID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return len(links) > 0
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package sql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -91,8 +90,7 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
|
||||
// For persistent databases, check integrity on startup
|
||||
// If corrupted, delete and recreate the database
|
||||
if !disableReuse {
|
||||
mode := normalizeStartupIntegrityMode(settings.Config.Server.IndexSqlConfig.StartupIntegrityCheck)
|
||||
if err := idxDB.runStartupIntegrityCheck(mode); err != nil {
|
||||
if err := idxDB.checkIntegrityAndRecreateIfNeeded(); err != nil {
|
||||
// Database was recreated due to corruption
|
||||
logger.Warningf("[DB_INIT] SQLite database was found with issues which could be due to a dirty shutdown, recreating: tmp/index_%s.db", name)
|
||||
isNewDb = true
|
||||
@@ -114,73 +112,10 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
|
||||
return idxDB, isNewDb, nil
|
||||
}
|
||||
|
||||
func normalizeStartupIntegrityMode(m string) string {
|
||||
switch m {
|
||||
case "", settings.IndexStartupIntegrityQuickCheck:
|
||||
return settings.IndexStartupIntegrityQuickCheck
|
||||
case settings.IndexStartupIntegrityProbe:
|
||||
return settings.IndexStartupIntegrityProbe
|
||||
case settings.IndexStartupIntegrityOff:
|
||||
return settings.IndexStartupIntegrityOff
|
||||
default:
|
||||
logger.Warningf("[DB_INIT] unknown indexSqlConfig.startupIntegrityCheck %q, using %s", m, settings.IndexStartupIntegrityQuickCheck)
|
||||
return settings.IndexStartupIntegrityQuickCheck
|
||||
}
|
||||
}
|
||||
|
||||
// runStartupIntegrityCheck runs the configured startup check (quick_check, probe, or off).
|
||||
func (db *IndexDB) runStartupIntegrityCheck(mode string) error {
|
||||
switch mode {
|
||||
case settings.IndexStartupIntegrityOff:
|
||||
logger.Debugf("[DB_INTEGRITY] startup integrity skipped (startupIntegrityCheck=off)")
|
||||
return nil
|
||||
case settings.IndexStartupIntegrityProbe:
|
||||
return db.checkAccessibilityProbe()
|
||||
default:
|
||||
// quickCheck (including normalized default)
|
||||
return db.checkIntegrityAndRecreateIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
// checkAccessibilityProbe verifies the DB file is readable: sqlite_master and optionally one row from index_items.
|
||||
// If index_items is absent (brand-new file), succeeds so CreateIndexTable can create it.
|
||||
func (db *IndexDB) checkAccessibilityProbe() error {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
logger.Debugf("[DB_INTEGRITY] startup probe completed in %v", time.Since(start))
|
||||
}()
|
||||
|
||||
var n int
|
||||
err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'index_items'`).Scan(&n)
|
||||
if err != nil {
|
||||
logger.Warningf("[DB_INTEGRITY] probe: cannot read sqlite_master: %v", err)
|
||||
return db.deleteCorruptedDatabase()
|
||||
}
|
||||
if n == 0 {
|
||||
logger.Debugf("[DB_INTEGRITY] probe: index_items not present yet (new database), skipping row read")
|
||||
return nil
|
||||
}
|
||||
|
||||
var dummy int
|
||||
err = db.QueryRow(`SELECT 1 FROM index_items LIMIT 1`).Scan(&dummy)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warningf("[DB_INTEGRITY] probe: cannot read index_items: %v", err)
|
||||
return db.deleteCorruptedDatabase()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkIntegrityAndRecreateIfNeeded checks database integrity.
|
||||
// If corruption is detected, deletes the database file and returns an error to trigger recreation.
|
||||
// Returns nil if integrity is OK, error if database needs to be recreated.
|
||||
func (db *IndexDB) checkIntegrityAndRecreateIfNeeded() error {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
logger.Debugf("[DB_INTEGRITY] PRAGMA quick_check completed in %v", time.Since(start))
|
||||
}()
|
||||
// Quick integrity check
|
||||
var result string
|
||||
err := db.QueryRow("PRAGMA quick_check").Scan(&result)
|
||||
@@ -1263,6 +1198,23 @@ func (db *IndexDB) GetSizeGroupsForDuplicates(source string, minSize int64, path
|
||||
return sizes, sizeCounts, rows.Err()
|
||||
}
|
||||
|
||||
// GetTotalSize returns the sum of all file sizes in the index for a specific source (excluding directories).
|
||||
func (db *IndexDB) GetTotalSize(source string) (uint64, error) {
|
||||
query := `SELECT COALESCE(SUM(size), 0) FROM index_items WHERE source = ? AND is_dir = 0`
|
||||
|
||||
var totalSize int64
|
||||
err := db.QueryRow(query, source).Scan(&totalSize)
|
||||
if err != nil {
|
||||
// Soft failure: DB is busy or locked, return 0
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return uint64(totalSize), nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// getNextPathPrefix returns the lexicographically next string after the given prefix.
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0o644, 0o755)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestNormalizeStartupIntegrityMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"", settings.IndexStartupIntegrityQuickCheck},
|
||||
{"quickCheck", settings.IndexStartupIntegrityQuickCheck},
|
||||
{"probe", settings.IndexStartupIntegrityProbe},
|
||||
{"off", settings.IndexStartupIntegrityOff},
|
||||
{"not-a-real-mode", settings.IndexStartupIntegrityQuickCheck},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := normalizeStartupIntegrityMode(tt.in); got != tt.want {
|
||||
t.Errorf("normalizeStartupIntegrityMode(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pushTestIndexConfig sets CacheDir and index SQL options for NewIndexDB; returned func restores prior values.
|
||||
func pushTestIndexConfig(t *testing.T, cacheDir string, idx settings.IndexSqlConfig) func() {
|
||||
t.Helper()
|
||||
prevDir := settings.Config.Server.CacheDir
|
||||
prevIdx := settings.Config.Server.IndexSqlConfig
|
||||
settings.Config.Server.CacheDir = cacheDir
|
||||
settings.Config.Server.IndexSqlConfig = idx
|
||||
return func() {
|
||||
settings.Config.Server.CacheDir = prevDir
|
||||
settings.Config.Server.IndexSqlConfig = prevIdx
|
||||
}
|
||||
}
|
||||
|
||||
func testIndexSQLConfig(mode string) settings.IndexSqlConfig {
|
||||
return settings.IndexSqlConfig{
|
||||
WalMode: false,
|
||||
BatchSize: 1000,
|
||||
CacheSizeMB: 32,
|
||||
DisableReuse: false,
|
||||
StartupIntegrityCheck: mode,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIndexDB_StartupProbe_NewFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityProbe))
|
||||
defer pop()
|
||||
|
||||
db, _, err := NewIndexDB("probe_new", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
}
|
||||
|
||||
func TestNewIndexDB_StartupProbe_ReopenWithData(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityProbe))
|
||||
defer pop()
|
||||
|
||||
db, _, err := NewIndexDB("probe_reopen", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info := &iteminfo.FileInfo{
|
||||
Path: "/a.txt",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "a.txt",
|
||||
Size: 12,
|
||||
ModTime: time.Unix(1, 0),
|
||||
Type: "text/plain",
|
||||
Hidden: false,
|
||||
},
|
||||
}
|
||||
if err = db.InsertItem("src1", "/a.txt", info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var db2 *IndexDB
|
||||
db2, _, err = NewIndexDB("probe_reopen", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db2.Close()
|
||||
}
|
||||
|
||||
func TestNewIndexDB_StartupQuickCheckStillWorks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityQuickCheck))
|
||||
defer pop()
|
||||
|
||||
db, _, err := NewIndexDB("quick_default", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
}
|
||||
|
||||
func TestNewIndexDB_StartupOff(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityOff))
|
||||
defer pop()
|
||||
|
||||
db, _, err := NewIndexDB("off_mode", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
}
|
||||
@@ -113,16 +113,10 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, scope := range adjustedScopes {
|
||||
// ensure full path exists
|
||||
if scope.Scope == "" {
|
||||
return fmt.Errorf("scope name is required")
|
||||
}
|
||||
}
|
||||
user.Scopes = adjustedScopes
|
||||
err = files.MakeUserDirs(user, false)
|
||||
err = files.MakeUserDirs(user, true)
|
||||
if err != nil {
|
||||
return err
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
}
|
||||
// converting scopes to map of paths intead of names (names can change)
|
||||
@@ -179,7 +173,7 @@ func (st usersBackend) Save(user *users.User, changePass, disableScopeChange boo
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pass, err := utils.HashPwd(user.Password)
|
||||
pass, err := users.HashPwd(user.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -192,7 +186,7 @@ func (st usersBackend) Save(user *users.User, changePass, disableScopeChange boo
|
||||
return err
|
||||
}
|
||||
user.Scopes = adjustedScopes
|
||||
err = files.MakeUserDirs(user, true)
|
||||
err = files.MakeUserDirs(user, disableScopeChange)
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
@@ -298,7 +292,7 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("password does not meet complexity requirements")
|
||||
}
|
||||
value, err := utils.HashPwd(user.Password)
|
||||
value, err := users.HashPwd(user.Password)
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
|
||||
@@ -6,12 +6,11 @@ import (
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
func init() {
|
||||
utils.BcryptCost = 4 // bcrypt.MinCost for faster tests
|
||||
users.BcryptCost = 4 // bcrypt.MinCost for faster tests
|
||||
|
||||
// Initialize test source configuration
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
var userStore *users.Storage
|
||||
|
||||
func InitializeDb(path string) (*bolt.BoltStore, bool, error) {
|
||||
logger.Debugf("Initializing database: %v", path)
|
||||
exists, err := dbExists(path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -118,7 +117,7 @@ func CreateUser(userInfo users.User, permissions users.Permissions) error {
|
||||
return fmt.Errorf("password is required to create a password login user")
|
||||
}
|
||||
} else {
|
||||
hashpass, err := utils.HashPwd(userInfo.Username)
|
||||
hashpass, err := users.HashPwd(userInfo.Username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var BcryptCost = bcrypt.DefaultCost
|
||||
|
||||
// HashPwd hashes a password.
|
||||
func HashPwd(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPwd checks if a password is correct.
|
||||
func CheckPwd(password, hash string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
}
|
||||
@@ -56,14 +56,12 @@ type Preview struct {
|
||||
DisableHideSidebar bool `json:"disableHideSidebar"` // disable the hide sidebar preview for previews and editors
|
||||
Image bool `json:"image"` // show thumbnail preview image for image files
|
||||
Video bool `json:"video"` // show thumbnail preview image for video files
|
||||
Audio bool `json:"audio"` // show thumbnail preview image for audio files
|
||||
MotionVideoPreview bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
|
||||
Office bool `json:"office"` // show thumbnail preview image for office files
|
||||
PopUp bool `json:"popup"` // show larger popup preview when hovering over thumbnail
|
||||
AutoplayMedia bool `json:"autoplayMedia"` // autoplay media files in preview
|
||||
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable html5 media player and use the default media player
|
||||
Folder bool `json:"folder"` // show thumbnail preview image for folder files
|
||||
Models bool `json:"models"` // show live thumbnails for 3D models files
|
||||
}
|
||||
|
||||
// User describes a user.
|
||||
@@ -121,13 +119,9 @@ type NonAdminEditable struct {
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
|
||||
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
|
||||
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
|
||||
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
|
||||
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
|
||||
+43
-43
@@ -4,35 +4,35 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/dsoprea/go-exif/v3 v3.0.1
|
||||
github.com/gen2brain/go-fitz v1.24.15
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/go-playground/validator/v10 v10.30.2
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-logger v1.0.2
|
||||
github.com/kovidgoyal/imaging v1.8.21
|
||||
github.com/mattn/go-sqlite3 v1.14.42
|
||||
github.com/kovidgoyal/imaging v1.8.19
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/image v0.39.0
|
||||
golang.org/x/mod v0.35.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/time v0.15.0
|
||||
golang.org/x/tools v0.43.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/image v0.36.0
|
||||
golang.org/x/mod v0.33.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/time v0.14.0
|
||||
golang.org/x/tools v0.42.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.48.2
|
||||
modernc.org/sqlite v1.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -55,13 +55,13 @@ require (
|
||||
github.com/Djarvur/go-err113 v0.1.1 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
|
||||
github.com/MirrexOne/unqueryvet v1.5.3 // indirect
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||
github.com/air-verse/air v1.65.0 // indirect
|
||||
github.com/air-verse/air v1.64.5 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
|
||||
github.com/alexkohler/prealloc v1.1.0 // indirect
|
||||
github.com/alexkohler/prealloc v1.0.2 // indirect
|
||||
github.com/alfatraining/structtag v1.0.0 // indirect
|
||||
github.com/alingse/asasalint v0.0.11 // indirect
|
||||
github.com/alingse/nilnesserr v0.2.0 // indirect
|
||||
@@ -101,9 +101,9 @@ require (
|
||||
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/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.0-alpha.4 // indirect
|
||||
github.com/ettle/strcase v0.2.0 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
github.com/firefart/nonamedreturns v1.0.6 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
@@ -113,17 +113,17 @@ require (
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-critic/go-critic v0.14.3 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.5 // indirect
|
||||
github.com/go-openapi/spec v0.22.4 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.4 // indirect
|
||||
github.com/go-openapi/spec v0.22.3 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-toolsmith/astcast v1.1.0 // indirect
|
||||
@@ -139,13 +139,13 @@ require (
|
||||
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
|
||||
github.com/gofrs/flock v0.13.0 // indirect
|
||||
github.com/gohugoio/hugo v0.149.1 // indirect
|
||||
github.com/golang/geo v0.0.0-20260401045841-3289a7742193 // indirect
|
||||
github.com/golang/geo v0.0.0-20260129164528-943061e2742c // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/golangci/asciicheck v0.5.0 // indirect
|
||||
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
|
||||
github.com/golangci/go-printf-func-name v0.1.1 // indirect
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.11.4 // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.10.1 // indirect
|
||||
github.com/golangci/golines v0.15.0 // indirect
|
||||
github.com/golangci/misspell v0.8.0 // indirect
|
||||
github.com/golangci/plugin-module-register v0.1.2 // indirect
|
||||
@@ -171,7 +171,7 @@ require (
|
||||
github.com/julz/importas v0.2.0 // indirect
|
||||
github.com/jupiterrider/ffi v0.6.0 // indirect
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
|
||||
github.com/kisielk/errcheck v1.10.0 // indirect
|
||||
github.com/kisielk/errcheck v1.9.0 // indirect
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
|
||||
github.com/kovidgoyal/go-shm v1.0.0 // indirect
|
||||
@@ -196,10 +196,10 @@ require (
|
||||
github.com/maratori/testpackage v1.1.2 // 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.21 // 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.15.0 // indirect
|
||||
github.com/mgechev/revive v1.14.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
|
||||
github.com/moricho/tparallel v0.3.2 // indirect
|
||||
@@ -228,15 +228,15 @@ require (
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/ryancurrah/gomodguard v1.4.1 // indirect
|
||||
github.com/ryanrolds/sqlclosecheck v0.6.0 // 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.2 // indirect
|
||||
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
|
||||
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 // indirect
|
||||
github.com/securego/gosec/v2 v2.23.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/sivchari/containedctx v1.0.3 // indirect
|
||||
github.com/sonatard/noctx v0.5.1 // indirect
|
||||
github.com/sonatard/noctx v0.4.0 // indirect
|
||||
github.com/sourcegraph/go-diff v0.7.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
@@ -257,7 +257,7 @@ require (
|
||||
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.1 // indirect
|
||||
github.com/uudashr/gocognit v1.2.0 // indirect
|
||||
github.com/uudashr/iface v1.4.1 // indirect
|
||||
github.com/xen0n/gosmopolitan v1.3.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
@@ -273,15 +273,15 @@ require (
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
honnef.co/go/tools v0.7.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/libc v1.68.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
mvdan.cc/gofumpt v0.9.2 // indirect
|
||||
|
||||
+75
-96
@@ -77,16 +77,12 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/MirrexOne/unqueryvet v1.5.3 h1:LpT3rsH+IY3cQddWF9bg4C7jsbASdGnrOSofY8IPEiw=
|
||||
github.com/MirrexOne/unqueryvet v1.5.3/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
|
||||
github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ=
|
||||
github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
|
||||
github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4=
|
||||
github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/air-verse/air v1.65.0 h1:MCJTmXL+UBS1Ji05jsIaUBXokeW10W+Y6ODkx6GcfZk=
|
||||
github.com/air-verse/air v1.65.0/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
||||
@@ -106,8 +102,6 @@ github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQ
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
|
||||
github.com/alexkohler/prealloc v1.0.2 h1:MPo8cIkGkZytq7WNH9UHv3DIX1mPz1RatPXnZb0zHWQ=
|
||||
github.com/alexkohler/prealloc v1.0.2/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
|
||||
github.com/alexkohler/prealloc v1.1.0 h1:cKGRBqlXw5iyQGLYhrXrDlcHxugXpTq4tQ5c91wkf8M=
|
||||
github.com/alexkohler/prealloc v1.1.0/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
|
||||
github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc=
|
||||
github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus=
|
||||
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
|
||||
@@ -209,8 +203,8 @@ github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyM
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
@@ -254,8 +248,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.10.0-alpha.4 h1:JzPbdf+cqbyT9sZtP4xnqelwUXwf7LvD8xKS6+ofTds=
|
||||
github.com/ebitengine/purego v0.10.0-alpha.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
@@ -266,8 +260,6 @@ github.com/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs=
|
||||
github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
|
||||
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
|
||||
github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E=
|
||||
@@ -302,53 +294,53 @@ github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
||||
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
|
||||
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
|
||||
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
|
||||
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
|
||||
github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
|
||||
github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
|
||||
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
|
||||
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
|
||||
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
|
||||
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
|
||||
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
@@ -409,8 +401,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
|
||||
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20260401045841-3289a7742193 h1:TWcQGetTogk7h5+TRKI6dnvXRQCtEdSPXLu4RdouuM8=
|
||||
github.com/golang/geo v0.0.0-20260401045841-3289a7742193/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/geo v0.0.0-20260129164528-943061e2742c h1:ysO2h2Odnl1AJM1I2Lm/fa6JvO0pECMSt2CwBaa+ITo=
|
||||
github.com/golang/geo v0.0.0-20260129164528-943061e2742c/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
@@ -452,8 +444,6 @@ github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0a
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
|
||||
github.com/golangci/golangci-lint/v2 v2.10.1 h1:flhw5Px6ojbLyEFzXvJn5B2HEdkkRlkhE1SnmCbQBiE=
|
||||
github.com/golangci/golangci-lint/v2 v2.10.1/go.mod h1:dBsrOk6zj0vDhlTv+IiJGqkDokR24IVTS7W3EVfPTQY=
|
||||
github.com/golangci/golangci-lint/v2 v2.11.4 h1:GK+UlZBN5y7rh2PBnHA93XLSX6RaF7uhzJQ3JwU1wuA=
|
||||
github.com/golangci/golangci-lint/v2 v2.11.4/go.mod h1:ODQDCASMA3VqfZYIbbQLpTRTzV7O/vjmIRF6u8NyFwI=
|
||||
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
|
||||
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
|
||||
github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
|
||||
@@ -581,8 +571,6 @@ github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhE
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
|
||||
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
|
||||
github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
|
||||
github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw=
|
||||
github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
|
||||
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
|
||||
@@ -592,8 +580,8 @@ github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811ui
|
||||
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
|
||||
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
|
||||
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
|
||||
github.com/kovidgoyal/imaging v1.8.21 h1:95S2+dowTeKJJHNpf6lnScvIennTr2H0zQotu+ptNQw=
|
||||
github.com/kovidgoyal/imaging v1.8.21/go.mod h1:976F+zjiQeZ7sd87Pxlm0a64S/w9bImSIWg3sSk1rdQ=
|
||||
github.com/kovidgoyal/imaging v1.8.19 h1:zWJdQqF2tfSKjvoB7XpLRhVGbYsze++M0iaqZ4ZkhNk=
|
||||
github.com/kovidgoyal/imaging v1.8.19/go.mod h1:I0q8RdoEuyc4G8GFOF9CaluTUHQSf68d6TmsqpvfRI8=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -652,18 +640,16 @@ github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/mgechev/revive v1.14.0 h1:CC2Ulb3kV7JFYt+izwORoS3VT/+Plb8BvslI/l1yZsc=
|
||||
github.com/mgechev/revive v1.14.0/go.mod h1:MvnujelCZBZCaoDv5B3foPo6WWgULSSFxvfxp7GsPfo=
|
||||
github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q=
|
||||
github.com/mgechev/revive v1.15.0/go.mod h1:LlAKO3QQe9OJ0pVZzI2GPa8CbXGZ/9lNpCGvK4T/a8A=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
@@ -785,8 +771,6 @@ github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb
|
||||
github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I=
|
||||
github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU=
|
||||
github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ=
|
||||
github.com/ryanrolds/sqlclosecheck v0.6.0 h1:pEyL9okISdg1F1SEpJNlrEotkTGerv5BMk7U4AG0eVg=
|
||||
github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU=
|
||||
github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0=
|
||||
github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
@@ -797,8 +781,6 @@ github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iM
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
|
||||
github.com/securego/gosec/v2 v2.23.0 h1:h4TtF64qFzvnkqvsHC/knT7YC5fqyOCItlVR8+ptEBo=
|
||||
github.com/securego/gosec/v2 v2.23.0/go.mod h1:qRHEgXLFuYUDkI2T7W7NJAmOkxVhkR0x9xyHOIcMNZ0=
|
||||
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 h1:AoLtJX4WUtZkhhUUMFy3GgecAALp/Mb4S1iyQOA2s0U=
|
||||
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08/go.mod h1:+XLCJiRE95ga77XInNELh2M6zQP+PdqiT9Zpm0D9Wpk=
|
||||
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
|
||||
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
|
||||
@@ -813,8 +795,6 @@ github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+W
|
||||
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
|
||||
github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
|
||||
github.com/sonatard/noctx v0.4.0/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
|
||||
github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs=
|
||||
github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
|
||||
github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0=
|
||||
github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
@@ -882,8 +862,6 @@ github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYRA=
|
||||
github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU=
|
||||
github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4=
|
||||
github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
|
||||
github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU=
|
||||
github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
|
||||
@@ -950,8 +928,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -962,16 +940,16 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk=
|
||||
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
@@ -997,8 +975,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -1041,16 +1019,16 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -1066,8 +1044,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -1116,11 +1094,12 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -1137,13 +1116,13 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -1194,8 +1173,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
|
||||
@@ -1314,18 +1293,18 @@ honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
|
||||
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -1334,8 +1313,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
|
||||
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
|
||||
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
+205
-266
@@ -8,12 +8,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
@@ -27,7 +25,7 @@ import (
|
||||
// POST /resources/archive — server-side only; does not return archive data.
|
||||
//
|
||||
// @Summary Create an archive on the server
|
||||
// @Description Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.
|
||||
// @Description Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission.
|
||||
// @Description
|
||||
// @Description **Request body parameters:**
|
||||
// @Description - **fromSource** (string, required): Source name where the paths to archive live. Example: `"default"`
|
||||
@@ -45,7 +43,6 @@ import (
|
||||
// @Failure 400 {object} map[string]string "Invalid request (e.g. missing required field, invalid path)"
|
||||
// @Failure 403 {object} map[string]string "Forbidden (create permission or access denied)"
|
||||
// @Failure 404 {object} map[string]string "Source not found"
|
||||
// @Failure 413 {object} map[string]string "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/resources/archive [post]
|
||||
func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
@@ -158,30 +155,22 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
return http.StatusBadRequest, fmt.Errorf("no paths accessible; add at least one path you have access to")
|
||||
}
|
||||
|
||||
// Check archive size limit if configured
|
||||
estimatedSize, err := computeArchiveSize(req.FromSource, req.Paths, d)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
if config.Server.MaxArchiveSizeGB > 0 {
|
||||
var estimatedSize int64
|
||||
estimatedSize, err = computeArchiveSize(req.FromSource, itemPaths, d)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to compute archive size: %v", err)
|
||||
}
|
||||
maxSizeBytes := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
|
||||
if estimatedSize > maxSizeBytes {
|
||||
return http.StatusRequestEntityTooLarge, fmt.Errorf("archive size would exceed the maximum allowed size (maxArchiveSize: %d GB)", config.Server.MaxArchiveSizeGB)
|
||||
maxSize := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
|
||||
if estimatedSize > maxSize {
|
||||
return http.StatusRequestEntityTooLarge, fmt.Errorf("pre-archive combined size of files exceeds maximum limit of %d GB", config.Server.MaxArchiveSizeGB)
|
||||
}
|
||||
}
|
||||
|
||||
var createErr error
|
||||
file, err := os.OpenFile(destRealPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if format == "zip" {
|
||||
createErr = createZip(d, req.FromSource, file, itemPaths...)
|
||||
createErr = createZip(d, req.FromSource, destRealPath, itemPaths...)
|
||||
} else {
|
||||
createErr = createTarGzWithLevel(d, req.FromSource, file, compression, itemPaths...)
|
||||
createErr = createTarGzWithLevel(d, req.FromSource, destRealPath, compression, itemPaths...)
|
||||
}
|
||||
if createErr != nil {
|
||||
return http.StatusInternalServerError, createErr
|
||||
@@ -220,7 +209,7 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
// unarchiveHandler extracts an archive on the server. POST /resources/unarchive — server-side only.
|
||||
//
|
||||
// @Summary Extract an archive on the server
|
||||
// @Description Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.
|
||||
// @Description Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission.
|
||||
// @Description
|
||||
// @Description **Request body parameters:**
|
||||
// @Description - **fromSource** (string, required): Source name where the archive file lives. Example: `"default"`
|
||||
@@ -236,7 +225,6 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
// @Failure 400 {object} map[string]string "Invalid request (e.g. missing required field, unsupported format)"
|
||||
// @Failure 403 {object} map[string]string "Forbidden (create permission or access denied)"
|
||||
// @Failure 404 {object} map[string]string "Source or archive file not found"
|
||||
// @Failure 413 {object} map[string]string "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/resources/unarchive [post]
|
||||
func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
@@ -260,7 +248,7 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
|
||||
pathClean, err := utils.SanitizeUserPath(req.Path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
destClean, err := utils.SanitizeUserPath(req.Destination)
|
||||
if err != nil {
|
||||
@@ -325,14 +313,6 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusBadRequest, fmt.Errorf("path is not an archive file: %s", req.Path)
|
||||
}
|
||||
|
||||
// Check archive size limit if configured
|
||||
if config.Server.MaxArchiveSizeGB > 0 {
|
||||
maxSizeBytes := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
|
||||
if info.Size() > maxSizeBytes {
|
||||
return http.StatusRequestEntityTooLarge, fmt.Errorf("archive size would exceed the maximum allowed size (maxArchiveSize: %d GB)", config.Server.MaxArchiveSizeGB)
|
||||
}
|
||||
}
|
||||
|
||||
lower := strings.ToLower(archiveReal)
|
||||
var extractErr error
|
||||
if strings.HasSuffix(lower, ".zip") {
|
||||
@@ -364,10 +344,22 @@ func addFile(source string, path string, d *requestContext, tarWriter *tar.Write
|
||||
}
|
||||
|
||||
// Check access control directly for each file and silently skip if access is denied
|
||||
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
|
||||
return nil // Silently skip this file/folder
|
||||
if d.share == nil && store.Access != nil {
|
||||
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
|
||||
return nil // Silently skip this file/folder
|
||||
}
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
_, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
FollowSymlinks: true,
|
||||
}, store.Access, d.user, store.Share)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
realPath, _, _ := idx.GetRealPath(path)
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
@@ -418,21 +410,16 @@ func addFile(source string, path string, d *requestContext, tarWriter *tar.Write
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
if tarWriter != nil {
|
||||
header, err := tar.FileInfoHeader(fileInfo, "")
|
||||
if err != nil {
|
||||
return err
|
||||
header := &tar.Header{
|
||||
Name: relPath + "/",
|
||||
Mode: int64(fileutils.PermDir),
|
||||
Typeflag: tar.TypeDir,
|
||||
ModTime: fileInfo.ModTime(),
|
||||
}
|
||||
header.Name = relPath + "/"
|
||||
return tarWriter.WriteHeader(header)
|
||||
}
|
||||
if zipWriter != nil {
|
||||
zh, err := zip.FileInfoHeader(fileInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zh.Name = relPath + "/"
|
||||
zh.Method = zip.Store
|
||||
_, err = zipWriter.CreateHeader(zh)
|
||||
_, err := zipWriter.Create(relPath + "/")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -494,14 +481,66 @@ func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWrite
|
||||
return nil
|
||||
}
|
||||
|
||||
// createZip writes a ZIP archive into w containing the given paths; access rules apply.
|
||||
func createZip(d *requestContext, source string, w io.Writer, filenames ...string) error {
|
||||
zipWriter := zip.NewWriter(w)
|
||||
// computeArchiveSize returns the combined size of the given paths, respecting access rules.
|
||||
// Paths denied by access are skipped (not counted).
|
||||
func computeArchiveSize(source string, fileList []string, d *requestContext) (int64, error) {
|
||||
var estimatedSize int64
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return 0, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
|
||||
for _, filepath := range filenames {
|
||||
err := addFile(source, filepath, d, nil, zipWriter, false)
|
||||
var userScope string
|
||||
var err error
|
||||
if d.share == nil {
|
||||
userScope, err = d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to add %s to ZIP: %v", filepath, err)
|
||||
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range fileList {
|
||||
var fullPath string
|
||||
if d.share == nil {
|
||||
fullPath = utils.JoinPathAsUnix(userScope, path)
|
||||
if store.Access != nil && !store.Access.Permitted(idx.Path, fullPath, d.user.Username) {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
fullPath = path
|
||||
}
|
||||
|
||||
realPath, isDir, err := idx.GetRealPath(fullPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
indexPath := idx.MakeIndexPath(realPath, isDir)
|
||||
info, ok := idx.GetReducedMetadata(indexPath, isDir)
|
||||
if !ok {
|
||||
info, err = idx.GetFsInfo(indexPath, false, true)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get file info for %s : %v", path, err)
|
||||
}
|
||||
}
|
||||
estimatedSize += info.Size
|
||||
}
|
||||
return estimatedSize, nil
|
||||
}
|
||||
|
||||
// createZip writes a ZIP archive to tmpPath containing the given paths; access rules apply.
|
||||
func createZip(d *requestContext, source string, tmpPath string, filenames ...string) error {
|
||||
file, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(file)
|
||||
|
||||
for _, fname := range filenames {
|
||||
err := addFile(source, fname, d, nil, zipWriter, false)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to add %s to ZIP: %v", fname, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -512,15 +551,21 @@ func createZip(d *requestContext, source string, w io.Writer, filenames ...strin
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTarGz writes a tar.gz archive into w containing the given paths; access rules apply.
|
||||
func createTarGz(d *requestContext, source string, w io.Writer, filenames ...string) error {
|
||||
gzWriter := gzip.NewWriter(w)
|
||||
// createTarGz writes a tar.gz archive to tmpPath containing the given paths; access rules apply.
|
||||
func createTarGz(d *requestContext, source string, tmpPath string, filenames ...string) error {
|
||||
file, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
gzWriter := gzip.NewWriter(file)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
for _, filepath := range filenames {
|
||||
err := addFile(source, filepath, d, tarWriter, nil, false)
|
||||
for _, fname := range filenames {
|
||||
err := addFile(source, fname, d, tarWriter, nil, false)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to add %s to TAR.GZ: %v", filepath, err)
|
||||
logger.Errorf("Failed to add %s to TAR.GZ: %v", fname, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -534,26 +579,31 @@ func createTarGz(d *requestContext, source string, w io.Writer, filenames ...str
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTarGzWithLevel writes a tar.gz archive into w with the given gzip compression level (0=default, 1-9).
|
||||
func createTarGzWithLevel(d *requestContext, source string, w io.Writer, level int, filenames ...string) error {
|
||||
// createTarGzWithLevel writes a tar.gz archive with the given gzip compression level (0=default, 1-9).
|
||||
func createTarGzWithLevel(d *requestContext, source string, destPath string, level int, filenames ...string) error {
|
||||
file, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var gzWriter *gzip.Writer
|
||||
if level >= 1 && level <= 9 {
|
||||
var err error
|
||||
gzWriter, err = gzip.NewWriterLevel(w, level)
|
||||
gzWriter, err = gzip.NewWriterLevel(file, level)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
gzWriter = gzip.NewWriter(w)
|
||||
gzWriter = gzip.NewWriter(file)
|
||||
}
|
||||
defer gzWriter.Close()
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
defer tarWriter.Close()
|
||||
|
||||
for _, filepath := range filenames {
|
||||
err := addFile(source, filepath, d, tarWriter, nil, false)
|
||||
for _, fname := range filenames {
|
||||
err := addFile(source, fname, d, tarWriter, nil, false)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to add %s to TAR.GZ: %v", filepath, err)
|
||||
logger.Errorf("Failed to add %s to TAR.GZ: %v", fname, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -564,15 +614,39 @@ func createTarGzWithLevel(d *requestContext, source string, w io.Writer, level i
|
||||
}
|
||||
|
||||
// BuildAndStreamArchive resolves paths, creates a zip or tar.gz archive, and streams it to w.
|
||||
// It respects access rules. Used only by the raw handler for multi-file/directory download.
|
||||
// It respects access rules and max archive size. Used only by the raw handler for multi-file/directory download.
|
||||
func BuildAndStreamArchive(w http.ResponseWriter, r *http.Request, d *requestContext, source string, fileList []string) (int, error) {
|
||||
firstFilePath := fileList[0]
|
||||
var userscope string
|
||||
var err error
|
||||
|
||||
if d.share == nil {
|
||||
userscope, err = d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
|
||||
}
|
||||
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
realPath, isDir, err := idx.GetRealPath(fileList[0])
|
||||
realPath, isDir, err := idx.GetRealPath(firstFilePath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to get real path for %s: %v", fileList[0], err)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
estimatedSize, err := computeArchiveSize(source, fileList, d)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
if config.Server.MaxArchiveSizeGB > 0 {
|
||||
maxSize := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
|
||||
if estimatedSize > maxSize {
|
||||
return http.StatusRequestEntityTooLarge, fmt.Errorf("pre-archive combined size of files exceeds maximum limit of %d GB", config.Server.MaxArchiveSizeGB)
|
||||
}
|
||||
}
|
||||
|
||||
algo := r.URL.Query().Get("algo")
|
||||
@@ -586,7 +660,7 @@ func BuildAndStreamArchive(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
return http.StatusInternalServerError, errors.New("format not implemented")
|
||||
}
|
||||
|
||||
baseDirName := filepath.Base(filepath.Dir(realPath))
|
||||
baseDirName := filepath.Base(filepath.Dir(firstFilePath))
|
||||
if baseDirName == "" || baseDirName == "/" {
|
||||
baseDirName = "download"
|
||||
}
|
||||
@@ -595,25 +669,51 @@ func BuildAndStreamArchive(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
}
|
||||
originalFileName := baseDirName + extension
|
||||
|
||||
setContentDisposition(w, r, originalFileName)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
|
||||
var writer io.Writer = w
|
||||
if d.share != nil && d.share.MaxBandwidth > 0 {
|
||||
limit := rate.Limit(d.share.MaxBandwidth * 1024)
|
||||
burst := d.share.MaxBandwidth * 1024
|
||||
writer = newThrottledWriter(w, limit, burst, r.Context())
|
||||
}
|
||||
|
||||
archiveData := filepath.Join(config.Server.CacheDir, utils.InsecureRandomIdentifier(10))
|
||||
if extension == ".zip" {
|
||||
err = createZip(d, source, writer, fileList...)
|
||||
archiveData = archiveData + ".zip"
|
||||
err = createZip(d, source, archiveData, fileList...)
|
||||
} else {
|
||||
err = createTarGz(d, source, writer, fileList...)
|
||||
archiveData = archiveData + ".tar.gz"
|
||||
err = createTarGz(d, source, archiveData, fileList...)
|
||||
}
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
fd, err := os.Open(archiveData)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
fileInfo, err := fd.Stat()
|
||||
if err != nil {
|
||||
os.Remove(archiveData)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
sizeInMB := fileInfo.Size() / 1024 / 1024
|
||||
if sizeInMB > 500 {
|
||||
logger.Debugf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, originalFileName)
|
||||
}
|
||||
|
||||
setContentDisposition(w, r, originalFileName)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
|
||||
var reader io.Reader = fd
|
||||
if d.share != nil && d.share.MaxBandwidth > 0 {
|
||||
limit := rate.Limit(d.share.MaxBandwidth * 1024)
|
||||
burst := d.share.MaxBandwidth * 1024
|
||||
reader = newThrottledReadSeeker(fd, limit, burst, r.Context())
|
||||
}
|
||||
_, err = io.Copy(w, reader)
|
||||
os.Remove(archiveData)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to copy archive data to response: %v", err)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -670,117 +770,6 @@ func safeExtractPath(destDir, name string) (string, error) {
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// applyArchivedTimesAndPerm sets permission bits and modification time from an archive entry using
|
||||
func applyArchivedTimesAndPerm(path string, mode fs.FileMode, modTime time.Time) error {
|
||||
switch {
|
||||
case mode.Type() == fs.ModeSymlink:
|
||||
if !modTime.IsZero() {
|
||||
_ = os.Chtimes(path, modTime, modTime)
|
||||
}
|
||||
return nil
|
||||
case mode.Type() == fs.ModeDir:
|
||||
if p := mode.Perm(); p != 0 {
|
||||
_ = os.Chmod(path, p)
|
||||
}
|
||||
if modTime.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return os.Chtimes(path, modTime, modTime)
|
||||
case mode.IsRegular():
|
||||
if p := mode.Perm(); p != 0 {
|
||||
_ = os.Chmod(path, p)
|
||||
}
|
||||
if modTime.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return os.Chtimes(path, modTime, modTime)
|
||||
default:
|
||||
if !modTime.IsZero() {
|
||||
_ = os.Chtimes(path, modTime, modTime)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// symlinkTargetStaysUnderDest rejects absolute targets and paths that would resolve outside destRoot.
|
||||
func symlinkTargetStaysUnderDest(destRoot, destPath, linkname string) error {
|
||||
if linkname == "" {
|
||||
return fmt.Errorf("empty symlink target")
|
||||
}
|
||||
if filepath.IsAbs(linkname) {
|
||||
return fmt.Errorf("symlink has absolute target")
|
||||
}
|
||||
destAbs, err := filepath.Abs(destRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolved := filepath.Clean(filepath.Join(filepath.Dir(destPath), linkname))
|
||||
resAbs, err := filepath.Abs(resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sep := string(filepath.Separator)
|
||||
if resAbs != destAbs && !strings.HasPrefix(resAbs, destAbs+sep) {
|
||||
return fmt.Errorf("symlink target escapes destination directory")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractArchivedDir creates a directory from an archive entry and reapplies mode and mtime.
|
||||
func extractArchivedDir(destPath string, mode fs.FileMode, modTime time.Time) error {
|
||||
perm := mode.Perm()
|
||||
if perm == 0 {
|
||||
perm = fileutils.PermDir
|
||||
}
|
||||
if err := os.MkdirAll(destPath, perm); err != nil {
|
||||
return err
|
||||
}
|
||||
return applyArchivedTimesAndPerm(destPath, mode, modTime)
|
||||
}
|
||||
|
||||
// extractArchivedRegularFile writes one regular file from src (closed by this function) and reapplies mode and mtime.
|
||||
func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.Time, src io.ReadCloser) error {
|
||||
defer func() { _ = src.Close() }()
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
perm := mode.Perm()
|
||||
if perm == 0 {
|
||||
perm = fileutils.PermFile
|
||||
}
|
||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(out, src)
|
||||
cerr := out.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
return applyArchivedTimesAndPerm(destPath, mode, modTime)
|
||||
}
|
||||
|
||||
// extractArchivedSymlink creates a symlink after validating the target stays under destRoot.
|
||||
func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time) error {
|
||||
target = strings.TrimSpace(target)
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := symlinkTargetStaysUnderDest(destRoot, destPath, target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(destPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(target, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return applyArchivedTimesAndPerm(destPath, fs.ModeSymlink, modTime)
|
||||
}
|
||||
|
||||
func extractZip(archivePath, destDir string) error {
|
||||
r, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
@@ -789,49 +778,33 @@ func extractZip(archivePath, destDir string) error {
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
var destPath string
|
||||
destPath, err = safeExtractPath(destDir, f.Name)
|
||||
destPath, err := safeExtractPath(destDir, f.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fi := f.FileInfo()
|
||||
mode := fi.Mode()
|
||||
|
||||
if mode.Type() == fs.ModeDir || strings.HasSuffix(f.Name, "/") {
|
||||
if err = extractArchivedDir(destPath, mode, f.Modified); err != nil {
|
||||
if f.FileInfo().IsDir() {
|
||||
if err = os.MkdirAll(destPath, fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if mode.Type() == fs.ModeSymlink {
|
||||
var rc io.ReadCloser
|
||||
rc, err = f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf, rerr := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
if err = extractArchivedSymlink(destDir, destPath, string(buf), f.Modified); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
if err = os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !mode.IsRegular() {
|
||||
continue
|
||||
}
|
||||
|
||||
var rc io.ReadCloser
|
||||
rc, err = f.Open()
|
||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = extractArchivedRegularFile(destPath, mode, f.Modified, rc); err != nil {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, rc)
|
||||
rc.Close()
|
||||
out.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -860,64 +833,30 @@ func extractTarGz(archivePath, destDir string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var destPath string
|
||||
destPath, err = safeExtractPath(destDir, h.Name)
|
||||
destPath, err := safeExtractPath(destDir, h.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch h.Typeflag {
|
||||
case tar.TypeDir:
|
||||
mode := h.FileInfo().Mode()
|
||||
if err = extractArchivedDir(destPath, mode, h.ModTime); err != nil {
|
||||
if err := os.MkdirAll(destPath, fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
mode := h.FileInfo().Mode()
|
||||
if err = extractArchivedRegularFile(destPath, mode, h.ModTime, io.NopCloser(tr)); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeSymlink:
|
||||
if err = extractArchivedSymlink(destDir, destPath, h.Linkname, h.ModTime); err != nil {
|
||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
if h.Size > 0 {
|
||||
if _, err = io.CopyN(io.Discard, tr, h.Size); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, tr); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
out.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeArchiveSize returns the combined size of the given paths, respecting access rules.
|
||||
// Paths denied by access are skipped (not counted).
|
||||
func computeArchiveSize(source string, fileList []string, d *requestContext) (int64, error) {
|
||||
var estimatedSize int64
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return 0, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
|
||||
for _, path := range fileList {
|
||||
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
|
||||
continue
|
||||
}
|
||||
realPath, isDir, err := idx.GetRealPath(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
indexPath := idx.MakeIndexPath(realPath, isDir)
|
||||
info, ok := idx.GetReducedMetadata(indexPath, isDir)
|
||||
if !ok {
|
||||
info, err = idx.GetFsInfo(indexPath, false, true)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get file info for %s : %v", path, err)
|
||||
}
|
||||
}
|
||||
estimatedSize += info.Size
|
||||
}
|
||||
return estimatedSize, nil
|
||||
}
|
||||
+11
-36
@@ -24,13 +24,6 @@ type throttledReadSeeker struct {
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// throttledWriter is a wrapper around an io.Writer that throttles the writing speed.
|
||||
type throttledWriter struct {
|
||||
w io.Writer
|
||||
limiter *rate.Limiter
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// newThrottledReadSeeker creates a new throttledReadSeeker.
|
||||
func newThrottledReadSeeker(rs io.ReadSeeker, limit rate.Limit, burst int, ctx context.Context) *throttledReadSeeker {
|
||||
return &throttledReadSeeker{
|
||||
@@ -58,29 +51,6 @@ func (r *throttledReadSeeker) Seek(offset int64, whence int) (int64, error) {
|
||||
return r.rs.Seek(offset, whence)
|
||||
}
|
||||
|
||||
// newThrottledWriter creates a new throttledWriter.
|
||||
func newThrottledWriter(w io.Writer, limit rate.Limit, burst int, ctx context.Context) *throttledWriter {
|
||||
return &throttledWriter{
|
||||
w: w,
|
||||
limiter: rate.NewLimiter(limit, burst),
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *throttledWriter) Write(p []byte) (n int, err error) {
|
||||
n, err = w.w.Write(p)
|
||||
if n > 0 {
|
||||
if waitErr := w.limiter.WaitN(w.ctx, n); waitErr != nil {
|
||||
if err != nil {
|
||||
// The original error (like io.EOF) is potentially more important
|
||||
// than the context error.
|
||||
err = waitErr
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// toASCIIFilename converts a filename to ASCII-safe format by replacing non-ASCII characters with underscores
|
||||
func toASCIIFilename(fileName string) string {
|
||||
var result strings.Builder
|
||||
@@ -169,7 +139,6 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
var documentId string
|
||||
var logContext *OnlyOfficeLogContext
|
||||
|
||||
// modify all filepaths for user scope
|
||||
if d.share == nil {
|
||||
userscope, err = d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
@@ -191,11 +160,8 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
}
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
for i, filePath := range fileList {
|
||||
fileList[i] = utils.JoinPathAsUnix(userscope, filePath)
|
||||
}
|
||||
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
|
||||
}
|
||||
firstFilePath = fileList[0]
|
||||
// For shares, the path is already correctly resolved by publicRawHandler
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
@@ -219,7 +185,11 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
}
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// Compute estimated download size (for single-file branch and archive size check)
|
||||
estimatedSize, err := computeArchiveSize(source, fileList, d)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
// ** Single file download with Content-Length **
|
||||
if len(fileList) == 1 && !isDir {
|
||||
// Get document ID and log context for OnlyOffice downloads
|
||||
@@ -279,6 +249,11 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
w.Header().Set("Cache-Control", "private")
|
||||
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 MB, log it
|
||||
if sizeInMB > 500 {
|
||||
logger.Debugf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName)
|
||||
}
|
||||
// serve content allows for range requests.
|
||||
// video scrubbing, etc.
|
||||
// Note: http.ServeContent will respect our already-set Content-Disposition header
|
||||
|
||||
@@ -139,7 +139,7 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
var err error
|
||||
path, err = utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
|
||||
// Parse lines parameter
|
||||
@@ -275,7 +275,7 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
path = cleanPath
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
_ "net/http/pprof"
|
||||
@@ -119,7 +119,6 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
api.HandleFunc("POST /resources/unarchive", withUser(unarchiveHandler))
|
||||
api.HandleFunc("GET /resources/download", withUser(downloadHandler))
|
||||
api.HandleFunc("GET /resources/preview", withTimeout(60*time.Second, withUserHelper(previewHandler)))
|
||||
api.HandleFunc("POST /resources/pause", withUser(resourcePauseHandler))
|
||||
publicApi.HandleFunc("GET /resources", withHashFile(publicGetResourceHandler))
|
||||
publicApi.HandleFunc("GET /resources/items", withHashFile(publicItemsGetHandler))
|
||||
publicApi.HandleFunc("POST /resources", withHashFile(publicUploadHandler))
|
||||
@@ -129,7 +128,6 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
publicApi.HandleFunc("PATCH /resources", withHashFile(publicPatchHandler))
|
||||
publicApi.HandleFunc("GET /resources/download", withHashFile(publicDownloadHandler))
|
||||
publicApi.HandleFunc("GET /resources/preview", withHashFile(publicPreviewHandler))
|
||||
publicApi.HandleFunc("POST /resources/pause", withHashFile(publicPauseHandler))
|
||||
// Legacy routes (backwards compatibility)
|
||||
api.HandleFunc("GET /raw", withUser(downloadHandler))
|
||||
publicApi.HandleFunc("GET /raw", withHashFile(publicDownloadHandler))
|
||||
@@ -173,11 +171,9 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
api.HandleFunc("GET /tools/fileWatcher/sse", withUser(fileWatchSSEHandler))
|
||||
|
||||
// ========================================
|
||||
// Media Routes - /api/media/ (with public routes)
|
||||
// Media Routes - /api/media/
|
||||
// ========================================
|
||||
api.HandleFunc("GET /media/subtitles", withUser(subtitlesHandler))
|
||||
api.HandleFunc("GET /media/metadata", withUser(metadataHandler))
|
||||
publicApi.HandleFunc("GET /media/metadata", withHashFile(publicMetadataHandler))
|
||||
|
||||
// ========================================
|
||||
// OnlyOffice Routes - /api/office/ (with public routes)
|
||||
|
||||
@@ -24,7 +24,7 @@ func AuthenticateLDAPUser(username, password string) (*users.User, error) {
|
||||
logger.Debugf("ldap authentication failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
// Use the configured UserIdentifier if available, otherwise use the login username
|
||||
ldapCfg := settings.Config.Auth.Methods.LdapAuth
|
||||
mappedUsername := username
|
||||
@@ -36,7 +36,7 @@ func AuthenticateLDAPUser(username, password string) (*users.User, error) {
|
||||
logger.Warningf("ldap: userIdentifier '%s' not found in LDAP entry for user %s, using login username", ldapCfg.UserIdentifier, username)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
logger.Debugf("ldap authentication successful, getting or creating user %s", mappedUsername)
|
||||
return getOrCreateLdapUser(mappedUsername, groups)
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func authenticateLDAP(username, password string) ([]string, map[string]string, e
|
||||
}
|
||||
|
||||
groups := entry.GetAttributeValues(groupAttr)
|
||||
|
||||
|
||||
// Extract user attributes for identifier mapping
|
||||
userAttributes := make(map[string]string)
|
||||
if c.UserIdentifier != "" {
|
||||
@@ -132,7 +132,7 @@ func authenticateLDAP(username, password string) ([]string, map[string]string, e
|
||||
userAttributes[c.UserIdentifier] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return groups, userAttributes, nil
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ func ldapGroupMatchesAdmin(groupDN, adminGroup string) bool {
|
||||
func getOrCreateLdapUser(username string, groups []string) (*users.User, error) {
|
||||
logger.Debugf("getting or creating ldap user %s", username)
|
||||
ldapCfg := config.Auth.Methods.LdapAuth
|
||||
|
||||
|
||||
// Check if user is in required groups (if userGroups is configured)
|
||||
if len(ldapCfg.UserGroups) > 0 {
|
||||
userInAllowedGroup := false
|
||||
@@ -208,7 +208,7 @@ func getOrCreateLdapUser(username string, groups []string) (*users.User, error)
|
||||
}
|
||||
logger.Debugf("User %s is in required group, allowing access.", username)
|
||||
}
|
||||
|
||||
|
||||
isAdmin := false
|
||||
if ldapCfg.AdminGroup != "" {
|
||||
for _, g := range groups {
|
||||
|
||||
@@ -80,11 +80,17 @@ func TestPickUserEntry(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := pickUserEntry(tt.entries)
|
||||
if tt.wantNil && got != nil {
|
||||
t.Errorf("pickUserEntry() = %v, want nil", got)
|
||||
if tt.wantNil {
|
||||
if got != nil {
|
||||
t.Errorf("pickUserEntry() = %v, want nil", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !tt.wantNil && got == nil {
|
||||
t.Errorf("pickUserEntry() = nil, want non-nil entry")
|
||||
if got == nil {
|
||||
t.Fatal("pickUserEntry() = nil, want non-nil entry")
|
||||
}
|
||||
if got.DN != tt.wantDN {
|
||||
t.Errorf("pickUserEntry() DN = %v, want %v", got.DN, tt.wantDN)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"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/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
@@ -101,77 +99,3 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
http.ServeContent(w, r, name, time.Now(), bytes.NewReader([]byte(content)))
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
// metadataHandler returns the same directory resource shape as GET /api/resources with metadata enabled,
|
||||
// for client-side patching after a fast listing load.
|
||||
// @Summary Directory with media metadata
|
||||
// @Description Same ExtendedFileInfo as resources GET with metadata=true (typically used for directories).
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "Path to the directory or file"
|
||||
// @Param source query string true "Source name"
|
||||
// @Param albumArt query bool false "When true, include embedded album art bytes in audio metadata"
|
||||
// @Success 200 {object} iteminfo.ExtendedFileInfo
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 404 {object} map[string]string "Not found"
|
||||
// @Router /api/media/metadata [get]
|
||||
func metadataHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
albumArt := r.URL.Query().Get("albumArt") == "true"
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: true,
|
||||
Content: false,
|
||||
Metadata: true,
|
||||
AlbumArt: albumArt,
|
||||
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
SkipExtendedAttrs: false,
|
||||
ShowSharedAttr: true,
|
||||
}, store.Access, d.user, store.Share)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
return renderJSON(w, r, fileInfo)
|
||||
}
|
||||
|
||||
// publicMetadataHandler is the share-link variant of metadataHandler.
|
||||
// @Summary Directory with media metadata (public share)
|
||||
// @Tags Shares
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash"
|
||||
// @Param path query string false "Path within the share"
|
||||
// @Param albumArt query bool false "When true, include embedded album art bytes in audio metadata (heavier)"
|
||||
// @Success 200 {object} iteminfo.ExtendedFileInfo
|
||||
// @Router /public/api/media/metadata [get]
|
||||
func publicMetadataHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if d.share.ShareType == "upload" {
|
||||
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
|
||||
}
|
||||
path := r.URL.Query().Get("path")
|
||||
albumArt := r.URL.Query().Get("albumArt") == "true"
|
||||
sourceCfg, ok := config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
return http.StatusNotFound, fmt.Errorf("source not found")
|
||||
}
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Path: d.IndexPath,
|
||||
Source: sourceCfg.Name,
|
||||
Expand: true,
|
||||
Content: false,
|
||||
Metadata: true,
|
||||
AlbumArt: albumArt,
|
||||
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && d.share.ExtractEmbeddedSubtitles,
|
||||
ShowHidden: d.share.ShowHidden,
|
||||
FollowSymlinks: false,
|
||||
}, store.Access, d.shareUser, store.Share)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
fileInfo.Path = utils.AddTrailingSlashIfNotExists(path)
|
||||
return renderJSON(w, r, fileInfo)
|
||||
}
|
||||
+37
-46
@@ -5,9 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -116,9 +114,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
}
|
||||
data.IndexPath = pathWithoutUserScope
|
||||
// skip file fetch for certain apis
|
||||
if (r.Method == "POST" && strings.Contains(r.URL.Path, "/resources")) ||
|
||||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items")) ||
|
||||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/metadata")) {
|
||||
if r.Method == "POST" && strings.Contains(r.URL.Path, "/resources") || r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items") {
|
||||
return fn(w, r, data)
|
||||
}
|
||||
file, err := FileInfoFasterFunc(utils.FileOptions{
|
||||
@@ -316,24 +312,38 @@ func withoutUserHelper(fn handleFunc) handleFunc {
|
||||
// allow user without OTP to pass
|
||||
func userWithoutOTPhelper(fn handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
|
||||
if config.Auth.Methods.ProxyAuth.Enabled {
|
||||
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
|
||||
if proxyUser != "" {
|
||||
return getProxyUser(w, r, d, fn, proxyUser)
|
||||
// Check if request has a valid admin token first
|
||||
if tokenStr, err := extractToken(r); err == nil && tokenStr != "" {
|
||||
keyFunc := func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(config.Auth.Key), nil
|
||||
}
|
||||
var tk users.AuthToken
|
||||
if token, err := jwt.ParseWithClaims(tokenStr, &tk, keyFunc); err == nil && token.Valid {
|
||||
if !auth.IsRevokedApiToken(store.Access, tokenStr) {
|
||||
if tk.BelongsTo == 0 {
|
||||
// Minimal token - look up user ID
|
||||
userID, found := store.Access.GetUserIDFromToken(tokenStr)
|
||||
if found {
|
||||
tk.BelongsTo = userID
|
||||
}
|
||||
}
|
||||
if tk.BelongsTo > 0 {
|
||||
user, err := store.Users.Get(tk.BelongsTo)
|
||||
if err == nil && user.Permissions.Admin {
|
||||
// Valid admin token - allow operation
|
||||
d.user = user
|
||||
return fn(w, r, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No valid admin token - proceed with username/password authentication
|
||||
username := r.URL.Query().Get("username")
|
||||
password := r.Header.Get("X-Password")
|
||||
// Try LDAP first if enabled; on success set d.user and continue to handler
|
||||
if config.Auth.Methods.LdapAuth.Enabled {
|
||||
// No valid admin token - proceed with username/password authentication
|
||||
username := r.URL.Query().Get("username")
|
||||
password := r.Header.Get("X-Password")
|
||||
// URL-decode password to support special characters in headers
|
||||
password, err := url.QueryUnescape(password)
|
||||
if err != nil {
|
||||
return 401, fmt.Errorf("invalid password encoding")
|
||||
}
|
||||
logger.Debug("ldap auth, calling AuthenticateLDAPUser")
|
||||
ldapUser, err := AuthenticateLDAPUser(username, password)
|
||||
if err == nil {
|
||||
@@ -381,7 +391,7 @@ func withUserHelper(fn handleFunc) handleFunc {
|
||||
}
|
||||
return fn(w, r, data)
|
||||
}
|
||||
|
||||
|
||||
// Check for JWT external auth first (header or query param)
|
||||
if config.Auth.Methods.JwtAuth.Enabled {
|
||||
jwtToken := r.Header.Get(config.Auth.Methods.JwtAuth.Header)
|
||||
@@ -389,12 +399,12 @@ func withUserHelper(fn handleFunc) handleFunc {
|
||||
// Check query parameter (hardcoded to "jwt")
|
||||
jwtToken = r.URL.Query().Get("jwt")
|
||||
}
|
||||
|
||||
|
||||
if jwtToken != "" {
|
||||
return getJwtUser(w, r, data, fn, jwtToken)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
|
||||
isProxyUser := config.Auth.Methods.ProxyAuth.Enabled && proxyUser != ""
|
||||
keyFunc := func(token *jwt.Token) (interface{}, error) {
|
||||
@@ -475,7 +485,7 @@ func getJwtUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn
|
||||
logger.Debugf("JWT verification failed: %v", err)
|
||||
return http.StatusForbidden, fmt.Errorf("JWT authentication failed: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Setup user based on JWT claims
|
||||
user, err := setupJwtUser(r, data, username, claims)
|
||||
if err != nil {
|
||||
@@ -486,7 +496,7 @@ func getJwtUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn
|
||||
if data.user.Username == "" {
|
||||
return http.StatusForbidden, errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
|
||||
// Generate a FileBrowser session token for JWT users if they don't have one
|
||||
if data.token == "" {
|
||||
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
|
||||
@@ -497,7 +507,7 @@ func getJwtUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn
|
||||
}
|
||||
data.token = tokenString
|
||||
}
|
||||
|
||||
|
||||
// Call the handler function, passing in the context (or return OK if no handler)
|
||||
if fn == nil {
|
||||
return http.StatusOK, nil
|
||||
@@ -748,26 +758,6 @@ func setUserInResponseWriter(w http.ResponseWriter, user *users.User) {
|
||||
}
|
||||
}
|
||||
|
||||
func getRemoteIP(r *http.Request) string {
|
||||
// 1. Check X-Forwarded-For
|
||||
xff := r.Header.Get("X-Forwarded-For")
|
||||
if xff != "" {
|
||||
// The first IP is the original client
|
||||
ips := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(ips[0])
|
||||
}
|
||||
|
||||
// 2. Check X-Real-IP
|
||||
xri := r.Header.Get("X-Real-IP")
|
||||
if xri != "" {
|
||||
return xri
|
||||
}
|
||||
|
||||
// 3. Fallback to RemoteAddr (strip port if necessary)
|
||||
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
return ip
|
||||
}
|
||||
|
||||
// LoggingMiddleware logs each request and its status code.
|
||||
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -776,6 +766,7 @@ func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
if rcv := recover(); rcv != nil {
|
||||
method := r.Method
|
||||
url := r.URL.String()
|
||||
remoteAddr := r.RemoteAddr
|
||||
username := "unknown" // Default username
|
||||
|
||||
// Attempt to get username from ResponseWriterWrapper if it's set
|
||||
@@ -788,7 +779,7 @@ func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
stackTrace := string(buf[:n])
|
||||
|
||||
logger.Errorf("PANIC RECOVERED: %v\nUser: %s\nMethod: %s\nURL: %s\nRemoteAddr: %s\nGo Stack Trace:\n%s",
|
||||
rcv, username, method, url, getRemoteIP(r), stackTrace)
|
||||
rcv, username, method, url, remoteAddr, stackTrace)
|
||||
|
||||
// Attempt to send a 500 error response to the client
|
||||
// This is a best-effort; the connection might be broken or process too unstable.
|
||||
@@ -831,7 +822,7 @@ func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"",
|
||||
r.Method,
|
||||
wrappedWriter.StatusCode,
|
||||
getRemoteIP(r),
|
||||
r.RemoteAddr,
|
||||
truncUser,
|
||||
fmt.Sprintf("%vms", duration.Milliseconds()),
|
||||
fullURL))
|
||||
|
||||
+23
-102
@@ -4,7 +4,6 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
@@ -42,112 +41,32 @@ func (u *userInfoUnmarshaller) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract groups if configured (supports nested claims like "resource_access.filebrowser-client.roles")
|
||||
// Extract groups if configured
|
||||
if u.groupsClaim != "" {
|
||||
u.userInfo.Groups = extractGroupsFromOIDCClaims(raw, u.groupsClaim)
|
||||
}
|
||||
|
||||
u.userInfo.Claims = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractGroupsFromOIDCClaims extracts groups from OIDC claims supporting nested paths.
|
||||
// Supports both direct claims (e.g., "groups") and nested claims using '.' separator (e.g., "resource_access.filebrowser-client.roles")
|
||||
func extractGroupsFromOIDCClaims(claims map[string]interface{}, groupsClaimField string) []string {
|
||||
var groups []string
|
||||
|
||||
if groupsClaimField == "" {
|
||||
return groups
|
||||
}
|
||||
|
||||
// Try to resolve nested path first (e.g., "custom.groups")
|
||||
groupsVal := resolveOIDCNestedClaim(claims, groupsClaimField)
|
||||
|
||||
if groupsVal == nil {
|
||||
// If nested path didn't work, try direct lookup
|
||||
if val, ok := claims[groupsClaimField]; ok {
|
||||
groupsVal = val
|
||||
}
|
||||
}
|
||||
|
||||
if groupsVal != nil {
|
||||
groups = parseOIDCGroupsValue(groupsVal)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// resolveOIDCNestedClaim resolves nested OIDC claims using '.' as a separator
|
||||
func resolveOIDCNestedClaim(claims map[string]interface{}, path string) interface{} {
|
||||
parts := strings.Split(path, ".")
|
||||
current := interface{}(claims)
|
||||
|
||||
for _, part := range parts {
|
||||
switch v := current.(type) {
|
||||
case map[string]interface{}:
|
||||
if val, ok := v[part]; ok {
|
||||
current = val
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
// parseOIDCGroupsValue parses OIDC claim values into a string slice of groups
|
||||
// Returns nil for missing/empty non-array values, []string{} for empty arrays
|
||||
func parseOIDCGroupsValue(groupsVal interface{}) []string {
|
||||
var groups []string
|
||||
|
||||
switch val := groupsVal.(type) {
|
||||
case map[string]interface{}:
|
||||
// Groups as map keys - return empty slice for empty map
|
||||
groups = slices.Collect(maps.Keys(val))
|
||||
case []interface{}:
|
||||
// Groups as array of interfaces - always initialize as empty slice for arrays
|
||||
groups = []string{}
|
||||
for _, g := range val {
|
||||
if groupStr, ok := g.(string); ok {
|
||||
groups = append(groups, strings.TrimSpace(groupStr))
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
// Groups as array of strings - always initialize as empty slice for arrays
|
||||
groups = val
|
||||
if groups == nil {
|
||||
groups = []string{}
|
||||
}
|
||||
case string:
|
||||
// Single group as string or comma-separated groups
|
||||
// Return nil for empty strings, parsed array otherwise
|
||||
if val != "" {
|
||||
parts := strings.Split(val, ",")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
groups = parts
|
||||
}
|
||||
// else: groups stays nil for empty string
|
||||
default:
|
||||
// Try to unmarshal as JSON array
|
||||
if jsonBytes, err := json.Marshal(val); err == nil {
|
||||
var groupsArray []string
|
||||
if err := json.Unmarshal(jsonBytes, &groupsArray); err == nil {
|
||||
// Initialize as empty slice if array was parsed
|
||||
if groupsArray == nil {
|
||||
groups = []string{}
|
||||
} else {
|
||||
groups = groupsArray
|
||||
if v, ok := raw[u.groupsClaim]; ok {
|
||||
switch val := v.(type) {
|
||||
case []interface{}:
|
||||
groups := make([]string, len(val))
|
||||
for i, g := range val {
|
||||
if s, ok := g.(string); ok {
|
||||
groups[i] = strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
u.userInfo.Groups = groups
|
||||
case string:
|
||||
if val != "" {
|
||||
parts := strings.Split(val, ",")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
u.userInfo.Groups = parts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
u.userInfo.Claims = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
// oidcLoginHandler initiates OIDC login.
|
||||
@@ -239,6 +158,7 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
logger.Errorf("failed to exchange token: %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to exchange token: %v", err)
|
||||
}
|
||||
fmt.Println("token", token)
|
||||
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
// accessToken := token.AccessToken // Access token is needed for UserInfo, already in 'token'
|
||||
@@ -255,7 +175,7 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
|
||||
// --- Attempt to process ID Token ---
|
||||
if ok && rawIDToken != "" {
|
||||
logger.Debugf("ID token found in token response, attempting verification. Raw ID Token: %s", rawIDToken)
|
||||
logger.Debug("ID token found in token response, attempting verification.")
|
||||
|
||||
// Verify the ID token
|
||||
// This uses the verifier initialized with the provider's JWKS endpoint and client ID
|
||||
@@ -280,6 +200,7 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
claimsFromIDToken = true
|
||||
}
|
||||
}
|
||||
logger.Debugf("Failed to verify ID token: %v", verify_err)
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
@@ -114,64 +114,6 @@ func TestUserInfoUnmarshaller(t *testing.T) {
|
||||
Groups: []string{"admins"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested single-level claim",
|
||||
jsonData: `{"username":"john","custom":{"groups":["admin","users"]}}`,
|
||||
groupsClaim: "custom.groups",
|
||||
expected: userInfo{
|
||||
Claims: map[string]interface{}{
|
||||
"username": "john",
|
||||
"custom": map[string]interface{}{
|
||||
"groups": []interface{}{"admin","users"},
|
||||
},
|
||||
},
|
||||
Groups: []string{"admin", "users"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keycloak resource_access nested structure",
|
||||
jsonData: `{"sub":"user123","resource_access":{"filebrowser-client":{"roles":["admin","editor","viewer"]}}}`,
|
||||
groupsClaim: "resource_access.filebrowser-client.roles",
|
||||
expected: userInfo{
|
||||
Claims: map[string]interface{}{
|
||||
"sub": "user123",
|
||||
"resource_access": map[string]interface{}{
|
||||
"filebrowser-client": map[string]interface{}{
|
||||
"roles": []interface{}{"admin","editor","viewer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Groups: []string{"admin", "editor", "viewer"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested claim with single string value",
|
||||
jsonData: `{"username":"bob","auth":{"group":"admin"}}`,
|
||||
groupsClaim: "auth.group",
|
||||
expected: userInfo{
|
||||
Claims: map[string]interface{}{
|
||||
"username": "bob",
|
||||
"auth": map[string]interface{}{
|
||||
"group": "admin",
|
||||
},
|
||||
},
|
||||
Groups: []string{"admin"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid nested path returns empty groups",
|
||||
jsonData: `{"username":"alice","custom":{"roles":["user"]}}`,
|
||||
groupsClaim: "custom.nonexistent.roles",
|
||||
expected: userInfo{
|
||||
Claims: map[string]interface{}{
|
||||
"username": "alice",
|
||||
"custom": map[string]interface{}{
|
||||
"roles": []interface{}{"user"},
|
||||
},
|
||||
},
|
||||
Groups: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
+42
-17
@@ -99,7 +99,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(providedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
providedPath = cleanPath
|
||||
|
||||
@@ -280,12 +280,14 @@ func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token strin
|
||||
}
|
||||
|
||||
escapedPath := url.QueryEscape(path)
|
||||
downloadURL := fmt.Sprintf("%s/api/resources/download?file=%s&auth=%s&source=%s",
|
||||
strings.TrimSuffix(baseURL, "/"), escapedPath, token, url.QueryEscape(source))
|
||||
downloadURL := fmt.Sprintf("%s/api/resources/download?file=%s&auth=%s",
|
||||
strings.TrimSuffix(baseURL, "/"), escapedPath, token)
|
||||
if hash != "" {
|
||||
downloadURL = fmt.Sprintf("%s/public/api/resources/download?file=%s&auth=%s&hash=%s",
|
||||
strings.TrimSuffix(baseURL, "/"), escapedPath, token, hash)
|
||||
downloadURL = downloadURL + "&hash=" + hash
|
||||
} else {
|
||||
downloadURL = downloadURL + "&source=" + url.QueryEscape(source)
|
||||
}
|
||||
|
||||
return downloadURL
|
||||
}
|
||||
|
||||
@@ -352,13 +354,6 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
// Extract clean parameters from query string
|
||||
source := r.URL.Query().Get("source")
|
||||
path := r.URL.Query().Get("path")
|
||||
user := d.user
|
||||
|
||||
if d.share != nil {
|
||||
source = d.share.GetSourceName()
|
||||
path = d.IndexPath
|
||||
user = d.shareUser
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (path == "" || source == "") && d.fileInfo.Hash == "" {
|
||||
@@ -369,10 +364,40 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return returnOnlyOfficeError(w, r, 400, err.Error())
|
||||
logger.Errorf("OnlyOffice callback: invalid path: %v", err)
|
||||
return returnOnlyOfficeError(w, r, 400, "invalid path")
|
||||
}
|
||||
path = cleanPath
|
||||
|
||||
var sourceInfo *settings.Source
|
||||
var ok bool
|
||||
if d.fileInfo.Hash != "" {
|
||||
sourceInfo, ok = settings.Config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
logger.Error("OnlyOffice: share source not found")
|
||||
return returnOnlyOfficeError(w, r, 404, "source not found")
|
||||
}
|
||||
} else {
|
||||
sourceInfo, ok = settings.Config.Server.NameToSource[source]
|
||||
if !ok {
|
||||
logger.Error("OnlyOffice: source not found")
|
||||
return returnOnlyOfficeError(w, r, 404, "source not found")
|
||||
}
|
||||
}
|
||||
|
||||
if d.fileInfo.Hash == "" {
|
||||
// Regular user request - need to resolve scope
|
||||
userScope, scopeErr := d.user.GetScopeForSourceName(source)
|
||||
if scopeErr != nil {
|
||||
logger.Errorf("OnlyOffice callback: source %s not available for user %s: %v", source, d.user.Username, scopeErr)
|
||||
return returnOnlyOfficeError(w, r, 403, "source not available")
|
||||
}
|
||||
path = utils.JoinPathAsUnix(userScope, path)
|
||||
} else {
|
||||
source = sourceInfo.Name
|
||||
// path is index path, so we build from share path
|
||||
path = utils.JoinPathAsUnix(d.share.Path, path)
|
||||
}
|
||||
// Handle document closure - clean up document key cache
|
||||
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
|
||||
data.Status == onlyOfficeStatusDocumentClosedWithNoChanges {
|
||||
@@ -477,14 +502,14 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
}
|
||||
|
||||
// Check share permissions first if this is a share request
|
||||
if d.share != nil {
|
||||
if d.fileInfo.Hash != "" {
|
||||
if !d.share.AllowModify {
|
||||
logger.Errorf("OnlyOffice callback: edit permission not allowed for this share")
|
||||
return returnOnlyOfficeError(w, r, 403, "edit permission not allowed for this share")
|
||||
}
|
||||
} else {
|
||||
// Verify user has modify permissions
|
||||
if !user.Permissions.Modify {
|
||||
if !d.user.Permissions.Modify {
|
||||
logger.Errorf("OnlyOffice callback: user %s lacks modify permissions for path=%s",
|
||||
d.user.Username, path)
|
||||
return returnOnlyOfficeError(w, r, 403, "user lacks modify permissions")
|
||||
@@ -518,7 +543,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
_, err = files.FileInfoFaster(utils.FileOptions{
|
||||
Source: source,
|
||||
Path: path,
|
||||
}, store.Access, user, store.Share)
|
||||
}, store.Access, d.user, store.Share)
|
||||
if err != nil {
|
||||
logger.Errorf("OnlyOffice callback: original file no longer exists at path=%s: %v",
|
||||
path, err)
|
||||
@@ -533,7 +558,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
}
|
||||
|
||||
// Get user scope to resolve full index path for write operation
|
||||
userScope, err := user.GetScopeForSourceName(source)
|
||||
userScope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return returnOnlyOfficeError(w, r, 403, "user scope not found")
|
||||
}
|
||||
|
||||
@@ -119,7 +119,6 @@ func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*i
|
||||
|
||||
source := d.fileInfo.Source
|
||||
path := utils.JoinPathAsUnix(d.fileInfo.Path, name)
|
||||
user := d.user
|
||||
if d.share != nil {
|
||||
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
@@ -127,7 +126,6 @@ func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*i
|
||||
}
|
||||
source = sourceInfo.Name
|
||||
path = d.IndexPath + name
|
||||
user = d.shareUser
|
||||
}
|
||||
fileInfo, err := files.FileInfoFaster(
|
||||
utils.FileOptions{
|
||||
@@ -135,7 +133,7 @@ func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*i
|
||||
Source: source,
|
||||
AlbumArt: true,
|
||||
Metadata: true,
|
||||
}, store.Access, user, store.Share)
|
||||
}, store.Access, d.user, store.Share)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+36
-9
@@ -4,12 +4,14 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"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/preview"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
|
||||
@@ -243,8 +245,8 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if !d.share.AllowModify {
|
||||
return http.StatusForbidden, fmt.Errorf("create is not allowed for this share")
|
||||
}
|
||||
sourceName := d.share.GetSourceName()
|
||||
if sourceName == "" {
|
||||
source, err := d.share.GetSourceName()
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
|
||||
@@ -257,11 +259,11 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
|
||||
resolvedPath := utils.JoinPathAsUnix(d.share.Path, cleanPath)
|
||||
err = files.WriteFile(sourceName, resolvedPath, r.Body)
|
||||
err = files.WriteFile(source, resolvedPath, r.Body)
|
||||
// hide the error
|
||||
if err != nil {
|
||||
logger.Errorf("public put handler: error updating resource with error %v", err)
|
||||
@@ -341,8 +343,8 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
}
|
||||
|
||||
// Get the source from the share
|
||||
sourceName := d.share.GetSourceName()
|
||||
if sourceName == "" {
|
||||
source, err := d.share.GetSourceName()
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
|
||||
@@ -368,9 +370,9 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
// Note: Share paths are absolute, so we don't strip user scope here
|
||||
// resourcePatchHandler will skip adding scope for shares
|
||||
for i := range req.Items {
|
||||
req.Items[i].FromSource = sourceName
|
||||
req.Items[i].FromSource = source
|
||||
req.Items[i].FromPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].FromPath)
|
||||
req.Items[i].ToSource = sourceName
|
||||
req.Items[i].ToSource = source
|
||||
req.Items[i].ToPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].ToPath)
|
||||
}
|
||||
d.Data = req
|
||||
@@ -416,7 +418,7 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
|
||||
}
|
||||
|
||||
sourceName, assetPath, err := d.share.GetShareImagePartsHelper(isBanner)
|
||||
sourceName, assetPath, err := getShareImagePartsHelper(d.share, isBanner)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid asset configuration: %v", err)
|
||||
}
|
||||
@@ -462,6 +464,31 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
return status, err
|
||||
}
|
||||
|
||||
func getShareImagePartsHelper(share *share.Link, isBanner bool) (string, string, error) {
|
||||
// Get the asset query string from share
|
||||
var assetQueryString string
|
||||
if isBanner {
|
||||
assetQueryString = share.Banner
|
||||
} else {
|
||||
assetQueryString = share.Favicon
|
||||
}
|
||||
|
||||
if assetQueryString == "" {
|
||||
return "", "", fmt.Errorf("asset not configured for this share")
|
||||
}
|
||||
|
||||
// Parse the query string to extract source and path
|
||||
assetParams, err := url.ParseQuery(assetQueryString)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid asset configuration: %v", err)
|
||||
}
|
||||
|
||||
sourceName := assetParams.Get("source")
|
||||
assetPath := assetParams.Get("path")
|
||||
|
||||
return sourceName, assetPath, nil
|
||||
}
|
||||
|
||||
// publicItemsGetHandler efficiently returns a basic list of items for a directory in a public share.
|
||||
// @Summary Get directory items (public share)
|
||||
// @Description Efficiently returns a basic list of items for the specified path in a public share. Use hash for authentication instead of source. Use 'only' parameter to filter by only files or folders.
|
||||
|
||||
+24
-129
@@ -13,7 +13,6 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
@@ -23,24 +22,9 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/preview"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
var pauseCache = cache.NewCache[string](1 * time.Minute)
|
||||
var publicPauseCache = cache.NewCache[string](1 * time.Minute)
|
||||
|
||||
// pauseCacheKeySep separates segments in pause cache keys (not used in source names/paths).
|
||||
const pauseCacheKeySep = "\x1e"
|
||||
|
||||
func pauseUploadCacheKey(source, path string) string {
|
||||
return source + pauseCacheKeySep + path
|
||||
}
|
||||
|
||||
func publicPauseUploadCacheKey(shareHash, source, indexPath string) string {
|
||||
return shareHash + pauseCacheKeySep + source + pauseCacheKeySep + indexPath
|
||||
}
|
||||
|
||||
// validateMoveOperation checks if a move/rename operation is valid at the HTTP level
|
||||
// It prevents moving a directory into itself or its subdirectories
|
||||
func validateMoveOperation(src, dst string, isSrcDir bool) error {
|
||||
@@ -77,10 +61,10 @@ func validateMoveOperation(src, dst string, isSrcDir bool) error {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "Path to the resource"
|
||||
// @Param skipExtendedAttrs query string false "When true, omit index-level extended fields (e.g. hasPreview); does not disable ffmpeg/media extraction"
|
||||
// @Param skipExtendedAttrs query string false "Skip extended attributes for faster retrieval, no hasPreview"
|
||||
// @Param source query string true "Source name for the desired source, default is used if not provided"
|
||||
// @Param content query string false "Include file content if true"
|
||||
// @Param metadata query string false "When true, run audio/video metadata extraction, subtitles, and directory media batch processing"
|
||||
// @Param metadata query string false "Extract audio/video metadata if true"
|
||||
// @Param checksum query string false "Optional checksum validation"
|
||||
// @Success 200 {object} iteminfo.FileInfo "Resource metadata"
|
||||
// @Failure 404 {object} map[string]string "Resource not found"
|
||||
@@ -102,7 +86,6 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
SkipExtendedAttrs: skipExtendedAttrs,
|
||||
ShowSharedAttr: true,
|
||||
}, store.Access, d.user, store.Share)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
@@ -272,12 +255,12 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
}
|
||||
|
||||
if d.share != nil {
|
||||
sourceName := d.share.GetSourceName()
|
||||
if sourceName == "" {
|
||||
source, err := d.share.GetSourceName()
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
// get user scope path from share
|
||||
userScope, err := d.shareUser.GetScopeForSourceName(sourceName)
|
||||
userScope, err := d.shareUser.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
@@ -292,7 +275,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: indexPath,
|
||||
Source: sourceName,
|
||||
Source: source,
|
||||
ShowHidden: true,
|
||||
}, store.Access, filePermUser, store.Share)
|
||||
if err != nil {
|
||||
@@ -300,7 +283,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
}
|
||||
|
||||
// Delete the file/directory
|
||||
err = files.DeleteFiles(sourceName, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
logger.Errorf("resource bulk delete handler: error deleting file/directory: %v", err)
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
@@ -347,7 +330,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
// Get file info
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: item.Path,
|
||||
Path: idx.MakeIndexPath(item.Path, false),
|
||||
Source: item.Source,
|
||||
ShowHidden: true,
|
||||
}, store.Access, filePermUser, store.Share)
|
||||
@@ -383,74 +366,6 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
return renderJSON(w, r, response, statusCode)
|
||||
}
|
||||
|
||||
// resourcePauseHandler registers a graceful pause for an in-flight chunked upload.
|
||||
// Call immediately before aborting the chunk POST so the server keeps partial data.
|
||||
// @Summary Register graceful chunked upload pause
|
||||
// @Description Sets a short-lived server flag for the given source and path. When the active chunk POST then fails (e.g. client abort), the server trims the incomplete chunk but keeps the temp partial file for resume.
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "Destination path (same as upload)"
|
||||
// @Param source query string true "Source name (same as upload)"
|
||||
// @Success 200 "Pause registered"
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 404 {object} map[string]string "Source not found"
|
||||
// @Router /api/resources/pause [post]
|
||||
func resourcePauseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if d.share != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("use public pause endpoint for share uploads")
|
||||
}
|
||||
if !d.user.Permissions.Create {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to pause uploads")
|
||||
}
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
path = cleanPath
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
logger.Debugf("source %s not found", source)
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
}
|
||||
if _, err := d.user.GetScopeForSourceName(source); err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
|
||||
}
|
||||
pauseCache.Set(pauseUploadCacheKey(source, path), "1")
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
// publicPauseHandler registers a graceful pause for a public share chunked upload.
|
||||
// @Summary Register graceful chunked upload pause (public share)
|
||||
// @Description Same as authenticated pause, for upload shares. Uses hash and path query parameters like other public resource APIs.
|
||||
// @Tags Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash"
|
||||
// @Param path query string true "Path within the share (same as upload)"
|
||||
// @Success 200 "Pause registered"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 404 {object} map[string]string "Source not found"
|
||||
// @Router /public/api/resources/pause [post]
|
||||
func publicPauseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if d.share.ShareType != "upload" && !d.share.AllowCreate {
|
||||
return http.StatusForbidden, fmt.Errorf("pausing uploads is not allowed for this share")
|
||||
}
|
||||
src, ok := config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
return http.StatusNotFound, fmt.Errorf("source not found")
|
||||
}
|
||||
sourceName := src.Name
|
||||
publicPauseCache.Set(publicPauseUploadCacheKey(d.share.Hash, sourceName, d.IndexPath), "1")
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
// resourcePostHandler creates or uploads a new resource.
|
||||
// @Summary Create or upload a resource
|
||||
// @Description Creates a new resource or uploads a file at the specified path. Supports file uploads and directory creation.
|
||||
@@ -474,7 +389,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
path = cleanPath
|
||||
|
||||
@@ -590,7 +505,13 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// Create a unique name for the temporary file to avoid collisions.
|
||||
hasher := md5.New()
|
||||
hasher.Write([]byte(realPath))
|
||||
tempFilePath := fmt.Sprintf("%s.%s.uploading.tmp", realPath, hex.EncodeToString(hasher.Sum(nil)))
|
||||
uploadID := hex.EncodeToString(hasher.Sum(nil))
|
||||
tempFilePath := filepath.Join(settings.Config.Server.CacheDir, "uploads", uploadID)
|
||||
|
||||
if err = os.MkdirAll(filepath.Dir(tempFilePath), fileutils.PermDir); err != nil {
|
||||
logger.Debugf("could not create temp dir: %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("could not create temp dir: %v", err)
|
||||
}
|
||||
// Create or open the temporary file
|
||||
var outFile *os.File
|
||||
outFile, err = os.OpenFile(tempFilePath, os.O_CREATE|os.O_WRONLY, fileutils.PermFile)
|
||||
@@ -612,31 +533,6 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
chunkSize, err = io.Copy(outFile, r.Body)
|
||||
if err != nil {
|
||||
logger.Debugf("could not write chunk to temp file: %v", err)
|
||||
if truncErr := outFile.Truncate(offset); truncErr != nil {
|
||||
logger.Debugf("could not truncate temp file after failed chunk (offset=%d): %v", offset, truncErr)
|
||||
}
|
||||
_ = outFile.Sync()
|
||||
|
||||
gracefulPause := false
|
||||
if d.share != nil {
|
||||
k := publicPauseUploadCacheKey(d.share.Hash, source, path)
|
||||
if _, ok := publicPauseCache.Get(k); ok {
|
||||
gracefulPause = true
|
||||
publicPauseCache.Delete(k)
|
||||
}
|
||||
} else {
|
||||
k := pauseUploadCacheKey(source, path)
|
||||
if _, ok := pauseCache.Get(k); ok {
|
||||
gracefulPause = true
|
||||
pauseCache.Delete(k)
|
||||
}
|
||||
}
|
||||
|
||||
if gracefulPause {
|
||||
logger.Debugf("chunk upload ended after graceful pause; keeping partial file (source=%s path=%s)", source, path)
|
||||
return 499, nil
|
||||
}
|
||||
_ = os.Remove(tempFilePath)
|
||||
return http.StatusInternalServerError, fmt.Errorf("could not write chunk to temp file: %v", err)
|
||||
}
|
||||
// check if the file is complete
|
||||
@@ -692,15 +588,21 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
path = cleanPath
|
||||
|
||||
// Only allow PUT for files.
|
||||
if strings.HasSuffix(path, "/") {
|
||||
return http.StatusMethodNotAllowed, nil
|
||||
}
|
||||
// Get user scope to resolve full index path for write operation
|
||||
userScope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
|
||||
|
||||
// Check access control for the target path
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
@@ -711,14 +613,7 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
|
||||
}
|
||||
|
||||
// check if destination is a directory
|
||||
stat, err := os.Stat(filepath.Join(idx.Path + fullIndexPath))
|
||||
if err == nil && stat.IsDir() {
|
||||
// if directory return StatusMethodNotAllowed
|
||||
return http.StatusMethodNotAllowed, fmt.Errorf("path is a directory")
|
||||
}
|
||||
|
||||
err = files.WriteFile(source, fullIndexPath, r.Body)
|
||||
err = files.WriteFile(source, utils.JoinPathAsUnix(userScope, path), r.Body)
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
|
||||
+14
-55
@@ -2,10 +2,8 @@ package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
@@ -14,14 +12,12 @@ import (
|
||||
)
|
||||
|
||||
type searchOptions struct {
|
||||
query string
|
||||
sources []string
|
||||
searchScope string
|
||||
combinedPath map[string]string // source -> combinedPath
|
||||
sessionId string
|
||||
largest bool
|
||||
olderThanUnix int64 // optional; 0 = unset. Modified time must be strictly before this Unix second.
|
||||
newerThanUnix int64 // optional; 0 = unset. Modified time must be >= this Unix second.
|
||||
query string
|
||||
sources []string
|
||||
searchScope string
|
||||
combinedPath map[string]string // source -> combinedPath
|
||||
sessionId string
|
||||
largest bool
|
||||
}
|
||||
|
||||
// searchHandler handles search requests for files based on the provided query.
|
||||
@@ -43,8 +39,6 @@ type searchOptions struct {
|
||||
// - source: Source name (deprecated, use 'sources' instead)
|
||||
// - sources: Comma-separated list of source names (e.g., "source1,source2")
|
||||
// - scope: Optional path within user scope to search
|
||||
// - olderThan: Optional Unix time in seconds; only items modified strictly before this instant
|
||||
// - newerThan: Optional Unix time in seconds; only items modified on or after this instant
|
||||
//
|
||||
// Example request (single source):
|
||||
//
|
||||
@@ -79,8 +73,6 @@ type searchOptions struct {
|
||||
// @Param source query string false "Source name for the desired source (deprecated, use 'sources' instead)"
|
||||
// @Param sources query string false "Comma-separated list of source names to search across multiple sources. When multiple sources are specified, scope is always the user's scope for each source."
|
||||
// @Param scope query string false "path within user scope to search, for example '/first/second' to search within the second directory only. Ignored when multiple sources are specified."
|
||||
// @Param olderThan query int false "Unix seconds; only results modified strictly before this time"
|
||||
// @Param newerThan query int false "Unix seconds; only results modified on or after this time"
|
||||
// @Param SessionId header string false "User session ID, add unique value to prevent collisions"
|
||||
// @Success 200 {array} indexing.SearchResult "List of search results with source field populated"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
@@ -91,11 +83,6 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
searchSize := indexing.DefaultSearchResults
|
||||
if searchOptions.largest {
|
||||
searchSize = 200
|
||||
}
|
||||
|
||||
var response []*indexing.SearchResult
|
||||
if len(searchOptions.sources) == 1 {
|
||||
// Single source - use the existing Search method for backward compatibility
|
||||
@@ -104,10 +91,10 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", searchOptions.sources[0])
|
||||
}
|
||||
combinedPath := searchOptions.combinedPath[searchOptions.sources[0]]
|
||||
response = index.Search(searchOptions.query, combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix)
|
||||
response = index.Search(searchOptions.query, combinedPath, searchOptions.sessionId, searchOptions.largest, indexing.DefaultSearchResults)
|
||||
} else {
|
||||
// Multiple sources - use the new SearchMultiSources function
|
||||
response = indexing.SearchMultiSources(searchOptions.query, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix)
|
||||
response = indexing.SearchMultiSources(searchOptions.query, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, indexing.DefaultSearchResults)
|
||||
}
|
||||
|
||||
// Filter out items that are not permitted according to access rules and trim user scope from paths
|
||||
@@ -135,17 +122,6 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
|
||||
sourceParam := r.URL.Query().Get("source") // deprecated, but still supported
|
||||
scope := r.URL.Query().Get("scope") // Go automatically decodes query params
|
||||
largest := r.URL.Query().Get("largest") == "true"
|
||||
olderThanUnix, err := parseOptionalUnixQueryParam("olderThan", r.URL.Query().Get("olderThan"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newerThanUnix, err := parseOptionalUnixQueryParam("newerThan", r.URL.Query().Get("newerThan"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if scope == "" {
|
||||
scope = "/"
|
||||
}
|
||||
cleanScope, err := utils.SanitizeUserPath(scope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid scope: %v", err)
|
||||
@@ -195,28 +171,11 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
|
||||
}
|
||||
|
||||
return &searchOptions{
|
||||
query: query,
|
||||
sources: sources,
|
||||
searchScope: searchScope,
|
||||
combinedPath: combinedPathMap,
|
||||
sessionId: sessionId,
|
||||
largest: largest,
|
||||
olderThanUnix: olderThanUnix,
|
||||
newerThanUnix: newerThanUnix,
|
||||
query: query,
|
||||
sources: sources,
|
||||
searchScope: searchScope,
|
||||
combinedPath: combinedPathMap,
|
||||
sessionId: sessionId,
|
||||
largest: largest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseOptionalUnixQueryParam(name, raw string) (int64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
v, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid %s: must be a non-negative Unix timestamp in seconds", name)
|
||||
}
|
||||
if v > math.MaxInt64 {
|
||||
return 0, fmt.Errorf("invalid %s: value too large", name)
|
||||
}
|
||||
return int64(v), nil
|
||||
}
|
||||
+18
-45
@@ -33,14 +33,13 @@ type ShareResponse struct {
|
||||
}
|
||||
|
||||
// convertToFrontendShareResponse converts shares to response format with usernames
|
||||
func convertToFrontendShareResponse(r *http.Request, shares []*share.Link, user *users.User) ([]*ShareResponse, error) {
|
||||
func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*ShareResponse, error) {
|
||||
responses := make([]*ShareResponse, 0, len(shares))
|
||||
for _, s := range shares {
|
||||
// Look for the username of the user who created the share
|
||||
creator, err := store.Users.Get(s.UserID)
|
||||
user, err := store.Users.Get(s.UserID)
|
||||
username := ""
|
||||
if err == nil {
|
||||
username = creator.Username
|
||||
username = user.Username
|
||||
}
|
||||
|
||||
// Get source info to convert path to name for frontend
|
||||
@@ -63,9 +62,6 @@ func convertToFrontendShareResponse(r *http.Request, shares []*share.Link, user
|
||||
s.CommonShare.HasPassword = s.HasPassword()
|
||||
s.DownloadURL = getShareURL(r, s.Hash, true, s.Token)
|
||||
s.ShareURL = getShareURL(r, s.Hash, false, s.Token)
|
||||
if s.UserCanEdit(user) {
|
||||
s.CommonShare.SourceURL = s.SourceURL(user)
|
||||
}
|
||||
// Create response with source name (overrides the embedded Link's source field)
|
||||
responses = append(responses, &ShareResponse{
|
||||
Link: s,
|
||||
@@ -98,7 +94,7 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
shares = utils.NonNilSlice(shares)
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, shares, d.user)
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, shares)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -138,7 +134,7 @@ func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error getting share info from server")
|
||||
}
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, s, d.user)
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, s)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -163,16 +159,7 @@ func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
return http.StatusBadRequest, nil
|
||||
}
|
||||
|
||||
// only allow users to delete their own shares
|
||||
thisShare, err := store.Share.GetByHash(hash)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("share not found")
|
||||
}
|
||||
if thisShare.UserID != d.user.ID && !d.user.Permissions.Admin {
|
||||
return http.StatusForbidden, fmt.Errorf("you are not allowed to delete this share")
|
||||
}
|
||||
|
||||
err = store.Share.Delete(hash)
|
||||
err := store.Share.Delete(hash)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
@@ -206,16 +193,8 @@ func sharePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
return http.StatusBadRequest, fmt.Errorf("hash and path are required")
|
||||
}
|
||||
|
||||
// only allow users to update their own shares
|
||||
thisShare, err := store.Share.GetByHash(body.Hash)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("share not found")
|
||||
}
|
||||
if thisShare.UserID != d.user.ID && !d.user.Permissions.Admin {
|
||||
return http.StatusForbidden, fmt.Errorf("you are not allowed to update this share")
|
||||
}
|
||||
// Update the share path
|
||||
err = store.Share.UpdateSharePath(body.Hash, body.Path)
|
||||
err := store.Share.UpdateSharePath(body.Hash, body.Path)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -227,7 +206,7 @@ func sharePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{updatedShare}, d.user)
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{updatedShare})
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -380,7 +359,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(providedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
|
||||
body.Path = utils.JoinPathAsUnix(userscope, cleanPath)
|
||||
@@ -407,7 +386,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if err = store.Share.Save(s); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{s}, d.user)
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{s})
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -629,11 +608,16 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
}
|
||||
commonShare := shareLink.CommonShare
|
||||
commonShare.ShareURL = getShareURL(r, hash, false, "")
|
||||
commonShare.BannerUrl = shareLink.BannerURL()
|
||||
commonShare.FaviconUrl = shareLink.FaviconURL()
|
||||
_, _, err = getShareImagePartsHelper(shareLink, true)
|
||||
if err == nil {
|
||||
commonShare.BannerUrl = fmt.Sprintf("%spublic/api/share/image?banner=true&hash=%s", config.Server.BaseURL, hash)
|
||||
}
|
||||
_, _, err = getShareImagePartsHelper(shareLink, false)
|
||||
if err == nil {
|
||||
commonShare.FaviconUrl = fmt.Sprintf("%spublic/api/share/image?favicon=true&hash=%s", config.Server.BaseURL, hash)
|
||||
}
|
||||
commonShare.Source = ""
|
||||
commonShare.Path = ""
|
||||
commonShare.DownloadURL = ""
|
||||
commonShare.SidebarLinks = []users.SidebarLink{}
|
||||
for _, link := range shareLink.SidebarLinks {
|
||||
if link.Category == "download" && shareLink.ShareType == "upload" {
|
||||
@@ -642,17 +626,6 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
commonShare.SidebarLinks = append(commonShare.SidebarLinks, link)
|
||||
}
|
||||
}
|
||||
commonShare.SourceURL = shareLink.SourceURL(d.user)
|
||||
commonShare.CanEditShare = shareLink.UserCanEdit(d.user)
|
||||
if commonShare.SourceURL != "" {
|
||||
link := users.SidebarLink{
|
||||
Name: "sourceLocation",
|
||||
Category: "custom",
|
||||
Target: commonShare.SourceURL,
|
||||
}
|
||||
commonShare.SidebarLinks = append(commonShare.SidebarLinks, link)
|
||||
}
|
||||
|
||||
return renderJSON(w, r, commonShare)
|
||||
}
|
||||
|
||||
|
||||
+29
-24
@@ -3,12 +3,12 @@ package http
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
@@ -25,18 +25,10 @@ type TemplateRenderer struct {
|
||||
|
||||
// Render renders a template document with headers and data
|
||||
func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data interface{}) error {
|
||||
templates := t.templates
|
||||
// If in dev mode, reload templates on every render by creating a fresh instance
|
||||
// If in dev mode, reload templates on every render.
|
||||
if t.devMode {
|
||||
var err error
|
||||
templates = template.New("").Funcs(template.FuncMap{
|
||||
"marshal": func(v interface{}) (string, error) {
|
||||
var a []byte
|
||||
a, err = json.Marshal(v)
|
||||
return string(a), err
|
||||
},
|
||||
})
|
||||
templates, err = templates.ParseFS(assetFs, "public/index.html")
|
||||
t.templates, err = t.templates.ParseFS(assetFs, "public/index.html")
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reloading template: %w", err)
|
||||
}
|
||||
@@ -47,7 +39,7 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
|
||||
w.Header().Set("X-Accel-Expires", "0")
|
||||
w.Header().Set("Transfer-Encoding", "identity")
|
||||
// Execute the template with the provided data
|
||||
return templates.ExecuteTemplate(w, name, data)
|
||||
return t.templates.ExecuteTemplate(w, name, data)
|
||||
}
|
||||
|
||||
func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestContext, file, contentType string) (int, error) {
|
||||
@@ -119,10 +111,21 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
if d.share.DisableSidebar {
|
||||
disableSidebar = true
|
||||
}
|
||||
banner = d.share.BannerURL()
|
||||
shareFavicon := d.share.FaviconURL()
|
||||
if shareFavicon != "" {
|
||||
favicon = shareFavicon
|
||||
if strings.HasPrefix(strings.ToLower(d.share.Banner), "http") {
|
||||
banner = d.share.Banner
|
||||
} else {
|
||||
_, _, err := getShareImagePartsHelper(d.share, true)
|
||||
if err == nil {
|
||||
banner = fmt.Sprintf("%s%spublic/api/share/image?banner=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(d.share.Favicon), "http") {
|
||||
favicon = d.share.Favicon
|
||||
} else {
|
||||
_, _, err := getShareImagePartsHelper(d.share, false)
|
||||
if err == nil {
|
||||
favicon = fmt.Sprintf("%s%spublic/api/share/image?favicon=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
|
||||
}
|
||||
}
|
||||
shareHash = d.share.Hash
|
||||
}
|
||||
@@ -177,8 +180,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
|
||||
data["htmlVars"] = map[string]interface{}{
|
||||
"title": title,
|
||||
"customCSS": template.CSS(config.Frontend.Styling.CustomCSSRaw),
|
||||
"userSelectedTheme": template.CSS(userSelectedTheme),
|
||||
"customCSS": config.Frontend.Styling.CustomCSSRaw,
|
||||
"userSelectedTheme": userSelectedTheme,
|
||||
"lightBackground": config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": config.Frontend.Styling.DarkBackground,
|
||||
"staticURL": staticURL,
|
||||
@@ -189,7 +192,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"winIcon": staticURL + "/mstile-256x256.png",
|
||||
"appIcon": staticURL + "/apple-touch-icon.png",
|
||||
"description": description,
|
||||
"loadingSpinnersCSS": template.CSS(loadingSpinnersCSS),
|
||||
"loadingSpinnersCSS": loadingSpinnersCSS,
|
||||
"banner": banner,
|
||||
"image": ogImage,
|
||||
"url": fullURL,
|
||||
@@ -214,7 +217,6 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"externalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
|
||||
"onlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
|
||||
"oidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
|
||||
"jwtAvailable": config.Auth.Methods.JwtAuth.Enabled,
|
||||
"proxyAvailable": config.Auth.Methods.ProxyAuth.Enabled,
|
||||
"passwordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
|
||||
"ldapAvailable": config.Auth.Methods.LdapAuth.Enabled,
|
||||
@@ -239,8 +241,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
return http.StatusInternalServerError, fmt.Errorf("error marshaling globalVars: %w", err)
|
||||
}
|
||||
|
||||
// Mark as JS for html/template to avoid escaping
|
||||
data["globalVars"] = template.JS(globalVarsJSON)
|
||||
// Replace with JSON strings for direct template usage
|
||||
data["globalVars"] = string(globalVarsJSON)
|
||||
|
||||
// Render the template with global variables
|
||||
if err := templateRenderer.Render(w, file, data); err != nil {
|
||||
@@ -295,8 +297,11 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", `default-src 'self'; style-src 'unsafe-inline';`)
|
||||
|
||||
// Strip baseURL and /static/ prefix to get clean asset path
|
||||
path := strings.TrimPrefix(r.URL.Path, "/public")
|
||||
path = strings.TrimPrefix(path, "/static/")
|
||||
path := r.URL.Path
|
||||
path = strings.TrimPrefix(path, config.Server.BaseURL) // Remove /testing/ or other baseURL
|
||||
path = strings.TrimPrefix(path, "/") // Remove leading slash FIRST
|
||||
path = strings.TrimPrefix(path, "static/") // Then remove static/ prefix
|
||||
|
||||
// Handle special routes that need path mapping
|
||||
var assetPath string
|
||||
switch path {
|
||||
|
||||
+1
-54
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -257,59 +255,16 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusCreated, nil
|
||||
}
|
||||
|
||||
// passwordUpdateRequested reports whether the client intends to change the target user's password
|
||||
// (non-empty new password and "password" in the which list). This matches storage parseFields behavior.
|
||||
func passwordUpdateRequested(req *UserRequest) bool {
|
||||
if req.User.Password == "" {
|
||||
return false
|
||||
}
|
||||
for _, w := range req.Which {
|
||||
if strings.EqualFold(w, "password") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// verifyActorPasswordForUserPasswordChange ensures the authenticated actor re-proves their password
|
||||
// when changing another user's or their own password via PUT. Requires URL-encoded X-Password (same as login).
|
||||
func verifyActorPasswordForUserPasswordChange(r *http.Request, d *requestContext, target *users.User) (int, error) {
|
||||
if d.user.LoginMethod != users.LoginMethodPassword || target.LoginMethod != users.LoginMethodPassword {
|
||||
return http.StatusForbidden, fmt.Errorf("password can only be changed when both accounts use password login")
|
||||
}
|
||||
encoded := r.Header.Get("X-Password")
|
||||
if encoded == "" {
|
||||
return http.StatusUnauthorized, fmt.Errorf("X-Password header is required to confirm your password")
|
||||
}
|
||||
plain, err := url.QueryUnescape(encoded)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid password encoding")
|
||||
}
|
||||
if plain == "" {
|
||||
return http.StatusUnauthorized, fmt.Errorf("X-Password header is required to confirm your password")
|
||||
}
|
||||
actor, err := store.Users.Get(d.user.ID)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
if err := utils.CheckPwd(plain, actor.Password); err != nil {
|
||||
return http.StatusUnauthorized, fmt.Errorf("invalid password")
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// userPutHandler updates an existing user's details.
|
||||
// @Summary Update a user's details
|
||||
// @Description Updates the details of a user identified by ID. When updating the target user's password the actor must send their current password in the X-Password header
|
||||
// @Description Updates the details of a user identified by ID.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Param id query string false "user ID to update"
|
||||
// @Param id query string false "usename to update"
|
||||
// @Param X-Password header string false "Actor's current password (URL-encoded); required when changing a password user's password"
|
||||
// @Param data body users.User true "User data to update"
|
||||
// @Success 204 "No Content - User updated successfully"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized - invalid or missing actor password for password change"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||
// @Router /api/users [put]
|
||||
@@ -361,14 +316,6 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
return http.StatusBadRequest, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
if passwordUpdateRequested(&req) {
|
||||
var status int
|
||||
status, err = verifyActorPasswordForUserPasswordChange(r, d, oldUser)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
}
|
||||
|
||||
err = store.Users.Update(&req.User, d.user.Permissions.Admin, req.Which...)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
|
||||
+66
-198
@@ -2,7 +2,6 @@ package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -14,7 +13,6 @@ import (
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
@@ -45,22 +43,20 @@ type filteredFileSystem struct {
|
||||
fs webdav.FileSystem
|
||||
source string
|
||||
user *users.User
|
||||
userScope string
|
||||
userscope string
|
||||
// Cache FileInfoFaster results per path to avoid redundant calls within the same request
|
||||
fileInfoCache map[string]*iteminfo.ExtendedFileInfo
|
||||
}
|
||||
|
||||
// getFileInfo retrieves file information with caching and access control
|
||||
// requestPath should NOT include user scope - FileInfoFaster applies it internally
|
||||
// This function prefers cache, then tries FileInfoFaster which applies access control
|
||||
func (ffs *filteredFileSystem) getFileInfo(requestPath string, expand bool) (*iteminfo.ExtendedFileInfo, error) {
|
||||
// getCachedFileInfo retrieves cached FileInfoFaster result, or calls FileInfoFaster and caches it
|
||||
func (ffs *filteredFileSystem) getCachedFileInfo(permissionPath string, expand bool) (*iteminfo.ExtendedFileInfo, error) {
|
||||
// Initialize cache if needed
|
||||
if ffs.fileInfoCache == nil {
|
||||
ffs.fileInfoCache = make(map[string]*iteminfo.ExtendedFileInfo)
|
||||
}
|
||||
|
||||
// Create cache key (don't modify requestPath itself!)
|
||||
cacheKey := requestPath
|
||||
// Check cache - first try exact match
|
||||
cacheKey := permissionPath
|
||||
if expand {
|
||||
cacheKey += ":expand"
|
||||
}
|
||||
@@ -71,33 +67,34 @@ func (ffs *filteredFileSystem) getFileInfo(requestPath string, expand bool) (*it
|
||||
// If we're looking for non-expanded but have expanded cached, we can use it
|
||||
// (expanded contains all the same info plus more)
|
||||
if !expand {
|
||||
expandedKey := requestPath + ":expand"
|
||||
expandedKey := permissionPath + ":expand"
|
||||
if cached, found := ffs.fileInfoCache[expandedKey]; found {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Call FileInfoFaster with clean requestPath (without scope or cache suffix)
|
||||
// FileInfoFaster applies user scope internally AND enforces access control
|
||||
// Call FileInfoFaster
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Path: requestPath,
|
||||
Path: permissionPath,
|
||||
Source: ffs.source,
|
||||
Expand: expand,
|
||||
ShowHidden: ffs.user.ShowHidden,
|
||||
SkipExtendedAttrs: true,
|
||||
FollowSymlinks: true,
|
||||
}, store.Access, ffs.user, store.Share)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache result using the cache key
|
||||
// Cache result
|
||||
ffs.fileInfoCache[cacheKey] = fileInfo
|
||||
|
||||
// If we got an expanded directory listing, also cache individual file entries
|
||||
// so Stat calls on those files can use the cache
|
||||
if expand && fileInfo.Type == "directory" {
|
||||
dirPath := requestPath
|
||||
dirPath := permissionPath
|
||||
if !strings.HasSuffix(dirPath, "/") && dirPath != "" {
|
||||
dirPath += "/"
|
||||
}
|
||||
|
||||
// Cache each file and folder from the directory listing
|
||||
for _, file := range fileInfo.Files {
|
||||
filePath := utils.JoinPathAsUnix(dirPath, file.Name)
|
||||
@@ -124,83 +121,12 @@ func (ffs *filteredFileSystem) getFileInfo(requestPath string, expand bool) (*it
|
||||
return fileInfo, nil
|
||||
}
|
||||
|
||||
// checkAccess validates if the user can access a given path
|
||||
// This is used by all write operations (mkdir, delete, rename, etc.)
|
||||
// Returns nil if access is allowed, error otherwise
|
||||
func (ffs *filteredFileSystem) checkAccess(requestPath string) error {
|
||||
// First, validate permissions using CheckPermissions
|
||||
indexPath, _, err := files.CheckPermissions(utils.FileOptions{
|
||||
FollowSymlinks: false,
|
||||
Path: requestPath,
|
||||
Source: ffs.source,
|
||||
ShowHidden: ffs.user.ShowHidden,
|
||||
}, store.Access, ffs.user)
|
||||
if err != nil {
|
||||
logger.Debugf("checkAccess: CheckPermissions denied for %s: %v", requestPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Try to get file info to verify it's accessible
|
||||
// Use non-expanded to be faster (we just need to know if it exists and is accessible)
|
||||
_, err = ffs.getFileInfo(requestPath, false)
|
||||
if err == nil {
|
||||
// Successfully got info - access allowed
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle specific error cases
|
||||
if errors.Is(err, commonerrors.ErrAccessDenied) {
|
||||
logger.Debugf("checkAccess: access explicitly denied for %s", requestPath)
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
// CRITICAL: If item is not indexed AND not viewable, deny access
|
||||
// This prevents WebDAV from creating/modifying files in non-indexed areas
|
||||
if errors.Is(err, commonerrors.ErrNotViewable) {
|
||||
logger.Debugf("checkAccess: path not viewable for %s", requestPath)
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
// If not indexed but potentially viewable, we need to check more carefully
|
||||
if errors.Is(err, commonerrors.ErrNotIndexed) {
|
||||
// Get the index to check viewability
|
||||
idx := indexing.GetIndex(ffs.source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("source not found")
|
||||
}
|
||||
|
||||
// Check if the item is viewable using GetFileInfo (without expand)
|
||||
info, getErr := idx.GetFileInfo(indexing.FileInfoRequest{
|
||||
IndexPath: indexPath,
|
||||
FollowSymlinks: false,
|
||||
ShowHidden: ffs.user.ShowHidden,
|
||||
Expand: false,
|
||||
SkipExtendedAttrs: true,
|
||||
})
|
||||
|
||||
// If GetFileInfo succeeds, the item is viewable despite not being indexed
|
||||
if getErr == nil && info != nil {
|
||||
logger.Debugf("checkAccess: path not indexed but viewable for %s", requestPath)
|
||||
return nil // Allow access to viewable items
|
||||
}
|
||||
|
||||
// Not viewable - deny access
|
||||
logger.Debugf("checkAccess: path not indexed and not viewable for %s", requestPath)
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
// For other errors (like file not found), that's okay for new file/directory creation
|
||||
// The caller will handle whether the operation is appropriate
|
||||
logger.Debugf("checkAccess: path check returned: %v (may be acceptable for new items)", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// filteredFile wraps a webdav.File and filters Readdir results based on FileInfoFaster
|
||||
type filteredFile struct {
|
||||
webdav.File
|
||||
fs *filteredFileSystem
|
||||
requestPath string // The request path (without user scope)
|
||||
isDir bool // Whether this is a directory
|
||||
fs *filteredFileSystem
|
||||
dirPath string // The directory path this file represents (relative to scope root)
|
||||
isDir bool // Whether this is a directory
|
||||
}
|
||||
|
||||
func (ff *filteredFile) Readdir(count int) ([]os.FileInfo, error) {
|
||||
@@ -209,23 +135,21 @@ func (ff *filteredFile) Readdir(count int) ([]os.FileInfo, error) {
|
||||
return ff.File.Readdir(count)
|
||||
}
|
||||
|
||||
// Pass the requestPath (without scope) to getCachedFileInfo
|
||||
// FileInfoFaster will apply the user's scope internally
|
||||
fileInfo, err := ff.fs.getFileInfo(ff.requestPath, true)
|
||||
if err != nil {
|
||||
logger.Debugf("readdir: getFileInfo failed for requestPath=%s: %v", ff.requestPath, err)
|
||||
// Handle errors gracefully - return empty directory for access/indexing issues
|
||||
// This is especially important when user's scope points to a non-viewable directory
|
||||
if errors.Is(err, commonerrors.ErrAccessDenied) ||
|
||||
errors.Is(err, commonerrors.ErrNotIndexed) ||
|
||||
errors.Is(err, commonerrors.ErrNotViewable) {
|
||||
logger.Debugf("readdir: access issue for %s: %v - returning empty", ff.requestPath, err)
|
||||
return []os.FileInfo{}, nil
|
||||
}
|
||||
// Other errors - propagate them
|
||||
return nil, err
|
||||
// For directories, use FileInfoFaster to get filtered contents
|
||||
// Convert dirPath (relative to scope root) to permission path (relative to userscope)
|
||||
// Handle root path specially
|
||||
var permissionPath string
|
||||
if ff.dirPath == "" || ff.dirPath == "/" {
|
||||
permissionPath = ff.fs.userscope
|
||||
} else {
|
||||
permissionPath = utils.JoinPathAsUnix(ff.fs.userscope, ff.dirPath)
|
||||
}
|
||||
|
||||
// Use cached FileInfoFaster result if available
|
||||
fileInfo, err := ff.fs.getCachedFileInfo(permissionPath, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Build os.FileInfo list directly from FileInfoFaster's filtered results
|
||||
// No need to read from underlying filesystem - FileInfoFaster already filtered by permissions
|
||||
entries := make([]os.FileInfo, 0, len(fileInfo.Files)+len(fileInfo.Folders))
|
||||
@@ -251,34 +175,11 @@ func (ffs *filteredFileSystem) Mkdir(ctx context.Context, name string, perm os.F
|
||||
if !ffs.user.Permissions.Create {
|
||||
return fmt.Errorf("create permission required")
|
||||
}
|
||||
|
||||
// Check access before creating directory
|
||||
if err := ffs.checkAccess(name); err != nil {
|
||||
logger.Debugf("Mkdir: access denied for %s: %v", name, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return ffs.fs.Mkdir(ctx, name, perm)
|
||||
}
|
||||
|
||||
func (ffs *filteredFileSystem) OpenFile(ctx context.Context, requestPath string, flag int, perm os.FileMode) (webdav.File, error) {
|
||||
// Check if this is a write operation
|
||||
isWrite := (flag&os.O_WRONLY) != 0 || (flag&os.O_RDWR) != 0 || (flag&os.O_CREATE) != 0
|
||||
|
||||
if isWrite {
|
||||
// Check user permissions first
|
||||
if !ffs.user.Permissions.Create && !ffs.user.Permissions.Modify {
|
||||
return nil, fmt.Errorf("write permission required")
|
||||
}
|
||||
|
||||
// For write operations, check access
|
||||
if err := ffs.checkAccess(requestPath); err != nil {
|
||||
logger.Debugf("OpenFile: write access denied for %s: %v", requestPath, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
file, err := ffs.fs.OpenFile(ctx, requestPath, flag, perm)
|
||||
func (ffs *filteredFileSystem) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
|
||||
file, err := ffs.fs.OpenFile(ctx, name, flag, perm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -290,77 +191,51 @@ func (ffs *filteredFileSystem) OpenFile(ctx context.Context, requestPath string,
|
||||
}
|
||||
|
||||
// Wrap the file to filter directory listings
|
||||
// name is the request path (without user scope)
|
||||
return &filteredFile{
|
||||
File: file,
|
||||
fs: ffs,
|
||||
requestPath: requestPath,
|
||||
isDir: stat.IsDir(),
|
||||
File: file,
|
||||
fs: ffs,
|
||||
dirPath: name,
|
||||
isDir: stat.IsDir(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ffs *filteredFileSystem) RemoveAll(ctx context.Context, requestPath string) error {
|
||||
func (ffs *filteredFileSystem) RemoveAll(ctx context.Context, name string) error {
|
||||
if !ffs.user.Permissions.Delete {
|
||||
return fmt.Errorf("delete permission required")
|
||||
}
|
||||
|
||||
// Check access before deleting
|
||||
if err := ffs.checkAccess(requestPath); err != nil {
|
||||
logger.Debugf("RemoveAll: access denied for %s: %v", requestPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return ffs.fs.RemoveAll(ctx, requestPath)
|
||||
return ffs.fs.RemoveAll(ctx, name)
|
||||
}
|
||||
|
||||
func (ffs *filteredFileSystem) Rename(ctx context.Context, oldPath, newPath string) error {
|
||||
func (ffs *filteredFileSystem) Rename(ctx context.Context, oldName, newName string) error {
|
||||
if !ffs.user.Permissions.Modify {
|
||||
return fmt.Errorf("modify permission required")
|
||||
}
|
||||
|
||||
// Check access for both old and new paths
|
||||
if err := ffs.checkAccess(oldPath); err != nil {
|
||||
logger.Debugf("Rename: access denied for source %s: %v", oldPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ffs.checkAccess(newPath); err != nil {
|
||||
logger.Debugf("Rename: access denied for destination %s: %v", newPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return ffs.fs.Rename(ctx, oldPath, newPath)
|
||||
return ffs.fs.Rename(ctx, oldName, newName)
|
||||
}
|
||||
|
||||
func (ffs *filteredFileSystem) Stat(ctx context.Context, requestPath string) (os.FileInfo, error) {
|
||||
// Try to get file info (uses cache if available, FileInfoFaster otherwise)
|
||||
_, err := ffs.getFileInfo(requestPath, false)
|
||||
if err == nil {
|
||||
// Successfully got info - use underlying filesystem
|
||||
return ffs.fs.Stat(ctx, requestPath)
|
||||
func (ffs *filteredFileSystem) Stat(ctx context.Context, name string) (os.FileInfo, error) {
|
||||
// Root path "/" should always be allowed
|
||||
if name == "" || name == "/" {
|
||||
return ffs.fs.Stat(ctx, name)
|
||||
}
|
||||
|
||||
// Handle specific error cases
|
||||
if errors.Is(err, commonerrors.ErrAccessDenied) || errors.Is(err, commonerrors.ErrNotViewable) {
|
||||
return nil, os.ErrPermission
|
||||
// Check permission before stat using cached result if available
|
||||
permissionPath := utils.JoinPathAsUnix(ffs.userscope, name)
|
||||
_, err := ffs.getCachedFileInfo(permissionPath, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// For not indexed, check if it's viewable
|
||||
if errors.Is(err, commonerrors.ErrNotIndexed) {
|
||||
// Use checkAccess to determine if viewable
|
||||
if accessErr := ffs.checkAccess(requestPath); accessErr != nil {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
// Viewable - allow underlying filesystem
|
||||
return ffs.fs.Stat(ctx, requestPath)
|
||||
}
|
||||
|
||||
// For other errors (like file not found), let underlying filesystem handle it
|
||||
return ffs.fs.Stat(ctx, requestPath)
|
||||
return ffs.fs.Stat(ctx, name)
|
||||
}
|
||||
|
||||
// webDAVHandler serves WebDAV requests.
|
||||
func webDAVHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
path := r.PathValue("path")
|
||||
source := r.PathValue("source")
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
if !d.user.Permissions.Download {
|
||||
return http.StatusForbidden, fmt.Errorf("download permission required")
|
||||
}
|
||||
@@ -371,19 +246,15 @@ func webDAVHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
if isWrite && !userCanWrite(d.user.Permissions) {
|
||||
return http.StatusForbidden, fmt.Errorf("user has no permission to modify")
|
||||
}
|
||||
requestPath := utils.AddTrailingSlashIfNotExists(r.PathValue("path"))
|
||||
source := r.PathValue("source")
|
||||
if !strings.HasPrefix(requestPath, "/") {
|
||||
requestPath = "/" + requestPath
|
||||
}
|
||||
logger.Debugf("webdav: method=%s, request=%s, source=%s, requestPath=%s", r.Method, r.URL.Path, source, requestPath)
|
||||
_, userScope, err := files.CheckPermissions(utils.FileOptions{
|
||||
logger.Debugf("webdav: method=%s, request=%s, source=%s, path=%s", r.Method, r.URL.Path, source, path)
|
||||
indexPath, userScope, err := files.CheckPermissions(utils.FileOptions{
|
||||
FollowSymlinks: false,
|
||||
Path: requestPath,
|
||||
Path: path,
|
||||
Source: source,
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
logger.Debugf("error checking file permissions for path %s: %v", path, err)
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
@@ -391,25 +262,28 @@ func webDAVHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
}
|
||||
|
||||
// Get the user's scope to determine the WebDAV root directory
|
||||
// Resolve the scope path to get the real filesystem root for WebDAV
|
||||
// This is the root directory that WebDAV will use to resolve relative paths
|
||||
scopePath, _, err := idx.GetRealPath(userScope)
|
||||
if err != nil {
|
||||
logger.Debugf("error resolving scope path: %v", err)
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
|
||||
// Construct the WebDAV prefix from BaseURL
|
||||
webDavPrefix := config.Server.BaseURL + "dav"
|
||||
prefix := webDavPrefix + "/" + source
|
||||
logger.Debugf("webdav: virtual_path=%s, indexPath=%s, scope_path=%s", path, indexPath, scopePath)
|
||||
|
||||
// Wrap the filesystem to filter directory listings using FileInfoFaster
|
||||
// We pass requestPath (without scope) to FileInfoFaster, which applies scope internally
|
||||
// This prevents items the user can't access from appearing in listings,
|
||||
// which stops clients from repeatedly trying to access them
|
||||
filteredFS := &filteredFileSystem{
|
||||
fs: webdav.Dir(scopePath),
|
||||
source: source,
|
||||
user: d.user,
|
||||
userScope: userScope,
|
||||
userscope: userScope,
|
||||
}
|
||||
|
||||
wd := &webdav.Handler{
|
||||
@@ -417,13 +291,7 @@ func webDAVHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
FileSystem: filteredFS,
|
||||
LockSystem: idx.WebdavLock,
|
||||
Logger: func(req *http.Request, err error) {
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
// Filter out expected/benign errors that don't indicate actual failures
|
||||
if strings.Contains(errStr, "no such file or directory") ||
|
||||
strings.Contains(errStr, "skip this directory") {
|
||||
return
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "no such file or directory") {
|
||||
logger.Errorf("webdav handler failed on path %s: %s", req.URL.Path, err)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,803 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
storm "github.com/asdine/storm/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
|
||||
// setupWebDAVTestEnv sets up the test environment with multiple sources and users
|
||||
func setupWebDAVTestEnv(t *testing.T) (string, string) {
|
||||
// Create temp directories for test sources
|
||||
tempDir := t.TempDir()
|
||||
source1Path := filepath.Join(tempDir, "source1")
|
||||
source2Path := filepath.Join(tempDir, "source2")
|
||||
dockerPath := filepath.Join(source1Path, "_docker")
|
||||
|
||||
// Create directory structure
|
||||
dirs := []string{
|
||||
filepath.Join(source1Path, "public"),
|
||||
filepath.Join(source1Path, "private"),
|
||||
filepath.Join(source1Path, "viewable-only"),
|
||||
filepath.Join(source1Path, "not-viewable"),
|
||||
dockerPath,
|
||||
filepath.Join(dockerPath, "data"),
|
||||
filepath.Join(source2Path, "shared"),
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create test files
|
||||
testFiles := map[string]string{
|
||||
filepath.Join(source1Path, "public", "readme.txt"): "public content",
|
||||
filepath.Join(source1Path, "private", "secret.txt"): "private content",
|
||||
filepath.Join(source1Path, "viewable-only", "data.txt"): "viewable content",
|
||||
filepath.Join(source1Path, "not-viewable", "hidden.txt"): "hidden content",
|
||||
filepath.Join(dockerPath, "data", "config.yml"): "docker config",
|
||||
filepath.Join(source2Path, "shared", "document.txt"): "shared content",
|
||||
}
|
||||
|
||||
for path, content := range testFiles {
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup database
|
||||
dbPath := filepath.Join(tempDir, "db")
|
||||
db, err := storm.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err = bolt.NewStorage(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Clear any previous access rules
|
||||
store.Access.AllRules = make(access.SourceRuleMap)
|
||||
store.Access.Groups = make(access.GroupMap)
|
||||
|
||||
// Set cache directory for index database
|
||||
settings.Config.Server.CacheDir = tempDir
|
||||
|
||||
// Setup config with sources - update both the local config var AND the global settings.Config
|
||||
config = &settings.Settings{
|
||||
Server: settings.Server{
|
||||
BaseURL: "/", // Use / for tests to avoid prefix mismatch
|
||||
CacheDir: tempDir,
|
||||
SourceMap: map[string]*settings.Source{
|
||||
source1Path: {
|
||||
Path: source1Path,
|
||||
Name: "source1",
|
||||
},
|
||||
source2Path: {
|
||||
Path: source2Path,
|
||||
Name: "source2",
|
||||
},
|
||||
},
|
||||
NameToSource: map[string]*settings.Source{
|
||||
"source1": {
|
||||
Path: source1Path,
|
||||
Name: "source1",
|
||||
},
|
||||
"source2": {
|
||||
Path: source2Path,
|
||||
Name: "source2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// CRITICAL: Also update the global settings.Config so access.Permitted can find sources
|
||||
settings.Config.Server.SourceMap = config.Server.SourceMap
|
||||
settings.Config.Server.NameToSource = config.Server.NameToSource
|
||||
|
||||
// Initialize user resolvers
|
||||
settings.InitializeUserResolvers()
|
||||
|
||||
// Create minimal mock indices for webdav handler
|
||||
indexing.SetTestIndex("source1", source1Path)
|
||||
indexing.SetTestIndex("source2", source2Path)
|
||||
t.Cleanup(func() {
|
||||
indexing.ClearTestIndices()
|
||||
})
|
||||
|
||||
// Mock CheckPermissions to bypass index requirement
|
||||
mockCheckPermissions(t, source1Path, source2Path)
|
||||
|
||||
// Initialize mock indexing - this mocks FileInfoFaster
|
||||
mockWebDAVIndexing(t, source1Path, source2Path)
|
||||
|
||||
return source1Path, source2Path
|
||||
}
|
||||
|
||||
// mockCheckPermissions mocks the CheckPermissions function to bypass index lookups
|
||||
func mockCheckPermissions(t *testing.T, source1Path, source2Path string) {
|
||||
t.Helper()
|
||||
|
||||
// Store original function
|
||||
originalCheckPermissions := files.CheckPermissionsFunc
|
||||
t.Cleanup(func() { files.CheckPermissionsFunc = originalCheckPermissions })
|
||||
|
||||
// Mock the function
|
||||
files.CheckPermissionsFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
|
||||
// Get the source path
|
||||
var sourcePath string
|
||||
if opts.Source == "source1" {
|
||||
sourcePath = source1Path
|
||||
} else if opts.Source == "source2" {
|
||||
sourcePath = source2Path
|
||||
} else {
|
||||
return "", "", fmt.Errorf("unknown source: %s", opts.Source)
|
||||
}
|
||||
|
||||
// Get user scope for this source - match by source PATH, not name
|
||||
userScope := "/"
|
||||
hasScope := false
|
||||
for _, scope := range user.Scopes {
|
||||
if scope.Name == sourcePath {
|
||||
userScope = scope.Scope
|
||||
hasScope = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If user has no scope for this source, deny access
|
||||
if !hasScope && len(user.Scopes) > 0 {
|
||||
return "", "", fmt.Errorf("user has no access to source: %s", opts.Source)
|
||||
}
|
||||
|
||||
// Sanitize path
|
||||
safePath, err := utils.SanitizeUserPath(opts.Path)
|
||||
if err != nil {
|
||||
return "", "", commonerrors.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Combine scope + sanitized path
|
||||
indexPath := utils.JoinPathAsUnix(userScope, safePath)
|
||||
|
||||
// Check access control
|
||||
if access != nil && !access.Permitted(sourcePath, indexPath, user.Username) {
|
||||
return "", "", commonerrors.ErrAccessDenied
|
||||
}
|
||||
|
||||
return indexPath, userScope, nil
|
||||
}
|
||||
}
|
||||
|
||||
// mockWebDAVIndexing mocks the indexing system for WebDAV tests
|
||||
func mockWebDAVIndexing(t *testing.T, source1Path, source2Path string) {
|
||||
// Mock FileInfoFaster to simulate indexing behavior
|
||||
originalFileInfoFaster := files.FileInfoFasterFunc
|
||||
t.Cleanup(func() { files.FileInfoFasterFunc = originalFileInfoFaster })
|
||||
|
||||
files.FileInfoFasterFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
// Resolve source name to source path first
|
||||
sourcePath := ""
|
||||
if opts.Source == "source1" {
|
||||
sourcePath = source1Path
|
||||
} else if opts.Source == "source2" {
|
||||
sourcePath = source2Path
|
||||
} else {
|
||||
return nil, fmt.Errorf("unknown source: %s", opts.Source)
|
||||
}
|
||||
|
||||
// Simulate access control
|
||||
if access != nil && user != nil {
|
||||
// Simple scope check based on user's configured scopes (which use source PATH)
|
||||
hasAccess := false
|
||||
for _, scope := range user.Scopes {
|
||||
if scope.Name == sourcePath {
|
||||
hasAccess = true
|
||||
// Check if path is within user's scope
|
||||
userScope := scope.Scope
|
||||
fullPath := utils.JoinPathAsUnix(userScope, opts.Path)
|
||||
|
||||
// Check if user has permission using the access storage
|
||||
if !access.Permitted(sourcePath, fullPath, user.Username) {
|
||||
return nil, commonerrors.ErrAccessDenied
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAccess && len(user.Scopes) > 0 {
|
||||
return nil, commonerrors.ErrAccessDenied
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate indexing rules
|
||||
path := opts.Path
|
||||
|
||||
// Paths containing "not-viewable" are not indexed and not viewable
|
||||
if strings.Contains(path, "not-viewable") {
|
||||
return nil, commonerrors.ErrNotViewable
|
||||
}
|
||||
|
||||
// Paths containing "viewable-only" are not indexed but viewable
|
||||
if strings.Contains(path, "viewable-only") {
|
||||
return nil, commonerrors.ErrNotIndexed
|
||||
}
|
||||
|
||||
// Simulate directory expansion
|
||||
var files []iteminfo.ExtendedItemInfo
|
||||
var folders []iteminfo.ItemInfo
|
||||
|
||||
if opts.Expand && (path == "/" || strings.HasSuffix(path, "/")) {
|
||||
// Return mock directory contents based on path
|
||||
if path == "/" || path == "" {
|
||||
folders = []iteminfo.ItemInfo{
|
||||
{Name: "public", Type: "directory"},
|
||||
{Name: "private", Type: "directory"},
|
||||
{Name: "viewable-only", Type: "directory"},
|
||||
}
|
||||
} else if strings.Contains(path, "public") {
|
||||
files = []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "readme.txt", Size: 14, Type: "text/plain"}},
|
||||
}
|
||||
} else if strings.Contains(path, "private") {
|
||||
files = []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "secret.txt", Size: 15, Type: "text/plain"}},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &iteminfo.ExtendedFileInfo{
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
Path: path,
|
||||
Files: files,
|
||||
Folders: folders,
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: filepath.Base(path),
|
||||
Type: "directory",
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Test PROPFIND with different user scopes
|
||||
func TestWebDAV_PROPFIND_UserScopes(t *testing.T) {
|
||||
source1Path, source2Path := setupWebDAVTestEnv(t)
|
||||
|
||||
// Create users with different scopes
|
||||
// IMPORTANT: SourceScope.Name should be the source PATH, not the source name
|
||||
adminUser := &users.User{
|
||||
ID: 1,
|
||||
Username: "admin",
|
||||
Permissions: users.Permissions{
|
||||
Admin: true,
|
||||
Download: true,
|
||||
Create: true,
|
||||
Delete: true,
|
||||
Modify: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/"},
|
||||
{Name: source2Path, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
scopedUser := &users.User{
|
||||
ID: 2,
|
||||
Username: "scoped",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/_docker"},
|
||||
},
|
||||
}
|
||||
|
||||
restrictedUser := &users.User{
|
||||
ID: 3,
|
||||
Username: "restricted",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/public"},
|
||||
},
|
||||
}
|
||||
|
||||
// Save users
|
||||
if err := store.Users.Save(adminUser, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Users.Save(scopedUser, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Users.Save(restrictedUser, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *users.User
|
||||
source string
|
||||
path string
|
||||
expectedStatus int
|
||||
shouldHaveContent bool
|
||||
}{
|
||||
{
|
||||
name: "Admin can access root of source1",
|
||||
user: adminUser,
|
||||
source: "source1",
|
||||
path: "/",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
shouldHaveContent: true,
|
||||
},
|
||||
{
|
||||
name: "Scoped user can access their scope root",
|
||||
user: scopedUser,
|
||||
source: "source1",
|
||||
path: "/",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
shouldHaveContent: false, // Empty because _docker is not viewable
|
||||
},
|
||||
{
|
||||
name: "Restricted user can only see public folder",
|
||||
user: restrictedUser,
|
||||
source: "source1",
|
||||
path: "/",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
shouldHaveContent: true,
|
||||
},
|
||||
{
|
||||
name: "Admin can access source2",
|
||||
user: adminUser,
|
||||
source: "source2",
|
||||
path: "/",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
shouldHaveContent: true,
|
||||
},
|
||||
{
|
||||
name: "Scoped user cannot access source2",
|
||||
user: scopedUser,
|
||||
source: "source2",
|
||||
path: "/",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
}
|
||||
|
||||
// Initialize indexing for sources
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
initTestIndex(t, "source2", source2Path)
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("PROPFIND", "/dav/"+tc.source+tc.path, nil)
|
||||
req.SetPathValue("source", tc.source)
|
||||
req.SetPathValue("path", tc.path)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ctx := &requestContext{user: tc.user}
|
||||
|
||||
status, err := webDAVHandler(w, req, ctx)
|
||||
|
||||
// If handler returned an error status, use that instead of response code
|
||||
if status != 0 && status != http.StatusOK {
|
||||
if w.Code == 0 || w.Code == http.StatusOK {
|
||||
w.Code = status
|
||||
}
|
||||
}
|
||||
|
||||
// Check the actual HTTP response status code from ResponseWriter
|
||||
if w.Code != tc.expectedStatus {
|
||||
t.Errorf("expected status %d, got %d (err: %v)", tc.expectedStatus, w.Code, err)
|
||||
}
|
||||
|
||||
if tc.shouldHaveContent && w.Code == 207 {
|
||||
body := w.Body.String()
|
||||
if body == "" {
|
||||
t.Error("expected response body to have content")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test WebDAV write operations with access control
|
||||
func TestWebDAV_WriteOperations(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
|
||||
// Create users with different permissions
|
||||
// IMPORTANT: SourceScope.Name should be the source PATH, not the source name
|
||||
fullAccessUser := &users.User{
|
||||
ID: 1,
|
||||
Username: "fullaccess",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
Create: true,
|
||||
Delete: true,
|
||||
Modify: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
readOnlyUser := &users.User{
|
||||
ID: 2,
|
||||
Username: "readonly",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
scopedUser := &users.User{
|
||||
ID: 3,
|
||||
Username: "scoped",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
Create: true,
|
||||
Modify: true,
|
||||
Delete: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/public"},
|
||||
},
|
||||
}
|
||||
|
||||
// Save users
|
||||
if err := store.Users.Save(fullAccessUser, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Users.Save(readOnlyUser, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Users.Save(scopedUser, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *users.User
|
||||
method string
|
||||
path string
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "Full access user can PUT file",
|
||||
user: fullAccessUser,
|
||||
method: http.MethodPut,
|
||||
path: "/public/newfile.txt",
|
||||
expectedStatus: http.StatusCreated, // 201 for new file creation
|
||||
},
|
||||
{
|
||||
name: "Read-only user cannot PUT file",
|
||||
user: readOnlyUser,
|
||||
method: http.MethodPut,
|
||||
path: "/public/newfile.txt",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "Full access user can MKCOL",
|
||||
user: fullAccessUser,
|
||||
method: "MKCOL",
|
||||
path: "/newdir",
|
||||
expectedStatus: http.StatusCreated, // 201 for new directory
|
||||
},
|
||||
{
|
||||
name: "Read-only user cannot MKCOL",
|
||||
user: readOnlyUser,
|
||||
method: "MKCOL",
|
||||
path: "/newdir",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "Full access user can DELETE",
|
||||
user: fullAccessUser,
|
||||
method: http.MethodDelete,
|
||||
path: "/public/readme.txt",
|
||||
expectedStatus: http.StatusNoContent, // 204 for successful deletion
|
||||
},
|
||||
{
|
||||
name: "Read-only user cannot DELETE",
|
||||
user: readOnlyUser,
|
||||
method: http.MethodDelete,
|
||||
path: "/public/readme.txt",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "Scoped user can write in their scope",
|
||||
user: scopedUser,
|
||||
method: http.MethodPut,
|
||||
path: "/scopedfile.txt", // Path relative to scope (/public)
|
||||
expectedStatus: http.StatusCreated, // 201 for new file
|
||||
},
|
||||
{
|
||||
name: "User cannot write to not-viewable directory",
|
||||
user: fullAccessUser,
|
||||
method: http.MethodPut,
|
||||
path: "/not-viewable/file.txt",
|
||||
expectedStatus: 404, // Not viewable = acts like it doesn't exist
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var body io.Reader
|
||||
if tc.method == http.MethodPut {
|
||||
body = strings.NewReader("test content")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(tc.method, "/dav/source1"+tc.path, body)
|
||||
req.SetPathValue("source", "source1")
|
||||
req.SetPathValue("path", tc.path)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ctx := &requestContext{user: tc.user}
|
||||
|
||||
status, err := webDAVHandler(w, req, ctx)
|
||||
|
||||
// If handler returned an error status, use that instead of response code
|
||||
if status != 0 && status != http.StatusOK {
|
||||
if w.Code == 0 || w.Code == http.StatusOK {
|
||||
w.Code = status
|
||||
}
|
||||
}
|
||||
|
||||
// Check the actual HTTP response status code from ResponseWriter
|
||||
if w.Code != tc.expectedStatus {
|
||||
t.Errorf("expected status %d, got %d (err: %v)", tc.expectedStatus, w.Code, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test access control rules
|
||||
func TestWebDAV_AccessControl(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
|
||||
// Create users
|
||||
// IMPORTANT: SourceScope.Name should be the source PATH, not the source name
|
||||
user1 := &users.User{
|
||||
ID: 1,
|
||||
Username: "user1",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
Create: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
user2 := &users.User{
|
||||
ID: 2,
|
||||
Username: "user2",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
// Save users
|
||||
if err := store.Users.Save(user1, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Users.Save(user2, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Initialize index
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
|
||||
// Create access rules by using the internal structure
|
||||
// User1 can access /private, user2 cannot
|
||||
// Set up a deny rule for user2 at /private
|
||||
// IMPORTANT: Access system requires trailing slashes for exact match
|
||||
rule := &access.AccessRule{
|
||||
Deny: access.RuleSet{
|
||||
Users: access.StringSet{"user2": struct{}{}},
|
||||
},
|
||||
}
|
||||
|
||||
// Add the rule to the access storage
|
||||
if store.Access.AllRules == nil {
|
||||
store.Access.AllRules = make(access.SourceRuleMap)
|
||||
}
|
||||
if store.Access.AllRules[source1Path] == nil {
|
||||
store.Access.AllRules[source1Path] = make(access.RuleMap)
|
||||
}
|
||||
store.Access.AllRules[source1Path]["/private/"] = rule // Add trailing slash
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *users.User
|
||||
path string
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "User1 can access private folder",
|
||||
user: user1,
|
||||
path: "/private",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
},
|
||||
{
|
||||
name: "User2 cannot access private folder",
|
||||
user: user2,
|
||||
path: "/private",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "Both users can access public folder",
|
||||
user: user1,
|
||||
path: "/public",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
},
|
||||
{
|
||||
name: "User2 can also access public folder",
|
||||
user: user2,
|
||||
path: "/public",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("PROPFIND", "/dav/source1"+tc.path, nil)
|
||||
req.SetPathValue("source", "source1")
|
||||
req.SetPathValue("path", tc.path)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ctx := &requestContext{user: tc.user}
|
||||
|
||||
status, err := webDAVHandler(w, req, ctx)
|
||||
|
||||
// If handler returned an error status, use that instead of response code
|
||||
if status != 0 && status != http.StatusOK {
|
||||
if w.Code == 0 || w.Code == http.StatusOK {
|
||||
w.Code = status
|
||||
}
|
||||
}
|
||||
|
||||
// Check the actual HTTP response status code from ResponseWriter
|
||||
if w.Code != tc.expectedStatus {
|
||||
t.Errorf("expected status %d, got %d (err: %v)", tc.expectedStatus, w.Code, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test indexing states (indexed, viewable, not-viewable)
|
||||
func TestWebDAV_IndexingStates(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
|
||||
// IMPORTANT: SourceScope.Name should be the source PATH, not the source name
|
||||
user := &users.User{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
Permissions: users.Permissions{
|
||||
Download: true,
|
||||
Create: true,
|
||||
Modify: true,
|
||||
Delete: true,
|
||||
},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: source1Path, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.Users.Save(user, true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
path string
|
||||
method string
|
||||
expectedStatus int
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Can read indexed directory",
|
||||
path: "/public",
|
||||
method: "PROPFIND",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
description: "Indexed directories should be accessible",
|
||||
},
|
||||
{
|
||||
name: "Can read viewable-only directory",
|
||||
path: "/viewable-only",
|
||||
method: "PROPFIND",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
description: "Not indexed but viewable directories should be readable",
|
||||
},
|
||||
{
|
||||
name: "Cannot read not-viewable directory",
|
||||
path: "/not-viewable",
|
||||
method: "PROPFIND",
|
||||
expectedStatus: 405, // WebDAV returns 405 Method Not Allowed when resource denied
|
||||
description: "Not viewable directories should be denied",
|
||||
},
|
||||
{
|
||||
name: "Cannot write to viewable-only directory",
|
||||
path: "/viewable-only/newfile.txt",
|
||||
method: http.MethodPut,
|
||||
expectedStatus: 404, // Returns 404 when trying to write to non-existent file in denied location
|
||||
description: "Viewable-only directories should deny write",
|
||||
},
|
||||
{
|
||||
name: "Cannot write to not-viewable directory",
|
||||
path: "/not-viewable/newfile.txt",
|
||||
method: http.MethodPut,
|
||||
expectedStatus: 404, // Not viewable = acts like it doesn't exist
|
||||
description: "Not viewable directories should deny all access",
|
||||
},
|
||||
{
|
||||
name: "Can write to indexed directory",
|
||||
path: "/public/newfile.txt",
|
||||
method: http.MethodPut,
|
||||
expectedStatus: http.StatusCreated, // 201 for new file creation
|
||||
description: "Indexed directories should allow write with permissions",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var body io.Reader
|
||||
if tc.method == http.MethodPut {
|
||||
body = strings.NewReader("test content")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(tc.method, "/dav/source1"+tc.path, body)
|
||||
req.SetPathValue("source", "source1")
|
||||
req.SetPathValue("path", tc.path)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ctx := &requestContext{user: user}
|
||||
|
||||
status, err := webDAVHandler(w, req, ctx)
|
||||
|
||||
// If handler returned an error status, use that instead of response code
|
||||
if status != 0 && status != http.StatusOK {
|
||||
if w.Code == 0 || w.Code == http.StatusOK {
|
||||
w.Code = status
|
||||
}
|
||||
}
|
||||
|
||||
// Check the actual HTTP response status code from ResponseWriter
|
||||
if w.Code != tc.expectedStatus {
|
||||
t.Errorf("%s: expected status %d, got %d (err: %v)", tc.description, tc.expectedStatus, w.Code, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to initialize a test index - simplified for WebDAV tests
|
||||
func initTestIndex(t *testing.T, name, path string) {
|
||||
// For WebDAV tests, indices are already initialized in setupWebDAVTestEnv
|
||||
t.Helper()
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
)
|
||||
|
||||
// setupConditionalTestIndex creates a test index with conditional rules configured
|
||||
@@ -624,149 +623,3 @@ func TestIsNeverWatchPath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsViewable_DoesNotMutateFolderPaths verifies that IsViewable doesn't modify FolderPaths
|
||||
func TestIsViewable_DoesNotMutateFolderPaths(t *testing.T) {
|
||||
idx := setupConditionalTestIndex(settings.ConditionalFilter{})
|
||||
idx.Config.ResolvedRules.FolderNames["private"] = settings.ConditionalRule{
|
||||
FolderNames: "private",
|
||||
Viewable: false,
|
||||
}
|
||||
|
||||
if len(idx.Config.ResolvedRules.FolderPaths) != 0 {
|
||||
t.Fatalf("expected no preloaded folder path rules, got %d", len(idx.Config.ResolvedRules.FolderPaths))
|
||||
}
|
||||
|
||||
if idx.IsViewable(true, "/private/", false, false) {
|
||||
t.Fatal("expected /private/ to be non-viewable")
|
||||
}
|
||||
|
||||
if len(idx.Config.ResolvedRules.FolderPaths) != 0 {
|
||||
t.Fatalf("IsViewable should not mutate FolderPaths, got %d entries", len(idx.Config.ResolvedRules.FolderPaths))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsViewableWithParentCheck_RecursiveParentWalking tests API request parent checking
|
||||
func TestIsViewableWithParentCheck_RecursiveParentWalking(t *testing.T) {
|
||||
idx := setupConditionalTestIndex(settings.ConditionalFilter{})
|
||||
idx.Config.ResolvedRules.FolderPaths["/private"] = settings.ConditionalRule{
|
||||
FolderPath: "/private",
|
||||
Viewable: false,
|
||||
}
|
||||
// Enable global hidden folder blocking
|
||||
idx.Config.ResolvedRules.IgnoreAllHidden = true
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
isDir bool
|
||||
expected bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Direct non-viewable folder",
|
||||
path: "/private",
|
||||
isDir: true,
|
||||
expected: false,
|
||||
description: "Folder with explicit non-viewable rule should not be viewable",
|
||||
},
|
||||
{
|
||||
name: "Child of non-viewable folder",
|
||||
path: "/private/subfolder",
|
||||
isDir: true,
|
||||
expected: false,
|
||||
description: "Child should inherit parent's non-viewable status",
|
||||
},
|
||||
{
|
||||
name: "Deep child of non-viewable folder",
|
||||
path: "/private/subfolder/deep/nested",
|
||||
isDir: true,
|
||||
expected: false,
|
||||
description: "Deeply nested child should inherit parent's non-viewable status",
|
||||
},
|
||||
{
|
||||
name: "File in non-viewable folder",
|
||||
path: "/private/secret.txt",
|
||||
isDir: false,
|
||||
expected: false,
|
||||
description: "File should inherit parent folder's non-viewable status",
|
||||
},
|
||||
{
|
||||
name: "Unrelated folder",
|
||||
path: "/public/documents",
|
||||
isDir: true,
|
||||
expected: true,
|
||||
description: "Unrelated folder should be viewable",
|
||||
},
|
||||
{
|
||||
name: "Hidden folder",
|
||||
path: "/.hiddenDir",
|
||||
isDir: true,
|
||||
expected: false,
|
||||
description: "Hidden folder should not be viewable when IgnoreAllHidden is true",
|
||||
},
|
||||
{
|
||||
name: "File in hidden folder",
|
||||
path: "/.hiddenDir/nested.txt",
|
||||
isDir: false,
|
||||
expected: false,
|
||||
description: "File in hidden folder should inherit parent's non-viewable status",
|
||||
},
|
||||
{
|
||||
name: "Nested hidden folder",
|
||||
path: "/subfolderExclusions/.hiddenDir",
|
||||
isDir: true,
|
||||
expected: false,
|
||||
description: "Nested hidden folder should not be viewable",
|
||||
},
|
||||
{
|
||||
name: "File in nested hidden folder",
|
||||
path: "/subfolderExclusions/.hiddenDir/nested.txt",
|
||||
isDir: false,
|
||||
expected: false,
|
||||
description: "File in nested hidden folder should not be viewable",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Determine if the path is hidden
|
||||
realPath := utils.JoinPathAsUnix(idx.Path, tt.path)
|
||||
isHidden := IsHidden(realPath)
|
||||
|
||||
result := idx.IsViewableWithParentCheck(tt.isDir, tt.path, false, isHidden)
|
||||
if result != tt.expected {
|
||||
t.Errorf("%s: expected %v, got %v (path=%s, isHidden=%v)",
|
||||
tt.description, tt.expected, result, tt.path, isHidden)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsViewableWithParentCheck_DoesNotMutate verifies thread safety
|
||||
func TestIsViewableWithParentCheck_DoesNotMutate(t *testing.T) {
|
||||
idx := setupConditionalTestIndex(settings.ConditionalFilter{})
|
||||
idx.Config.ResolvedRules.FolderNames["restricted"] = settings.ConditionalRule{
|
||||
FolderNames: "restricted",
|
||||
Viewable: false,
|
||||
}
|
||||
|
||||
initialFolderPathCount := len(idx.Config.ResolvedRules.FolderPaths)
|
||||
|
||||
// Call IsViewableWithParentCheck multiple times with various paths
|
||||
paths := []string{
|
||||
"/restricted/data/file.txt",
|
||||
"/projects/restricted/subfolder/doc.pdf",
|
||||
"/restricted/deep/nested/path/file.csv",
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
_ = idx.IsViewableWithParentCheck(false, path, false, false)
|
||||
}
|
||||
|
||||
// Verify no mutation occurred
|
||||
if len(idx.Config.ResolvedRules.FolderPaths) != initialFolderPathCount {
|
||||
t.Errorf("FolderPaths was mutated: expected %d, got %d",
|
||||
initialFolderPathCount, len(idx.Config.ResolvedRules.FolderPaths))
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,10 @@ func TestProcessDirectoryItem(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
if itemInfo == nil {
|
||||
t.Fatalf("expected non-nil itemInfo")
|
||||
}
|
||||
|
||||
if size != tt.expectSize {
|
||||
t.Errorf("expected size=%d, got %d", tt.expectSize, size)
|
||||
}
|
||||
|
||||
@@ -487,14 +487,7 @@ func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool) (*Pa
|
||||
// Returns whether the path is viewable and/or indexable
|
||||
// Does NOT check user permissions (that's handled in the API layer)
|
||||
func (idx *Index) evaluateIndexRules(ctx *PathContext, isRoutineScan bool) (isViewable bool, isIndexable bool) {
|
||||
// For scanner (routine scan), only check the item itself
|
||||
// For API requests, check item and all parent paths
|
||||
if isRoutineScan {
|
||||
isViewable = idx.IsViewable(ctx.IsDir, ctx.IndexPath, ctx.IsSymlink, ctx.IsHidden)
|
||||
} else {
|
||||
isViewable = idx.IsViewableWithParentCheck(ctx.IsDir, ctx.IndexPath, ctx.IsSymlink, ctx.IsHidden)
|
||||
}
|
||||
|
||||
isViewable = idx.IsViewable(ctx.IsDir, ctx.IndexPath, ctx.IsSymlink)
|
||||
shouldSkip := idx.ShouldSkip(ctx.IsDir, ctx.IndexPath, ctx.IsHidden, ctx.IsSymlink, isRoutineScan)
|
||||
isIndexable = !shouldSkip
|
||||
return isViewable, isIndexable
|
||||
@@ -510,11 +503,12 @@ func (idx *Index) GetFileInfo(req FileInfoRequest) (*iteminfo.FileInfo, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Apply index rules (non-routine scan)
|
||||
isViewable, isIndexable := idx.evaluateIndexRules(ctx, false)
|
||||
// API request: path must be viewable
|
||||
if !isViewable {
|
||||
return nil, errors.ErrNotViewable
|
||||
// 2. Apply index rules
|
||||
isViewable, isIndexable := idx.evaluateIndexRules(ctx, req.IsRoutineScan)
|
||||
|
||||
// Path must be either viewable OR indexable
|
||||
if !isViewable && !isIndexable {
|
||||
return nil, errors.ErrNotIndexed
|
||||
}
|
||||
|
||||
// 3. Return appropriate info
|
||||
@@ -659,16 +653,16 @@ func (idx *Index) GetFsInfoCore(indexPath string, opts Options) (*iteminfo.FileI
|
||||
// Check if item is accessible
|
||||
hidden := IsHidden(realPath)
|
||||
isSymlink := dirInfo.Mode()&os.ModeSymlink != 0
|
||||
isViewable := idx.IsViewable(dirInfo.IsDir(), indexPath, isSymlink, hidden)
|
||||
isViewable := idx.IsViewable(dirInfo.IsDir(), indexPath, isSymlink)
|
||||
isSkipped := idx.ShouldSkip(dirInfo.IsDir(), indexPath, hidden, isSymlink, false)
|
||||
|
||||
// Deny access if not viewable AND skipped
|
||||
if opts.CheckViewable {
|
||||
if !isViewable {
|
||||
return nil, errors.ErrNotViewable
|
||||
return nil, errors.ErrNotIndexed
|
||||
}
|
||||
} else if isSkipped {
|
||||
return nil, errors.ErrNotViewable
|
||||
return nil, errors.ErrNotIndexed
|
||||
}
|
||||
|
||||
realSize, _ := idx.handleFile(dirInfo, indexPath, realPath, false, nil)
|
||||
@@ -933,7 +927,7 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
|
||||
// Check viewable/indexable rules
|
||||
if !shouldSkip {
|
||||
isViewable := idx.IsViewable(isDir, fullCombined, isSymlink, hidden)
|
||||
isViewable := idx.IsViewable(isDir, fullCombined, isSymlink)
|
||||
isSkipped := idx.ShouldSkip(isDir, fullCombined, hidden, isSymlink, opts.IsRoutineScan)
|
||||
if opts.CheckViewable {
|
||||
shouldSkip = !isViewable && isSkipped
|
||||
@@ -1341,41 +1335,53 @@ func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
|
||||
}
|
||||
|
||||
// IsViewable checks if a path is viewable (allows FS access)
|
||||
// Only checks the item itself, not parent paths
|
||||
func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool, isHidden bool) bool {
|
||||
if indexPath == "/" || idx.Config.ResolvedRules.NoRules {
|
||||
func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool) bool {
|
||||
if indexPath == "/" {
|
||||
return true
|
||||
}
|
||||
|
||||
rules := idx.Config.ResolvedRules
|
||||
|
||||
// Check if hidden and should be blocked
|
||||
if isHidden && rules.IgnoreAllHidden {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check symlinks
|
||||
if isSymlink && rules.IgnoreAllSymlinks {
|
||||
if isSymlink && idx.Config.ResolvedRules.IgnoreAllSymlinks {
|
||||
return false
|
||||
}
|
||||
|
||||
rules := idx.Config.ResolvedRules
|
||||
baseName := filepath.Base(indexPath)
|
||||
if indexPath == "/" {
|
||||
baseName = filepath.Base(idx.Path)
|
||||
}
|
||||
|
||||
if isDir {
|
||||
// Check folder rules
|
||||
// Check folder rules - return false if explicitly set to non-viewable
|
||||
if rule, exists := rules.FolderNames[baseName]; exists {
|
||||
// Cache this match in FolderPaths so children inherit via prefix matching
|
||||
if _, ok := rules.FolderPaths[indexPath]; !ok {
|
||||
rules.FolderPaths[indexPath] = rule
|
||||
}
|
||||
return rule.Viewable
|
||||
}
|
||||
if rule, exists := rules.FolderPaths[indexPath]; exists {
|
||||
return rule.Viewable
|
||||
}
|
||||
for path, rule := range rules.FolderPaths {
|
||||
if strings.HasPrefix(indexPath, path) {
|
||||
return rule.Viewable
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.FolderEndsWith {
|
||||
if strings.HasSuffix(baseName, rule.FolderEndsWith) {
|
||||
// Cache this match in FolderPaths so children inherit
|
||||
if _, ok := rules.FolderPaths[indexPath]; !ok {
|
||||
rules.FolderPaths[indexPath] = rule
|
||||
}
|
||||
return rule.Viewable
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.FolderStartsWith {
|
||||
if strings.HasPrefix(baseName, rule.FolderStartsWith) {
|
||||
// Cache this match in FolderPaths so children inherit
|
||||
if _, ok := rules.FolderPaths[indexPath]; !ok {
|
||||
rules.FolderPaths[indexPath] = rule
|
||||
}
|
||||
return rule.Viewable
|
||||
}
|
||||
}
|
||||
@@ -1387,6 +1393,11 @@ func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool, isHid
|
||||
if rule, exists := rules.FilePaths[indexPath]; exists {
|
||||
return rule.Viewable
|
||||
}
|
||||
for path, rule := range rules.FilePaths {
|
||||
if strings.HasPrefix(indexPath, path) {
|
||||
return rule.Viewable
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.FileEndsWith {
|
||||
if strings.HasSuffix(baseName, rule.FileEndsWith) {
|
||||
return rule.Viewable
|
||||
@@ -1397,44 +1408,27 @@ func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool, isHid
|
||||
return rule.Viewable
|
||||
}
|
||||
}
|
||||
// Check if file inherits viewable status from parent folder
|
||||
for path, rule := range rules.FolderPaths {
|
||||
if strings.HasPrefix(indexPath, path) {
|
||||
return rule.Viewable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true // Default: viewable unless explicitly set to false
|
||||
}
|
||||
|
||||
// IsViewableWithParentCheck checks if a path is viewable by walking up the directory tree
|
||||
// Used for API requests where we need to check inherited rules from parent folders
|
||||
func (idx *Index) IsViewableWithParentCheck(isDir bool, indexPath string, isSymlink bool, isHidden bool) bool {
|
||||
if idx.Config.DisableIndexing || idx.Config.ResolvedRules.NoRules {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check the item itself - if not viewable, stop here
|
||||
if !idx.IsViewable(isDir, indexPath, isSymlink, isHidden) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Item is viewable, now check parent recursively
|
||||
parentDir := utils.GetParentDirectoryPath(indexPath)
|
||||
if parentDir == "" || parentDir == "/" {
|
||||
return true // Reached root with no non-viewable rules, default to viewable
|
||||
}
|
||||
|
||||
// Check if parent is hidden
|
||||
realPath, _, _ := idx.GetRealPath(parentDir)
|
||||
return idx.IsViewableWithParentCheck(true, parentDir, false, IsHidden(realPath))
|
||||
}
|
||||
|
||||
// ShouldSkip checks if a path should be skipped from indexing
|
||||
// isRoutineScan: true for scanner (no parent checks), false for API (check parents)
|
||||
func (idx *Index) ShouldSkip(isDir bool, adjustedPath string, isHidden bool, isSymlink bool, isRoutineScan bool) bool {
|
||||
rules := idx.Config.ResolvedRules
|
||||
if adjustedPath == "/" {
|
||||
return false
|
||||
}
|
||||
if idx.Config.DisableIndexing {
|
||||
return true
|
||||
}
|
||||
rules := idx.Config.ResolvedRules
|
||||
if adjustedPath == "/" || rules.NoRules {
|
||||
return false
|
||||
}
|
||||
if isHidden && rules.IgnoreAllHidden {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ type Scanner struct {
|
||||
lastScanned time.Time
|
||||
quickScanTime int
|
||||
fullScanTime int
|
||||
statsMu sync.RWMutex
|
||||
scanStartTime int64
|
||||
isScanning bool // True when scanner is actively scanning or waiting for mutex
|
||||
|
||||
@@ -42,18 +41,6 @@ type Scanner struct {
|
||||
stopOnce sync.Once // Ensures stopChan is only closed once
|
||||
}
|
||||
|
||||
func (s *Scanner) withStatsLock(fn func()) {
|
||||
s.statsMu.Lock()
|
||||
defer s.statsMu.Unlock()
|
||||
fn()
|
||||
}
|
||||
|
||||
func (s *Scanner) withStatsRLock(fn func()) {
|
||||
s.statsMu.RLock()
|
||||
defer s.statsMu.RUnlock()
|
||||
fn()
|
||||
}
|
||||
|
||||
// start begins the scanner's main loop
|
||||
func (s *Scanner) start() {
|
||||
// Wait a bit to stagger child scanner initial scans (root goes first)
|
||||
@@ -139,20 +126,14 @@ func (s *Scanner) runRootScan(quick bool) {
|
||||
|
||||
if quick {
|
||||
s.runQuickScanRoot()
|
||||
s.statsMu.Lock()
|
||||
s.quickScanTime = int(time.Since(startTime).Seconds())
|
||||
s.statsMu.Unlock()
|
||||
} else {
|
||||
s.runFullScanRoot()
|
||||
s.statsMu.Lock()
|
||||
s.fullScanTime = int(time.Since(startTime).Seconds())
|
||||
s.statsMu.Unlock()
|
||||
s.updateComplexity()
|
||||
}
|
||||
|
||||
s.statsMu.Lock()
|
||||
s.lastScanned = time.Now()
|
||||
s.statsMu.Unlock()
|
||||
s.checkForNewChildDirectories()
|
||||
if err := s.idx.db.ShrinkMemory(); err != nil {
|
||||
logger.Errorf("Failed to shrink memory: %v", err)
|
||||
@@ -166,10 +147,8 @@ func (s *Scanner) runFullScanRoot() {
|
||||
}
|
||||
|
||||
// Reset counters and initialize state for full scan
|
||||
s.withStatsLock(func() {
|
||||
s.numDirs = 0
|
||||
s.numFiles = 0
|
||||
})
|
||||
s.numDirs = 0
|
||||
s.numFiles = 0
|
||||
s.scanStartTime = time.Now().Unix()
|
||||
s.idx.mu.Lock()
|
||||
if s.idx.scanSessionStartTime == 0 {
|
||||
@@ -182,9 +161,7 @@ func (s *Scanner) runFullScanRoot() {
|
||||
|
||||
batchSize := s.idx.db.BatchSize
|
||||
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
|
||||
s.withStatsLock(func() {
|
||||
s.filesChanged = false
|
||||
})
|
||||
s.filesChanged = false
|
||||
|
||||
_, _, err := s.idx.indexDirectory("/", config, s)
|
||||
if err != nil {
|
||||
@@ -207,9 +184,7 @@ func (s *Scanner) runFullScanRoot() {
|
||||
|
||||
func (s *Scanner) runQuickScanRoot() {
|
||||
s.scanStartTime = time.Now().Unix()
|
||||
s.withStatsLock(func() {
|
||||
s.filesChanged = false
|
||||
})
|
||||
s.filesChanged = false
|
||||
|
||||
// Get root-level folders from folderSizes map
|
||||
s.idx.folderSizesMu.RLock()
|
||||
@@ -253,21 +228,15 @@ func (s *Scanner) runChildScan(quick bool) {
|
||||
if quick {
|
||||
// Quick scan: iterate folderSizes, check modtimes only (257x faster!)
|
||||
s.runQuickScanChild()
|
||||
s.withStatsLock(func() {
|
||||
s.quickScanTime = int(time.Since(startTime).Seconds())
|
||||
})
|
||||
s.quickScanTime = int(time.Since(startTime).Seconds())
|
||||
} else {
|
||||
// Full scan: walk filesystem recursively, read all directory contents
|
||||
s.runFullScanChild()
|
||||
s.withStatsLock(func() {
|
||||
s.fullScanTime = int(time.Since(startTime).Seconds())
|
||||
})
|
||||
s.fullScanTime = int(time.Since(startTime).Seconds())
|
||||
s.updateComplexity()
|
||||
}
|
||||
|
||||
s.withStatsLock(func() {
|
||||
s.lastScanned = time.Now()
|
||||
})
|
||||
s.lastScanned = time.Now()
|
||||
}
|
||||
|
||||
func (s *Scanner) runFullScanChild() {
|
||||
@@ -277,10 +246,8 @@ func (s *Scanner) runFullScanChild() {
|
||||
}
|
||||
|
||||
// Reset counters and initialize state for full scan
|
||||
s.withStatsLock(func() {
|
||||
s.numDirs = 0
|
||||
s.numFiles = 0
|
||||
})
|
||||
s.numDirs = 0
|
||||
s.numFiles = 0
|
||||
s.scanStartTime = time.Now().Unix()
|
||||
s.idx.mu.Lock()
|
||||
if s.idx.scanSessionStartTime == 0 {
|
||||
@@ -290,9 +257,7 @@ func (s *Scanner) runFullScanChild() {
|
||||
|
||||
s.processedInodes = make(map[uint64]struct{})
|
||||
s.foundHardLinks = make(map[string]uint64)
|
||||
s.withStatsLock(func() {
|
||||
s.filesChanged = false
|
||||
})
|
||||
s.filesChanged = false
|
||||
|
||||
batchSize := s.idx.db.BatchSize
|
||||
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
|
||||
@@ -332,9 +297,7 @@ func (s *Scanner) runFullScanChild() {
|
||||
|
||||
func (s *Scanner) runQuickScanChild() {
|
||||
s.scanStartTime = time.Now().Unix()
|
||||
s.withStatsLock(func() {
|
||||
s.filesChanged = false
|
||||
})
|
||||
s.filesChanged = false
|
||||
|
||||
// Get folders in this scanner's scope from folderSizes map
|
||||
s.idx.folderSizesMu.RLock()
|
||||
@@ -488,10 +451,7 @@ func (s *Scanner) getTopLevelDirs() []string {
|
||||
|
||||
// calculateSleepTime determines how long to wait before the next scan
|
||||
func (s *Scanner) calculateSleepTime() time.Duration {
|
||||
var sleepTime time.Duration
|
||||
s.withStatsRLock(func() {
|
||||
sleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
|
||||
})
|
||||
sleepTime := scanSchedule[s.currentSchedule] + s.smartModifier
|
||||
if s.idx.Config.IndexingInterval > 0 {
|
||||
sleepTime = time.Duration(s.idx.Config.IndexingInterval) * time.Minute
|
||||
}
|
||||
@@ -501,8 +461,6 @@ func (s *Scanner) calculateSleepTime() time.Duration {
|
||||
|
||||
// updateSchedule adjusts the scanner's schedule based on whether files changed
|
||||
func (s *Scanner) updateSchedule() {
|
||||
s.statsMu.Lock()
|
||||
defer s.statsMu.Unlock()
|
||||
|
||||
// Calculate sleep time BEFORE updating schedule (based on current/old schedule)
|
||||
s.nextSleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
|
||||
@@ -548,9 +506,6 @@ func (s *Scanner) updateSchedule() {
|
||||
// updateComplexity calculates the complexity level (1-10) for this scanner's directory
|
||||
// 0: unknown
|
||||
func (s *Scanner) updateComplexity() {
|
||||
s.statsMu.Lock()
|
||||
defer s.statsMu.Unlock()
|
||||
|
||||
s.complexity = calculateComplexity(s.fullScanTime, s.numDirs)
|
||||
|
||||
// Set smartModifier based on complexity level
|
||||
@@ -609,10 +564,8 @@ func (s *Scanner) syncStatsWithDB() {
|
||||
return
|
||||
}
|
||||
|
||||
s.withStatsLock(func() {
|
||||
s.numDirs = dirs
|
||||
s.numFiles = files
|
||||
})
|
||||
s.numDirs = dirs
|
||||
s.numFiles = files
|
||||
}
|
||||
|
||||
// checkFolderModtime checks if a folder's modtime changed since last scan
|
||||
@@ -637,9 +590,7 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
|
||||
|
||||
if modtimeChanged {
|
||||
// Modtime changed - read directory contents and update database
|
||||
s.withStatsLock(func() {
|
||||
s.filesChanged = true
|
||||
})
|
||||
s.filesChanged = true
|
||||
|
||||
dir, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -116,9 +116,7 @@ func (idx *Index) markFilesChanged() {
|
||||
idx.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
scanner.withStatsLock(func() {
|
||||
scanner.filesChanged = true
|
||||
})
|
||||
scanner.filesChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,9 +145,7 @@ func (idx *Index) incrementScannerDirsUnlocked() {
|
||||
}
|
||||
scanner, exists := idx.scanners[activePath]
|
||||
if exists {
|
||||
scanner.withStatsLock(func() {
|
||||
scanner.numDirs++
|
||||
})
|
||||
scanner.numDirs++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,9 +162,7 @@ func (idx *Index) incrementScannerFiles() {
|
||||
idx.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
scanner.withStatsLock(func() {
|
||||
scanner.numFiles++
|
||||
})
|
||||
scanner.numFiles++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,15 +252,13 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
|
||||
rootScanner := idx.createScanner("/")
|
||||
if persistedScanners != nil {
|
||||
if rootInfo, ok := persistedScanners["/"]; ok {
|
||||
rootScanner.withStatsLock(func() {
|
||||
rootScanner.complexity = rootInfo.Complexity
|
||||
rootScanner.currentSchedule = rootInfo.CurrentSchedule
|
||||
rootScanner.quickScanTime = rootInfo.QuickScanTime
|
||||
rootScanner.fullScanTime = rootInfo.FullScanTime
|
||||
rootScanner.numDirs = rootInfo.NumDirs
|
||||
rootScanner.numFiles = rootInfo.NumFiles
|
||||
rootScanner.lastScanned = rootInfo.LastScanned
|
||||
})
|
||||
rootScanner.complexity = rootInfo.Complexity
|
||||
rootScanner.currentSchedule = rootInfo.CurrentSchedule
|
||||
rootScanner.quickScanTime = rootInfo.QuickScanTime
|
||||
rootScanner.fullScanTime = rootInfo.FullScanTime
|
||||
rootScanner.numDirs = rootInfo.NumDirs
|
||||
rootScanner.numFiles = rootInfo.NumFiles
|
||||
rootScanner.lastScanned = rootInfo.LastScanned
|
||||
}
|
||||
}
|
||||
idx.mu.Lock()
|
||||
@@ -284,15 +276,13 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
|
||||
// Restore persisted stats for child scanner if available (and DB is not new)
|
||||
if persistedScanners != nil {
|
||||
if childInfo, ok := persistedScanners[dirPath]; ok {
|
||||
childScanner.withStatsLock(func() {
|
||||
childScanner.complexity = childInfo.Complexity
|
||||
childScanner.currentSchedule = childInfo.CurrentSchedule
|
||||
childScanner.quickScanTime = childInfo.QuickScanTime
|
||||
childScanner.fullScanTime = childInfo.FullScanTime
|
||||
childScanner.numDirs = childInfo.NumDirs
|
||||
childScanner.numFiles = childInfo.NumFiles
|
||||
childScanner.lastScanned = childInfo.LastScanned
|
||||
})
|
||||
childScanner.complexity = childInfo.Complexity
|
||||
childScanner.currentSchedule = childInfo.CurrentSchedule
|
||||
childScanner.quickScanTime = childInfo.QuickScanTime
|
||||
childScanner.fullScanTime = childInfo.FullScanTime
|
||||
childScanner.numDirs = childInfo.NumDirs
|
||||
childScanner.numFiles = childInfo.NumFiles
|
||||
childScanner.lastScanned = childInfo.LastScanned
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,25 +318,22 @@ func (idx *Index) GetScannerStatus() map[string]interface{} {
|
||||
activePath := idx.getActiveScannerPathUnlocked()
|
||||
status["activeScanner"] = activePath
|
||||
status["isScanning"] = activePath != ""
|
||||
status["status"] = idx.Status
|
||||
|
||||
// Individual scanner stats
|
||||
scannerStats := make([]map[string]interface{}, 0, len(idx.scanners))
|
||||
for path, scanner := range idx.scanners {
|
||||
scannerInfo := map[string]interface{}{
|
||||
"path": path,
|
||||
"path": path,
|
||||
"lastScanned": scanner.lastScanned.Format(time.RFC3339),
|
||||
"complexity": scanner.complexity,
|
||||
"currentSchedule": scanner.currentSchedule,
|
||||
"quickScanTime": scanner.quickScanTime,
|
||||
"fullScanTime": scanner.fullScanTime,
|
||||
"numDirs": scanner.numDirs,
|
||||
"numFiles": scanner.numFiles,
|
||||
"filesChanged": scanner.filesChanged,
|
||||
"smartModifier": scanner.smartModifier.String(),
|
||||
}
|
||||
scanner.withStatsRLock(func() {
|
||||
scannerInfo["lastScanned"] = scanner.lastScanned.Format(time.RFC3339)
|
||||
scannerInfo["complexity"] = scanner.complexity
|
||||
scannerInfo["currentSchedule"] = scanner.currentSchedule
|
||||
scannerInfo["quickScanTime"] = scanner.quickScanTime
|
||||
scannerInfo["fullScanTime"] = scanner.fullScanTime
|
||||
scannerInfo["numDirs"] = scanner.numDirs
|
||||
scannerInfo["numFiles"] = scanner.numFiles
|
||||
scannerInfo["filesChanged"] = scanner.filesChanged
|
||||
scannerInfo["smartModifier"] = scanner.smartModifier.String()
|
||||
})
|
||||
scannerStats = append(scannerStats, scannerInfo)
|
||||
}
|
||||
status["scanners"] = scannerStats
|
||||
|
||||
@@ -128,11 +128,7 @@ func TestMultiScannerMutex(t *testing.T) {
|
||||
|
||||
// Verify markFilesChanged works
|
||||
idx.markFilesChanged()
|
||||
var changed bool
|
||||
scanner2.withStatsRLock(func() {
|
||||
changed = scanner2.filesChanged
|
||||
})
|
||||
if !changed {
|
||||
if !scanner2.filesChanged {
|
||||
t.Error("Expected scanner2.filesChanged=true")
|
||||
}
|
||||
|
||||
|
||||
@@ -282,7 +282,6 @@ var model3DMimeType = map[string]string{
|
||||
".gltf": "model/gltf+json",
|
||||
".glb": "model/gltf-binary",
|
||||
".obj": "model/obj",
|
||||
".fbx": "model/fbx",
|
||||
".stl": "model/stl",
|
||||
".ply": "model/ply",
|
||||
".dae": "model/vnd.collada+xml",
|
||||
|
||||
@@ -56,5 +56,6 @@ type ExtendedFileInfo struct {
|
||||
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
|
||||
Source string `json:"source,omitempty"` // associated index source for the file
|
||||
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
|
||||
HasMetadata bool `json:"hasMetadata"` // whether the file or folder has metadata
|
||||
RealPath string `json:"-"`
|
||||
}
|
||||
@@ -375,13 +375,6 @@ func (fi ItemInfo) ContainsSearchTerm(searchTerm string, options SearchOptions)
|
||||
}
|
||||
}
|
||||
|
||||
if options.ModifiedNewerThan > 0 && fi.ModTime.Unix() < options.ModifiedNewerThan {
|
||||
return false
|
||||
}
|
||||
if options.ModifiedOlderThan > 0 && fi.ModTime.Unix() >= options.ModifiedOlderThan {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package iteminfo
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var typeRegexp = regexp.MustCompile(`type:(\S+)`)
|
||||
@@ -12,11 +11,7 @@ type SearchOptions struct {
|
||||
Conditions map[string]bool
|
||||
LargerThan int
|
||||
SmallerThan int
|
||||
// ModifiedOlderThan is Unix seconds (UTC start of day). If > 0, item must have ModTime strictly before this instant.
|
||||
ModifiedOlderThan int64
|
||||
// ModifiedNewerThan is Unix seconds (UTC start of day). If > 0, item must have ModTime >= this instant.
|
||||
ModifiedNewerThan int64
|
||||
Terms []string
|
||||
Terms []string
|
||||
}
|
||||
|
||||
func ParseSearch(value string) SearchOptions {
|
||||
@@ -66,18 +61,6 @@ func ParseSearch(value string) SearchOptions {
|
||||
size := strings.TrimPrefix(filter, "smallerThan=")
|
||||
opts.SmallerThan = UpdateSize(size)
|
||||
}
|
||||
if strings.HasPrefix(filter, "olderThan=") {
|
||||
dateStr := strings.TrimPrefix(filter, "olderThan=")
|
||||
if ts, ok := parseSearchDateUTCStart(dateStr); ok {
|
||||
opts.ModifiedOlderThan = ts
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(filter, "newerThan=") {
|
||||
dateStr := strings.TrimPrefix(filter, "newerThan=")
|
||||
if ts, ok := parseSearchDateUTCStart(dateStr); ok {
|
||||
opts.ModifiedNewerThan = ts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(types) > 0 {
|
||||
@@ -102,16 +85,3 @@ func ParseSearch(value string) SearchOptions {
|
||||
opts.Terms = strings.Split(value, "|")
|
||||
return opts
|
||||
}
|
||||
|
||||
// parseSearchDateUTCStart parses YYYY-MM-DD as midnight UTC; used for search date filters.
|
||||
func parseSearchDateUTCStart(s string) (int64, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) != 10 {
|
||||
return 0, false
|
||||
}
|
||||
t, err := time.ParseInLocation("2006-01-02", s, time.UTC)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return t.Unix(), true
|
||||
}
|
||||
@@ -27,8 +27,6 @@ func (i *ExtendedFileInfo) GetSubtitles(parentInfo *FileInfo) {
|
||||
if !strings.HasPrefix(i.Type, "video") {
|
||||
return
|
||||
}
|
||||
|
||||
// could be myvideo.mov
|
||||
ext := filepath.Ext(i.Name)
|
||||
baseWithoutExt := strings.TrimSuffix(i.Name, ext)
|
||||
|
||||
@@ -36,29 +34,25 @@ func (i *ExtendedFileInfo) GetSubtitles(parentInfo *FileInfo) {
|
||||
var externalSubs []utils.SubtitleTrack
|
||||
if parentInfo != nil && parentInfo.Files != nil {
|
||||
for _, f := range parentInfo.Files {
|
||||
// handle case myvideo.srt
|
||||
fileExt := filepath.Ext(f.Name)
|
||||
fileBaseWithoutExt := strings.TrimSuffix(f.Name, fileExt)
|
||||
// handle case myvideo.en.srt
|
||||
langExt := filepath.Ext(fileBaseWithoutExt)
|
||||
fileBaseWithoutExt2 := strings.TrimSuffix(fileBaseWithoutExt, langExt)
|
||||
matches := fileBaseWithoutExt == baseWithoutExt || fileBaseWithoutExt2 == baseWithoutExt
|
||||
fileBase := strings.TrimSuffix(f.Name, fileExt)
|
||||
|
||||
// Check if this file has the same base name and a subtitle extension
|
||||
if matches && SubtitleExts[strings.ToLower(fileExt)] {
|
||||
if fileBase == baseWithoutExt && SubtitleExts[strings.ToLower(fileExt)] {
|
||||
track := utils.SubtitleTrack{
|
||||
Name: f.Name,
|
||||
Embedded: false,
|
||||
}
|
||||
|
||||
// Try to infer language from filename patterns like "video.en.srt"
|
||||
parts := strings.Split(fileBaseWithoutExt, ".")
|
||||
parts := strings.Split(fileBase, ".")
|
||||
if len(parts) > 1 {
|
||||
lastPart := parts[len(parts)-1]
|
||||
if len(lastPart) == 2 || len(lastPart) == 3 {
|
||||
track.Language = lastPart
|
||||
}
|
||||
}
|
||||
|
||||
externalSubs = append(externalSubs, track)
|
||||
}
|
||||
}
|
||||
@@ -106,15 +100,29 @@ func (info *FileInfo) SortItems() {
|
||||
}
|
||||
|
||||
// ResolveSymlinks resolves symlinks in the given path and returns
|
||||
// Uses Go's filepath.EvalSymlinks which properly detects circular symlinks.
|
||||
// the final resolved path, whether it's a directory (considering bundle logic), and any error.
|
||||
func ResolveSymlinks(path string) (string, bool, error) {
|
||||
resolvedPath, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return path, false, fmt.Errorf("could not resolve symlinks for %s: %v", path, err)
|
||||
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 := IsDirectory(info)
|
||||
return path, isDir, nil
|
||||
}
|
||||
}
|
||||
info, err := os.Lstat(resolvedPath)
|
||||
if err != nil {
|
||||
return resolvedPath, false, fmt.Errorf("could not stat resolved path %s: %v", resolvedPath, err)
|
||||
}
|
||||
return resolvedPath, IsDirectory(info), nil
|
||||
}
|
||||
+14
-18
@@ -289,9 +289,7 @@ func (idx *Index) IterateFiles(fn func(path, name string, size, modTime int64))
|
||||
}
|
||||
|
||||
func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, error) {
|
||||
indexesMutex.Lock()
|
||||
idx, ok := indexes[sourceName]
|
||||
indexesMutex.Unlock()
|
||||
if !ok {
|
||||
return ReducedIndex{}, fmt.Errorf("index %s not found", sourceName)
|
||||
}
|
||||
@@ -316,7 +314,7 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
|
||||
}
|
||||
|
||||
idx.SetUsage(totalBytes)
|
||||
|
||||
|
||||
// Also fetch OS-reported partition used space (total - free)
|
||||
usedAlt, err := fileutils.GetPartitionUsed(sourcePath)
|
||||
if err != nil {
|
||||
@@ -326,7 +324,7 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
|
||||
idx.mu.Lock()
|
||||
idx.UsedAlt = usedAlt
|
||||
idx.mu.Unlock()
|
||||
|
||||
|
||||
utils.DiskUsageCache.Set(cacheKey, true)
|
||||
}
|
||||
|
||||
@@ -337,24 +335,22 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
|
||||
scannerInfos = append(scannerInfos, &ScannerInfo{
|
||||
Path: scanner.scanPath,
|
||||
CurrentSchedule: scanner.currentSchedule,
|
||||
Stats: Stats{
|
||||
LastScanned: scanner.lastScanned,
|
||||
Complexity: scanner.complexity,
|
||||
QuickScanTime: scanner.quickScanTime,
|
||||
FullScanTime: scanner.fullScanTime,
|
||||
NumDirs: scanner.numDirs,
|
||||
NumFiles: scanner.numFiles,
|
||||
},
|
||||
Stats: Stats{
|
||||
LastScanned: scanner.lastScanned,
|
||||
Complexity: scanner.complexity,
|
||||
QuickScanTime: scanner.quickScanTime,
|
||||
FullScanTime: scanner.fullScanTime,
|
||||
NumDirs: scanner.numDirs,
|
||||
NumFiles: scanner.numFiles,
|
||||
},
|
||||
})
|
||||
}
|
||||
reducedIdx := idx.ReducedIndex
|
||||
var diskUsed uint64
|
||||
rootTotal, ok := idx.GetFolderSize("/")
|
||||
if !ok {
|
||||
logger.Errorf("Failed to get root-level indexed total for index %s", sourceName)
|
||||
// Compute DiskUsed from database (total size of all files indexed)
|
||||
diskUsed, err := idx.db.GetTotalSize(idx.Name)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get total size for index %s: %v", sourceName, err)
|
||||
diskUsed = 0
|
||||
} else {
|
||||
diskUsed = rootTotal
|
||||
}
|
||||
reducedIdx.DiskUsed = diskUsed
|
||||
reducedIdx.UsedAlt = idx.UsedAlt
|
||||
|
||||
@@ -28,19 +28,13 @@ type SearchResult struct {
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func (idx *Index) Search(search string, scope string, sourceSession string, largest bool, limit int, olderThanUnix, newerThanUnix int64) []*SearchResult {
|
||||
func (idx *Index) Search(search string, scope string, sourceSession string, largest bool, limit int) []*SearchResult {
|
||||
// Ensure scope has consistent trailing slash for directory matching
|
||||
scope = utils.AddTrailingSlashIfNotExists(scope)
|
||||
|
||||
runningHash := utils.InsecureRandomIdentifier(4)
|
||||
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
|
||||
searchOptions := iteminfo.ParseSearch(search)
|
||||
if olderThanUnix > 0 {
|
||||
searchOptions.ModifiedOlderThan = olderThanUnix
|
||||
}
|
||||
if newerThanUnix > 0 {
|
||||
searchOptions.ModifiedNewerThan = newerThanUnix
|
||||
}
|
||||
results := make(map[string]*SearchResult, 0)
|
||||
count := 0
|
||||
|
||||
@@ -108,8 +102,7 @@ func (idx *Index) Search(search string, scope string, sourceSession string, larg
|
||||
} else {
|
||||
typeMatches = !hasDirCondition || (hasDirCondition && !dirCondition)
|
||||
}
|
||||
dateMatches := searchDateMatches(item.ModTime.Unix(), searchOptions)
|
||||
matches = sizeMatches && typeMatches && dateMatches
|
||||
matches = sizeMatches && typeMatches
|
||||
} else {
|
||||
matches = item.ContainsSearchTerm(searchTerm, searchOptions)
|
||||
}
|
||||
@@ -150,22 +143,12 @@ func (idx *Index) Search(search string, scope string, sourceSession string, larg
|
||||
return sortedKeys
|
||||
}
|
||||
|
||||
func searchDateMatches(modUnix int64, opts iteminfo.SearchOptions) bool {
|
||||
if opts.ModifiedNewerThan > 0 && modUnix < opts.ModifiedNewerThan {
|
||||
return false
|
||||
}
|
||||
if opts.ModifiedOlderThan > 0 && modUnix >= opts.ModifiedOlderThan {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SearchMultiSources searches across multiple sources in a single database query.
|
||||
// sources is a list of source names to search.
|
||||
// sourceScopes is a map from source name to the scope path for that source.
|
||||
// sourceSession is used for cancellation tracking.
|
||||
// largest and limit work the same as in Search.
|
||||
func SearchMultiSources(search string, sources []string, sourceScopes map[string]string, sourceSession string, largest bool, limit int, olderThanUnix, newerThanUnix int64) []*SearchResult {
|
||||
func SearchMultiSources(search string, sources []string, sourceScopes map[string]string, sourceSession string, largest bool, limit int) []*SearchResult {
|
||||
if len(sources) == 0 {
|
||||
return []*SearchResult{}
|
||||
}
|
||||
@@ -185,12 +168,6 @@ func SearchMultiSources(search string, sources []string, sourceScopes map[string
|
||||
runningHash := utils.InsecureRandomIdentifier(4)
|
||||
sessionInProgress.Store(sourceSession, runningHash)
|
||||
searchOptions := iteminfo.ParseSearch(search)
|
||||
if olderThanUnix > 0 {
|
||||
searchOptions.ModifiedOlderThan = olderThanUnix
|
||||
}
|
||||
if newerThanUnix > 0 {
|
||||
searchOptions.ModifiedNewerThan = newerThanUnix
|
||||
}
|
||||
results := make(map[string]*SearchResult, 0)
|
||||
count := 0
|
||||
|
||||
@@ -260,8 +237,7 @@ func SearchMultiSources(search string, sources []string, sourceScopes map[string
|
||||
} else {
|
||||
typeMatches = !hasDirCondition || (hasDirCondition && !dirCondition)
|
||||
}
|
||||
dateMatches := searchDateMatches(item.ModTime.Unix(), searchOptions)
|
||||
matches = sizeMatches && typeMatches && dateMatches
|
||||
matches = sizeMatches && typeMatches
|
||||
} else {
|
||||
matches = item.ContainsSearchTerm(searchTerm, searchOptions)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func BenchmarkSearchAllIndexes(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Execute the SearchAllIndexes function
|
||||
for _, term := range searchTerms {
|
||||
idx.Search(term, "/", "test", false, DefaultSearchResults, 0, 0)
|
||||
idx.Search(term, "/", "test", false, DefaultSearchResults)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,15 +102,6 @@ func TestParseSearch(t *testing.T) {
|
||||
Terms: []string{"thisfile"},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "type:olderThan=2024-06-01 type:newerThan=2024-01-01 findme",
|
||||
want: iteminfo.SearchOptions{
|
||||
Conditions: map[string]bool{"exact": false},
|
||||
Terms: []string{"findme"},
|
||||
ModifiedOlderThan: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).Unix(),
|
||||
ModifiedNewerThan: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).Unix(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -174,7 +165,7 @@ func TestSearchWhileIndexing(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func(searchTerm string) {
|
||||
defer wg.Done()
|
||||
idx.Search(searchTerm, "/", "test", false, DefaultSearchResults, 0, 0)
|
||||
idx.Search(searchTerm, "/", "test", false, DefaultSearchResults)
|
||||
}(term)
|
||||
}
|
||||
|
||||
@@ -452,7 +443,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.search, func(t *testing.T) {
|
||||
result := index.Search(tt.search, tt.scope, "", false, DefaultSearchResults, 0, 0)
|
||||
result := index.Search(tt.search, tt.scope, "", false, DefaultSearchResults)
|
||||
// Convert results to comparable format (without Modified field)
|
||||
expected := make([]SearchResult, len(tt.expectedResult))
|
||||
for i, r := range tt.expectedResult {
|
||||
@@ -544,7 +535,7 @@ func TestSearchLargestModeExcludesRoot(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test that when largest=true and scope="/", the root directory "/" is NOT included
|
||||
result := index.Search("", "/", "test-session", true, DefaultSearchResults, 0, 0)
|
||||
result := index.Search("", "/", "test-session", true, DefaultSearchResults)
|
||||
|
||||
// Verify that "/" is NOT in the results
|
||||
rootFound := false
|
||||
@@ -640,7 +631,7 @@ func TestSearchLargestModeExcludesScopeDirectory(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test that when largest=true and scope="/test/", the scope directory "/test/" is NOT included
|
||||
result := index.Search("", "/test/", "test-session", true, DefaultSearchResults, 0, 0)
|
||||
result := index.Search("", "/test/", "test-session", true, DefaultSearchResults)
|
||||
|
||||
// Verify that "/test/" is NOT in the results
|
||||
scopeDirFound := false
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
// SetTestIndex allows tests to register mock indices without database initialization
|
||||
// This is useful for testing code that depends on GetIndex() without needing full setup
|
||||
func SetTestIndex(name string, path string) {
|
||||
indexesMutex.Lock()
|
||||
defer indexesMutex.Unlock()
|
||||
|
||||
idx := &Index{
|
||||
Source: settings.Source{
|
||||
Name: name,
|
||||
Path: path,
|
||||
},
|
||||
WebdavLock: webdav.NewMemLS(),
|
||||
mock: true,
|
||||
}
|
||||
indexes[name] = idx
|
||||
}
|
||||
|
||||
// ClearTestIndices removes all test indices - call in test cleanup
|
||||
func ClearTestIndices() {
|
||||
indexesMutex.Lock()
|
||||
defer indexesMutex.Unlock()
|
||||
indexes = make(map[string]*Index)
|
||||
}
|
||||
+7
-25
@@ -11,7 +11,6 @@ import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gen2brain/go-fitz"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
@@ -33,14 +32,10 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
|
||||
s.docGenMutex.Lock()
|
||||
defer s.docGenMutex.Unlock()
|
||||
|
||||
// Lock the current goroutine to a single OS thread for CGo calls
|
||||
// 2. Lock the current goroutine to a single OS thread for CGo calls
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
// Create a 2-second timeout for document generation after acquiring locks
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
docPath := file.RealPath
|
||||
// copy file to a temporary location if needed
|
||||
if strings.HasPrefix(file.Type, "text") && !strings.HasSuffix(file.RealPath, ".txt") {
|
||||
@@ -77,29 +72,21 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
|
||||
return nil, fmt.Errorf("text snippet: failed to close temporary file '%s': %w", tempFilePath, err)
|
||||
}
|
||||
|
||||
docPath = tempFilePath // Update docPath to point to the new temporary text snippet file
|
||||
docPath = tempFilePath // Update docPath to point to the new temporary text snippet file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check timeout before opening document
|
||||
if timeoutCtx.Err() != nil {
|
||||
return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name)
|
||||
}
|
||||
|
||||
doc, err := fitz.New(docPath) // This calls the CGo version
|
||||
if err != nil {
|
||||
// The error message you received: "failed to open PDF: fitz: cannot open memory"
|
||||
return nil, fmt.Errorf("failed to open PDF from memory for file '%s': %w", docPath, err)
|
||||
}
|
||||
defer doc.Close()
|
||||
|
||||
// Check timeout after opening document
|
||||
if timeoutCtx.Err() != nil {
|
||||
return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name)
|
||||
}
|
||||
|
||||
// Get the image from the doc page
|
||||
// Ensure pageNumber is valid (e.g., >= 0 for 0-indexed or >= 1 for 1-indexed, check go-fitz docs)
|
||||
// And pageNumber < doc.NumPage()
|
||||
numPages := doc.NumPage()
|
||||
if pageNumber < 0 || pageNumber >= numPages {
|
||||
if pageNumber < 0 || pageNumber >= numPages { // Assuming 0-indexed for Image()
|
||||
return nil, fmt.Errorf("invalid page number %d for PDF with %d pages ('%s')", pageNumber, numPages, docPath)
|
||||
}
|
||||
|
||||
@@ -108,11 +95,6 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
|
||||
return nil, fmt.Errorf("failed to get image from page %d of '%s': %w", pageNumber, docPath, err)
|
||||
}
|
||||
|
||||
// Check timeout after rendering image
|
||||
if timeoutCtx.Err() != nil {
|
||||
return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name)
|
||||
}
|
||||
|
||||
// Create a new buffer to hold the image bytes
|
||||
var buf bytes.Buffer
|
||||
|
||||
|
||||
@@ -1074,69 +1074,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/media/metadata": {
|
||||
"get": {
|
||||
"description": "Same ExtendedFileInfo as resources GET with metadata=true (typically used for directories).",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Resources"
|
||||
],
|
||||
"summary": "Directory with media metadata",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Path to the directory or file",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source name",
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "When true, include embedded album art bytes in audio metadata",
|
||||
"name": "albumArt",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/iteminfo.ExtendedFileInfo"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/media/subtitles": {
|
||||
"get": {
|
||||
"description": "Returns raw subtitle content from external files or embedded streams",
|
||||
@@ -1447,7 +1384,7 @@ const docTemplate = `{
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "When true, omit index-level extended fields (e.g. hasPreview); does not disable ffmpeg/media extraction",
|
||||
"description": "Skip extended attributes for faster retrieval, no hasPreview",
|
||||
"name": "skipExtendedAttrs",
|
||||
"in": "query"
|
||||
},
|
||||
@@ -1466,7 +1403,7 @@ const docTemplate = `{
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "When true, run audio/video metadata extraction, subtitles, and directory media batch processing",
|
||||
"description": "Extract audio/video metadata if true",
|
||||
"name": "metadata",
|
||||
"in": "query"
|
||||
},
|
||||
@@ -1791,7 +1728,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/resources/archive": {
|
||||
"post": {
|
||||
"description": "Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the paths to archive live. Example: ` + "`" + `\"default\"` + "`" + `\n- **toSource** (string, optional): Source name where the archive file will be written. Defaults to fromSource if omitted. Example: ` + "`" + `\"backups\"` + "`" + `\n- **paths** (array of strings, required): Paths of files or directories to add to the archive (relative to fromSource). Directories are walked; access-denied entries are skipped. Example: ` + "`" + `[\"/docs/file.txt\", \"/photos\"]` + "`" + `\n- **destination** (string, required): Full path where the archive file will be created (on toSource). Must end with .zip or .tar.gz (or format is inferred). Example: ` + "`" + `\"/backups/my-archive.zip\"` + "`" + `\n- **format** (string, optional): Archive format. One of: ` + "`" + `\"zip\"` + "`" + `, ` + "`" + `\"tar.gz\"` + "`" + `. Default inferred from destination extension. Example: ` + "`" + `\"zip\"` + "`" + `\n- **compression** (integer, optional): Gzip compression level for tar.gz only (0–9). 0 = default. Ignored for zip. Example: ` + "`" + `6` + "`" + `\n- **deleteAfter** (boolean, optional): If true, delete source files/directories after successful creation. Requires delete permission. Example: ` + "`" + `true` + "`" + `",
|
||||
"description": "Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the paths to archive live. Example: ` + "`" + `\"default\"` + "`" + `\n- **toSource** (string, optional): Source name where the archive file will be written. Defaults to fromSource if omitted. Example: ` + "`" + `\"backups\"` + "`" + `\n- **paths** (array of strings, required): Paths of files or directories to add to the archive (relative to fromSource). Directories are walked; access-denied entries are skipped. Example: ` + "`" + `[\"/docs/file.txt\", \"/photos\"]` + "`" + `\n- **destination** (string, required): Full path where the archive file will be created (on toSource). Must end with .zip or .tar.gz (or format is inferred). Example: ` + "`" + `\"/backups/my-archive.zip\"` + "`" + `\n- **format** (string, optional): Archive format. One of: ` + "`" + `\"zip\"` + "`" + `, ` + "`" + `\"tar.gz\"` + "`" + `. Default inferred from destination extension. Example: ` + "`" + `\"zip\"` + "`" + `\n- **compression** (integer, optional): Gzip compression level for tar.gz only (0–9). 0 = default. Ignored for zip. Example: ` + "`" + `6` + "`" + `\n- **deleteAfter** (boolean, optional): If true, delete source files/directories after successful creation. Requires delete permission. Example: ` + "`" + `true` + "`" + `",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1850,15 +1787,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
@@ -2092,69 +2020,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/resources/pause": {
|
||||
"post": {
|
||||
"description": "Sets a short-lived server flag for the given source and path. When the active chunk POST then fails (e.g. client abort), the server trims the incomplete chunk but keeps the temp partial file for resume.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Resources"
|
||||
],
|
||||
"summary": "Register graceful chunked upload pause",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Destination path (same as upload)",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source name (same as upload)",
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Pause registered"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Source not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/resources/preview": {
|
||||
"get": {
|
||||
"description": "Returns a preview image based on the requested path and size.",
|
||||
@@ -2249,7 +2114,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/resources/unarchive": {
|
||||
"post": {
|
||||
"description": "Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the archive file lives. Example: ` + "`" + `\"default\"` + "`" + `\n- **toSource** (string, optional): Source name where contents will be extracted. Defaults to fromSource if omitted. Example: ` + "`" + `\"restored\"` + "`" + `\n- **path** (string, required): Path to the archive file (on fromSource). Must be .zip, .tar.gz, or .tgz. Example: ` + "`" + `\"/downloads/data.zip\"` + "`" + `\n- **destination** (string, required): Directory path (on toSource) to extract into. Example: ` + "`" + `\"/projects/imported\"` + "`" + `\n- **deleteAfter** (boolean, optional): If true, delete the archive file after successful extraction. Default: false. Example: ` + "`" + `true` + "`" + `",
|
||||
"description": "Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the archive file lives. Example: ` + "`" + `\"default\"` + "`" + `\n- **toSource** (string, optional): Source name where contents will be extracted. Defaults to fromSource if omitted. Example: ` + "`" + `\"restored\"` + "`" + `\n- **path** (string, required): Path to the archive file (on fromSource). Must be .zip, .tar.gz, or .tgz. Example: ` + "`" + `\"/downloads/data.zip\"` + "`" + `\n- **destination** (string, required): Directory path (on toSource) to extract into. Example: ` + "`" + `\"/projects/imported\"` + "`" + `\n- **deleteAfter** (boolean, optional): If true, delete the archive file after successful extraction. Default: false. Example: ` + "`" + `true` + "`" + `",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -2308,15 +2173,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
@@ -2977,18 +2833,6 @@ const docTemplate = `{
|
||||
"name": "scope",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Unix seconds; only results modified strictly before this time",
|
||||
"name": "olderThan",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Unix seconds; only results modified on or after this time",
|
||||
"name": "newerThan",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User session ID, add unique value to prevent collisions",
|
||||
@@ -3076,7 +2920,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"description": "Updates the details of a user identified by ID. When updating the target user's password the actor must send their current password in the X-Password header",
|
||||
"description": "Updates the details of a user identified by ID.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3097,12 +2941,6 @@ const docTemplate = `{
|
||||
"name": "id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Actor's current password (URL-encoded); required when changing a password user's password",
|
||||
"name": "X-Password",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "User data to update",
|
||||
"name": "data",
|
||||
@@ -3126,15 +2964,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing actor password for password change",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
@@ -3271,46 +3100,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/public/api/media/metadata": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Shares"
|
||||
],
|
||||
"summary": "Directory with media metadata (public share)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Share hash",
|
||||
"name": "hash",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Path within the share",
|
||||
"name": "path",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "When true, include embedded album art bytes in audio metadata (heavier)",
|
||||
"name": "albumArt",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/iteminfo.ExtendedFileInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/public/api/resources": {
|
||||
"get": {
|
||||
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
|
||||
@@ -3921,60 +3710,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/public/api/resources/pause": {
|
||||
"post": {
|
||||
"description": "Same as authenticated pause, for upload shares. Uses hash and path query parameters like other public resource APIs.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Shares"
|
||||
],
|
||||
"summary": "Register graceful chunked upload pause (public share)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Share hash",
|
||||
"name": "hash",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Path within the share (same as upload)",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Pause registered"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Source not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/public/api/resources/preview": {
|
||||
"get": {
|
||||
"description": "Returns a preview (thumbnail) for images or videos accessible via a public share. Preview generation can be disabled globally or per-share. Not available for upload-only shares.",
|
||||
@@ -4367,9 +4102,6 @@ const docTemplate = `{
|
||||
"bannerUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"canEditShare": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4481,9 +4213,6 @@ const docTemplate = `{
|
||||
"description": "Override embedded field to show source name",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"themeColor": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4676,99 +4405,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"iteminfo.ExtendedFileInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"checksums": {
|
||||
"description": "checksums for the file",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"description": "text content of a file, if requested",
|
||||
"type": "string"
|
||||
},
|
||||
"files": {
|
||||
"description": "files in the directory with optional metadata",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/iteminfo.ExtendedItemInfo"
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"description": "folders in the directory",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/iteminfo.ItemInfo"
|
||||
}
|
||||
},
|
||||
"hasPreview": {
|
||||
"description": "whether the file has a thumbnail preview",
|
||||
"type": "boolean"
|
||||
},
|
||||
"hash": {
|
||||
"description": "hash for the file -- used for sharing",
|
||||
"type": "string"
|
||||
},
|
||||
"hidden": {
|
||||
"description": "whether the file is hidden",
|
||||
"type": "boolean"
|
||||
},
|
||||
"isShared": {
|
||||
"description": "whether the file or folder is shared",
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "media metadata for audio/video files (includes duration)",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/iteminfo.MediaMetadata"
|
||||
}
|
||||
]
|
||||
},
|
||||
"modified": {
|
||||
"description": "modification time",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "name of the file",
|
||||
"type": "string"
|
||||
},
|
||||
"onlyOfficeId": {
|
||||
"description": "id for onlyoffice files",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "path scoped to the associated index",
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"description": "length in bytes for regular files",
|
||||
"type": "integer"
|
||||
},
|
||||
"source": {
|
||||
"description": "associated index source for the file",
|
||||
"type": "string"
|
||||
},
|
||||
"subtitles": {
|
||||
"description": "subtitles for video files",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/utils.SubtitleTrack"
|
||||
}
|
||||
},
|
||||
"token": {
|
||||
"description": "token for the file -- used for sharing",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "type of the file, either \"directory\" or a file mimetype",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"iteminfo.ExtendedItemInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5190,15 +4826,6 @@ const docTemplate = `{
|
||||
"description": "enable to always create a new indexing database on startup.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"startupIntegrityCheck": {
|
||||
"description": "the method used to check the integrity of the index database on startup (default: quickCheck)",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"quickCheck",
|
||||
"probe",
|
||||
"off"
|
||||
]
|
||||
},
|
||||
"walMode": {
|
||||
"description": "enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.",
|
||||
"type": "boolean"
|
||||
@@ -5659,7 +5286,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"maxArchiveSize": {
|
||||
"description": "maximum archive/unarchive size in GB. 0 means no limit. (default: 20)",
|
||||
"description": "max pre-archive combined size of files/folder that are allowed to be archived (in GB)",
|
||||
"type": "integer"
|
||||
},
|
||||
"minSearchLength": {
|
||||
@@ -5745,7 +5372,7 @@ const docTemplate = `{
|
||||
]
|
||||
},
|
||||
"createUserDir": {
|
||||
"description": "deprecated: create a user directory for each user under defaultUserScope + username (always true now)",
|
||||
"description": "create a user directory for each user under defaultUserScope + username",
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultEnabled": {
|
||||
@@ -5836,10 +5463,6 @@ const docTemplate = `{
|
||||
"description": "debug onlyoffice editor",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deleteAfterArchive": {
|
||||
"description": "delete source files after successful creation/extraction of archives",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deleteWithoutConfirming": {
|
||||
"description": "delete files without confirmation",
|
||||
"type": "boolean"
|
||||
@@ -5892,10 +5515,6 @@ const docTemplate = `{
|
||||
"description": "0-9 - the size of the gallery thumbnails",
|
||||
"type": "integer"
|
||||
},
|
||||
"hideFilesInTree": {
|
||||
"description": "hide files in the sidebar tree navigation, when true, will show only directories.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"hideSidebarFileActions": {
|
||||
"description": "hide the file actions in the sidebar",
|
||||
"type": "boolean"
|
||||
@@ -5915,10 +5534,6 @@ const docTemplate = `{
|
||||
"permissions": {
|
||||
"$ref": "#/definitions/settings.UserDefaultsPermissions"
|
||||
},
|
||||
"preferEditorForMarkdown": {
|
||||
"description": "prefer editor first for markdown files instead of the Markdown Viewer.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"preview": {
|
||||
"$ref": "#/definitions/settings.UserDefaultsPreview"
|
||||
},
|
||||
@@ -5992,10 +5607,6 @@ const docTemplate = `{
|
||||
"settings.UserDefaultsPreview": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"audio": {
|
||||
"description": "show thumbnails for audio files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"autoplayMedia": {
|
||||
"description": "autoplay media files in preview",
|
||||
"type": "boolean"
|
||||
@@ -6020,10 +5631,6 @@ const docTemplate = `{
|
||||
"description": "show thumbnails for image files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"models": {
|
||||
"description": "show live thumbnails for 3D models files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"motionVideoPreview": {
|
||||
"description": "show multiple frames for videos in thumbnail preview when hovering",
|
||||
"type": "boolean"
|
||||
@@ -6072,9 +5679,6 @@ const docTemplate = `{
|
||||
"bannerUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"canEditShare": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6171,9 +5775,6 @@ const docTemplate = `{
|
||||
"description": "backend source is path to maintain between name changes",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"themeColor": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6216,9 +5817,6 @@ const docTemplate = `{
|
||||
"bannerUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"canEditShare": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6324,9 +5922,6 @@ const docTemplate = `{
|
||||
"description": "backend source is path to maintain between name changes",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"themeColor": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6372,9 +5967,6 @@ const docTemplate = `{
|
||||
"bannerUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"canEditShare": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6483,9 +6075,6 @@ const docTemplate = `{
|
||||
"description": "backend source is path to maintain between name changes",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"themeColor": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6616,10 +6205,6 @@ const docTemplate = `{
|
||||
"users.Preview": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"audio": {
|
||||
"description": "show thumbnail preview image for audio files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"autoplayMedia": {
|
||||
"description": "autoplay media files in preview",
|
||||
"type": "boolean"
|
||||
@@ -6640,10 +6225,6 @@ const docTemplate = `{
|
||||
"description": "show thumbnail preview image for image files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"models": {
|
||||
"description": "show live thumbnails for 3D models files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"motionVideoPreview": {
|
||||
"description": "show multiple frames for videos in thumbnail preview when hovering",
|
||||
"type": "boolean"
|
||||
@@ -6735,10 +6316,6 @@ const docTemplate = `{
|
||||
"description": "debug onlyoffice editor",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deleteAfterArchive": {
|
||||
"description": "delete source files after successful creation/extraction of archives",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deleteWithoutConfirming": {
|
||||
"description": "delete files without confirmation",
|
||||
"type": "boolean"
|
||||
@@ -6790,10 +6367,6 @@ const docTemplate = `{
|
||||
"description": "0-9 - the size of the gallery thumbnails",
|
||||
"type": "integer"
|
||||
},
|
||||
"hideFilesInTree": {
|
||||
"description": "hide files in the sidebar tree navigation, when true, will show only directories.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"hideSidebarFileActions": {
|
||||
"description": "hide the file actions in the sidebar",
|
||||
"type": "boolean"
|
||||
@@ -6829,10 +6402,6 @@ const docTemplate = `{
|
||||
"permissions": {
|
||||
"$ref": "#/definitions/users.Permissions"
|
||||
},
|
||||
"preferEditorForMarkdown": {
|
||||
"description": "prefer editor first for markdown files instead of the Markdown Viewer",
|
||||
"type": "boolean"
|
||||
},
|
||||
"preview": {
|
||||
"$ref": "#/definitions/users.Preview"
|
||||
},
|
||||
@@ -6849,10 +6418,6 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/users.SourceScope"
|
||||
}
|
||||
},
|
||||
"showCopyPath": {
|
||||
"description": "show copy path action in the context menu",
|
||||
"type": "boolean"
|
||||
},
|
||||
"showFirstLogin": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -6909,35 +6474,6 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"utils.SubtitleTrack": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"codec": {
|
||||
"description": "codec name for embedded subtitles",
|
||||
"type": "string"
|
||||
},
|
||||
"embedded": {
|
||||
"description": "true for embedded subtitles, false for external files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"index": {
|
||||
"description": "stream index for embedded subtitles (nil for external)",
|
||||
"type": "integer"
|
||||
},
|
||||
"language": {
|
||||
"description": "language code",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "filename for external, or descriptive name for embedded",
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"description": "title/description",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
@@ -1063,69 +1063,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/media/metadata": {
|
||||
"get": {
|
||||
"description": "Same ExtendedFileInfo as resources GET with metadata=true (typically used for directories).",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Resources"
|
||||
],
|
||||
"summary": "Directory with media metadata",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Path to the directory or file",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source name",
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "When true, include embedded album art bytes in audio metadata",
|
||||
"name": "albumArt",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/iteminfo.ExtendedFileInfo"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/media/subtitles": {
|
||||
"get": {
|
||||
"description": "Returns raw subtitle content from external files or embedded streams",
|
||||
@@ -1436,7 +1373,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "When true, omit index-level extended fields (e.g. hasPreview); does not disable ffmpeg/media extraction",
|
||||
"description": "Skip extended attributes for faster retrieval, no hasPreview",
|
||||
"name": "skipExtendedAttrs",
|
||||
"in": "query"
|
||||
},
|
||||
@@ -1455,7 +1392,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "When true, run audio/video metadata extraction, subtitles, and directory media batch processing",
|
||||
"description": "Extract audio/video metadata if true",
|
||||
"name": "metadata",
|
||||
"in": "query"
|
||||
},
|
||||
@@ -1780,7 +1717,7 @@
|
||||
},
|
||||
"/api/resources/archive": {
|
||||
"post": {
|
||||
"description": "Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the paths to archive live. Example: `\"default\"`\n- **toSource** (string, optional): Source name where the archive file will be written. Defaults to fromSource if omitted. Example: `\"backups\"`\n- **paths** (array of strings, required): Paths of files or directories to add to the archive (relative to fromSource). Directories are walked; access-denied entries are skipped. Example: `[\"/docs/file.txt\", \"/photos\"]`\n- **destination** (string, required): Full path where the archive file will be created (on toSource). Must end with .zip or .tar.gz (or format is inferred). Example: `\"/backups/my-archive.zip\"`\n- **format** (string, optional): Archive format. One of: `\"zip\"`, `\"tar.gz\"`. Default inferred from destination extension. Example: `\"zip\"`\n- **compression** (integer, optional): Gzip compression level for tar.gz only (0–9). 0 = default. Ignored for zip. Example: `6`\n- **deleteAfter** (boolean, optional): If true, delete source files/directories after successful creation. Requires delete permission. Example: `true`",
|
||||
"description": "Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the paths to archive live. Example: `\"default\"`\n- **toSource** (string, optional): Source name where the archive file will be written. Defaults to fromSource if omitted. Example: `\"backups\"`\n- **paths** (array of strings, required): Paths of files or directories to add to the archive (relative to fromSource). Directories are walked; access-denied entries are skipped. Example: `[\"/docs/file.txt\", \"/photos\"]`\n- **destination** (string, required): Full path where the archive file will be created (on toSource). Must end with .zip or .tar.gz (or format is inferred). Example: `\"/backups/my-archive.zip\"`\n- **format** (string, optional): Archive format. One of: `\"zip\"`, `\"tar.gz\"`. Default inferred from destination extension. Example: `\"zip\"`\n- **compression** (integer, optional): Gzip compression level for tar.gz only (0–9). 0 = default. Ignored for zip. Example: `6`\n- **deleteAfter** (boolean, optional): If true, delete source files/directories after successful creation. Requires delete permission. Example: `true`",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1839,15 +1776,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
@@ -2081,69 +2009,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/resources/pause": {
|
||||
"post": {
|
||||
"description": "Sets a short-lived server flag for the given source and path. When the active chunk POST then fails (e.g. client abort), the server trims the incomplete chunk but keeps the temp partial file for resume.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Resources"
|
||||
],
|
||||
"summary": "Register graceful chunked upload pause",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Destination path (same as upload)",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source name (same as upload)",
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Pause registered"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Source not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/resources/preview": {
|
||||
"get": {
|
||||
"description": "Returns a preview image based on the requested path and size.",
|
||||
@@ -2238,7 +2103,7 @@
|
||||
},
|
||||
"/api/resources/unarchive": {
|
||||
"post": {
|
||||
"description": "Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the archive file lives. Example: `\"default\"`\n- **toSource** (string, optional): Source name where contents will be extracted. Defaults to fromSource if omitted. Example: `\"restored\"`\n- **path** (string, required): Path to the archive file (on fromSource). Must be .zip, .tar.gz, or .tgz. Example: `\"/downloads/data.zip\"`\n- **destination** (string, required): Directory path (on toSource) to extract into. Example: `\"/projects/imported\"`\n- **deleteAfter** (boolean, optional): If true, delete the archive file after successful extraction. Default: false. Example: `true`",
|
||||
"description": "Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the archive file lives. Example: `\"default\"`\n- **toSource** (string, optional): Source name where contents will be extracted. Defaults to fromSource if omitted. Example: `\"restored\"`\n- **path** (string, required): Path to the archive file (on fromSource). Must be .zip, .tar.gz, or .tgz. Example: `\"/downloads/data.zip\"`\n- **destination** (string, required): Directory path (on toSource) to extract into. Example: `\"/projects/imported\"`\n- **deleteAfter** (boolean, optional): If true, delete the archive file after successful extraction. Default: false. Example: `true`",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -2297,15 +2162,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
@@ -2966,18 +2822,6 @@
|
||||
"name": "scope",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Unix seconds; only results modified strictly before this time",
|
||||
"name": "olderThan",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Unix seconds; only results modified on or after this time",
|
||||
"name": "newerThan",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User session ID, add unique value to prevent collisions",
|
||||
@@ -3065,7 +2909,7 @@
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"description": "Updates the details of a user identified by ID. When updating the target user's password the actor must send their current password in the X-Password header",
|
||||
"description": "Updates the details of a user identified by ID.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3086,12 +2930,6 @@
|
||||
"name": "id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Actor's current password (URL-encoded); required when changing a password user's password",
|
||||
"name": "X-Password",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "User data to update",
|
||||
"name": "data",
|
||||
@@ -3115,15 +2953,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing actor password for password change",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
@@ -3260,46 +3089,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/public/api/media/metadata": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Shares"
|
||||
],
|
||||
"summary": "Directory with media metadata (public share)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Share hash",
|
||||
"name": "hash",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Path within the share",
|
||||
"name": "path",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "When true, include embedded album art bytes in audio metadata (heavier)",
|
||||
"name": "albumArt",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/iteminfo.ExtendedFileInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/public/api/resources": {
|
||||
"get": {
|
||||
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
|
||||
@@ -3910,60 +3699,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/public/api/resources/pause": {
|
||||
"post": {
|
||||
"description": "Same as authenticated pause, for upload shares. Uses hash and path query parameters like other public resource APIs.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Shares"
|
||||
],
|
||||
"summary": "Register graceful chunked upload pause (public share)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Share hash",
|
||||
"name": "hash",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Path within the share (same as upload)",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Pause registered"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Source not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/public/api/resources/preview": {
|
||||
"get": {
|
||||
"description": "Returns a preview (thumbnail) for images or videos accessible via a public share. Preview generation can be disabled globally or per-share. Not available for upload-only shares.",
|
||||
@@ -4356,9 +4091,6 @@
|
||||
"bannerUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"canEditShare": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4470,9 +4202,6 @@
|
||||
"description": "Override embedded field to show source name",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"themeColor": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4665,99 +4394,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"iteminfo.ExtendedFileInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"checksums": {
|
||||
"description": "checksums for the file",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"description": "text content of a file, if requested",
|
||||
"type": "string"
|
||||
},
|
||||
"files": {
|
||||
"description": "files in the directory with optional metadata",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/iteminfo.ExtendedItemInfo"
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"description": "folders in the directory",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/iteminfo.ItemInfo"
|
||||
}
|
||||
},
|
||||
"hasPreview": {
|
||||
"description": "whether the file has a thumbnail preview",
|
||||
"type": "boolean"
|
||||
},
|
||||
"hash": {
|
||||
"description": "hash for the file -- used for sharing",
|
||||
"type": "string"
|
||||
},
|
||||
"hidden": {
|
||||
"description": "whether the file is hidden",
|
||||
"type": "boolean"
|
||||
},
|
||||
"isShared": {
|
||||
"description": "whether the file or folder is shared",
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "media metadata for audio/video files (includes duration)",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/iteminfo.MediaMetadata"
|
||||
}
|
||||
]
|
||||
},
|
||||
"modified": {
|
||||
"description": "modification time",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "name of the file",
|
||||
"type": "string"
|
||||
},
|
||||
"onlyOfficeId": {
|
||||
"description": "id for onlyoffice files",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "path scoped to the associated index",
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"description": "length in bytes for regular files",
|
||||
"type": "integer"
|
||||
},
|
||||
"source": {
|
||||
"description": "associated index source for the file",
|
||||
"type": "string"
|
||||
},
|
||||
"subtitles": {
|
||||
"description": "subtitles for video files",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/utils.SubtitleTrack"
|
||||
}
|
||||
},
|
||||
"token": {
|
||||
"description": "token for the file -- used for sharing",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "type of the file, either \"directory\" or a file mimetype",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"iteminfo.ExtendedItemInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5179,15 +4815,6 @@
|
||||
"description": "enable to always create a new indexing database on startup.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"startupIntegrityCheck": {
|
||||
"description": "the method used to check the integrity of the index database on startup (default: quickCheck)",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"quickCheck",
|
||||
"probe",
|
||||
"off"
|
||||
]
|
||||
},
|
||||
"walMode": {
|
||||
"description": "enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.",
|
||||
"type": "boolean"
|
||||
@@ -5648,7 +5275,7 @@
|
||||
}
|
||||
},
|
||||
"maxArchiveSize": {
|
||||
"description": "maximum archive/unarchive size in GB. 0 means no limit. (default: 20)",
|
||||
"description": "max pre-archive combined size of files/folder that are allowed to be archived (in GB)",
|
||||
"type": "integer"
|
||||
},
|
||||
"minSearchLength": {
|
||||
@@ -5734,7 +5361,7 @@
|
||||
]
|
||||
},
|
||||
"createUserDir": {
|
||||
"description": "deprecated: create a user directory for each user under defaultUserScope + username (always true now)",
|
||||
"description": "create a user directory for each user under defaultUserScope + username",
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultEnabled": {
|
||||
@@ -5825,10 +5452,6 @@
|
||||
"description": "debug onlyoffice editor",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deleteAfterArchive": {
|
||||
"description": "delete source files after successful creation/extraction of archives",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deleteWithoutConfirming": {
|
||||
"description": "delete files without confirmation",
|
||||
"type": "boolean"
|
||||
@@ -5881,10 +5504,6 @@
|
||||
"description": "0-9 - the size of the gallery thumbnails",
|
||||
"type": "integer"
|
||||
},
|
||||
"hideFilesInTree": {
|
||||
"description": "hide files in the sidebar tree navigation, when true, will show only directories.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"hideSidebarFileActions": {
|
||||
"description": "hide the file actions in the sidebar",
|
||||
"type": "boolean"
|
||||
@@ -5904,10 +5523,6 @@
|
||||
"permissions": {
|
||||
"$ref": "#/definitions/settings.UserDefaultsPermissions"
|
||||
},
|
||||
"preferEditorForMarkdown": {
|
||||
"description": "prefer editor first for markdown files instead of the Markdown Viewer.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"preview": {
|
||||
"$ref": "#/definitions/settings.UserDefaultsPreview"
|
||||
},
|
||||
@@ -5981,10 +5596,6 @@
|
||||
"settings.UserDefaultsPreview": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"audio": {
|
||||
"description": "show thumbnails for audio files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"autoplayMedia": {
|
||||
"description": "autoplay media files in preview",
|
||||
"type": "boolean"
|
||||
@@ -6009,10 +5620,6 @@
|
||||
"description": "show thumbnails for image files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"models": {
|
||||
"description": "show live thumbnails for 3D models files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"motionVideoPreview": {
|
||||
"description": "show multiple frames for videos in thumbnail preview when hovering",
|
||||
"type": "boolean"
|
||||
@@ -6061,9 +5668,6 @@
|
||||
"bannerUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"canEditShare": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6160,9 +5764,6 @@
|
||||
"description": "backend source is path to maintain between name changes",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"themeColor": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6205,9 +5806,6 @@
|
||||
"bannerUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"canEditShare": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6313,9 +5911,6 @@
|
||||
"description": "backend source is path to maintain between name changes",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"themeColor": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6361,9 +5956,6 @@
|
||||
"bannerUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"canEditShare": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6472,9 +6064,6 @@
|
||||
"description": "backend source is path to maintain between name changes",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"themeColor": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6605,10 +6194,6 @@
|
||||
"users.Preview": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"audio": {
|
||||
"description": "show thumbnail preview image for audio files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"autoplayMedia": {
|
||||
"description": "autoplay media files in preview",
|
||||
"type": "boolean"
|
||||
@@ -6629,10 +6214,6 @@
|
||||
"description": "show thumbnail preview image for image files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"models": {
|
||||
"description": "show live thumbnails for 3D models files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"motionVideoPreview": {
|
||||
"description": "show multiple frames for videos in thumbnail preview when hovering",
|
||||
"type": "boolean"
|
||||
@@ -6724,10 +6305,6 @@
|
||||
"description": "debug onlyoffice editor",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deleteAfterArchive": {
|
||||
"description": "delete source files after successful creation/extraction of archives",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deleteWithoutConfirming": {
|
||||
"description": "delete files without confirmation",
|
||||
"type": "boolean"
|
||||
@@ -6779,10 +6356,6 @@
|
||||
"description": "0-9 - the size of the gallery thumbnails",
|
||||
"type": "integer"
|
||||
},
|
||||
"hideFilesInTree": {
|
||||
"description": "hide files in the sidebar tree navigation, when true, will show only directories.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"hideSidebarFileActions": {
|
||||
"description": "hide the file actions in the sidebar",
|
||||
"type": "boolean"
|
||||
@@ -6818,10 +6391,6 @@
|
||||
"permissions": {
|
||||
"$ref": "#/definitions/users.Permissions"
|
||||
},
|
||||
"preferEditorForMarkdown": {
|
||||
"description": "prefer editor first for markdown files instead of the Markdown Viewer",
|
||||
"type": "boolean"
|
||||
},
|
||||
"preview": {
|
||||
"$ref": "#/definitions/users.Preview"
|
||||
},
|
||||
@@ -6838,10 +6407,6 @@
|
||||
"$ref": "#/definitions/users.SourceScope"
|
||||
}
|
||||
},
|
||||
"showCopyPath": {
|
||||
"description": "show copy path action in the context menu",
|
||||
"type": "boolean"
|
||||
},
|
||||
"showFirstLogin": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -6898,35 +6463,6 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"utils.SubtitleTrack": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"codec": {
|
||||
"description": "codec name for embedded subtitles",
|
||||
"type": "string"
|
||||
},
|
||||
"embedded": {
|
||||
"description": "true for embedded subtitles, false for external files",
|
||||
"type": "boolean"
|
||||
},
|
||||
"index": {
|
||||
"description": "stream index for embedded subtitles (nil for external)",
|
||||
"type": "integer"
|
||||
},
|
||||
"language": {
|
||||
"description": "language code",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "filename for external, or descriptive name for embedded",
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"description": "title/description",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,8 +131,6 @@ definitions:
|
||||
type: string
|
||||
bannerUrl:
|
||||
type: string
|
||||
canEditShare:
|
||||
type: boolean
|
||||
description:
|
||||
type: string
|
||||
disableAnonymous:
|
||||
@@ -210,8 +208,6 @@ definitions:
|
||||
source:
|
||||
description: Override embedded field to show source name
|
||||
type: string
|
||||
sourceURL:
|
||||
type: string
|
||||
themeColor:
|
||||
type: string
|
||||
title:
|
||||
@@ -359,72 +355,6 @@ definitions:
|
||||
type:
|
||||
type: string
|
||||
type: object
|
||||
iteminfo.ExtendedFileInfo:
|
||||
properties:
|
||||
checksums:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: checksums for the file
|
||||
type: object
|
||||
content:
|
||||
description: text content of a file, if requested
|
||||
type: string
|
||||
files:
|
||||
description: files in the directory with optional metadata
|
||||
items:
|
||||
$ref: '#/definitions/iteminfo.ExtendedItemInfo'
|
||||
type: array
|
||||
folders:
|
||||
description: folders in the directory
|
||||
items:
|
||||
$ref: '#/definitions/iteminfo.ItemInfo'
|
||||
type: array
|
||||
hasPreview:
|
||||
description: whether the file has a thumbnail preview
|
||||
type: boolean
|
||||
hash:
|
||||
description: hash for the file -- used for sharing
|
||||
type: string
|
||||
hidden:
|
||||
description: whether the file is hidden
|
||||
type: boolean
|
||||
isShared:
|
||||
description: whether the file or folder is shared
|
||||
type: boolean
|
||||
metadata:
|
||||
allOf:
|
||||
- $ref: '#/definitions/iteminfo.MediaMetadata'
|
||||
description: media metadata for audio/video files (includes duration)
|
||||
modified:
|
||||
description: modification time
|
||||
type: string
|
||||
name:
|
||||
description: name of the file
|
||||
type: string
|
||||
onlyOfficeId:
|
||||
description: id for onlyoffice files
|
||||
type: string
|
||||
path:
|
||||
description: path scoped to the associated index
|
||||
type: string
|
||||
size:
|
||||
description: length in bytes for regular files
|
||||
type: integer
|
||||
source:
|
||||
description: associated index source for the file
|
||||
type: string
|
||||
subtitles:
|
||||
description: subtitles for video files
|
||||
items:
|
||||
$ref: '#/definitions/utils.SubtitleTrack'
|
||||
type: array
|
||||
token:
|
||||
description: token for the file -- used for sharing
|
||||
type: string
|
||||
type:
|
||||
description: type of the file, either "directory" or a file mimetype
|
||||
type: string
|
||||
type: object
|
||||
iteminfo.ExtendedItemInfo:
|
||||
properties:
|
||||
hasPreview:
|
||||
@@ -751,14 +681,6 @@ definitions:
|
||||
disableReuse:
|
||||
description: enable to always create a new indexing database on startup.
|
||||
type: boolean
|
||||
startupIntegrityCheck:
|
||||
description: 'the method used to check the integrity of the index database
|
||||
on startup (default: quickCheck)'
|
||||
enum:
|
||||
- quickCheck
|
||||
- probe
|
||||
- "off"
|
||||
type: string
|
||||
walMode:
|
||||
description: enable the more complex WAL journaling mode. Slower, more memory
|
||||
usage, but better for deployments with constant user activity.
|
||||
@@ -1114,8 +1036,8 @@ definitions:
|
||||
$ref: '#/definitions/settings.LogConfig'
|
||||
type: array
|
||||
maxArchiveSize:
|
||||
description: 'maximum archive/unarchive size in GB. 0 means no limit. (default:
|
||||
20)'
|
||||
description: max pre-archive combined size of files/folder that are allowed
|
||||
to be archived (in GB)
|
||||
type: integer
|
||||
minSearchLength:
|
||||
description: 'minimum length of search query to begin searching (default:
|
||||
@@ -1177,8 +1099,8 @@ definitions:
|
||||
- $ref: '#/definitions/settings.ConditionalFilter'
|
||||
description: 'deprecated: use source.rules instead'
|
||||
createUserDir:
|
||||
description: 'deprecated: create a user directory for each user under defaultUserScope
|
||||
+ username (always true now)'
|
||||
description: create a user directory for each user under defaultUserScope
|
||||
+ username
|
||||
type: boolean
|
||||
defaultEnabled:
|
||||
description: should be added as a default source for new users?
|
||||
@@ -1255,9 +1177,6 @@ definitions:
|
||||
debugOffice:
|
||||
description: debug onlyoffice editor
|
||||
type: boolean
|
||||
deleteAfterArchive:
|
||||
description: delete source files after successful creation/extraction of archives
|
||||
type: boolean
|
||||
deleteWithoutConfirming:
|
||||
description: delete files without confirmation
|
||||
type: boolean
|
||||
@@ -1295,10 +1214,6 @@ definitions:
|
||||
gallerySize:
|
||||
description: 0-9 - the size of the gallery thumbnails
|
||||
type: integer
|
||||
hideFilesInTree:
|
||||
description: hide files in the sidebar tree navigation, when true, will show
|
||||
only directories.
|
||||
type: boolean
|
||||
hideSidebarFileActions:
|
||||
description: hide the file actions in the sidebar
|
||||
type: boolean
|
||||
@@ -1313,10 +1228,6 @@ definitions:
|
||||
type: string
|
||||
permissions:
|
||||
$ref: '#/definitions/settings.UserDefaultsPermissions'
|
||||
preferEditorForMarkdown:
|
||||
description: prefer editor first for markdown files instead of the Markdown
|
||||
Viewer.
|
||||
type: boolean
|
||||
preview:
|
||||
$ref: '#/definitions/settings.UserDefaultsPreview'
|
||||
quickDownload:
|
||||
@@ -1373,9 +1284,6 @@ definitions:
|
||||
type: object
|
||||
settings.UserDefaultsPreview:
|
||||
properties:
|
||||
audio:
|
||||
description: show thumbnails for audio files
|
||||
type: boolean
|
||||
autoplayMedia:
|
||||
description: autoplay media files in preview
|
||||
type: boolean
|
||||
@@ -1394,9 +1302,6 @@ definitions:
|
||||
image:
|
||||
description: show thumbnails for image files
|
||||
type: boolean
|
||||
models:
|
||||
description: show live thumbnails for 3D models files
|
||||
type: boolean
|
||||
motionVideoPreview:
|
||||
description: show multiple frames for videos in thumbnail preview when hovering
|
||||
type: boolean
|
||||
@@ -1431,8 +1336,6 @@ definitions:
|
||||
type: string
|
||||
bannerUrl:
|
||||
type: string
|
||||
canEditShare:
|
||||
type: boolean
|
||||
description:
|
||||
type: string
|
||||
disableAnonymous:
|
||||
@@ -1500,8 +1403,6 @@ definitions:
|
||||
source:
|
||||
description: backend source is path to maintain between name changes
|
||||
type: string
|
||||
sourceURL:
|
||||
type: string
|
||||
themeColor:
|
||||
type: string
|
||||
title:
|
||||
@@ -1532,8 +1433,6 @@ definitions:
|
||||
type: string
|
||||
bannerUrl:
|
||||
type: string
|
||||
canEditShare:
|
||||
type: boolean
|
||||
description:
|
||||
type: string
|
||||
disableAnonymous:
|
||||
@@ -1607,8 +1506,6 @@ definitions:
|
||||
source:
|
||||
description: backend source is path to maintain between name changes
|
||||
type: string
|
||||
sourceURL:
|
||||
type: string
|
||||
themeColor:
|
||||
type: string
|
||||
title:
|
||||
@@ -1641,8 +1538,6 @@ definitions:
|
||||
type: string
|
||||
bannerUrl:
|
||||
type: string
|
||||
canEditShare:
|
||||
type: boolean
|
||||
description:
|
||||
type: string
|
||||
disableAnonymous:
|
||||
@@ -1718,8 +1613,6 @@ definitions:
|
||||
source:
|
||||
description: backend source is path to maintain between name changes
|
||||
type: string
|
||||
sourceURL:
|
||||
type: string
|
||||
themeColor:
|
||||
type: string
|
||||
title:
|
||||
@@ -1816,9 +1709,6 @@ definitions:
|
||||
type: object
|
||||
users.Preview:
|
||||
properties:
|
||||
audio:
|
||||
description: show thumbnail preview image for audio files
|
||||
type: boolean
|
||||
autoplayMedia:
|
||||
description: autoplay media files in preview
|
||||
type: boolean
|
||||
@@ -1834,9 +1724,6 @@ definitions:
|
||||
image:
|
||||
description: show thumbnail preview image for image files
|
||||
type: boolean
|
||||
models:
|
||||
description: show live thumbnails for 3D models files
|
||||
type: boolean
|
||||
motionVideoPreview:
|
||||
description: show multiple frames for videos in thumbnail preview when hovering
|
||||
type: boolean
|
||||
@@ -1903,9 +1790,6 @@ definitions:
|
||||
debugOffice:
|
||||
description: debug onlyoffice editor
|
||||
type: boolean
|
||||
deleteAfterArchive:
|
||||
description: delete source files after successful creation/extraction of archives
|
||||
type: boolean
|
||||
deleteWithoutConfirming:
|
||||
description: delete files without confirmation
|
||||
type: boolean
|
||||
@@ -1942,10 +1826,6 @@ definitions:
|
||||
gallerySize:
|
||||
description: 0-9 - the size of the gallery thumbnails
|
||||
type: integer
|
||||
hideFilesInTree:
|
||||
description: hide files in the sidebar tree navigation, when true, will show
|
||||
only directories.
|
||||
type: boolean
|
||||
hideSidebarFileActions:
|
||||
description: hide the file actions in the sidebar
|
||||
type: boolean
|
||||
@@ -1969,10 +1849,6 @@ definitions:
|
||||
description: legacy for migration purposes... og filebrowser has perm attribute
|
||||
permissions:
|
||||
$ref: '#/definitions/users.Permissions'
|
||||
preferEditorForMarkdown:
|
||||
description: prefer editor first for markdown files instead of the Markdown
|
||||
Viewer
|
||||
type: boolean
|
||||
preview:
|
||||
$ref: '#/definitions/users.Preview'
|
||||
quickDownload:
|
||||
@@ -1984,9 +1860,6 @@ definitions:
|
||||
items:
|
||||
$ref: '#/definitions/users.SourceScope'
|
||||
type: array
|
||||
showCopyPath:
|
||||
description: show copy path action in the context menu
|
||||
type: boolean
|
||||
showFirstLogin:
|
||||
type: boolean
|
||||
showHidden:
|
||||
@@ -2030,27 +1903,6 @@ definitions:
|
||||
description: 'view mode to use: eg. normal, list, grid, or compact'
|
||||
type: string
|
||||
type: object
|
||||
utils.SubtitleTrack:
|
||||
properties:
|
||||
codec:
|
||||
description: codec name for embedded subtitles
|
||||
type: string
|
||||
embedded:
|
||||
description: true for embedded subtitles, false for external files
|
||||
type: boolean
|
||||
index:
|
||||
description: stream index for embedded subtitles (nil for external)
|
||||
type: integer
|
||||
language:
|
||||
description: language code
|
||||
type: string
|
||||
name:
|
||||
description: filename for external, or descriptive name for embedded
|
||||
type: string
|
||||
title:
|
||||
description: title/description
|
||||
type: string
|
||||
type: object
|
||||
info:
|
||||
contact: {}
|
||||
paths:
|
||||
@@ -2768,49 +2620,6 @@ paths:
|
||||
summary: List API tokens
|
||||
tags:
|
||||
- Auth
|
||||
/api/media/metadata:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Same ExtendedFileInfo as resources GET with metadata=true (typically
|
||||
used for directories).
|
||||
parameters:
|
||||
- description: Path to the directory or file
|
||||
in: query
|
||||
name: path
|
||||
required: true
|
||||
type: string
|
||||
- description: Source name
|
||||
in: query
|
||||
name: source
|
||||
required: true
|
||||
type: string
|
||||
- description: When true, include embedded album art bytes in audio metadata
|
||||
in: query
|
||||
name: albumArt
|
||||
type: boolean
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/iteminfo.ExtendedFileInfo'
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: Not found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Directory with media metadata
|
||||
tags:
|
||||
- Resources
|
||||
/api/media/subtitles:
|
||||
get:
|
||||
consumes:
|
||||
@@ -3056,8 +2865,7 @@ paths:
|
||||
name: path
|
||||
required: true
|
||||
type: string
|
||||
- description: When true, omit index-level extended fields (e.g. hasPreview);
|
||||
does not disable ffmpeg/media extraction
|
||||
- description: Skip extended attributes for faster retrieval, no hasPreview
|
||||
in: query
|
||||
name: skipExtendedAttrs
|
||||
type: string
|
||||
@@ -3070,8 +2878,7 @@ paths:
|
||||
in: query
|
||||
name: content
|
||||
type: string
|
||||
- description: When true, run audio/video metadata extraction, subtitles, and
|
||||
directory media batch processing
|
||||
- description: Extract audio/video metadata if true
|
||||
in: query
|
||||
name: metadata
|
||||
type: string
|
||||
@@ -3260,7 +3067,7 @@ paths:
|
||||
consumes:
|
||||
- application/json
|
||||
description: |-
|
||||
Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.
|
||||
Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission.
|
||||
|
||||
**Request body parameters:**
|
||||
- **fromSource** (string, required): Source name where the paths to archive live. Example: `"default"`
|
||||
@@ -3305,13 +3112,6 @@ paths:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"413":
|
||||
description: Request Entity Too Large (archive size exceeds maxArchiveSizeGB
|
||||
limit)
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
@@ -3482,50 +3282,6 @@ paths:
|
||||
summary: Get directory items
|
||||
tags:
|
||||
- Resources
|
||||
/api/resources/pause:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Sets a short-lived server flag for the given source and path. When
|
||||
the active chunk POST then fails (e.g. client abort), the server trims the
|
||||
incomplete chunk but keeps the temp partial file for resume.
|
||||
parameters:
|
||||
- description: Destination path (same as upload)
|
||||
in: query
|
||||
name: path
|
||||
required: true
|
||||
type: string
|
||||
- description: Source name (same as upload)
|
||||
in: query
|
||||
name: source
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Pause registered
|
||||
"400":
|
||||
description: Bad request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: Source not found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Register graceful chunked upload pause
|
||||
tags:
|
||||
- Resources
|
||||
/api/resources/preview:
|
||||
get:
|
||||
consumes:
|
||||
@@ -3593,7 +3349,7 @@ paths:
|
||||
consumes:
|
||||
- application/json
|
||||
description: |-
|
||||
Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.
|
||||
Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission.
|
||||
|
||||
**Request body parameters:**
|
||||
- **fromSource** (string, required): Source name where the archive file lives. Example: `"default"`
|
||||
@@ -3637,13 +3393,6 @@ paths:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"413":
|
||||
description: Request Entity Too Large (archive size exceeds maxArchiveSizeGB
|
||||
limit)
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
@@ -4102,14 +3851,6 @@ paths:
|
||||
in: query
|
||||
name: scope
|
||||
type: string
|
||||
- description: Unix seconds; only results modified strictly before this time
|
||||
in: query
|
||||
name: olderThan
|
||||
type: integer
|
||||
- description: Unix seconds; only results modified on or after this time
|
||||
in: query
|
||||
name: newerThan
|
||||
type: integer
|
||||
- description: User session ID, add unique value to prevent collisions
|
||||
in: header
|
||||
name: SessionId
|
||||
@@ -4237,9 +3978,7 @@ paths:
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Updates the details of a user identified by ID. When updating the
|
||||
target user's password the actor must send their current password in the X-Password
|
||||
header
|
||||
description: Updates the details of a user identified by ID.
|
||||
parameters:
|
||||
- description: user ID to update
|
||||
in: query
|
||||
@@ -4249,11 +3988,6 @@ paths:
|
||||
in: query
|
||||
name: id
|
||||
type: string
|
||||
- description: Actor's current password (URL-encoded); required when changing
|
||||
a password user's password
|
||||
in: header
|
||||
name: X-Password
|
||||
type: string
|
||||
- description: User data to update
|
||||
in: body
|
||||
name: data
|
||||
@@ -4269,13 +4003,6 @@ paths:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized - invalid or missing actor password for password
|
||||
change
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
@@ -4304,33 +4031,6 @@ paths:
|
||||
schema:
|
||||
$ref: '#/definitions/http.HttpResponse'
|
||||
summary: Health Check
|
||||
/public/api/media/metadata:
|
||||
get:
|
||||
parameters:
|
||||
- description: Share hash
|
||||
in: query
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: Path within the share
|
||||
in: query
|
||||
name: path
|
||||
type: string
|
||||
- description: When true, include embedded album art bytes in audio metadata
|
||||
(heavier)
|
||||
in: query
|
||||
name: albumArt
|
||||
type: boolean
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/iteminfo.ExtendedFileInfo'
|
||||
summary: Directory with media metadata (public share)
|
||||
tags:
|
||||
- Shares
|
||||
/public/api/resources:
|
||||
get:
|
||||
consumes:
|
||||
@@ -4756,43 +4456,6 @@ paths:
|
||||
summary: Get directory items (public share)
|
||||
tags:
|
||||
- Shares
|
||||
/public/api/resources/pause:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Same as authenticated pause, for upload shares. Uses hash and path
|
||||
query parameters like other public resource APIs.
|
||||
parameters:
|
||||
- description: Share hash
|
||||
in: query
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: Path within the share (same as upload)
|
||||
in: query
|
||||
name: path
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Pause registered
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: Source not found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Register graceful chunked upload pause (public share)
|
||||
tags:
|
||||
- Shares
|
||||
/public/api/resources/preview:
|
||||
get:
|
||||
consumes:
|
||||
|
||||
Generated
+1265
-306
File diff suppressed because it is too large.
Load diff
@@ -16,8 +16,6 @@
|
||||
"lint:fix": "eslint --fix --ext .js,.vue,.ts src/",
|
||||
"i18n:sync": "node ./scripts/sync-translations.js",
|
||||
"i18n:check": "node ./scripts/sync-translations.js --check",
|
||||
"i18n:enforce-order": "node scripts/sync-translations.js --enforce-order",
|
||||
"i18n:cleanup": "node scripts/sync-translations.js --cleanup",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run "
|
||||
},
|
||||
@@ -29,6 +27,7 @@
|
||||
"css-vars-ponyfill": "^2.4.3",
|
||||
"dompurify": "^3.2.4",
|
||||
"epubjs": "^0.3.93",
|
||||
"file-loader": "^6.2.0",
|
||||
"glob": "^9.3.5",
|
||||
"highlight.js": "^11.11.1",
|
||||
"mammoth": "^1.9.1",
|
||||
@@ -44,8 +43,7 @@
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": "^10.2.1",
|
||||
"@xmldom/xmldom": "^0.8.12"
|
||||
"minimatch": "^10.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||
@@ -60,7 +58,6 @@
|
||||
"eslint-plugin-vue": "^9.24.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "^6.0.0",
|
||||
"vite": "^6.2.0",
|
||||
"vite-plugin-checker": "^0.10.3",
|
||||
"vite-plugin-compression2": "^1.0.0",
|
||||
|
||||
@@ -43,12 +43,13 @@ server:
|
||||
ignoreSymlinks: false # Excludes symbolic links
|
||||
defaultUserScope: "/" # defaults to root of index "/" should match folders under path
|
||||
defaultEnabled: true # should be added as a default source for new users?
|
||||
createUserDir: false # create a user directory for each user under defaultUserScope + username
|
||||
useLogicalSize: false # calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
|
||||
externalUrl: "" # used by share links if set (eg. http://mydomain.com)
|
||||
internalUrl: "" # used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
cacheDir: "tmp" # path to the cache directory, used for thumbnails and other cached files
|
||||
cacheDirCleanup: false # whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
|
||||
maxArchiveSize: 20 # maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
|
||||
maxArchiveSize: 50 # max pre-archive combined size of files/folder that are allowed to be archived (in GB)
|
||||
filesystem: # filesystem settings
|
||||
createFilePermission: "644" # Unix permissions like 644, 755, 2755 (default: 644) validate:required,file_permission
|
||||
createDirectoryPermission: "755" # Unix permissions like 755, 2755, 1777 (default: 755) validate:required,file_permission
|
||||
@@ -57,7 +58,6 @@ server:
|
||||
cacheSizeMB: 32 # size of the SQLite cache in MB
|
||||
walMode: false # enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
|
||||
disableReuse: false # enable to always create a new indexing database on startup.
|
||||
startupIntegrityCheck: "quickCheck" # the method used to check the integrity of the index database on startup (default: quickCheck) validate:omitempty,oneof=quickCheck probe off
|
||||
disableWebDAV: false # disable webdav support (default: false)
|
||||
auth:
|
||||
tokenExpirationHours: 2 # time in hours each web UI session token is valid for. Default is 2 hours.
|
||||
@@ -156,7 +156,6 @@ userDefaults:
|
||||
disableQuickToggles: false # disable the quick toggles in the sidebar
|
||||
disableSearchOptions: false # disable the search options in the search bar
|
||||
stickySidebar: true # keep sidebar open when navigating
|
||||
hideFilesInTree: false # hide files in the sidebar tree navigation, when true, will show only directories.
|
||||
darkMode: true # should dark mode be enabled
|
||||
locale: "en" # language to use: eg. de, en, or fr
|
||||
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
|
||||
@@ -174,14 +173,12 @@ userDefaults:
|
||||
disableHideSidebar: false # keep sidebar open when previewing files
|
||||
image: true # show thumbnails for image files
|
||||
video: true # show thumbnails for video files
|
||||
audio: true # show thumbnails for audio files
|
||||
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering
|
||||
office: true # show thumbnails for office files
|
||||
popup: true # show larger popup preview when hovering over thumbnail
|
||||
autoplayMedia: true # autoplay media files in preview
|
||||
defaultMediaPlayer: false # disable the styled feature-rich media player for browser default
|
||||
folder: true # show thumbnails for folders that have previewable contents
|
||||
models: true # show live thumbnails for 3D models files
|
||||
permissions:
|
||||
api: false # allow api access
|
||||
admin: false # allow admin access
|
||||
@@ -194,7 +191,6 @@ userDefaults:
|
||||
loginMethod: "password" # login method to use: eg. password, proxy, oidc
|
||||
disableUpdateNotifications: false # disable update notifications banner for admin users
|
||||
deleteWithoutConfirming: false # delete files without confirmation
|
||||
deleteAfterArchive: true # delete source files after successful creation/extraction of archives
|
||||
fileLoading: # upload and download settings
|
||||
maxConcurrentUpload: 10
|
||||
uploadChunkSizeMb: 10
|
||||
@@ -204,7 +200,6 @@ userDefaults:
|
||||
customTheme: "" # Name of theme to use chosen from custom themes config.
|
||||
showSelectMultiple: false # show select multiple files on desktop
|
||||
debugOffice: false # debug onlyoffice editor
|
||||
preferEditorForMarkdown: false # prefer editor first for markdown files instead of the Markdown Viewer.
|
||||
integrations:
|
||||
office: # validate:omitempty
|
||||
url: "" # The URL to the OnlyOffice Document Server, needs to be accessible to the user. validate:required
|
||||
|
||||
@@ -1,89 +1,6 @@
|
||||
/*
|
||||
* LoadingSpinner.vue + initial index.html shell.
|
||||
* Embedded in index.html via the backend; also @import'd from src/css/styles.css for the Vue app.
|
||||
*
|
||||
* Spinner glyph: filled SVG path (data URI mask) so ring thickness tapers along the arc — not alpha-only.
|
||||
* Regenerate: polygon from outer/inner circular arcs (~120° sweep) in viewBox 0 0 100 100; mirror X if spin/orientation needs flipping.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Core box */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.loader {
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
border: none;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Tapered ring: solid fill + SVG mask (true stroke-width taper, not alpha) */
|
||||
/* Path is mirrored so the thick head leads when rotating clockwise (W3C +angle = CW). */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.loader__comet {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
direction: ltr;
|
||||
background-color: var(--spinner-color, var(--primaryColor));
|
||||
/* Mirrored annulus-sector polygon, 120° sweep, viewBox 0 0 100 100 */
|
||||
--fb-loader-taper-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20100%20100%22%3E%3Cpath%20fill%3D%22black%22%20d%3D%22M57.47%2C7.65%20L54.99%2C7.29%20L52.50%2C7.07%20L50.00%2C7.00%20L47.50%2C7.07%20L45.01%2C7.29%20L42.53%2C7.65%20L40.08%2C8.16%20L37.67%2C8.81%20L35.29%2C9.59%20L32.97%2C10.52%20L30.70%2C11.57%20L28.50%2C12.76%20L26.37%2C14.07%20L24.32%2C15.51%20L22.36%2C17.06%20L20.49%2C18.72%20L18.72%2C20.49%20L17.06%2C22.36%20L15.51%2C24.32%20L14.07%2C26.37%20L12.76%2C28.50%20L11.57%2C30.70%20L10.52%2C32.97%20L9.59%2C35.29%20L8.81%2C37.67%20L8.16%2C40.08%20L7.65%2C42.53%20L7.29%2C45.01%20L7.07%2C47.50%20L7.00%2C50.00%20L7.07%2C52.50%20L7.29%2C54.99%20L7.65%2C57.47%20L8.16%2C59.92%20L8.81%2C62.33%20L9.59%2C64.71%20L10.16%2C64.50%20L9.56%2C62.11%20L9.12%2C59.69%20L8.81%2C57.26%20L8.65%2C54.83%20L8.63%2C52.41%20L8.75%2C50.00%20L9.01%2C47.61%20L9.41%2C45.26%20L9.94%2C42.94%20L10.61%2C40.66%20L11.40%2C38.44%20L12.32%2C36.28%20L13.36%2C34.19%20L14.51%2C32.18%20L15.77%2C30.24%20L17.14%2C28.39%20L18.60%2C26.63%20L20.16%2C24.96%20L21.81%2C23.40%20L23.53%2C21.95%20L25.33%2C20.60%20L27.20%2C19.37%20L29.12%2C18.26%20L31.10%2C17.26%20L33.12%2C16.39%20L35.18%2C15.64%20L37.27%2C15.02%20L39.38%2C14.52%20L41.50%2C14.15%20L43.64%2C13.91%20L45.77%2C13.79%20L47.89%2C13.79%20L50.00%2C13.92%20L52.09%2C14.18%20L54.14%2C14.55%20L56.16%2C15.04Z%22%2F%3E%3C%2Fsvg%3E");
|
||||
-webkit-mask-image: var(--fb-loader-taper-mask);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
mask-image: var(--fb-loader-taper-mask);
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
transform-origin: 50% 50%;
|
||||
transform-box: fill-box;
|
||||
animation: fb-spinner-rotation var(--animation-duration, 1s) linear infinite;
|
||||
}
|
||||
|
||||
@keyframes fb-spinner-rotation {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Size variants */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.loader--xsmall {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.loader--small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.loader--medium {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.loader--large {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Initial full-page shell (index.html) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/* NOTE: This file only contains styles needed by index.html.
|
||||
All other loader styles (base .loader, size variants, modes, etc.)
|
||||
are defined in LoadingSpinner.vue as scoped styles.*/
|
||||
body > .loader {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -94,256 +11,59 @@ body > .loader {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
transition: 0.1s ease opacity;
|
||||
transition: .1s ease opacity;
|
||||
pointer-events: none;
|
||||
/* Reset loader styles - spinner will be in pseudo-element */
|
||||
border: none;
|
||||
animation: none;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body > .loader.loader--xsmall,
|
||||
body > .loader.loader--small,
|
||||
body > .loader.loader--medium,
|
||||
body > .loader.loader--large {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body > .loader .loader__comet {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
body > .loader.loader--xsmall .loader__comet {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
body > .loader.loader--small .loader__comet {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
body > .loader.loader--medium .loader__comet {
|
||||
body > .loader::before {
|
||||
content: '';
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border-top: 3px solid var(--spinner-color, var(--primaryColor));
|
||||
border-right: 3px solid transparent;
|
||||
border-bottom: 3px solid transparent;
|
||||
box-sizing: border-box;
|
||||
animation: spinner-rotation var(--animation-duration, 1s) linear infinite;
|
||||
}
|
||||
|
||||
body > .loader.loader--large .loader__comet {
|
||||
/* Size variants for body > .loader */
|
||||
body > .loader.loader--xsmall::before {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-width: 1.5px;
|
||||
}
|
||||
|
||||
body > .loader.loader--small::before {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
body > .loader.loader--medium::before {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
body > .loader.loader--large::before {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-width: 4px;
|
||||
}
|
||||
|
||||
body > .loader.done {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Pulse mode */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.loader--pulse {
|
||||
border: none;
|
||||
background: var(--spinner-color, var(--primaryColor));
|
||||
animation: fb-pulse-scale var(--animation-duration, 0.75s) ease infinite;
|
||||
}
|
||||
|
||||
@keyframes fb-pulse-scale {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Bounce mode */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.loader--bounce {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3em;
|
||||
border: none;
|
||||
background: transparent;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.loader--bounce::before,
|
||||
.loader--bounce::after {
|
||||
content: "";
|
||||
width: 0.6em;
|
||||
height: 0.6em;
|
||||
background-color: var(--spinner-color, var(--primaryColor));
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: fb-bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.loader--bounce::before {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.loader--bounce::after {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
.loader--bounce .bounce-dot {
|
||||
width: 0.6em;
|
||||
height: 0.6em;
|
||||
background-color: var(--spinner-color, var(--primaryColor));
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: fb-bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes fb-bounce-scale {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.loader--bounce.loader--xsmall::before,
|
||||
.loader--bounce.loader--xsmall::after,
|
||||
.loader--bounce.loader--xsmall .bounce-dot {
|
||||
width: 0.3em;
|
||||
height: 0.3em;
|
||||
}
|
||||
|
||||
.loader--bounce.loader--small::before,
|
||||
.loader--bounce.loader--small::after,
|
||||
.loader--bounce.loader--small .bounce-dot {
|
||||
width: 0.45em;
|
||||
height: 0.45em;
|
||||
}
|
||||
|
||||
.loader--bounce.loader--medium::before,
|
||||
.loader--bounce.loader--medium::after,
|
||||
.loader--bounce.loader--medium .bounce-dot {
|
||||
width: 0.6em;
|
||||
height: 0.6em;
|
||||
}
|
||||
|
||||
.loader--bounce.loader--large::before,
|
||||
.loader--bounce.loader--large::after,
|
||||
.loader--bounce.loader--large .bounce-dot {
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Placeholder mode */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.loader--placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
border: none;
|
||||
animation: none;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.placeholder-item {
|
||||
background-color: rgba(128, 128, 128, 0.1);
|
||||
animation: fb-placeholder-ripple 1.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite,
|
||||
fb-placeholder-bg-fade 2s ease-in-out infinite;
|
||||
display: block;
|
||||
border-radius: 0.25em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
box-shadow 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.placeholder-item-1 {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.placeholder-item-2 {
|
||||
animation-delay: 0.36s;
|
||||
}
|
||||
|
||||
.placeholder-item-3 {
|
||||
animation-delay: 0.72s;
|
||||
}
|
||||
|
||||
@keyframes fb-placeholder-ripple {
|
||||
@keyframes spinner-rotation {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: none;
|
||||
opacity: 0.3;
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.07);
|
||||
box-shadow: inset 0 -3em 3em rgba(172, 172, 172, 0.211),
|
||||
0 0 0 2px var(--alt-background), 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.97);
|
||||
box-shadow: none;
|
||||
opacity: 0.3;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: none;
|
||||
opacity: 0.3;
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fb-placeholder-bg-fade {
|
||||
0% {
|
||||
background-color: rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
50% {
|
||||
background-color: rgba(128, 128, 128, 0.4);
|
||||
}
|
||||
100% {
|
||||
background-color: rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.loader--placeholder.loader--xsmall .placeholder-item {
|
||||
width: 30px;
|
||||
height: 7.5px;
|
||||
min-width: 30px;
|
||||
min-height: 7.5px;
|
||||
}
|
||||
|
||||
.loader--placeholder.loader--small .placeholder-item {
|
||||
width: 150px;
|
||||
height: 12.5px;
|
||||
min-width: 150px;
|
||||
min-height: 12.5px;
|
||||
}
|
||||
|
||||
.loader--placeholder.loader--medium .placeholder-item {
|
||||
width: 200px;
|
||||
height: 25px;
|
||||
min-width: 200px;
|
||||
min-height: 25px;
|
||||
}
|
||||
|
||||
.loader--placeholder.loader--large .placeholder-item {
|
||||
width: 400px;
|
||||
height: 50px;
|
||||
min-width: 400px;
|
||||
min-height: 50px;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>"{{ .htmlVars.title }}"</title>
|
||||
<title>{{ .htmlVars.title }}</title>
|
||||
|
||||
<link rel="icon" type="image/png" sizes="256x256" href="{{ .htmlVars.favicon }}">
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/roboto-normal-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/roboto-medium-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/roboto-bold-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-symbols.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-icons.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-symbols-outlined.woff2" as="font" type="font/woff2" crossorigin>
|
||||
|
||||
<!-- Add to home screen for Android and modern mobile browsers -->
|
||||
<link rel="manifest" href="{{ .htmlVars.manifestURL }}" crossorigin="use-credentials">
|
||||
@@ -83,7 +84,7 @@
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<span class="loader loader--medium" aria-busy="true"><span class="loader__comet" aria-hidden="true"></span></span>
|
||||
<span class="loader loader--medium"></span>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ import * as deepl from 'deepl-node';
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const checkOnly = args.includes('--check') || args.includes('-c');
|
||||
const enforceOrder = args.includes('--enforce-order'); // When using this will force the order of eng, will not perform new translations. If we move keys to a different block will count as new translations and will delete it from all the languages
|
||||
const cleanupOnly = args.includes('--cleanup'); // Will only delete keys that not longer exist in eng, no translation or reorder, just deletion.
|
||||
|
||||
// --- Configuration ---
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -21,14 +19,13 @@ const targetLocaleFiles = glob.sync(path.join(localesDir, '*.json'))
|
||||
.filter(file => path.basename(file) !== `${masterLanguageCode}.json`)
|
||||
.filter(file => path.basename(file) !== 'is.json'); // Exclude Icelandic - DeepL doesn't support it
|
||||
|
||||
const requireApiKey = !checkOnly && !enforceOrder && !cleanupOnly;
|
||||
const DEEPL_API_KEY = process.env.DEEPL_API_KEY;
|
||||
if (requireApiKey && !DEEPL_API_KEY) {
|
||||
if (!checkOnly && !DEEPL_API_KEY) {
|
||||
console.error("❌ Missing DEEPL_API_KEY in environment.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const translator = requireApiKey ? new deepl.Translator(DEEPL_API_KEY) : null;
|
||||
const translator = checkOnly ? null : new deepl.Translator(DEEPL_API_KEY);
|
||||
|
||||
const deeplLangMap = {
|
||||
'zh-cn': 'ZH-HANS',
|
||||
@@ -98,23 +95,8 @@ async function translateText(text, targetLanguage, keyPath = '') {
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder objects to match eng (master lang) key order
|
||||
function reorderObject(obj, masterObj) {
|
||||
// If obj is not an object, return as is
|
||||
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return obj;
|
||||
const newObj = {};
|
||||
for (const key in masterObj) {
|
||||
if (Object.prototype.hasOwnProperty.call(masterObj, key)) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
newObj[key] = reorderObject(obj[key], masterObj[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newObj;
|
||||
}
|
||||
|
||||
// --- Recursive key processor ---
|
||||
async function processKeys(masterObj, targetObj, targetLangCode, currentPathParts = [], doEnforceOrder, doCleanupOnly = false) {
|
||||
async function processKeys(masterObj, targetObj, targetLangCode, currentPathParts = []) {
|
||||
let changesMade = false;
|
||||
let meaningfulChanges = 0; // Only count meaningful changes
|
||||
|
||||
@@ -127,17 +109,19 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
|
||||
const masterValue = masterObj[key];
|
||||
|
||||
// Special handling for "languages" key - always copy the entire object from master
|
||||
if (key === 'languages' && currentPathParts.length === 0 && !doCleanupOnly) {
|
||||
if (!checkOnly && !doEnforceOrder) {
|
||||
if (key === 'languages' && currentPathParts.length === 0) {
|
||||
if (checkOnly) {
|
||||
// Don't print or count languages object copying - it's routine
|
||||
} else {
|
||||
console.log(`Copying entire "languages" object from master to ${targetLangCode}.json`);
|
||||
targetObj[key] = JSON.parse(JSON.stringify(masterValue)); // Deep copy
|
||||
}
|
||||
targetObj[key] = JSON.parse(JSON.stringify(masterValue)); // Deep copy
|
||||
changesMade = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof masterValue === 'object' && masterValue !== null && !Array.isArray(masterValue)) {
|
||||
if (!doCleanupOnly && (!targetObj[key] || typeof targetObj[key] !== 'object')) {
|
||||
if (!targetObj[key] || typeof targetObj[key] !== 'object') {
|
||||
if (checkOnly) {
|
||||
console.log(`Would create missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Creating new object structures is meaningful
|
||||
@@ -149,55 +133,41 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
|
||||
}
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
if (targetObj[key] && typeof targetObj[key] === 'object') {
|
||||
const result = await processKeys( masterValue, targetObj[key], targetLangCode, currentPathPartsNext,
|
||||
doEnforceOrder, doCleanupOnly);
|
||||
if (result == "UNSUPPORTED") {
|
||||
const result = await processKeys(masterValue, targetObj[key], targetLangCode, currentPathPartsNext);
|
||||
if (result == "UNSUPPORTED") {
|
||||
console.log(`Skipping translation for "${targetLangCode}" due to unsupported structure.`);
|
||||
return "UNSUPPORTED";
|
||||
}
|
||||
if (typeof result === 'number') {
|
||||
meaningfulChanges += result;
|
||||
changesMade = true;
|
||||
} else if (result) {
|
||||
changesMade = true;
|
||||
}
|
||||
}
|
||||
} else if (!doCleanupOnly) {
|
||||
// Only handle non‑object values
|
||||
if (typeof masterValue === 'string') {
|
||||
const existingTargetValue = targetObj[key];
|
||||
const isMissing = !Object.prototype.hasOwnProperty.call(targetObj, key) || existingTargetValue === '' || existingTargetValue === null;
|
||||
|
||||
if (isMissing) {
|
||||
if (checkOnly) {
|
||||
console.log(`Would translate "${currentKeyPath}" for ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Translation is meaningful
|
||||
} else if (doEnforceOrder) {
|
||||
// Skip translations for new keys
|
||||
console.log(`Skipping new key "${currentKeyPath}" for ${targetLangCode}.json`);
|
||||
} else {
|
||||
// Sync translation
|
||||
const result = await translateText(masterValue, targetLangCode, currentKeyPath);
|
||||
if (result == "") {
|
||||
return "UNSUPPORTED";
|
||||
}
|
||||
targetObj[key] = result;
|
||||
changesMade = true;
|
||||
if (typeof result === 'number') {
|
||||
meaningfulChanges += result;
|
||||
changesMade = true;
|
||||
} else if (result) {
|
||||
changesMade = true;
|
||||
}
|
||||
} else if (typeof masterValue === 'string') {
|
||||
if (!targetObj.hasOwnProperty(key) || targetObj[key] === '' || targetObj[key] === null) {
|
||||
if (checkOnly) {
|
||||
console.log(`Would translate "${currentKeyPath}" for ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Translation is meaningful
|
||||
} else {
|
||||
const result = await translateText(masterValue, targetLangCode, currentKeyPath);
|
||||
if (result == "") {
|
||||
return "UNSUPPORTED";
|
||||
}
|
||||
targetObj[key] = result;
|
||||
}
|
||||
} else {
|
||||
if (!Object.prototype.hasOwnProperty.call(targetObj, key)) {
|
||||
if (checkOnly) {
|
||||
console.log(`Would copy key "${currentKeyPath}" (non-string) from English to ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Copying non-string values is meaningful
|
||||
} else {
|
||||
console.log(`Key "${currentKeyPath}" (non-string) missing in ${targetLangCode}.json. Copying from English.`);
|
||||
targetObj[key] = masterValue;
|
||||
}
|
||||
changesMade = true;
|
||||
changesMade = true;
|
||||
}
|
||||
} else {
|
||||
if (!targetObj.hasOwnProperty(key)) {
|
||||
if (checkOnly) {
|
||||
console.log(`Would copy key "${currentKeyPath}" (non-string) from English to ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Copying non-string values is meaningful
|
||||
} else {
|
||||
console.log(`Key "${currentKeyPath}" (non-string) missing in ${targetLangCode}.json. Copying from English.`);
|
||||
targetObj[key] = masterValue;
|
||||
}
|
||||
changesMade = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,7 +177,7 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
|
||||
const keysToRemove = [];
|
||||
for (const key in targetObj) {
|
||||
if (Object.prototype.hasOwnProperty.call(targetObj, key)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(masterObj, key)) {
|
||||
if (!masterObj.hasOwnProperty(key)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
@@ -230,17 +200,9 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
|
||||
|
||||
// --- Main synchronization ---
|
||||
async function syncAllTranslations() {
|
||||
const isSync = !checkOnly && !enforceOrder && !cleanupOnly;
|
||||
const isEnforceOrder = enforceOrder && !cleanupOnly;
|
||||
const isCleanup = cleanupOnly;
|
||||
|
||||
if (checkOnly) {
|
||||
console.log("--- Checking for translation changes (no translations will be performed) ---");
|
||||
} else if (isCleanup) {
|
||||
console.warn("--- Only obsolete keys will be removed - No translations, or reordering will happen ---");
|
||||
} else if (isEnforceOrder) {
|
||||
console.warn("--- Enforcing order of all files to match master language (no translations will be performed) ---");
|
||||
} else if (isSync) {
|
||||
} else {
|
||||
console.warn("--- Using DeepL API for translation ---");
|
||||
}
|
||||
|
||||
@@ -258,61 +220,38 @@ async function syncAllTranslations() {
|
||||
for (const targetFile of targetLocaleFiles) {
|
||||
const targetLangCode = path.basename(targetFile, '.json');
|
||||
let targetContent = {};
|
||||
let originalTargetContent = null;
|
||||
let fileExisted = await fs.pathExists(targetFile);
|
||||
|
||||
if (fileExisted) {
|
||||
try {
|
||||
originalTargetContent = await fs.readJson(targetFile);
|
||||
targetContent = JSON.parse(JSON.stringify(originalTargetContent)); // start from original
|
||||
targetContent = await fs.readJson(targetFile);
|
||||
} catch (e) {
|
||||
console.warn(`Warning: Could not parse ${targetFile}. Starting fresh. Error: ${e.message}`);
|
||||
targetContent = {};
|
||||
originalTargetContent = null;
|
||||
}
|
||||
} else {
|
||||
if (isCleanup) continue;
|
||||
console.log(`\nTarget file ${targetFile} not found. Will create for language: ${targetLangCode}.`);
|
||||
}
|
||||
|
||||
const result = await processKeys(masterContent, targetContent, targetLangCode, [], isEnforceOrder, isCleanup);
|
||||
|
||||
let reorderedContent = targetContent;
|
||||
let orderChanged = false;
|
||||
if (isEnforceOrder && originalTargetContent && !checkOnly) {
|
||||
reorderedContent = reorderObject(targetContent, masterContent);
|
||||
// Compare the reordered content with the original to see if order changed
|
||||
const origStr = JSON.stringify(originalTargetContent);
|
||||
const reorderedStr = JSON.stringify(reorderedContent);
|
||||
if (origStr !== reorderedStr) {
|
||||
orderChanged = true;
|
||||
console.log(`- Reordered keys in ${targetLangCode}.json`);
|
||||
}
|
||||
}
|
||||
const result = await processKeys(masterContent, targetContent, targetLangCode);
|
||||
|
||||
if (checkOnly) {
|
||||
let wouldChange = false;
|
||||
if (typeof result === 'number' && result > 0) {
|
||||
meaningfulChanges += result;
|
||||
wouldChange = true;
|
||||
console.log(`Found ${result} meaningful changes needed for ${targetLangCode}.json`);
|
||||
}
|
||||
if (!fileExisted && !isCleanup) {
|
||||
wouldChange = true;
|
||||
console.log(`Would create new file for ${targetLangCode}.json`);
|
||||
}
|
||||
if (wouldChange) {
|
||||
hasMeaningfulChanges = true;
|
||||
console.log(`Found ${result} meaningful changes needed for ${targetLangCode}.json`);
|
||||
} else if (!fileExisted) {
|
||||
meaningfulChanges++;
|
||||
hasMeaningfulChanges = true;
|
||||
console.log(`Would create new file for ${targetLangCode}.json`);
|
||||
} else {
|
||||
// Only print if there are meaningful changes
|
||||
}
|
||||
} else {
|
||||
const needsWrite = result || orderChanged || (!isCleanup && !fileExisted);
|
||||
if (needsWrite) {
|
||||
const finalContent = (isEnforceOrder && orderChanged) ? reorderedContent : targetContent;
|
||||
if (result || !fileExisted) {
|
||||
try {
|
||||
await fs.writeJson(targetFile, finalContent, { spaces: 2 });
|
||||
if (!isCleanup && !isEnforceOrder) {
|
||||
console.log(`Successfully ${result ? 'updated' : 'created'} ${targetFile}`);
|
||||
}
|
||||
await fs.writeJson(targetFile, targetContent, { spaces: 2 });
|
||||
console.log(`Successfully ${result ? 'updated' : 'created'} ${targetFile}`);
|
||||
} catch (error) {
|
||||
console.error(`Error writing to ${targetFile}:`, error);
|
||||
}
|
||||
@@ -331,13 +270,7 @@ async function syncAllTranslations() {
|
||||
return 0; // Exit code 0 for no meaningful changes
|
||||
}
|
||||
} else {
|
||||
if (isCleanup) {
|
||||
console.log('\n✅ Cleanup complete (obsolete keys removed).');
|
||||
} else if (isEnforceOrder) {
|
||||
console.log('\n✅ Enforce order complete.');
|
||||
} else {
|
||||
console.log('\n✅ Translation synchronization complete (via DeepL).');
|
||||
}
|
||||
console.log('\n✅ Translation synchronization complete (via DeepL).');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,11 +132,7 @@ export async function getApiKeys() {
|
||||
const apiPath = getApiPath('auth/token/list')
|
||||
return await fetchJSON(apiPath)
|
||||
} catch (err) {
|
||||
// ignore 404 errors
|
||||
if (err.status !== 404) {
|
||||
notify.showError(err.message || 'Failed to get API tokens')
|
||||
throw err
|
||||
}
|
||||
notify.showError(err.message || 'Failed to get API tokens')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { fetchURL, adjustedData } from "./utils";
|
||||
import { fetchURL } from "./utils";
|
||||
import { notify } from "@/notify";
|
||||
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
|
||||
import { state } from "@/store";
|
||||
import { getApiPath } from "@/utils/url.js";
|
||||
|
||||
// GET /api/media/subtitles
|
||||
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
|
||||
@@ -19,50 +18,4 @@ export async function getSubtitleContent(source, path, subtitleName, embedded =
|
||||
notify.showError(err.message || `Error fetching subtitle ${subtitleName}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/media/metadata — directory or file with metadata; optional albumArt for embedded cover extraction.
|
||||
/** @param {boolean} albumArt when true, request embedded album art in audio metadata */
|
||||
/** @returns {Promise<object>} resource (adjustedData) */
|
||||
export async function fetchDirectoryMediaMetadata(source, path, albumArt = false) {
|
||||
const apiPath = getApiPath("media/metadata", {
|
||||
source,
|
||||
path,
|
||||
...(albumArt ? { albumArt: "true" } : {}),
|
||||
});
|
||||
const res = await fetchURL(apiPath);
|
||||
const data = await res.json();
|
||||
return adjustedData(data);
|
||||
}
|
||||
|
||||
// GET /public/api/media/metadata
|
||||
/** @param {boolean} albumArt when true, request embedded album art in audio metadata */
|
||||
/** @returns {Promise<object>} resource (adjustedData) */
|
||||
export async function fetchDirectoryMediaMetadataPublic(path, hash, password = "", albumArt = false) {
|
||||
const params = {
|
||||
path,
|
||||
hash,
|
||||
...(albumArt ? { albumArt: "true" } : {}),
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token }),
|
||||
};
|
||||
const apiPath = getPublicApiPath("media/metadata", params);
|
||||
const response = await fetch(apiPath, {
|
||||
headers: { "X-SHARE-PASSWORD": password || "" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = new Error(response.statusText);
|
||||
let data = null;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
if (data?.message) {
|
||||
error.message = data.message;
|
||||
}
|
||||
/** @type {any} */ (error).status = response.status;
|
||||
throw error;
|
||||
}
|
||||
const data = await response.json();
|
||||
return adjustedData(data);
|
||||
}
|
||||
@@ -47,31 +47,6 @@ export async function getItems(source, path, only = "") {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a graceful pause for the active chunked upload at this path.
|
||||
* Call immediately before aborting the chunk XHR so the server keeps partial data.
|
||||
*/
|
||||
export async function signalUploadPause(source, path, shareHash = null) {
|
||||
if (shareHash) {
|
||||
const apiPath = getPublicApiPath('resources/pause', {
|
||||
hash: shareHash,
|
||||
path: path,
|
||||
})
|
||||
const headers = {}
|
||||
const sharePassword = localStorage.getItem('sharepass:' + shareHash)
|
||||
if (sharePassword) {
|
||||
headers['X-SHARE-PASSWORD'] = sharePassword
|
||||
}
|
||||
await fetchURL(apiPath, { method: 'POST', headers })
|
||||
return
|
||||
}
|
||||
if (!source || source === undefined || source === null) {
|
||||
throw new Error('no source provided')
|
||||
}
|
||||
const apiPath = getApiPath('resources/pause', { path, source })
|
||||
await fetchURL(apiPath, { method: 'POST' })
|
||||
}
|
||||
|
||||
async function resourceAction(source, path, method, content) {
|
||||
if (!source || source === undefined || source === null) {
|
||||
throw new Error('no source provided')
|
||||
@@ -219,19 +194,6 @@ export async function download(format, files, shareHash = "") {
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/** Best-effort text from a failed download response (consumes body). */
|
||||
async function readDownloadErrorBody(response) {
|
||||
try {
|
||||
const text = await response.text()
|
||||
if (text && text.length > 0) {
|
||||
return text.length > 300 ? `${text.slice(0, 300)}…` : text
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function downloadChunked(file, shareHash = "") {
|
||||
const chunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
|
||||
|
||||
@@ -250,10 +212,10 @@ async function downloadChunked(file, shareHash = "") {
|
||||
downloadManager.setStatus(downloadId, "downloading")
|
||||
|
||||
// Show download prompt if not already shown (it should already be shown by downloadFiles, but check to be safe)
|
||||
const hasDownloadPrompt = state.prompts && state.prompts.some(h => h.name === 'download');
|
||||
const hasDownloadPrompt = state.hovers && state.hovers.some(h => h.name === 'download');
|
||||
|
||||
if (!hasDownloadPrompt) {
|
||||
mutations.showPrompt({ name: 'download' })
|
||||
mutations.showHover({ name: 'download' })
|
||||
}
|
||||
|
||||
const params = {
|
||||
@@ -297,18 +259,7 @@ async function downloadChunked(file, shareHash = "") {
|
||||
})
|
||||
|
||||
if (!response.ok && response.status !== 206) {
|
||||
const body = (await readDownloadErrorBody(response)).trim()
|
||||
const fromHttp =
|
||||
body ||
|
||||
[response.statusText, `HTTP ${response.status}`].filter(Boolean).join(" ").trim()
|
||||
throw new Error(fromHttp || "Request failed")
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error(
|
||||
[response.statusText, `HTTP ${response.status}`].filter(Boolean).join(" ").trim() ||
|
||||
"No response body"
|
||||
)
|
||||
throw new Error(`Failed to download chunk: ${response.statusText}`)
|
||||
}
|
||||
|
||||
// Track progress within the chunk using ReadableStream
|
||||
@@ -352,10 +303,6 @@ async function downloadChunked(file, shareHash = "") {
|
||||
throw readError; // Re-throw other errors
|
||||
}
|
||||
|
||||
if (chunkLoaded < expectedChunkSize) {
|
||||
throw new Error("Connection closed before the full chunk was received")
|
||||
}
|
||||
|
||||
// Combine chunk parts into single ArrayBuffer
|
||||
const chunk = new Uint8Array(chunkLoaded);
|
||||
let position = 0;
|
||||
@@ -408,9 +355,8 @@ async function downloadChunked(file, shareHash = "") {
|
||||
// Don't throw error or show notification for user-initiated cancellation
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message || "Unknown error" : String(error)
|
||||
downloadManager.setError(downloadId, message)
|
||||
downloadManager.setError(downloadId, error.message || 'Download failed')
|
||||
notify.showError(`Chunked download failed: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { fetchURL } from "./utils";
|
||||
import { notify } from "@/notify"; // Import notify for error handling
|
||||
import { getApiPath } from "@/utils/url.js";
|
||||
|
||||
export default async function search(base, sources, query, largest = false, extraParams = {}) {
|
||||
export default async function search(base, sources, query, largest = false) {
|
||||
try {
|
||||
// Ensure sources is an array
|
||||
const sourcesArray = Array.isArray(sources) ? sources : [sources];
|
||||
@@ -25,13 +25,6 @@ export default async function search(base, sources, query, largest = false, extr
|
||||
params.largest = "true";
|
||||
}
|
||||
|
||||
if (extraParams.olderThan !== undefined && extraParams.olderThan !== "") {
|
||||
params.olderThan = String(extraParams.olderThan);
|
||||
}
|
||||
if (extraParams.newerThan !== undefined && extraParams.newerThan !== "") {
|
||||
params.newerThan = String(extraParams.newerThan);
|
||||
}
|
||||
|
||||
const apiPath = getApiPath("tools/search", params);
|
||||
const res = await fetchURL(apiPath);
|
||||
let data = await res.json();
|
||||
|
||||
@@ -3,8 +3,7 @@ import { notify } from "@/notify";
|
||||
import { getApiPath } from "@/utils/url.js";
|
||||
|
||||
// GET /api/tools/search
|
||||
// extraParams: optional { olderThan, newerThan } — Unix timestamps in seconds (number or numeric string); modified time must be strictly before olderThan / on or after newerThan
|
||||
export async function search(base, sources, query, largest = false, extraParams = {}) {
|
||||
export async function search(base, sources, query, largest = false) {
|
||||
try {
|
||||
const sourcesArray = Array.isArray(sources) ? sources : [sources];
|
||||
|
||||
@@ -25,13 +24,6 @@ export async function search(base, sources, query, largest = false, extraParams
|
||||
params.largest = "true";
|
||||
}
|
||||
|
||||
if (extraParams.olderThan !== undefined && extraParams.olderThan !== "") {
|
||||
params.olderThan = String(extraParams.olderThan);
|
||||
}
|
||||
if (extraParams.newerThan !== undefined && extraParams.newerThan !== "") {
|
||||
params.newerThan = String(extraParams.newerThan);
|
||||
}
|
||||
|
||||
const apiPath = getApiPath("tools/search", params);
|
||||
const res = await fetchURL(apiPath);
|
||||
let data = await res.json();
|
||||
|
||||
@@ -48,8 +48,7 @@ export async function create(user) {
|
||||
}
|
||||
|
||||
// PUT /api/users (update user)
|
||||
// Optional `options.headers` (e.g. X-Password when changing a password user's password).
|
||||
export async function update(user, which = ['all'], options = {}) {
|
||||
export async function update(user, which = ['all']) {
|
||||
const excludeKeys = ['id', 'name']
|
||||
which = which.filter(item => !excludeKeys.includes(item))
|
||||
if (user.username === 'anonymous') {
|
||||
@@ -67,14 +66,12 @@ export async function update(user, which = ['all'], options = {}) {
|
||||
}
|
||||
|
||||
const apiPath = getApiPath('users', { id: user.id })
|
||||
const extraHeaders = options.headers || {}
|
||||
await fetchURL(apiPath, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
which: which,
|
||||
data: userData
|
||||
}),
|
||||
headers: extraHeaders,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
:title="label"
|
||||
class="action no-select"
|
||||
>
|
||||
<i v-if="icon == 'table_rows_narrow' || icon == 'content_copy' || icon == 'file_copy' || icon == 'copy_all'" class="material-symbols-outlined">{{ icon }}</i>
|
||||
<i v-else class="material-symbols">{{ icon }}</i>
|
||||
<i v-if="icon == 'table_rows_narrow'" class="material-symbols-outlined">{{ icon }}</i>
|
||||
<i v-else class="material-icons">{{ icon }}</i>
|
||||
|
||||
<span>{{ label }}</span>
|
||||
<span v-if="counter > 0" class="counter">{{ counter }}</span>
|
||||
@@ -81,15 +81,15 @@ export default {
|
||||
},
|
||||
actionMultiIcon() {
|
||||
if (this.show) {
|
||||
mutations.closeHovers();
|
||||
mutations.showPrompt(this.show);
|
||||
mutations.closeContextMenus();
|
||||
mutations.showHover(this.show);
|
||||
}
|
||||
this.$emit("action");
|
||||
},
|
||||
action() {
|
||||
if (this.show) {
|
||||
mutations.closeHovers();
|
||||
mutations.showPrompt(this.show);
|
||||
mutations.closeContextMenus();
|
||||
mutations.showHover(this.show);
|
||||
}
|
||||
this.$emit("action");
|
||||
},
|
||||
|
||||
@@ -10,25 +10,24 @@
|
||||
ref="contextMenu"
|
||||
v-if="showContext"
|
||||
:style="centered ? {} : { top: posY + 'px', left: posX + 'px' }"
|
||||
class="no-select floating-window"
|
||||
class="button no-select floating-window"
|
||||
:class="{ 'dark-mode': isDarkMode, 'centered': centered }"
|
||||
:key="showCreate ? 'create-mode' : 'normal-mode'"
|
||||
>
|
||||
<div v-if="!showLimitedOptions" class="context-menu-header">
|
||||
<div class="context-menu-header">
|
||||
<div
|
||||
class="action button clickable"
|
||||
v-if="showCreateButton"
|
||||
@click="toggleShowCreate"
|
||||
>
|
||||
<i v-if="!showCreate" class="material-symbols">add</i>
|
||||
<i v-else class="material-symbols">arrow_back</i>
|
||||
<i v-if="!showCreate" class="material-icons">add</i>
|
||||
<i v-else class="material-icons">arrow_back</i>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedCount > 0"
|
||||
@mouseleave="hideTooltip"
|
||||
@mouseenter="showTooltip($event, $t('buttons.selectedCount'))"
|
||||
class="button selected-count-header"
|
||||
:class="{ 'selected-count-header--circle': selectedCount <= 99 }"
|
||||
>
|
||||
<span>{{ selectedCount }}</span>
|
||||
</div>
|
||||
@@ -38,25 +37,25 @@
|
||||
v-if="showCreateActions"
|
||||
icon="create_new_folder"
|
||||
:label="$t('files.newFolder')"
|
||||
@action="showNewDirPrompt"
|
||||
@action="showNewDirHover"
|
||||
/>
|
||||
<action
|
||||
v-if="showCreateActions"
|
||||
icon="note_add"
|
||||
:label="$t('files.newFile')"
|
||||
@action="showPrompt('newFile')"
|
||||
@action="showHover('newFile')"
|
||||
/>
|
||||
<action
|
||||
v-if="showCreateActions"
|
||||
icon="file_upload"
|
||||
:label="$t('general.upload')"
|
||||
@action="showUploadPrompt"
|
||||
@action="showUploadHover"
|
||||
/>
|
||||
<action
|
||||
v-if="showInfo"
|
||||
icon="info"
|
||||
:label="$t('general.info')"
|
||||
@action="showInfoPrompt"
|
||||
@action="showInfoHover"
|
||||
/>
|
||||
|
||||
<action
|
||||
@@ -69,37 +68,31 @@
|
||||
v-if="showArchive"
|
||||
icon="archive"
|
||||
:label="$t('prompts.archive')"
|
||||
@action="showArchivePrompt"
|
||||
@action="showArchiveHover"
|
||||
/>
|
||||
<action
|
||||
v-if="showUnarchive"
|
||||
icon="unarchive"
|
||||
:label="$t('prompts.unarchive')"
|
||||
@action="showUnarchivePrompt"
|
||||
@action="showUnarchiveHover"
|
||||
/>
|
||||
<action
|
||||
v-if="showShareAction"
|
||||
icon="share"
|
||||
:label="$t('general.share')"
|
||||
@action="showSharePrompt"
|
||||
@action="showShareHover"
|
||||
/>
|
||||
<action
|
||||
v-if="showRename"
|
||||
icon="edit"
|
||||
icon="mode_edit"
|
||||
:label="$t('general.rename')"
|
||||
@action="showRenamePrompt"
|
||||
@action="showRenameHover"
|
||||
/>
|
||||
<action
|
||||
v-if="showCopy"
|
||||
icon="file_copy"
|
||||
icon="content_copy"
|
||||
:label="$t('buttons.copyFile')"
|
||||
@action="showCopyPrompt"
|
||||
/>
|
||||
<action
|
||||
v-if="showCopyPath"
|
||||
icon="copy_all"
|
||||
:label="$t('buttons.copyPath')"
|
||||
@action="copyPathToClipboard"
|
||||
@action="showCopyHover"
|
||||
/>
|
||||
<action
|
||||
v-if="showOpenParentFolder"
|
||||
@@ -117,7 +110,7 @@
|
||||
v-if="showMove"
|
||||
icon="forward"
|
||||
:label="$t('buttons.moveFile')"
|
||||
@action="showMovePrompt"
|
||||
@action="showMoveHover"
|
||||
/>
|
||||
<action
|
||||
v-if="showSelectAll"
|
||||
@@ -129,13 +122,13 @@
|
||||
v-if="showDelete"
|
||||
icon="delete"
|
||||
:label="$t('general.delete')"
|
||||
@action="showDeletePrompt"
|
||||
@action="showDeleteHover"
|
||||
/>
|
||||
<action
|
||||
v-if="showAccess"
|
||||
icon="lock"
|
||||
:label="$t('access.rules')"
|
||||
@action="showAccessPrompt"
|
||||
@action="showAccessHover"
|
||||
/>
|
||||
<action
|
||||
v-if="showSelectMultiple"
|
||||
@@ -159,20 +152,19 @@
|
||||
top: '3em',
|
||||
right: '1em',
|
||||
}"
|
||||
class="no-select floating-window"
|
||||
class="button no-select floating-window"
|
||||
:class="{ 'dark-mode': isDarkMode }"
|
||||
>
|
||||
<action icon="info" :label="$t('general.info')" @action="showInfoPrompt"/>
|
||||
<action icon="info" :label="$t('general.info')" @action="showInfoHover"/>
|
||||
<action v-if="showGoToRaw" icon="open_in_new" :label="$t('general.openFile')" @action="goToRaw()" />
|
||||
<action v-if="shouldShowParentFolder()" icon="folder" :label="$t('buttons.openParentFolder')" @action="openParentFolder" />
|
||||
<action v-if="isPreview && permissions.modify" icon="edit" :label="$t('general.rename')" @action="showRenamePromptForPreview" />
|
||||
<action v-if="isPreview && permissions.modify" icon="mode_edit" :label="$t('general.rename')" @action="showRenameHoverForPreview" />
|
||||
<action v-if="showWatch" icon="visibility" :label="$t('buttons.watchFile')" @action="watchFile()" />
|
||||
<action v-if="hasDownload" icon="file_download" :label="$t('general.download')" @action="startDownload" />
|
||||
<action v-if="showUnarchiveInOverflow" icon="folder_open" :label="$t('prompts.unarchive')" @action="showUnarchivePromptFromPreview" />
|
||||
<action v-if="showUnarchiveInOverflow" icon="folder_open" :label="$t('prompts.unarchive')" @action="showUnarchiveHoverFromPreview" />
|
||||
<action v-if="showEdit" icon="edit" :label="$t('general.edit')" @action="edit()" />
|
||||
<action v-if="markdownPreview" icon="visibility" :label="$t('general.preview')" @action="switchToMarkdown" />
|
||||
<action v-if="showSave" icon="save" :label="$t('general.save')" @action="save()" />
|
||||
<action v-if="showDelete" icon="delete" :label="$t('general.delete')" @action="showDeletePrompt" />
|
||||
<action v-if="showDelete" icon="delete" :label="$t('general.delete')" show="delete" />
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
@@ -216,10 +208,6 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showLimitedOptions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: null, // Array of item objects { name, path, source, isDir, type, ... }
|
||||
@@ -247,7 +235,10 @@ export default {
|
||||
return this.hasDownload && !this.req.isDir && !this.isShare;
|
||||
},
|
||||
showGoToItem() {
|
||||
return this.showLimitedOptions && this.selectedCount == 1;
|
||||
return this.isDuplicateFinder && this.selectedCount == 1;
|
||||
},
|
||||
isDuplicateFinder() {
|
||||
return getters.currentView() === "duplicateFinder";
|
||||
},
|
||||
permissions() {
|
||||
return getters.permissions();
|
||||
@@ -259,69 +250,65 @@ export default {
|
||||
return getters.isShare();
|
||||
},
|
||||
showCreateActions() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
if (this.isDuplicateFinder) return false;
|
||||
return this.showCreate && !this.isSearchActive;
|
||||
},
|
||||
showInfo() {
|
||||
if (this.showLimitedOptions) return this.selectedCount == 1;
|
||||
if (this.isDuplicateFinder) return this.selectedCount == 1;
|
||||
return !this.showCreate && this.selectedCount == 1;
|
||||
},
|
||||
showDownload() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
if (this.isDuplicateFinder) return false;
|
||||
return !this.showCreate && this.permissions.download && this.selectedCount > 0;
|
||||
},
|
||||
showArchive() {
|
||||
if (this.showLimitedOptions || getters.isShare()) return false;
|
||||
if (this.isDuplicateFinder || getters.isShare()) return false;
|
||||
if (!this.permissions.create) return false;
|
||||
return !this.showCreate && this.selectedCount > 0 && !this.showUnarchive;
|
||||
},
|
||||
showUnarchive() {
|
||||
if (this.showLimitedOptions || getters.isShare()) return false;
|
||||
if (this.isDuplicateFinder || getters.isShare()) return false;
|
||||
if (!this.permissions.create) return false;
|
||||
if (this.selectedCount !== 1) return false;
|
||||
const item = this.firstSelected;
|
||||
return item && isArchivePath(item.path || item.from || item.name);
|
||||
},
|
||||
showShareAction() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
if (this.isDuplicateFinder) return false;
|
||||
return (this.showCreate || this.selectedCount == 1) && this.showShare;
|
||||
},
|
||||
showRename() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
if (this.isDuplicateFinder) return false;
|
||||
return !this.showCreate && this.selectedCount == 1 && this.permissions.modify && !this.isSearchActive;
|
||||
},
|
||||
showCopy() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
if (this.isDuplicateFinder) return false;
|
||||
return !this.showCreate && this.selectedCount > 0 && this.permissions.modify;
|
||||
},
|
||||
showCopyPath() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
return !this.showCreate && this.selectedCount == 1 && !!state.user?.showCopyPath;
|
||||
},
|
||||
showOpenParentFolder() {
|
||||
return !this.showCreate && this.selectedCount == 1 && (this.isSearchActive || this.showLimitedOptions);
|
||||
return !this.showCreate && this.selectedCount == 1 && (this.isSearchActive || this.isDuplicateFinder);
|
||||
},
|
||||
showMove() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
if (this.isDuplicateFinder) return false;
|
||||
return !this.showCreate && this.selectedCount > 0 && this.permissions.modify;
|
||||
},
|
||||
showSelectAll() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
if (this.isDuplicateFinder) return false;
|
||||
return !this.showCreate && !this.isSearchActive && this.req?.items?.length > 0;
|
||||
},
|
||||
showCreateButton() {
|
||||
if (this.showLimitedOptions || this.createOnly) return false;
|
||||
if (this.isDuplicateFinder || this.createOnly) return false;
|
||||
return !this.isSearchActive && this.permissions.create && !this.isShare;
|
||||
},
|
||||
showDivider() {
|
||||
if (this.showLimitedOptions || this.createOnly) return false;
|
||||
if (this.isDuplicateFinder || this.createOnly) return false;
|
||||
if (getters.isShare()) {
|
||||
return state.shareInfo?.allowCreate
|
||||
}
|
||||
return state.user?.permissions?.create || state.user?.permissions?.share || state.user?.permissions?.admin;
|
||||
},
|
||||
showSelectMultiple() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
if (this.isDuplicateFinder) return false;
|
||||
if (this.isMultiple || this.isSearchActive) {
|
||||
return false;
|
||||
}
|
||||
@@ -337,7 +324,7 @@ export default {
|
||||
return this.showEdit || this.showDelete || this.showSave || this.showGoToRaw || this.hasDownload || this.showUnarchiveInOverflow;
|
||||
},
|
||||
showUnarchiveInOverflow() {
|
||||
if (!this.permissions.create || getters.isShare()) return false;
|
||||
if (!this.permissions.archive || getters.isShare()) return false;
|
||||
const req = state.req;
|
||||
return req && !req.isDir && isArchivePath(req.path || req.name);
|
||||
},
|
||||
@@ -353,7 +340,7 @@ export default {
|
||||
return cv == "markdownViewer" && this.permissions.modify;
|
||||
},
|
||||
showDelete() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
if (this.isDuplicateFinder) return false;
|
||||
if (this.selectedCount == 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -365,7 +352,15 @@ export default {
|
||||
return this.selectedCount > 0 && this.permissions.download;
|
||||
},
|
||||
isPreview() {
|
||||
return getters.isPreviewView();
|
||||
const cv = getters.currentView();
|
||||
return (
|
||||
cv == "preview" ||
|
||||
cv == "onlyOfficeEditor" ||
|
||||
cv == "markdownViewer" ||
|
||||
cv == "epubViewer" ||
|
||||
cv == "docViewer" ||
|
||||
cv == "editor"
|
||||
);
|
||||
},
|
||||
showSave() {
|
||||
const allowEdit = this.permissions.modify || (getters.isShare() && state.shareInfo.allowEdit);
|
||||
@@ -375,7 +370,7 @@ export default {
|
||||
return getters.currentPromptName() == "OverflowMenu";
|
||||
},
|
||||
showAccess() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
if (this.isDuplicateFinder) return false;
|
||||
if (getters.isShare()) {
|
||||
return false;
|
||||
}
|
||||
@@ -414,10 +409,6 @@ export default {
|
||||
currentPrompt() {
|
||||
return getters.currentPrompt();
|
||||
},
|
||||
markdownPreview() {
|
||||
if (getters.currentView() !== 'editor') return false;
|
||||
return state.req.type === 'text/markdown';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
hasOverflowItems: {
|
||||
@@ -447,13 +438,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showInfoPrompt() {
|
||||
if (!this.isPreview) {
|
||||
mutations.closeHovers();
|
||||
} else {
|
||||
mutations.closeTopPrompt();
|
||||
}
|
||||
mutations.showPrompt({
|
||||
showInfoHover() {
|
||||
mutations.closeContextMenus();
|
||||
mutations.showHover({
|
||||
name: "info",
|
||||
props: {
|
||||
item: this.firstSelected,
|
||||
@@ -463,7 +450,6 @@ export default {
|
||||
goToItem() {
|
||||
const item = this.firstSelected;
|
||||
url.goToItem(item.source, item.path, {}, true);
|
||||
mutations.closeHovers();
|
||||
},
|
||||
hideTooltip() {
|
||||
mutations.hideTooltip();
|
||||
@@ -485,14 +471,14 @@ export default {
|
||||
shouldShowParentFolder() {
|
||||
return this.isPreview && state.req.path != "/";
|
||||
},
|
||||
showAccessPrompt() {
|
||||
mutations.closeHovers();
|
||||
showAccessHover() {
|
||||
mutations.closeContextMenus();
|
||||
let sourceName = this.firstSelected?.source || state.req.source;
|
||||
let path = this.firstSelected?.path || state.req.path;
|
||||
if (this.firstSelected && !this.firstSelected.isDir) {
|
||||
path = url.removeLastDir(path) + '/';
|
||||
}
|
||||
mutations.showPrompt({
|
||||
mutations.showHover({
|
||||
name: "access",
|
||||
props: {
|
||||
sourceName: sourceName,
|
||||
@@ -568,21 +554,21 @@ export default {
|
||||
}
|
||||
this.showCreate = true;
|
||||
},
|
||||
showPrompt(value) {
|
||||
return mutations.showPrompt(value);
|
||||
showHover(value) {
|
||||
return mutations.showHover(value);
|
||||
},
|
||||
showSharePrompt() {
|
||||
mutations.closeHovers();
|
||||
mutations.showPrompt({
|
||||
showShareHover() {
|
||||
mutations.closeContextMenus();
|
||||
mutations.showHover({
|
||||
name: "share",
|
||||
props: {
|
||||
item: this.selectedCount == 1 ? this.firstSelected : state.req
|
||||
},
|
||||
});
|
||||
},
|
||||
showRenamePrompt() {
|
||||
mutations.closeHovers();
|
||||
mutations.showPrompt({
|
||||
showRenameHover() {
|
||||
mutations.closeContextMenus();
|
||||
mutations.showHover({
|
||||
name: "rename",
|
||||
props: {
|
||||
item: this.selectedCount == 1 ? this.firstSelected : state.req,
|
||||
@@ -590,11 +576,11 @@ export default {
|
||||
},
|
||||
});
|
||||
},
|
||||
showRenamePromptForPreview() {
|
||||
mutations.closeTopPrompt(); // Close the ContextMenu (if it was open from preview)
|
||||
showRenameHoverForPreview() {
|
||||
mutations.closeTopHover(); // Close the ContextMenu (if it was open from preview)
|
||||
// Get parent items from the listing
|
||||
const parentItems = state.navigation.listing || [];
|
||||
mutations.showPrompt({
|
||||
mutations.showHover({
|
||||
name: "rename",
|
||||
props: {
|
||||
item: state.req,
|
||||
@@ -621,26 +607,26 @@ export default {
|
||||
}
|
||||
},
|
||||
toggleMultipleSelection() {
|
||||
mutations.setMultiple(true);
|
||||
mutations.setMultiple(!state.multiple);
|
||||
mutations.closeHovers();
|
||||
},
|
||||
startDownload() {
|
||||
mutations.closeTopPrompt();
|
||||
mutations.closeTopHover();
|
||||
const items = this.providedItems;
|
||||
downloadFiles(items);
|
||||
},
|
||||
showDeletePrompt() {
|
||||
mutations.closeTopPrompt();
|
||||
mutations.showPrompt({
|
||||
showDeleteHover() {
|
||||
mutations.closeContextMenus();
|
||||
mutations.showHover({
|
||||
name: 'delete',
|
||||
props: {
|
||||
items: this.providedItems,
|
||||
},
|
||||
});
|
||||
},
|
||||
showMovePrompt() {
|
||||
mutations.closeHovers();
|
||||
mutations.showPrompt({
|
||||
showMoveHover() {
|
||||
mutations.closeContextMenus();
|
||||
mutations.showHover({
|
||||
name: 'move',
|
||||
props: {
|
||||
items: this.providedItems,
|
||||
@@ -648,9 +634,9 @@ export default {
|
||||
},
|
||||
});
|
||||
},
|
||||
showCopyPrompt() {
|
||||
mutations.closeHovers();
|
||||
mutations.showPrompt({
|
||||
showCopyHover() {
|
||||
mutations.closeContextMenus();
|
||||
mutations.showHover({
|
||||
name: 'copy',
|
||||
props: {
|
||||
items: this.providedItems,
|
||||
@@ -658,41 +644,8 @@ export default {
|
||||
},
|
||||
});
|
||||
},
|
||||
async copyPathToClipboard() {
|
||||
const item = this.firstSelected;
|
||||
const path = item?.path || "";
|
||||
|
||||
if (!path) {
|
||||
notify.showErrorToast(this.$t("prompts.copyToClipboardFailed"));
|
||||
mutations.closeHovers();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(path);
|
||||
notify.showSuccessToast(this.$t("buttons.copySuccess"));
|
||||
} catch (err) {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = path;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.opacity = "0";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
notify.showSuccessToast(this.$t("buttons.copySuccess"));
|
||||
} catch (e) {
|
||||
notify.showErrorToast(this.$t("prompts.copyToClipboardFailed"));
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
mutations.closeHovers();
|
||||
},
|
||||
showNewDirPrompt() {
|
||||
mutations.closeHovers();
|
||||
showNewDirHover() {
|
||||
mutations.closeContextMenus();
|
||||
// If the context menu was triggered on a directory, pass its path as base
|
||||
const selectedItem = this.firstSelected;
|
||||
let base = null;
|
||||
@@ -703,22 +656,22 @@ export default {
|
||||
source: selectedItem.source,
|
||||
};
|
||||
}
|
||||
mutations.showPrompt({
|
||||
mutations.showHover({
|
||||
name: "newDir",
|
||||
props: {
|
||||
base: base,
|
||||
},
|
||||
});
|
||||
},
|
||||
showArchivePrompt() {
|
||||
mutations.closeTopPrompt();
|
||||
showArchiveHover() {
|
||||
mutations.closeTopHover();
|
||||
const items = this.providedItems.map(item => ({
|
||||
path: item.path,
|
||||
name: item.name,
|
||||
source: item.source || state.req.source,
|
||||
}));
|
||||
if (items.length === 0) return;
|
||||
mutations.showPrompt({
|
||||
mutations.showHover({
|
||||
name: "archive",
|
||||
props: {
|
||||
items,
|
||||
@@ -727,14 +680,14 @@ export default {
|
||||
},
|
||||
});
|
||||
},
|
||||
showUnarchivePrompt() {
|
||||
mutations.closeTopPrompt();
|
||||
showUnarchiveHover() {
|
||||
mutations.closeTopHover();
|
||||
const item = this.firstSelected;
|
||||
if (!item) return;
|
||||
this.openUnarchivePrompt(item);
|
||||
},
|
||||
showUnarchivePromptFromPreview() {
|
||||
mutations.closeTopPrompt();
|
||||
showUnarchiveHoverFromPreview() {
|
||||
mutations.closeTopHover();
|
||||
const req = state.req;
|
||||
if (!req) return;
|
||||
this.openUnarchivePrompt({ path: req.path, source: req.source, name: req.name });
|
||||
@@ -742,7 +695,7 @@ export default {
|
||||
openUnarchivePrompt(item) {
|
||||
const path = item.path || item.from;
|
||||
const source = item.source || state.req.source;
|
||||
mutations.showPrompt({
|
||||
mutations.showHover({
|
||||
name: "unarchive",
|
||||
props: {
|
||||
item: { path, source, name: item.name },
|
||||
@@ -762,10 +715,10 @@ export default {
|
||||
false
|
||||
);
|
||||
window.open(downloadUrl, "_blank");
|
||||
mutations.closeHovers();
|
||||
mutations.closeContextMenus();
|
||||
},
|
||||
watchFile() {
|
||||
mutations.closeHovers();
|
||||
mutations.closeContextMenus();
|
||||
const source = state.req?.source || state.sources.current || "";
|
||||
const path = state.req?.path || "/";
|
||||
this.$router.push({
|
||||
@@ -777,10 +730,7 @@ export default {
|
||||
});
|
||||
},
|
||||
async edit() {
|
||||
this.$router.replace({ hash: '#edit' });
|
||||
},
|
||||
async switchToMarkdown() {
|
||||
this.$router.replace({ hash: '#preview' });
|
||||
window.location.hash = "#edit";
|
||||
},
|
||||
async save() {
|
||||
const button = "save";
|
||||
@@ -798,10 +748,10 @@ export default {
|
||||
// Don't show error notification here - API layer already showed it
|
||||
buttons.done(button);
|
||||
}
|
||||
mutations.closeHovers();
|
||||
mutations.closeContextMenus();
|
||||
},
|
||||
showUploadPrompt() {
|
||||
mutations.closeHovers();
|
||||
showUploadHover() {
|
||||
mutations.closeContextMenus();
|
||||
let targetPath = state.req.path;
|
||||
let targetSource = state.req.source;
|
||||
const selectedItem = this.firstSelected;
|
||||
@@ -809,7 +759,7 @@ export default {
|
||||
targetPath = selectedItem.path;
|
||||
targetSource = selectedItem.source;
|
||||
}
|
||||
mutations.showPrompt({
|
||||
mutations.showHover({
|
||||
name: "upload",
|
||||
props: {
|
||||
targetPath: targetPath,
|
||||
@@ -820,11 +770,19 @@ export default {
|
||||
openParentFolder() {
|
||||
const item = this.firstSelected;
|
||||
const parentPath = url.removeLastDir(item.path) || "/";
|
||||
url.goToItem(item.source, parentPath, {}, this.showLimitedOptions);
|
||||
mutations.closeHovers();
|
||||
url.goToItem(item.source, parentPath, {}, this.isDuplicateFinder);
|
||||
},
|
||||
selectAllItems() {
|
||||
mutations.selectAllItems()
|
||||
if (state.req && state.req.items && state.req.items.length > 0) {
|
||||
// Clear current selection first
|
||||
mutations.resetSelected();
|
||||
// Add all items from current directory to selection by their indices
|
||||
state.req.items.forEach((item, index) => {
|
||||
mutations.addSelected(index);
|
||||
});
|
||||
// Close the context menu
|
||||
mutations.closeContextMenus();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -836,14 +794,12 @@ export default {
|
||||
z-index: 1000;
|
||||
background-color: var(--background);
|
||||
max-width: 20em;
|
||||
min-width: 13em;
|
||||
min-width: 15em;
|
||||
min-height: 4em;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
border-radius: 1em;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
#context-menu.centered {
|
||||
@@ -858,18 +814,6 @@ export default {
|
||||
cursor: unset;
|
||||
}
|
||||
|
||||
/* Circle for up to 3 digits; 1000+ keeps pill shape from .button */
|
||||
.selected-count-header--circle {
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
min-width: 2em;
|
||||
min-height: 2em;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.context-menu-header > .action i {
|
||||
padding: 0.25em;
|
||||
}
|
||||
@@ -913,7 +857,5 @@ export default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
</style>
|
||||
@@ -7,11 +7,6 @@
|
||||
'--spinner-color': color
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="mode === 'spinner'"
|
||||
class="loader__comet"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span v-if="mode === 'bounce'" class="bounce-dot"></span>
|
||||
<span v-if="mode === 'bounce'" class="bounce-dot"></span>
|
||||
<div v-if="mode === 'placeholder'" class="placeholder-item placeholder-item-1"></div>
|
||||
@@ -46,3 +41,260 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.loader {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
border-top: 3px solid var(--spinner-color, var(--primaryColor));
|
||||
border-right: 3px solid transparent;
|
||||
border-bottom: 3px solid transparent;
|
||||
box-sizing: border-box;
|
||||
animation: spinner-rotation var(--animation-duration, 1s) linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spinner-rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SIZE VARIANTS
|
||||
============================================ */
|
||||
.loader--xsmall {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-width: 1.5px;
|
||||
}
|
||||
|
||||
.loader--small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.loader--medium {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.loader--large {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-width: 4px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PULSE MODE
|
||||
============================================ */
|
||||
.loader--pulse {
|
||||
border: none;
|
||||
background: var(--spinner-color, var(--primaryColor));
|
||||
animation: pulse-scale var(--animation-duration, 0.75s) ease infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-scale {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BOUNCE MODE
|
||||
============================================ */
|
||||
.loader--bounce {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3em;
|
||||
border: none;
|
||||
background: transparent;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.loader--bounce::before,
|
||||
.loader--bounce::after {
|
||||
content: '';
|
||||
width: 0.6em;
|
||||
height: 0.6em;
|
||||
background-color: var(--spinner-color, var(--primaryColor));
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.loader--bounce::before {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.loader--bounce::after {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
.loader--bounce .bounce-dot {
|
||||
width: 0.6em;
|
||||
height: 0.6em;
|
||||
background-color: var(--spinner-color, var(--primaryColor));
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes bounce-scale {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Size variants for bounce mode */
|
||||
.loader--bounce.loader--xsmall::before,
|
||||
.loader--bounce.loader--xsmall::after,
|
||||
.loader--bounce.loader--xsmall .bounce-dot {
|
||||
width: 0.3em;
|
||||
height: 0.3em;
|
||||
}
|
||||
|
||||
.loader--bounce.loader--small::before,
|
||||
.loader--bounce.loader--small::after,
|
||||
.loader--bounce.loader--small .bounce-dot {
|
||||
width: 0.45em;
|
||||
height: 0.45em;
|
||||
}
|
||||
|
||||
.loader--bounce.loader--medium::before,
|
||||
.loader--bounce.loader--medium::after,
|
||||
.loader--bounce.loader--medium .bounce-dot {
|
||||
width: 0.6em;
|
||||
height: 0.6em;
|
||||
}
|
||||
|
||||
.loader--bounce.loader--large::before,
|
||||
.loader--bounce.loader--large::after,
|
||||
.loader--bounce.loader--large .bounce-dot {
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PLACEHOLDER MODE
|
||||
============================================ */
|
||||
.loader--placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
border: none;
|
||||
animation: none;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.placeholder-item {
|
||||
background-color: rgba(128, 128, 128, 0.1);
|
||||
animation: placeholder-ripple 1.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite,
|
||||
placeholder-bg-fade 2s ease-in-out infinite;
|
||||
display: block;
|
||||
border-radius: 0.25em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
box-shadow 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.placeholder-item-1 {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.placeholder-item-2 {
|
||||
animation-delay: 0.36s;
|
||||
}
|
||||
|
||||
.placeholder-item-3 {
|
||||
animation-delay: 0.72s;
|
||||
}
|
||||
|
||||
@keyframes placeholder-ripple {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: none;
|
||||
opacity: 0.3;
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.07);
|
||||
box-shadow: inset 0 -3em 3em rgba(172, 172, 172, 0.211),
|
||||
0 0 0 2px var(--alt-background),
|
||||
0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.97);
|
||||
box-shadow: none;
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: none;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes placeholder-bg-fade {
|
||||
0% {
|
||||
background-color: rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
50% {
|
||||
background-color: rgba(128, 128, 128, 0.4);
|
||||
}
|
||||
100% {
|
||||
background-color: rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Size variants for placeholder mode */
|
||||
.loader--placeholder.loader--xsmall .placeholder-item {
|
||||
width: 30px;
|
||||
height: 7.5px;
|
||||
min-width: 30px;
|
||||
min-height: 7.5px;
|
||||
}
|
||||
|
||||
.loader--placeholder.loader--small .placeholder-item {
|
||||
width: 150px;
|
||||
height: 12.5px;
|
||||
min-width: 150px;
|
||||
min-height: 12.5px;
|
||||
}
|
||||
|
||||
.loader--placeholder.loader--medium .placeholder-item {
|
||||
width: 200px;
|
||||
height: 25px;
|
||||
min-width: 200px;
|
||||
min-height: 25px;
|
||||
}
|
||||
|
||||
.loader--placeholder.loader--large .placeholder-item {
|
||||
width: 400px;
|
||||
height: 50px;
|
||||
min-width: 400px;
|
||||
min-height: 50px;
|
||||
}
|
||||
</style>
|
||||
@@ -19,10 +19,10 @@
|
||||
}"
|
||||
>
|
||||
<!-- Close button - always present on every notification, separate from optional buttons array -->
|
||||
<i class="material-symbols" @click="closeNotification(notification.id)">close</i>
|
||||
<i class="material-icons" @click="closeNotification(notification.id)">close</i>
|
||||
<div class="notification-content-wrapper">
|
||||
<div class="notification-header">
|
||||
<i v-if="notification.icon" class="material-symbols notification-icon">
|
||||
<i v-if="notification.icon" class="material-icons notification-icon">
|
||||
{{ notification.icon }}
|
||||
</i>
|
||||
<div class="notification-message">{{ notification.message }}</div>
|
||||
@@ -255,13 +255,13 @@ export default {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.notification-item > .material-symbols:first-child {
|
||||
.notification-item > .material-icons:first-child {
|
||||
cursor: pointer;
|
||||
font-size: 1.75em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.notification-item > .material-symbols:first-child:hover {
|
||||
.notification-item > .material-icons:first-child:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/compon
|
||||
{{ displayed_text }}
|
||||
<i
|
||||
v-if="helpText && status === 'error'"
|
||||
class="material-symbols-outlined tooltip-info-icon"
|
||||
class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
@@ -354,9 +354,7 @@ export default {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tooltip-info-icon:hover,
|
||||
.tooltip-info-icon:hover .material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 1;
|
||||
.tooltip-info-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<!-- Close button visible when search is active -->
|
||||
<button v-if="active" class="action" @click="close" :aria-label="$t('general.close')"
|
||||
:title="$t('general.close')">
|
||||
<i class="material-symbols">close</i>
|
||||
<i class="material-icons">close</i>
|
||||
</button>
|
||||
<!-- Search icon when search is not active -->
|
||||
<i v-else class="material-symbols">search</i>
|
||||
<i v-else class="material-icons">search</i>
|
||||
<!-- Input field for search -->
|
||||
<input id="search-input" type="text"
|
||||
@keyup.exact="keyup" @input="submit" ref="input" :autofocus="active" v-model.trim="value"
|
||||
@@ -44,19 +44,17 @@
|
||||
<ButtonGroup :buttons="typeSelect" @button-clicked="addToTypes" @remove-button-clicked="removeFromTypes"
|
||||
:isDisabled="isTypeSelectDisabled" />
|
||||
<!-- Inputs for filtering by file size -->
|
||||
<div class="constraints">
|
||||
<div class="sizeConstraints">
|
||||
<div class="sizeInputWrapper">
|
||||
<p>{{ $t("search.smallerThan") }}</p>
|
||||
<input class="sizeInput" v-model="smallerThan" type="number" min="0"
|
||||
placeholder="MB" />
|
||||
<p>{{ $t("search.largerThan") }}</p>
|
||||
<input class="sizeInput" v-model="largerThan" type="number" placeholder="MB" />
|
||||
:placeholder="$t('general.number')" />
|
||||
<p>MB</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</div>
|
||||
<div class="sizeInputWrapper">
|
||||
<p>{{ $t("search.olderThanDate") }}</p>
|
||||
<input class="sizeInput" v-model="modifiedOlderThan" type="date" />
|
||||
<p>{{ $t("search.newerThanDate") }}</p>
|
||||
<input class="sizeInput" v-model="modifiedNewerThan" type="date" />
|
||||
<p>{{ $t("search.largerThan") }}</p>
|
||||
<input class="sizeInput" v-model="largerThan" type="number" :placeholder="$t('general.number')" />
|
||||
<p>MB</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- Toggle for showing preview images -->
|
||||
@@ -69,11 +67,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Loading icon when search is ongoing -->
|
||||
<LoadingSpinner v-if="isRunning" size="medium" />
|
||||
<LoadingSpinner v-if="ongoing" size="medium" />
|
||||
<!-- Message when no results are found -->
|
||||
<div class="searchPrompt" v-show="isEmpty && !isRunning">
|
||||
<p>{{ noneMessage }}</p>
|
||||
<i class="material-symbols-outlined tooltip-info-icon" @mouseenter="showHelpTooltip"
|
||||
<i class="no-select material-symbols-outlined tooltip-info-icon" @mouseenter="showHelpTooltip"
|
||||
@mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
@@ -133,8 +131,6 @@ export default {
|
||||
return {
|
||||
largerThan: "",
|
||||
smallerThan: "",
|
||||
modifiedOlderThan: "",
|
||||
modifiedNewerThan: "",
|
||||
noneMessage: this.$t("search.typeToSearch", { minSearchLength: globalVars.minSearchLength }),
|
||||
searchTypes: "",
|
||||
isTypeSelectDisabled: false,
|
||||
@@ -152,7 +148,7 @@ export default {
|
||||
],
|
||||
toggleOptionButton: [{ label: this.$t("search.showOptions") }],
|
||||
value: "",
|
||||
ongoing: 0,
|
||||
ongoing: false,
|
||||
results: [],
|
||||
reload: false,
|
||||
scrollable: null,
|
||||
@@ -161,27 +157,18 @@ export default {
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
currentSourceState(){
|
||||
this.selectedSource = state.sources.current;
|
||||
},
|
||||
largerThan() {
|
||||
this.submit();
|
||||
},
|
||||
smallerThan() {
|
||||
this.submit();
|
||||
},
|
||||
modifiedOlderThan() {
|
||||
this.submit();
|
||||
},
|
||||
modifiedNewerThan() {
|
||||
this.submit();
|
||||
},
|
||||
searchTypes() {
|
||||
this.submit();
|
||||
},
|
||||
value() {
|
||||
if (this.results.length) {
|
||||
this.ongoing = 0;
|
||||
this.ongoing = false;
|
||||
this.results = [];
|
||||
}
|
||||
},
|
||||
@@ -233,9 +220,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentSourceState() {
|
||||
return state.sources.current;
|
||||
},
|
||||
eventTheme() {
|
||||
return getters.eventTheme();
|
||||
},
|
||||
@@ -267,16 +251,17 @@ export default {
|
||||
return this.results.length === 0;
|
||||
},
|
||||
text() {
|
||||
if (this.ongoing > 0) {
|
||||
if (this.ongoing) {
|
||||
return "";
|
||||
}
|
||||
return this.$t("search.typeToSearch", { minSearchLength: globalVars.minSearchLength })
|
||||
},
|
||||
isRunning() {
|
||||
return this.ongoing > 0;
|
||||
return this.ongoing;
|
||||
},
|
||||
activeStates() {
|
||||
const selectedItems = state.selected ? [].concat(state.selected) : [];
|
||||
|
||||
if (selectedItems.length === 0) {
|
||||
// Return an array of all false if nothing is selected
|
||||
return new Array(this.results.length).fill(false);
|
||||
@@ -308,7 +293,7 @@ export default {
|
||||
let result = url.extractSourceFromPath(decodeURIComponent(state.route.path));
|
||||
if (this.selectedSource === "" || result.source === this.selectedSource) {
|
||||
return result.path;
|
||||
} else {
|
||||
} else {
|
||||
return "/"; // if searching on non-current source, search the whole thing
|
||||
}
|
||||
},
|
||||
@@ -317,12 +302,11 @@ export default {
|
||||
openContext(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
mutations.showPrompt({
|
||||
mutations.showHover({
|
||||
name: "ContextMenu",
|
||||
props: {
|
||||
posX: event.clientX,
|
||||
posY: event.clientY,
|
||||
showLimitedOptions: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -380,6 +364,9 @@ export default {
|
||||
const fullPath = context + '/' + path;
|
||||
return url.buildItemUrl(source, fullPath, true);
|
||||
},
|
||||
getIcon(mimetype) {
|
||||
return getMaterialIconForType(mimetype);
|
||||
},
|
||||
enableOptions() {
|
||||
this.hiddenOptions = false;
|
||||
this.toggleOptionButton = [{ label: "Hide Options" }];
|
||||
@@ -391,23 +378,6 @@ export default {
|
||||
humanSize(size) {
|
||||
return getHumanReadableFilesize(size);
|
||||
},
|
||||
/** YYYY-MM-DD from a date input → Unix seconds at 00:00:00 UTC for that calendar day. */
|
||||
dateToUnixStartOfDayUTC(isoDate) {
|
||||
if (isoDate === "" || typeof isoDate !== "string") {
|
||||
return null;
|
||||
}
|
||||
const parts = isoDate.split("-");
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
const y = Number(parts[0]);
|
||||
const m = Number(parts[1]);
|
||||
const d = Number(parts[2]);
|
||||
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) {
|
||||
return null;
|
||||
}
|
||||
return Math.floor(Date.UTC(y, m - 1, d) / 1000);
|
||||
},
|
||||
basePath(str, isDir) {
|
||||
let result = url.removeLastDir(str);
|
||||
if (!isDir) {
|
||||
@@ -420,6 +390,15 @@ export default {
|
||||
let part = parts.pop();
|
||||
return part;
|
||||
},
|
||||
open() {
|
||||
if (!state.isSearchActive) {
|
||||
mutations.closeHovers();
|
||||
mutations.closeSidebar();
|
||||
mutations.resetSelected();
|
||||
this.resetSearchFilters;
|
||||
mutations.setSearch(true);
|
||||
}
|
||||
},
|
||||
close(event) {
|
||||
this.value = "";
|
||||
event.stopPropagation();
|
||||
@@ -463,11 +442,12 @@ export default {
|
||||
},
|
||||
async submit(event) {
|
||||
this.results = [];
|
||||
|
||||
if (event != undefined) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (this.value === "" || this.value.length < globalVars.minSearchLength) {
|
||||
this.noneMessage = this.$t("search.notEnoughCharacters", { minSearchLength: globalVars.minSearchLength });
|
||||
this.ongoing = false; this.noneMessage = this.$t("search.notEnoughCharacters", { minSearchLength: globalVars.minSearchLength });
|
||||
return;
|
||||
}
|
||||
let searchTypesFull = this.searchTypes;
|
||||
@@ -477,16 +457,7 @@ export default {
|
||||
if (this.smallerThan != "") {
|
||||
searchTypesFull = searchTypesFull + "type:smallerThan=" + this.smallerThan + " ";
|
||||
}
|
||||
const dateParams = {};
|
||||
const olderUnix = this.dateToUnixStartOfDayUTC(this.modifiedOlderThan);
|
||||
if (olderUnix !== null) {
|
||||
dateParams.olderThan = olderUnix;
|
||||
}
|
||||
const newerUnix = this.dateToUnixStartOfDayUTC(this.modifiedNewerThan);
|
||||
if (newerUnix !== null) {
|
||||
dateParams.newerThan = newerUnix;
|
||||
}
|
||||
this.ongoing++;
|
||||
this.ongoing = true;
|
||||
|
||||
// Determine which sources to search
|
||||
let sourcesToSearch = [];
|
||||
@@ -501,21 +472,15 @@ export default {
|
||||
// Only pass scope if searching a single source
|
||||
const scope = sourcesToSearch.length === 1 ? this.getContext : null;
|
||||
|
||||
this.results = await toolsApi.search(
|
||||
scope,
|
||||
sourcesToSearch,
|
||||
searchTypesFull + this.value,
|
||||
false,
|
||||
dateParams
|
||||
);
|
||||
this.results = await toolsApi.search(scope, sourcesToSearch, searchTypesFull + this.value);
|
||||
|
||||
this.ongoing--;
|
||||
if (this.results.length == 0 && this.ongoing == 0) {
|
||||
this.ongoing = false;
|
||||
if (this.results.length == 0) {
|
||||
this.noneMessage = this.$t("search.noResults");
|
||||
}
|
||||
},
|
||||
showHelpTooltip(event) {
|
||||
const helpText = `${this.$t("search.helpText1", { minSearchLength: globalVars.minSearchLength })}\n\n${this.$t("search.helpText2")}`;
|
||||
const helpText = `${this.$t("search.helpText1")}\n\n${this.$t("search.helpText2")}`;
|
||||
mutations.showTooltip({
|
||||
content: helpText,
|
||||
x: event.clientX,
|
||||
@@ -689,12 +654,12 @@ export default {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input-container .material-symbols {
|
||||
.search-input-container .material-icons {
|
||||
font-size: 1.25em;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
#search.active .search-input-container .material-symbols {
|
||||
#search.active .search-input-container .material-icons {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@@ -706,11 +671,6 @@ export default {
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
#search-input {
|
||||
width: 100%;
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
|
||||
#search.active input {
|
||||
color: inherit;
|
||||
}
|
||||
@@ -909,7 +869,7 @@ body.rtl #search .boxes h3 {
|
||||
/* IE and Edge */
|
||||
}
|
||||
|
||||
.constraints {
|
||||
.sizeConstraints {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
<template>
|
||||
<div id="status-bar" :style="moveWithSidebar" :class="{ 'dark-mode-header': isDarkMode, 'active': showStatusBar }" @contextmenu.prevent.stop @touchstart.stop @touchend.stop>
|
||||
<div class="status-content" @contextmenu.prevent.stop @touchstart.stop @touchend.stop>
|
||||
<!-- Left side: selection/directory info and stats for the editor and markdown viewer -->
|
||||
<div class="status-info">
|
||||
<template v-if="isEditorOrMarkdownView">
|
||||
<span>{{ editorStatsText }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="selectedCount > 0" class="button">{{ selectedCount }}</span>
|
||||
<span v-if="selectedCount > 0">{{ selectedItemsText }}</span>
|
||||
<span v-else>{{ directoryInfoText }}</span>
|
||||
</template>
|
||||
<div v-if="selectedCount > 0" class="status-info">
|
||||
<span class="button">{{ selectedCount }}</span>
|
||||
<span>{{ selectedItemsText }}</span>
|
||||
</div>
|
||||
<div v-else class="status-info">
|
||||
<span class="directory-info">{{ directoryInfoText }}</span>
|
||||
</div>
|
||||
<!-- Right side: Slider to control the listing size or the font size in editor -->
|
||||
<div class="status-controls">
|
||||
<div v-if="currentView === 'listingView'" class="gallery-size-control">
|
||||
<div v-if="showGallerySize" class="gallery-size-control">
|
||||
<span class="size-label">{{ $t("general.size") }}</span>
|
||||
<input
|
||||
v-model="gallerySize"
|
||||
@@ -27,17 +22,6 @@
|
||||
@change="commitGallerySize"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="currentView === 'editor'" class="gallery-size-control">
|
||||
<span class="size-label">{{ $t("general.size") }}</span>
|
||||
<input
|
||||
v-model="editorFontSize"
|
||||
type="range"
|
||||
min="10"
|
||||
max="24"
|
||||
step="1"
|
||||
/>
|
||||
<span class="size-label">{{ editorFontSize }}px</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,17 +39,11 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
currentView() {
|
||||
return getters.currentView();
|
||||
},
|
||||
isEditorOrMarkdownView() {
|
||||
return this.currentView === 'editor' || this.currentView === 'markdownViewer';
|
||||
},
|
||||
showStatusBar() {
|
||||
if (getters.isShare() && state.shareInfo.shareType === "upload") {
|
||||
return false;
|
||||
}
|
||||
return this.currentView === "listingView" || this.isEditorOrMarkdownView;
|
||||
return getters.currentView() === "listingView";
|
||||
},
|
||||
isDarkMode() {
|
||||
return getters.isDarkMode();
|
||||
@@ -79,6 +57,9 @@ export default {
|
||||
numFiles() {
|
||||
return getters.reqNumFiles();
|
||||
},
|
||||
showGallerySize() {
|
||||
return getters.isCardView() && state.req?.items?.length > 0;
|
||||
},
|
||||
totalDirectorySize() {
|
||||
if (!Array.isArray(state.req?.items)) return 0;
|
||||
return state.req.items.reduce((total, item) => total + (item.size || 0), 0);
|
||||
@@ -136,30 +117,6 @@ export default {
|
||||
}
|
||||
return {};
|
||||
},
|
||||
editorStatsText() {
|
||||
const { lines, words, chars } = state.editorStats;
|
||||
return [
|
||||
[words, chars]
|
||||
.map((v, i) => v != null && this.$t(i === 0 ? 'editor.words' : 'editor.chars', { count: v }))
|
||||
.filter(Boolean)
|
||||
.join(', '),
|
||||
lines != null && this.$t('editor.lines', { count: lines })
|
||||
].filter(Boolean).join(' | ');
|
||||
},
|
||||
editorFontSize: {
|
||||
get() {
|
||||
return state.editorFontSize;
|
||||
},
|
||||
set(value) {
|
||||
mutations.setEditorFontSize(value);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('wheel', this.handleWheel, { passive: false });
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('wheel', this.handleWheel);
|
||||
},
|
||||
methods: {
|
||||
updateGallerySize(event) {
|
||||
@@ -200,28 +157,6 @@ export default {
|
||||
mutations.updateCurrentUser({ viewMode: newMode });
|
||||
}
|
||||
},
|
||||
// Ctrl + Mouse Wheel to adjust the slider sizes
|
||||
handleWheel(event) {
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
|
||||
const delta = event.deltaY > 0 ? 1 : -1; // Scroll down increases, up decreases
|
||||
|
||||
if (this.currentView === 'listingView') {
|
||||
event.preventDefault();
|
||||
let newSize = Math.min(9, Math.max(1, this.gallerySize - delta));
|
||||
if (newSize !== this.gallerySize) {
|
||||
this.gallerySize = newSize;
|
||||
mutations.setGallerySize(newSize);
|
||||
this.adjustViewMode();
|
||||
}
|
||||
} else if (this.currentView === 'editor') {
|
||||
event.preventDefault();
|
||||
let newSize = Math.min(24, Math.max(10, this.editorFontSize - delta));
|
||||
if (newSize !== this.editorFontSize) {
|
||||
this.editorFontSize = newSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getIconClass } from "@/utils/material-symbols";
|
||||
import { getIconClass } from "@/utils/material-icons";
|
||||
|
||||
export default {
|
||||
name: "Toast",
|
||||
|
||||
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user