Compare commits
5
Commits
v0.8.2-beta
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97e5f12f7a | ||
|
|
256f4ca0b8 | ||
|
|
831708fe65 | ||
|
|
50bd853266 | ||
|
|
206180de0d |
No files matched your search
@@ -39,7 +39,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: frontend/dist
|
||||
path: backend/http/dist
|
||||
build_backend:
|
||||
name: Build backend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -2,6 +2,69 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## v0.8.4-beta
|
||||
|
||||
**New Features**:
|
||||
- New media player styles and features
|
||||
- Custom Media Player: enhanced media player using plyr thanks @Kurami32 (see #1160)
|
||||
- Custom Media Player: also adds support for metadata
|
||||
- added embeded video subtitle support (for both native and custom player). @maxbin123 #1072 #1157
|
||||
- Users can disable the customer player and opt of native in profile settings.
|
||||
- Option to disable backend update check via `server.disableUpdateCheck` #1134
|
||||
- added `frontend.favicon` and `frontend.description` for html overrides
|
||||
- onlyoffice is now supported in shares. Both viewing and editing can be configured per-share.
|
||||
- Added only office debug view and wiki to assist with debugging issues #1068 #911 #1074
|
||||
- Dark mode enforcement possible for shared links #1029
|
||||
- added `System & Admin` section to settings
|
||||
- includes a new config viewer to see current running config (hides secrets) #838
|
||||
- added `server.minSearchLength` to allow adjusting the length requirement for search #1174
|
||||
|
||||
**Notes**:
|
||||
- access management: specific folders/files with access are shown instead permission denied for parent folder
|
||||
- navigation no longer appends last location hash which should fix some unwanted navation behavior #1070
|
||||
- altered the context menu style and behavior.
|
||||
- documentation update: comma or Space separated extensions #1138
|
||||
- Files and folders can be created with "/" or "\" on the name #1126
|
||||
- Share management should not be allowed without authentication #1163
|
||||
- Question about customizing session timeout #1184
|
||||
|
||||
**BugFixes**:
|
||||
- access management: delay showing rule changes in the list fixed. #1131
|
||||
- Color names are not localized #1159
|
||||
- rename issues #1170 #1171
|
||||
- some shortcuts not working #1056
|
||||
- Can't copy/paste text on mobile #1168
|
||||
- Can't change between images inside of the share image viewer. #1144
|
||||
- fixed and updated translations with variables always showing english.
|
||||
|
||||
## v0.8.3-beta
|
||||
|
||||
**BugFixes**:
|
||||
- fixed search bar style bug in mobile #1147
|
||||
|
||||
## v0.8.2-beta
|
||||
|
||||
**New Features**:
|
||||
- added `source.config.denyByDefault` configuration to enable a deny-by-default access rule. A source enabled with this will deny access unless an "allow" rule was specifically created. (Similar to creating a root-level denyAll rule)
|
||||
- allow oidc user source access and permission based on username and groups is fulfilled by denyByDefault source with access rules https://github.com/gtsteffaniak/filebrowser/issues/824
|
||||
- "open parent folder" in context menu and search results https://github.com/gtsteffaniak/filebrowser/issues/1121
|
||||
- added friendly "share not found" page.
|
||||
|
||||
**Notes**:
|
||||
- 8.0 ffmpeg version bundled with docker
|
||||
- go 1.25 upgrade with green tea GC enabled
|
||||
- totp secrets accept non-secure strings, only throwing warning
|
||||
- adjusted download limit so it also counts viewing text "content" of files (like in editor). You can also "disable file viewing" to stop the editor from showing. lower quality file image previews are not counted as downloads.
|
||||
- updated invalid share message to be more clear https://github.com/gtsteffaniak/filebrowser/issues/1120
|
||||
|
||||
**BugFixes**:
|
||||
- fixed /public/static routes issue
|
||||
- shares redirect to login - https://github.com/gtsteffaniak/filebrowser/issues/1109
|
||||
- some static assets not available to anonymous user - https://github.com/gtsteffaniak/filebrowser/issues/1102
|
||||
- more safari style issues https://github.com/gtsteffaniak/filebrowser/issues/1110
|
||||
- fix public share download issues https://github.com/gtsteffaniak/filebrowser/issues/1118 https://github.com/gtsteffaniak/filebrowser/issues/1089
|
||||
- fixed disable file viewer setting and enforced on backend
|
||||
|
||||
## v0.8.1-beta
|
||||
|
||||
**New Features**:
|
||||
|
||||
+11
-22
@@ -4,7 +4,7 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.24+** (see `backend/go.mod`)
|
||||
- **Go 1.25+** (see `backend/go.mod`)
|
||||
- **Node.js 18.0.0+** with npm 7.0.0+ (see `frontend/package.json`)
|
||||
- **Docker** (optional, for containerized development)
|
||||
- **Git**
|
||||
@@ -31,8 +31,8 @@ cd filebrowser
|
||||
# 2. Initial setup - installs dependencies and creates test config
|
||||
make setup
|
||||
|
||||
# 3. Run in development mode
|
||||
make run
|
||||
# 3. Run in development mode with hot-reloading
|
||||
make dev
|
||||
```
|
||||
|
||||
`make setup` installs all dependencies and creates a test configuration file.
|
||||
@@ -52,14 +52,14 @@ make run
|
||||
- **Framework**: Vue 3 + Vite + TypeScript
|
||||
- **State**: Custom store in `frontend/src/store/`
|
||||
- **API Client**: Axios-based in `frontend/src/api/`
|
||||
- **i18n**: 25+ languages with English as master
|
||||
- **i18n**: 25+ languages with English as main
|
||||
- **Components**: Feature-based organization
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Essential Commands
|
||||
```bash
|
||||
make run # Start development server
|
||||
make dev # Start development server with hot-reloading
|
||||
make test # Run all tests
|
||||
make lint # Check code quality
|
||||
make check-all # Lint + tests
|
||||
@@ -70,12 +70,12 @@ make build # Build Docker image
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
|
||||
Note: consider using make commands above instead.
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev # Dev server with hot reload
|
||||
npm run lint:fix # Auto-fix linting issues
|
||||
npm run typecheck # TypeScript validation
|
||||
npm run i18n:sync # Sync translations
|
||||
npm run i18n:sync # Sync translations changes
|
||||
```
|
||||
|
||||
## Testing
|
||||
@@ -128,12 +128,11 @@ make build-backend # Build Go binary with embedded assets
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
make build # Full image with ffmpeg (~200MB)
|
||||
make run-proxy # Docker Compose with nginx proxy
|
||||
make build # Full image with ffmpeg and muPDF
|
||||
```
|
||||
|
||||
### Configuration
|
||||
- **Test Config**: `backend/test_config.yaml` Auto-generated after running `make setup`
|
||||
- **Test Config**: `backend/test_config.yaml` Auto-generated after running `make setup`
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -141,7 +140,7 @@ make run-proxy # Docker Compose with nginx proxy
|
||||
|
||||
1. Fork and create a feature branch
|
||||
2. Make your changes following the code standards
|
||||
3. Run `make run` to build and run with your changes
|
||||
3. Run `make dev` to build and run with your changes. Supports hot-reloading frontend and backend changes.
|
||||
4. When ready, run `make check-all` to verify tests and linting
|
||||
5. Submit PR with a clear description
|
||||
|
||||
@@ -175,16 +174,6 @@ cd frontend && rm -rf node_modules && npm install
|
||||
cd backend && go mod tidy && go clean -modcache
|
||||
```
|
||||
|
||||
**Debugging:**
|
||||
```bash
|
||||
# Backend with debug logs
|
||||
cd backend
|
||||
FILEBROWSER_NO_EMBEDDED=true go run -tags mupdf . -c test_config.yaml --log-level debug
|
||||
|
||||
# API documentation
|
||||
# Visit /swagger endpoint when running
|
||||
```
|
||||
|
||||
**Authentication Issues:**
|
||||
|
||||
Always first enable debug logging in your config file if you have issues.
|
||||
|
||||
+5
-2
@@ -1,4 +1,4 @@
|
||||
FROM gtstef/ffmpeg:7.1.1-decode AS ffmpeg
|
||||
FROM gtstef/ffmpeg:8.0-decode AS ffmpeg
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
@@ -6,6 +6,7 @@ WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add --no-cache gcc musl-dev upx
|
||||
ENV CGO_ENABLED=1
|
||||
ENV GOEXPERIMENT=greenteagc
|
||||
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}'" \
|
||||
@@ -15,6 +16,9 @@ RUN upx filebrowser
|
||||
FROM node:lts-slim AS nbuild
|
||||
WORKDIR /app
|
||||
COPY ./frontend/package.json ./
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
|
||||
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
RUN npm i --maxsockets 1
|
||||
COPY ./frontend/ ./
|
||||
RUN npm run build-docker
|
||||
@@ -23,7 +27,6 @@ FROM alpine:latest
|
||||
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
|
||||
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
|
||||
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
|
||||
ENV FILEBROWSER_NO_EMBEDED="true"
|
||||
ENV PATH="$PATH:/home/filebrowser"
|
||||
RUN apk --no-cache add ca-certificates mailcap tzdata
|
||||
RUN adduser -D -s /bin/true -u 1000 filebrowser
|
||||
|
||||
@@ -15,4 +15,4 @@ COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
RUN ./filebrowser set -u testuser1,testuser1
|
||||
RUN FILEBROWSER_NO_EMBEDED=true ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -13,4 +13,4 @@ RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
RUN FILEBROWSER_NO_EMBEDED=true ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -10,4 +10,4 @@ COPY ["./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
# Run tests
|
||||
WORKDIR /app/frontend
|
||||
RUN node mock-oidc-server.js & sleep 2 && cd ../backend/ && FILEBROWSER_NO_EMBEDED=true ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
RUN node mock-oidc-server.js & sleep 2 && cd ../backend/ && ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -9,5 +9,6 @@ RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
RUN apt update && apt install nginx -y
|
||||
RUN mv default.conf /etc/nginx/conf.d/default.conf
|
||||
RUN mv .htpasswd /etc/nginx/conf.d/.htpasswd
|
||||
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
|
||||
RUN nginx & FILEBROWSER_NO_EMBEDED=true ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -14,4 +14,4 @@ RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# Run tests
|
||||
RUN FILEBROWSER_NO_EMBEDED=true ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -4,6 +4,7 @@ ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add upx
|
||||
ENV GOEXPERIMENT=greenteagc
|
||||
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}'" \
|
||||
@@ -13,12 +14,14 @@ RUN upx filebrowser
|
||||
FROM node:lts-slim AS nbuild
|
||||
WORKDIR /app
|
||||
COPY ./frontend/package.json ./
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
|
||||
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
RUN npm i --maxsockets 1
|
||||
COPY ./frontend/ ./
|
||||
RUN npm run build-docker
|
||||
|
||||
FROM alpine:latest
|
||||
ENV FILEBROWSER_NO_EMBEDED="true"
|
||||
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
|
||||
ENV PATH="$PATH:/home/filebrowser"
|
||||
RUN apk --no-cache add ca-certificates mailcap tzdata
|
||||
|
||||
@@ -6,6 +6,7 @@ services:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./src/proxy/backend/default.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./src/proxy/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
|
||||
filebrowser:
|
||||
hostname: filebrowser
|
||||
volumes:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
demo-127.0.0.1:$apr1$j5FRA4Bc$RYdNFuM7YAAo9Qtie8w4b/
|
||||
@@ -1,6 +1,6 @@
|
||||
server:
|
||||
port: 8080
|
||||
baseURL: "/"
|
||||
baseURL: "/subpath"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
@@ -32,5 +32,5 @@ userDefaults:
|
||||
permissions:
|
||||
admin: false
|
||||
modify: true
|
||||
share: false
|
||||
share: true
|
||||
api: false
|
||||
@@ -7,13 +7,25 @@ server {
|
||||
listen 80;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
location / {
|
||||
location /subpath/public/ {
|
||||
proxy_pass http://filebrowser:8080/subpath/public/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# requires basic auth demo-127.0.0.1:SecurePass123!
|
||||
location /subpath {
|
||||
auth_basic "Restricted Access";
|
||||
auth_basic_user_file /etc/nginx/conf.d/.htpasswd;
|
||||
proxy_set_header X-Username $uuid;
|
||||
add_header X-Username $uuid;
|
||||
proxy_pass http://filebrowser:8080;
|
||||
proxy_pass http://filebrowser:8080/subpath;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
//globalSetup: "./global-setup",
|
||||
timeout: 3000,
|
||||
globalSetup: "./tests/playwright/proxy-setup.ts",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/proxy",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
@@ -25,8 +25,8 @@ export default defineConfig({
|
||||
reporter: "line",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
actionTimeout: 3000,
|
||||
//storageState: "loginAuth.json",
|
||||
actionTimeout: 5000,
|
||||
storageState: "loginAuth.json",
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1",
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/filebrowser --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA=testingCommit' -X 'github.com/gtsteffaniak/filebrowser/backend/version.Version=testing'\" ."
|
||||
bin = "tmp/filebrowser"
|
||||
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
exclude_dir = ["tmp", "vendor", "http/dist", "http/embed", "swagger/docs"]
|
||||
log = "air.log"
|
||||
delay = 1000 # ms
|
||||
|
||||
[log]
|
||||
time = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
@@ -0,0 +1,5 @@
|
||||
tmp/
|
||||
air.log
|
||||
filebrowser
|
||||
generated.yaml
|
||||
test_config.yaml
|
||||
@@ -28,18 +28,16 @@ import (
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error) {
|
||||
response := iteminfo.ExtendedFileInfo{}
|
||||
func FileInfoFaster(opts iteminfo.FileOptions) (*iteminfo.ExtendedFileInfo, error) {
|
||||
response := &iteminfo.ExtendedFileInfo{}
|
||||
index := indexing.GetIndex(opts.Source)
|
||||
if index == nil {
|
||||
return response, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
if opts.Access != nil && !opts.Access.Permitted(index.Path, opts.Path, opts.Username) {
|
||||
return response, errors.ErrPermissionDenied
|
||||
}
|
||||
|
||||
realPath, isDir, err := index.GetRealPath(opts.Path)
|
||||
if err != nil {
|
||||
return response, err
|
||||
return response, fmt.Errorf("could not get real path for requested path: %v", opts.Path)
|
||||
}
|
||||
opts.IsDir = isDir
|
||||
var info *iteminfo.FileInfo
|
||||
@@ -60,33 +58,71 @@ func FileInfoFaster(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error
|
||||
return response, fmt.Errorf("could not get metadata for path: %v", opts.Path)
|
||||
}
|
||||
}
|
||||
if opts.Content {
|
||||
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
|
||||
content, err := getContent(realPath)
|
||||
if err != nil {
|
||||
logger.Debugf("could not get content for file: "+info.Path, info.Name, err)
|
||||
return response, err
|
||||
}
|
||||
response.Content = content
|
||||
} else {
|
||||
logger.Debug("skipping large text file contents (20MB limit): "+info.Path, info.Name)
|
||||
}
|
||||
}
|
||||
response.FileInfo = *info
|
||||
response.RealPath = realPath
|
||||
response.Source = index.Name
|
||||
response.Source = opts.Source
|
||||
|
||||
if opts.Access != nil && !opts.Access.Permitted(index.Path, opts.Path, opts.Username) {
|
||||
// check if any subpath is permitted
|
||||
// keep track of permitted paths and only show them at the end
|
||||
subFolders := info.Folders
|
||||
subFiles := info.Files
|
||||
response.Folders = make([]iteminfo.ItemInfo, 0)
|
||||
response.Files = make([]iteminfo.ItemInfo, 0)
|
||||
hasPermittedPaths := false
|
||||
for _, subFolder := range subFolders {
|
||||
indexPath := info.Path + subFolder.Name
|
||||
if opts.Access.Permitted(index.Path, indexPath, opts.Username) {
|
||||
hasPermittedPaths = true
|
||||
response.Folders = append(response.Folders, subFolder)
|
||||
}
|
||||
}
|
||||
for _, subFile := range subFiles {
|
||||
indexPath := info.Path + subFile.Name
|
||||
if opts.Access.Permitted(index.Path, indexPath, opts.Username) {
|
||||
hasPermittedPaths = true
|
||||
response.Files = append(response.Files, subFile)
|
||||
}
|
||||
}
|
||||
if !hasPermittedPaths {
|
||||
return response, errors.ErrPermissionDenied
|
||||
}
|
||||
}
|
||||
if opts.Content {
|
||||
processContent(response, index)
|
||||
}
|
||||
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
|
||||
response.OnlyOfficeId = generateOfficeId(realPath)
|
||||
}
|
||||
if strings.HasPrefix(info.Type, "video") {
|
||||
parentInfo, exists := index.GetReducedMetadata(filepath.Dir(info.Path), true)
|
||||
if exists {
|
||||
response.DetectSubtitles(parentInfo)
|
||||
}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index) {
|
||||
isVideo := strings.HasPrefix(info.Type, "video")
|
||||
if isVideo {
|
||||
parentInfo, exists := idx.GetReducedMetadata(filepath.Dir(info.Path), true)
|
||||
if exists {
|
||||
info.DetectSubtitles(parentInfo)
|
||||
err := info.LoadSubtitleContent()
|
||||
if err != nil {
|
||||
logger.Debug("failed to load subtitle content: " + err.Error())
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
|
||||
content, err := getContent(info.RealPath)
|
||||
if err != nil {
|
||||
logger.Debugf("could not get content for file: "+info.RealPath, info.Name, err)
|
||||
return
|
||||
}
|
||||
info.Content = content
|
||||
} else {
|
||||
logger.Debug("skipping large text file contents (20MB limit): "+info.Path, info.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func generateOfficeId(realPath string) string {
|
||||
key, ok := utils.OnlyOfficeCache.Get(realPath).(string)
|
||||
if !ok {
|
||||
|
||||
+17
-4
@@ -2,10 +2,13 @@ package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"log"
|
||||
|
||||
"crypto/sha256"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
// StorageBackend is a storage backend for auth storage.
|
||||
@@ -35,9 +38,19 @@ func NewStorage(back StorageBackend, userStore *users.Storage) (*Storage, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encryptionKey, err = base64.StdEncoding.DecodeString(settings.Config.Auth.TotpSecret)
|
||||
if err != nil {
|
||||
logger.Warning("failed to decode TOTP secret, using default key. This is insecure and should be fixed.")
|
||||
if settings.Config.Auth.TotpSecret != "" {
|
||||
key, err := base64.StdEncoding.DecodeString(settings.Config.Auth.TotpSecret)
|
||||
keyLen := len(key)
|
||||
|
||||
if err == nil && (keyLen == 16 || keyLen == 24 || keyLen == 25) {
|
||||
// Use the user-provided key if it's valid
|
||||
encryptionKey = key
|
||||
} else {
|
||||
// Otherwise, derive the key from the provided secret string
|
||||
log.Println("[WARN] totpSecret is not a valid Base64 encoded key. Deriving a key from it. For better security, generate a secret with 'openssl rand -base64 32'.")
|
||||
salt := []byte{0xda, 0x90, 0x45, 0xc3, 0x06, 0xb5, 0x99, 0x9f, 0xb6, 0xae, 0xfc, 0x14, 0xef, 0x27, 0x6e, 0x6a}
|
||||
encryptionKey = pbkdf2.Key([]byte(settings.Config.Auth.TotpSecret), salt, 4096, 32, sha256.New)
|
||||
}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
+1
-1
@@ -332,7 +332,7 @@ func createConfig(configpath string) {
|
||||
}
|
||||
// cleanup database if it exists
|
||||
if _, err := os.Stat(config.Server.Database); err == nil {
|
||||
response := askYesNoQuestion(reader, "Database specified already exists. Move databse file to backup to start fresh?", "no")
|
||||
response := askYesNoQuestion(reader, "Database specified already exists. Move database file to backup to start fresh?", "no")
|
||||
if !response {
|
||||
return
|
||||
}
|
||||
|
||||
+7
-5
@@ -54,12 +54,14 @@ func StartFilebrowser() {
|
||||
if !keepGoing {
|
||||
return
|
||||
}
|
||||
info, _ := utils.CheckForUpdates()
|
||||
if info.LatestVersion != "" {
|
||||
logger.Infof("A new version is available: %s (current: %s)", info.LatestVersion, info.CurrentVersion)
|
||||
logger.Infof("Release notes: %s", info.ReleaseNotes)
|
||||
if !settings.Config.Server.DisableUpdateCheck {
|
||||
info, _ := utils.CheckForUpdates()
|
||||
if info.LatestVersion != "" {
|
||||
logger.Infof("A new version is available: %s (current: %s)", info.LatestVersion, info.CurrentVersion)
|
||||
logger.Infof("Release notes: %s", info.ReleaseNotes)
|
||||
}
|
||||
go utils.StartCheckForUpdates()
|
||||
}
|
||||
go utils.StartCheckForUpdates()
|
||||
|
||||
// Create context and channels for graceful shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
@@ -12,12 +12,12 @@ import (
|
||||
)
|
||||
|
||||
type Auth struct {
|
||||
TokenExpirationHours int `json:"tokenExpirationHours"` // the number of hours until the token expires. Default is 2 hours.
|
||||
TokenExpirationHours int `json:"tokenExpirationHours"` // time in hours each web UI session token is valid for. Default is 2 hours.
|
||||
Methods LoginMethods `json:"methods"`
|
||||
Key string `json:"key"` // the key used to sign the JWT tokens. If not set, a random key will be generated.
|
||||
AdminUsername string `json:"adminUsername"` // the username of the admin user. If not set, the default is "admin".
|
||||
AdminPassword string `json:"adminPassword"` // the password of the admin user. If not set, the default is "admin".
|
||||
TotpSecret string `json:"totpSecret"` // secret used to encrypt TOTP secrets
|
||||
Key string `json:"key"` // secret: the key used to sign the JWT tokens. If not set, a random key will be generated.
|
||||
AdminUsername string `json:"adminUsername"` // secret: the username of the admin user. If not set, the default is "admin".
|
||||
AdminPassword string `json:"adminPassword"` // secret: the password of the admin user. If not set, the default is "admin".
|
||||
TotpSecret string `json:"totpSecret"` // secret: secret used to encrypt TOTP secrets
|
||||
AuthMethods []string `json:"-"`
|
||||
}
|
||||
|
||||
@@ -52,8 +52,8 @@ type Recaptcha struct {
|
||||
// OpenID OAuth2.0
|
||||
type OidcConfig struct {
|
||||
Enabled bool `json:"enabled"` // whether to enable OIDC authentication
|
||||
ClientID string `json:"clientId"` // client id of the OIDC application
|
||||
ClientSecret string `json:"clientSecret"` // client secret of the OIDC application
|
||||
ClientID string `json:"clientId"` // secret: client id of the OIDC application
|
||||
ClientSecret string `json:"clientSecret"` // secret: client secret of the OIDC application
|
||||
IssuerUrl string `json:"issuerUrl"` // authorization URL of the OIDC provider
|
||||
Scopes string `json:"scopes"` // scopes to request from the OIDC provider
|
||||
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username", can also be "email" or "username", or "phone"
|
||||
|
||||
@@ -44,10 +44,13 @@ func Initialize(configFile string) {
|
||||
setupAuth(false)
|
||||
setupSources(false)
|
||||
setupUrls()
|
||||
setupFrontend()
|
||||
setupFrontend(false)
|
||||
}
|
||||
|
||||
func setupFrontend() {
|
||||
func setupFrontend(generate bool) {
|
||||
if Config.Server.MinSearchLength == 0 {
|
||||
Config.Server.MinSearchLength = 3
|
||||
}
|
||||
if !Config.Frontend.DisableDefaultLinks {
|
||||
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
|
||||
Text: fmt.Sprintf("(%v)", version.Version),
|
||||
@@ -56,12 +59,15 @@ func setupFrontend() {
|
||||
})
|
||||
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
|
||||
Text: "Help",
|
||||
Url: "https://github.com/gtsteffaniak/filebrowser/wiki",
|
||||
Url: "help prompt",
|
||||
})
|
||||
}
|
||||
if Config.Frontend.Description == "" {
|
||||
Config.Frontend.Description = "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server"
|
||||
}
|
||||
Config.Frontend.Styling.LightBackground = FallbackColor(Config.Frontend.Styling.LightBackground, "#f5f5f5")
|
||||
Config.Frontend.Styling.DarkBackground = FallbackColor(Config.Frontend.Styling.DarkBackground, "#141D24")
|
||||
Config.Frontend.Styling.CustomCSS = readCustomCSS(Config.Frontend.Styling.CustomCSS)
|
||||
Config.Frontend.Styling.CustomCSSRaw = readCustomCSS(Config.Frontend.Styling.CustomCSS)
|
||||
Config.Frontend.Styling.CustomThemeOptions = map[string]CustomTheme{}
|
||||
Config.Frontend.Styling.CustomThemes = map[string]CustomTheme{}
|
||||
for name, theme := range Config.Frontend.Styling.CustomThemes {
|
||||
@@ -73,12 +79,21 @@ func setupFrontend() {
|
||||
// check if file exists
|
||||
if _, err := os.Stat("reduce-rounded-corners.css"); err == nil {
|
||||
addCustomTheme("alternative", "Reduce rounded corners", "reduce-rounded-corners.css")
|
||||
if generate {
|
||||
Config.Frontend.Styling.CustomThemes["alternative"] = CustomTheme{
|
||||
Description: "Reduce rounded corners",
|
||||
CSS: "reduce-rounded-corners.css",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_, ok := Config.Frontend.Styling.CustomThemes["default"]
|
||||
if !ok {
|
||||
addCustomTheme("default", "The default theme", "")
|
||||
}
|
||||
|
||||
// Load custom favicon if configured
|
||||
loadCustomFavicon()
|
||||
}
|
||||
|
||||
func getRealPath(path string) string {
|
||||
@@ -219,8 +234,14 @@ func setupLogging() {
|
||||
}
|
||||
}
|
||||
for _, logConfig := range Config.Server.Logging {
|
||||
// Enable debug logging automatically in dev mode
|
||||
levels := logConfig.Levels
|
||||
if os.Getenv("FILEBROWSER_DEVMODE") == "true" {
|
||||
levels = "info|warning|error|debug"
|
||||
}
|
||||
|
||||
logConfig := logger.JsonConfig{
|
||||
Levels: logConfig.Levels,
|
||||
Levels: levels,
|
||||
ApiLevels: logConfig.ApiLevels,
|
||||
Output: logConfig.Output,
|
||||
Utc: logConfig.Utc,
|
||||
@@ -499,6 +520,54 @@ func GetSources(u *users.User) []string {
|
||||
return sources
|
||||
}
|
||||
|
||||
func loadCustomFavicon() {
|
||||
// Check if a custom favicon path is configured
|
||||
if Config.Frontend.Favicon == "" {
|
||||
logger.Debug("No custom favicon configured, using default")
|
||||
return
|
||||
}
|
||||
|
||||
// Get absolute path for the favicon
|
||||
faviconPath, err := filepath.Abs(Config.Frontend.Favicon)
|
||||
if err != nil {
|
||||
logger.Warningf("Could not resolve favicon path '%v': %v", Config.Frontend.Favicon, err)
|
||||
Config.Frontend.Favicon = "" // Unset invalid path
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the favicon file exists and get info
|
||||
stat, err := os.Stat(faviconPath)
|
||||
if err != nil {
|
||||
logger.Warningf("Could not access custom favicon file '%v': %v", faviconPath, err)
|
||||
Config.Frontend.Favicon = "" // Unset invalid path
|
||||
return
|
||||
}
|
||||
|
||||
// Check file size (limit to 1MB for security)
|
||||
const maxFaviconSize = 1024 * 1024 // 1MB
|
||||
if stat.Size() > maxFaviconSize {
|
||||
logger.Warningf("Favicon file '%v' is too large (%d bytes), maximum allowed is %d bytes", faviconPath, stat.Size(), maxFaviconSize)
|
||||
Config.Frontend.Favicon = "" // Unset invalid path
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file format based on extension
|
||||
ext := strings.ToLower(filepath.Ext(faviconPath))
|
||||
switch ext {
|
||||
case ".ico", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp":
|
||||
// Valid favicon formats
|
||||
default:
|
||||
logger.Warningf("Unsupported favicon format '%v', supported formats: .ico, .png, .jpg, .gif, .svg, .webp", ext)
|
||||
Config.Frontend.Favicon = "" // Unset invalid path
|
||||
return
|
||||
}
|
||||
|
||||
// Update to absolute path and mark as valid
|
||||
Config.Frontend.Favicon = faviconPath
|
||||
|
||||
logger.Infof("Successfully validated custom favicon at '%v' (%d bytes, %s)", faviconPath, stat.Size(), ext)
|
||||
}
|
||||
|
||||
func modifyExcludeInclude(config *Source) {
|
||||
normalize := func(s []string, checkExists bool) {
|
||||
for i, v := range s {
|
||||
|
||||
@@ -14,19 +14,44 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// commentsMap[typeName][fieldName] = combined doc+inline comment text
|
||||
type commentsMap map[string]map[string]string
|
||||
// CommentsMap[typeName][fieldName] = combined doc+inline comment text
|
||||
type CommentsMap map[string]map[string]string
|
||||
|
||||
// collectComments parses all Go source in the directory of srcPath and returns commentsMap.
|
||||
func collectComments(srcPath string) (commentsMap, error) {
|
||||
// SecretFieldsMap[typeName][fieldName] = true if field should be redacted
|
||||
type SecretFieldsMap map[string]map[string]bool
|
||||
|
||||
// DeprecatedFieldsMap[typeName][fieldName] = true if field is deprecated
|
||||
type DeprecatedFieldsMap map[string]map[string]bool
|
||||
|
||||
// getStringStyle determines whether a string should be quoted in YAML
|
||||
func getStringStyle(value string) yaml.Style {
|
||||
// Always quote all strings for consistency
|
||||
return yaml.DoubleQuotedStyle
|
||||
}
|
||||
|
||||
// CollectComments parses all Go source in the directory of srcPath and returns CommentsMap.
|
||||
func CollectComments(srcPath string) (CommentsMap, error) {
|
||||
comments, _, _, err := CollectCommentsAndSecrets(srcPath)
|
||||
return comments, err
|
||||
}
|
||||
|
||||
// CollectCommentsAndSecrets parses all Go source and returns comments, secrets, and deprecated field mappings
|
||||
func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
|
||||
// parse entire package so we capture comments on all types
|
||||
dir := filepath.Dir(srcPath)
|
||||
dir := srcPath
|
||||
if filepath.IsAbs(srcPath) {
|
||||
// If it's an absolute path to a file, get the directory
|
||||
dir = filepath.Dir(srcPath)
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
out := make(commentsMap)
|
||||
comments := make(CommentsMap)
|
||||
secrets := make(SecretFieldsMap)
|
||||
deprecated := make(DeprecatedFieldsMap)
|
||||
for _, pkg := range pkgs {
|
||||
for _, file := range pkg.Files {
|
||||
for _, decl := range file.Decls {
|
||||
@@ -43,48 +68,81 @@ func collectComments(srcPath string) (commentsMap, error) {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m := make(map[string]string)
|
||||
out[ts.Name.Name] = m
|
||||
typeName := ts.Name.Name
|
||||
commentMap := make(map[string]string)
|
||||
secretMap := make(map[string]bool)
|
||||
deprecatedMap := make(map[string]bool)
|
||||
comments[typeName] = commentMap
|
||||
secrets[typeName] = secretMap
|
||||
deprecated[typeName] = deprecatedMap
|
||||
|
||||
for _, field := range st.Fields.List {
|
||||
if len(field.Names) == 0 {
|
||||
continue
|
||||
}
|
||||
name := field.Names[0].Name
|
||||
var parts []string
|
||||
var fullComment string
|
||||
|
||||
if field.Doc != nil {
|
||||
parts = append(parts, strings.TrimSpace(field.Doc.Text()))
|
||||
docText := strings.TrimSpace(field.Doc.Text())
|
||||
parts = append(parts, docText)
|
||||
fullComment += docText + " "
|
||||
}
|
||||
if field.Comment != nil {
|
||||
parts = append(parts, strings.TrimSpace(field.Comment.Text()))
|
||||
commentText := strings.TrimSpace(field.Comment.Text())
|
||||
parts = append(parts, commentText)
|
||||
fullComment += commentText
|
||||
}
|
||||
|
||||
if len(parts) > 0 {
|
||||
m[name] = strings.Join(parts, " : ")
|
||||
commentMap[name] = strings.Join(parts, " : ")
|
||||
}
|
||||
|
||||
// Check if field should be treated as secret
|
||||
if strings.Contains(strings.ToLower(fullComment), "secret:") {
|
||||
secretMap[name] = true
|
||||
}
|
||||
|
||||
// Check if field should be treated as deprecated
|
||||
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
|
||||
deprecatedMap[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return comments, secrets, deprecated, nil
|
||||
}
|
||||
|
||||
// buildNode constructs a yaml.Node for any Go value, injecting comments on struct fields.
|
||||
func buildNode(v reflect.Value, comm commentsMap) (*yaml.Node, error) {
|
||||
// BuildNode constructs a yaml.Node for any Go value, injecting comments on struct fields.
|
||||
func BuildNode(v reflect.Value, comm CommentsMap) (*yaml.Node, error) {
|
||||
return buildNodeWithDefaults(v, comm, reflect.Value{}, SecretFieldsMap{}, DeprecatedFieldsMap{})
|
||||
}
|
||||
|
||||
// buildNodeWithDefaults constructs a yaml.Node for any Go value, skipping fields that match defaults, redacting secrets, and filtering deprecated fields
|
||||
func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.Value, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
|
||||
// Dereference pointers
|
||||
if v.Kind() == reflect.Ptr {
|
||||
if v.IsNil() {
|
||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
|
||||
}
|
||||
return buildNode(v.Elem(), comm)
|
||||
var defaultsElem reflect.Value
|
||||
if defaults.IsValid() && !defaults.IsNil() {
|
||||
defaultsElem = defaults.Elem()
|
||||
}
|
||||
return buildNodeWithDefaults(v.Elem(), comm, defaultsElem, secrets, deprecated)
|
||||
}
|
||||
|
||||
switch v.Kind() {
|
||||
case reflect.String:
|
||||
value := v.String()
|
||||
return &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Tag: "!!str",
|
||||
Value: v.String(),
|
||||
Style: yaml.DoubleQuotedStyle,
|
||||
Value: value,
|
||||
Style: getStringStyle(value),
|
||||
}, nil
|
||||
case reflect.Struct:
|
||||
rt := v.Type()
|
||||
@@ -104,6 +162,26 @@ func buildNode(v reflect.Value, comm commentsMap) (*yaml.Node, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip deprecated fields if filtering is enabled
|
||||
if len(deprecated) > 0 && deprecated[typeName][sf.Name] {
|
||||
continue
|
||||
}
|
||||
|
||||
currentField := v.Field(i)
|
||||
|
||||
// If we have defaults, compare and skip if values match
|
||||
if defaults.IsValid() && i < defaults.NumField() {
|
||||
defaultField := defaults.Field(i)
|
||||
currentValue := currentField.Interface()
|
||||
defaultValue := defaultField.Interface()
|
||||
|
||||
isEqual := reflect.DeepEqual(currentValue, defaultValue)
|
||||
|
||||
if isEqual {
|
||||
continue // Skip this field as it matches the default
|
||||
}
|
||||
}
|
||||
|
||||
// determine key: yaml tag > json tag > field name
|
||||
yamlTag := sf.Tag.Get("yaml")
|
||||
jsonTag := sf.Tag.Get("json")
|
||||
@@ -118,22 +196,43 @@ func buildNode(v reflect.Value, comm commentsMap) (*yaml.Node, error) {
|
||||
|
||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: keyName}
|
||||
|
||||
// attach validate and comments inline
|
||||
// attach validate and comments inline (only when comments are enabled)
|
||||
var parts []string
|
||||
if cm := comm[typeName][sf.Name]; cm != "" {
|
||||
parts = append(parts, cm)
|
||||
}
|
||||
if vt := sf.Tag.Get("validate"); vt != "" {
|
||||
parts = append(parts, fmt.Sprintf(" validate:%s", vt))
|
||||
// Only add validation tags if comments map is not empty (meaning comments are enabled)
|
||||
if len(comm) > 0 {
|
||||
if vt := sf.Tag.Get("validate"); vt != "" {
|
||||
parts = append(parts, fmt.Sprintf(" validate:%s", vt))
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) > 0 {
|
||||
keyNode.LineComment = strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
valNode, err := buildNode(v.Field(i), comm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Check if this field should be redacted as a secret
|
||||
var valNode *yaml.Node
|
||||
var err error
|
||||
if secrets[typeName][sf.Name] {
|
||||
valNode = &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Tag: "!!str",
|
||||
Value: "**hidden**",
|
||||
Style: yaml.DoubleQuotedStyle, // Keep secrets quoted for clarity
|
||||
}
|
||||
} else {
|
||||
// Pass through the corresponding default field for recursive comparison
|
||||
var defaultField reflect.Value
|
||||
if defaults.IsValid() && i < defaults.NumField() {
|
||||
defaultField = defaults.Field(i)
|
||||
}
|
||||
|
||||
valNode, err = buildNodeWithDefaults(currentField, comm, defaultField, secrets, deprecated)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
mapNode.Content = append(mapNode.Content, keyNode, valNode)
|
||||
}
|
||||
@@ -148,7 +247,11 @@ func buildNode(v reflect.Value, comm commentsMap) (*yaml.Node, error) {
|
||||
// placeholder for empty slice of structs
|
||||
if v.Len() == 0 && v.Type().Elem().Kind() == reflect.Struct {
|
||||
zero := reflect.Zero(v.Type().Elem())
|
||||
n, err := buildNode(zero, comm)
|
||||
var defaultElem reflect.Value
|
||||
if defaults.IsValid() && defaults.Len() > 0 {
|
||||
defaultElem = defaults.Index(0)
|
||||
}
|
||||
n, err := buildNodeWithDefaults(zero, comm, defaultElem, secrets, deprecated)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -156,7 +259,11 @@ func buildNode(v reflect.Value, comm commentsMap) (*yaml.Node, error) {
|
||||
return seq, nil
|
||||
}
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
n, err := buildNode(v.Index(i), comm)
|
||||
var defaultElem reflect.Value
|
||||
if defaults.IsValid() && i < defaults.Len() {
|
||||
defaultElem = defaults.Index(i)
|
||||
}
|
||||
n, err := buildNodeWithDefaults(v.Index(i), comm, defaultElem, secrets, deprecated)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -164,6 +271,33 @@ func buildNode(v reflect.Value, comm commentsMap) (*yaml.Node, error) {
|
||||
}
|
||||
return seq, nil
|
||||
|
||||
case reflect.Map:
|
||||
mapNode := &yaml.Node{Kind: yaml.MappingNode}
|
||||
|
||||
for _, key := range v.MapKeys() {
|
||||
// Handle key
|
||||
keyStr := fmt.Sprintf("%v", key.Interface())
|
||||
keyNode := &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: keyStr,
|
||||
}
|
||||
|
||||
// Handle value recursively
|
||||
mapVal := v.MapIndex(key)
|
||||
var defaultVal reflect.Value
|
||||
if defaults.IsValid() {
|
||||
defaultVal = defaults.MapIndex(key)
|
||||
}
|
||||
|
||||
valNode, err := buildNodeWithDefaults(mapVal, comm, defaultVal, secrets, deprecated)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mapNode.Content = append(mapNode.Content, keyNode, valNode)
|
||||
}
|
||||
return mapNode, nil
|
||||
|
||||
default:
|
||||
n := &yaml.Node{}
|
||||
if err := n.Encode(v.Interface()); err != nil {
|
||||
@@ -185,22 +319,74 @@ func GenerateYaml() {
|
||||
setupAuth(true)
|
||||
setupSources(true)
|
||||
setupUrls()
|
||||
setupFrontend()
|
||||
input := "common/settings/settings.go" // "path to Go source file or directory containing structs"
|
||||
output := "generated.yaml" // "output YAML file"
|
||||
setupFrontend(true)
|
||||
output := "generated.yaml" // "output YAML file"
|
||||
|
||||
comm, err := collectComments(input)
|
||||
// Generate YAML with comments enabled, full config, and deprecated fields filtered
|
||||
// Force the source path to be correct for static generation
|
||||
yamlContent, err := GenerateConfigYamlWithSource(&Config, true, true, true, "common/settings")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error parsing comments: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "error generating YAML: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
node, err := buildNode(reflect.ValueOf(Config), comm)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error building YAML node: %v\n", err)
|
||||
if err := os.WriteFile(output, []byte(yamlContent), 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error writing YAML: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Generated YAML with comments (deprecated fields filtered): %s\n", output)
|
||||
}
|
||||
|
||||
// GenerateConfigYaml generates YAML from a given config with options for comments and filtering
|
||||
func GenerateConfigYaml(config *Settings, showComments bool, showFull bool, filterDeprecated bool) (string, error) {
|
||||
// Try different source paths to handle both runtime and test scenarios
|
||||
sourcePaths := []string{
|
||||
"common/settings", // When running from backend directory
|
||||
".", // When running tests from settings directory
|
||||
"../settings", // Alternative test path
|
||||
}
|
||||
|
||||
for _, sourcePath := range sourcePaths {
|
||||
yamlOutput, err := GenerateConfigYamlWithSource(config, showComments, showFull, filterDeprecated, sourcePath)
|
||||
if err == nil {
|
||||
return yamlOutput, nil
|
||||
}
|
||||
// If it's not a file not found error, return immediately
|
||||
if !strings.Contains(err.Error(), "no such file or directory") && !strings.Contains(err.Error(), "cannot find") {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// If all paths failed, try with empty maps to at least generate basic YAML
|
||||
return GenerateConfigYamlWithEmptyMaps(config, showFull)
|
||||
}
|
||||
|
||||
// GenerateConfigYamlWithEmptyMaps generates YAML without comment parsing when source files are unavailable
|
||||
func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool) (string, error) {
|
||||
// Create empty maps
|
||||
comm := make(CommentsMap)
|
||||
secrets := make(SecretFieldsMap)
|
||||
deprecated := make(DeprecatedFieldsMap)
|
||||
|
||||
var node *yaml.Node
|
||||
var err error
|
||||
|
||||
if showFull {
|
||||
// Show the full current config
|
||||
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.Value{}, secrets, deprecated)
|
||||
} else {
|
||||
// Show only non-default values by comparing with defaults during node building
|
||||
defaultConfig := setDefaults(true)
|
||||
defaultConfig.Server.Sources = []Source{{Path: "."}}
|
||||
|
||||
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.ValueOf(&defaultConfig), secrets, deprecated)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Convert to YAML
|
||||
doc := &yaml.Node{Kind: yaml.DocumentNode}
|
||||
doc.Content = []*yaml.Node{node}
|
||||
|
||||
@@ -208,17 +394,78 @@ func GenerateYaml() {
|
||||
enc := yaml.NewEncoder(&rawBuf)
|
||||
enc.SetIndent(2)
|
||||
if err := enc.Encode(doc); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error encoding YAML: %v\n", err)
|
||||
os.Exit(1)
|
||||
return "", err
|
||||
}
|
||||
|
||||
aligned := alignComments(rawBuf.String())
|
||||
return AlignComments(rawBuf.String()), nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(output, []byte(aligned), 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error writing YAML: %v\n", err)
|
||||
os.Exit(1)
|
||||
// GenerateConfigYamlWithSource generates YAML from a given config with options for comments and filtering, using a custom source path
|
||||
func GenerateConfigYamlWithSource(config *Settings, showComments bool, showFull bool, filterDeprecated bool, sourcePath string) (string, error) {
|
||||
var comm CommentsMap
|
||||
var secrets SecretFieldsMap
|
||||
var deprecated DeprecatedFieldsMap
|
||||
var err error
|
||||
|
||||
if showComments {
|
||||
// Collect comments, secrets, and deprecated fields from the settings source files
|
||||
comm, secrets, deprecated, err = CollectCommentsAndSecrets(sourcePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
// Still need to collect secrets and deprecated fields even if not showing comments
|
||||
_, secrets, deprecated, err = CollectCommentsAndSecrets(sourcePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Create empty comments map
|
||||
comm = make(CommentsMap)
|
||||
}
|
||||
fmt.Printf("Generated YAML with comments: %s\n", output)
|
||||
|
||||
// If not filtering deprecated fields, clear the deprecated map
|
||||
if !filterDeprecated {
|
||||
deprecated = make(DeprecatedFieldsMap)
|
||||
}
|
||||
|
||||
var node *yaml.Node
|
||||
|
||||
if showFull {
|
||||
// Show the full current config
|
||||
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.Value{}, secrets, deprecated)
|
||||
} else {
|
||||
// Show only non-default values by comparing with defaults during node building
|
||||
// Create a clean default config (no file loading, just pure defaults)
|
||||
defaultConfig := setDefaults(true)
|
||||
// Apply same setup as a fresh instance would have
|
||||
defaultConfig.Server.Sources = []Source{{Path: "."}}
|
||||
|
||||
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.ValueOf(&defaultConfig), secrets, deprecated)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create document
|
||||
doc := &yaml.Node{Kind: yaml.DocumentNode}
|
||||
doc.Content = []*yaml.Node{node}
|
||||
|
||||
// Encode to YAML
|
||||
var rawBuf bytes.Buffer
|
||||
enc := yaml.NewEncoder(&rawBuf)
|
||||
enc.SetIndent(2)
|
||||
if err := enc.Encode(doc); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Apply comment alignment if comments are enabled
|
||||
yamlOutput := rawBuf.String()
|
||||
if showComments {
|
||||
yamlOutput = AlignComments(yamlOutput)
|
||||
}
|
||||
|
||||
return yamlOutput, nil
|
||||
}
|
||||
|
||||
// formatLine applies padding to a single line so its comment starts at a target column.
|
||||
@@ -257,8 +504,8 @@ func formatLine(line string) string {
|
||||
return line
|
||||
}
|
||||
|
||||
// alignComments formats each line of the YAML string independently.
|
||||
func alignComments(input string) string {
|
||||
// AlignComments formats each line of the YAML string independently.
|
||||
func AlignComments(input string) string {
|
||||
lines := strings.Split(input, "\n")
|
||||
outputLines := make([]string, len(lines))
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateConfigYaml_Basic(t *testing.T) {
|
||||
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
// Test using the actual source directory structure
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "es", // Non-default value
|
||||
DarkMode: true, // Default value
|
||||
DisableOfficePreviewExt: ".docx .xlsx", // This field is deprecated
|
||||
},
|
||||
Auth: Auth{
|
||||
Key: "secret123", // This is a secret field
|
||||
AdminUsername: "testadmin", // This is a secret field
|
||||
AdminPassword: "testpassword", // This is a secret field
|
||||
},
|
||||
}
|
||||
|
||||
// Test different combinations
|
||||
testCases := []struct {
|
||||
name string
|
||||
showComments bool
|
||||
showFull bool
|
||||
filterDeprecated bool
|
||||
expectSecrets bool
|
||||
expectDeprecated bool
|
||||
}{
|
||||
{
|
||||
name: "API_mode_all_fields",
|
||||
showComments: false,
|
||||
showFull: true,
|
||||
filterDeprecated: false,
|
||||
expectSecrets: true, // Secrets should be hidden
|
||||
expectDeprecated: true, // Deprecated should be shown
|
||||
},
|
||||
{
|
||||
name: "Static_config_filter_deprecated",
|
||||
showComments: true,
|
||||
showFull: true,
|
||||
filterDeprecated: true,
|
||||
expectSecrets: true, // Secrets should be hidden
|
||||
expectDeprecated: false, // Deprecated should be filtered
|
||||
},
|
||||
{
|
||||
name: "Filtered_non_defaults",
|
||||
showComments: false,
|
||||
showFull: false,
|
||||
filterDeprecated: false,
|
||||
expectSecrets: true, // Secrets should be hidden
|
||||
expectDeprecated: false, // Might not appear if matches default
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Use the current directory structure for real testing
|
||||
yamlOutput, err := GenerateConfigYaml(config, tc.showComments, tc.showFull, tc.filterDeprecated)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml failed: %v", err)
|
||||
}
|
||||
|
||||
// Basic validation - should produce some output
|
||||
if len(yamlOutput) == 0 {
|
||||
t.Fatal("Generated empty YAML")
|
||||
}
|
||||
|
||||
// Test string quoting - string values should be quoted, numbers and booleans should not
|
||||
lines := strings.Split(yamlOutput, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, ":") && !strings.HasSuffix(strings.TrimSpace(line), ":") {
|
||||
// This is a key-value line, skip comment portion
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
value := strings.TrimSpace(parts[1])
|
||||
// Remove comments
|
||||
if commentIdx := strings.Index(value, "#"); commentIdx >= 0 {
|
||||
value = strings.TrimSpace(value[:commentIdx])
|
||||
}
|
||||
|
||||
// Skip empty values, arrays, and objects
|
||||
if value == "" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a number (should NOT be quoted)
|
||||
if reNumber.MatchString(value) {
|
||||
if strings.HasPrefix(value, "\"") {
|
||||
t.Errorf("Numeric value should not be quoted in line: %s (value: '%s')", line, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a boolean (should NOT be quoted)
|
||||
if value == "true" || value == "false" {
|
||||
if strings.HasPrefix(value, "\"") {
|
||||
t.Errorf("Boolean value should not be quoted in line: %s (value: '%s')", line, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else should be a quoted string
|
||||
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
|
||||
// Add debug info to see what's wrong
|
||||
t.Logf("DEBUG: Checking value '%s' from line '%s'", value, strings.TrimSpace(line))
|
||||
t.Errorf("String value should be quoted in line: %s (value: %s)", strings.TrimSpace(line), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test secret hiding
|
||||
if tc.expectSecrets {
|
||||
if !strings.Contains(yamlOutput, "**hidden**") {
|
||||
t.Error("Expected secrets to be hidden with **hidden**")
|
||||
}
|
||||
if strings.Contains(yamlOutput, "secret123") || strings.Contains(yamlOutput, "testadmin") || strings.Contains(yamlOutput, "testpassword") {
|
||||
t.Error("Secret values should not appear in output")
|
||||
}
|
||||
}
|
||||
|
||||
// Test deprecated field filtering
|
||||
hasDeprecated := strings.Contains(yamlOutput, "disableOfficePreviewExt")
|
||||
if tc.expectDeprecated && !hasDeprecated {
|
||||
t.Error("Expected deprecated field to be present")
|
||||
}
|
||||
if !tc.expectDeprecated && hasDeprecated && tc.filterDeprecated {
|
||||
t.Error("Expected deprecated field to be filtered out when filterDeprecated=true")
|
||||
}
|
||||
|
||||
// Test comments
|
||||
hasComments := strings.Contains(yamlOutput, "#")
|
||||
if tc.showComments && !hasComments {
|
||||
t.Error("Expected comments to be present when showComments=true")
|
||||
}
|
||||
|
||||
t.Logf("Test case: %s\nGenerated YAML:\n%s\n", tc.name, yamlOutput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectCommentsAndSecrets_Basic(t *testing.T) {
|
||||
// Test the comment and secret collection directly
|
||||
comments, secrets, deprecated, err := CollectCommentsAndSecrets(".")
|
||||
if err != nil {
|
||||
t.Fatalf("CollectCommentsAndSecrets failed: %v", err)
|
||||
}
|
||||
|
||||
// Should find some secrets in the actual source files
|
||||
foundSecrets := false
|
||||
for typeName, typeSecrets := range secrets {
|
||||
for fieldName := range typeSecrets {
|
||||
t.Logf("Found secret: %s.%s", typeName, fieldName)
|
||||
foundSecrets = true
|
||||
}
|
||||
}
|
||||
if !foundSecrets {
|
||||
t.Error("Expected to find some secret fields in the actual source code")
|
||||
}
|
||||
|
||||
// Should find some deprecated fields
|
||||
foundDeprecated := false
|
||||
for typeName, typeDeprecated := range deprecated {
|
||||
for fieldName := range typeDeprecated {
|
||||
t.Logf("Found deprecated: %s.%s", typeName, fieldName)
|
||||
foundDeprecated = true
|
||||
}
|
||||
}
|
||||
if !foundDeprecated {
|
||||
t.Error("Expected to find some deprecated fields in the actual source code")
|
||||
}
|
||||
|
||||
t.Logf("Comments: %d types", len(comments))
|
||||
t.Logf("Secrets: %d types", len(secrets))
|
||||
t.Logf("Deprecated: %d types", len(deprecated))
|
||||
}
|
||||
|
||||
func TestGenerateYaml_StaticGeneration(t *testing.T) {
|
||||
// Test the static generation function that's used by FILEBROWSER_GENERATE_CONFIG=true
|
||||
|
||||
// Create a temporary config for testing
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "en",
|
||||
DisableOfficePreviewExt: ".docx .xlsx", // This should be filtered out
|
||||
},
|
||||
Auth: Auth{
|
||||
Key: "test-secret", // This should be redacted
|
||||
},
|
||||
}
|
||||
|
||||
// Test static generation with comments and deprecated filtering
|
||||
yamlOutput, err := GenerateConfigYamlWithSource(config, true, true, true, ".")
|
||||
if err != nil {
|
||||
t.Fatalf("Static generation failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify comments are included
|
||||
if !strings.Contains(yamlOutput, "#") {
|
||||
t.Error("Static generation should include comments")
|
||||
}
|
||||
|
||||
// Verify deprecated field is filtered out
|
||||
if strings.Contains(yamlOutput, "disableOfficePreviewExt") {
|
||||
t.Error("Static generation should filter out deprecated field disableOfficePreviewExt")
|
||||
}
|
||||
|
||||
// Verify secrets are redacted
|
||||
if !strings.Contains(yamlOutput, "**hidden**") {
|
||||
t.Error("Static generation should redact secrets")
|
||||
}
|
||||
if strings.Contains(yamlOutput, "test-secret") {
|
||||
t.Error("Static generation should not contain actual secret values")
|
||||
}
|
||||
|
||||
// Verify it has proper structure
|
||||
if !strings.Contains(yamlOutput, "userDefaults:") {
|
||||
t.Error("Static generation should contain userDefaults section")
|
||||
}
|
||||
if !strings.Contains(yamlOutput, "auth:") {
|
||||
t.Error("Static generation should contain auth section")
|
||||
}
|
||||
|
||||
t.Logf("Static generation successful with comments and proper filtering")
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Test structs for comprehensive testing
|
||||
type TestConfig struct {
|
||||
PublicField string `json:"publicField"` // A regular public field
|
||||
SecretField string `json:"secretField"` // secret: this should be hidden
|
||||
DeprecatedField string `json:"deprecatedField"` // deprecated: this field is old
|
||||
EmptyField string `json:"emptyField"` // An empty string field
|
||||
DefaultField string `json:"defaultField"` // A field that matches default
|
||||
NonDefaultField string `json:"nonDefaultField"` // A field that differs from default
|
||||
NestedStruct TestNested `json:"nestedStruct"`
|
||||
}
|
||||
|
||||
type TestNested struct {
|
||||
NestedPublic string `json:"nestedPublic"` // A nested public field
|
||||
NestedSecret string `json:"nestedSecret"` // secret: nested secret field
|
||||
NestedDeprecated string `json:"nestedDeprecated"` // deprecated: nested deprecated field
|
||||
}
|
||||
|
||||
// Helper to create test source files for comment parsing
|
||||
func createTestSourceFile(t *testing.T, content string) (string, func()) {
|
||||
tmpFile, err := os.CreateTemp("", "test_*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := tmpFile.WriteString(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
cleanup := func() {
|
||||
os.Remove(tmpFile.Name())
|
||||
}
|
||||
|
||||
return tmpFile.Name(), cleanup
|
||||
}
|
||||
|
||||
func TestGenerateConfigYaml_StringQuoting(t *testing.T) {
|
||||
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
// Create a simple test source file for this test
|
||||
sourceContent := `package settings
|
||||
|
||||
type Settings struct {
|
||||
UserDefaults UserDefaults ` + "`json:\"userDefaults\"`" + `
|
||||
Auth Auth ` + "`json:\"auth\"`" + `
|
||||
}
|
||||
|
||||
type UserDefaults struct {
|
||||
Locale string ` + "`json:\"locale\"`" + `
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
AdminUsername string ` + "`json:\"adminUsername\"`" + `
|
||||
}
|
||||
`
|
||||
tmpFile, cleanup := createTestSourceFile(t, sourceContent)
|
||||
defer cleanup()
|
||||
sourcePath := tmpFile[:strings.LastIndex(tmpFile, "/")]
|
||||
|
||||
// Create real Settings with string values to test quoting
|
||||
settings := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "en-US",
|
||||
},
|
||||
Auth: Auth{
|
||||
AdminUsername: "admin",
|
||||
},
|
||||
}
|
||||
|
||||
// Test with no filtering - should quote all strings
|
||||
yamlOutput, err := GenerateConfigYamlWithSource(settings, false, true, false, sourcePath)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml failed: %v", err)
|
||||
}
|
||||
|
||||
// All string values should be quoted
|
||||
lines := strings.Split(yamlOutput, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, ":") && !strings.HasSuffix(strings.TrimSpace(line), ":") {
|
||||
// This is a key-value line
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
// Remove comments
|
||||
if commentIdx := strings.Index(value, "#"); commentIdx >= 0 {
|
||||
value = strings.TrimSpace(value[:commentIdx])
|
||||
}
|
||||
|
||||
// Skip empty values, arrays, objects, booleans
|
||||
if value == "" || value == "false" || value == "true" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a number (should NOT be quoted)
|
||||
if reNumber.MatchString(value) {
|
||||
if strings.HasPrefix(value, "\"") {
|
||||
t.Errorf("Numeric value should not be quoted: %s (value: '%s')", line, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else should be a quoted string
|
||||
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
|
||||
t.Errorf("String value should be quoted: %s (value: '%s')", line, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigYaml_SecretHiding(t *testing.T) {
|
||||
// Create temporary source file with our test structs
|
||||
sourceContent := `package settings
|
||||
|
||||
type TestConfig struct {
|
||||
PublicField string ` + "`json:\"publicField\"`" + ` // A regular public field
|
||||
SecretField string ` + "`json:\"secretField\"`" + ` // secret: this should be hidden
|
||||
}
|
||||
|
||||
type TestNested struct {
|
||||
NestedPublic string ` + "`json:\"nestedPublic\"`" + ` // A nested public field
|
||||
NestedSecret string ` + "`json:\"nestedSecret\"`" + ` // secret: nested secret field
|
||||
}
|
||||
`
|
||||
|
||||
tmpFile, cleanup := createTestSourceFile(t, sourceContent)
|
||||
defer cleanup()
|
||||
|
||||
// Test secret detection
|
||||
_, secrets, _, err := CollectCommentsAndSecrets(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("CollectCommentsAndSecrets failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify secret fields are detected
|
||||
if !secrets["TestConfig"]["SecretField"] {
|
||||
t.Error("SecretField should be marked as secret")
|
||||
}
|
||||
if !secrets["TestNested"]["NestedSecret"] {
|
||||
t.Error("NestedSecret should be marked as secret")
|
||||
}
|
||||
if secrets["TestConfig"]["PublicField"] {
|
||||
t.Error("PublicField should not be marked as secret")
|
||||
}
|
||||
|
||||
// Test that secrets are properly collected
|
||||
if len(secrets["TestConfig"]) != 1 {
|
||||
t.Errorf("Expected 1 secret field in TestConfig, got %d", len(secrets["TestConfig"]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigYaml_DeprecatedFiltering(t *testing.T) {
|
||||
// Create temporary source file with deprecated fields
|
||||
sourceContent := `package settings
|
||||
|
||||
type TestConfig struct {
|
||||
PublicField string ` + "`json:\"publicField\"`" + ` // A regular public field
|
||||
DeprecatedField string ` + "`json:\"deprecatedField\"`" + ` // deprecated: this field is old
|
||||
}
|
||||
`
|
||||
|
||||
tmpFile, cleanup := createTestSourceFile(t, sourceContent)
|
||||
defer cleanup()
|
||||
|
||||
// Test deprecated detection
|
||||
_, _, deprecated, err := CollectCommentsAndSecrets(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("CollectCommentsAndSecrets failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify deprecated fields are detected
|
||||
if !deprecated["TestConfig"]["DeprecatedField"] {
|
||||
t.Error("DeprecatedField should be marked as deprecated")
|
||||
}
|
||||
if deprecated["TestConfig"]["PublicField"] {
|
||||
t.Error("PublicField should not be marked as deprecated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigYaml_FullVsFiltered(t *testing.T) {
|
||||
// Create minimal test source
|
||||
sourceContent := `package settings
|
||||
|
||||
type Settings struct {
|
||||
UserDefaults UserDefaults ` + "`json:\"userDefaults\"`" + `
|
||||
}
|
||||
|
||||
type UserDefaults struct {
|
||||
DarkMode bool ` + "`json:\"darkMode\"`" + `
|
||||
Locale string ` + "`json:\"locale\"`" + `
|
||||
}
|
||||
`
|
||||
tmpFile, cleanup := createTestSourceFile(t, sourceContent)
|
||||
defer cleanup()
|
||||
sourcePath := tmpFile[:strings.LastIndex(tmpFile, "/")]
|
||||
|
||||
// Create a config with both default and non-default values
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
DarkMode: true, // This matches default
|
||||
Locale: "es", // This differs from default ("en")
|
||||
},
|
||||
}
|
||||
|
||||
// Test full=true - should show all fields
|
||||
fullYaml, err := GenerateConfigYamlWithSource(config, false, true, false, sourcePath)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml with full=true failed: %v", err)
|
||||
}
|
||||
|
||||
// Test full=false - should only show non-default fields
|
||||
filteredYaml, err := GenerateConfigYamlWithSource(config, false, false, false, sourcePath)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml with full=false failed: %v", err)
|
||||
}
|
||||
|
||||
// Full output should be longer than filtered
|
||||
if len(fullYaml) <= len(filteredYaml) {
|
||||
t.Error("Full YAML should be longer than filtered YAML")
|
||||
}
|
||||
|
||||
// Filtered output should contain the non-default field
|
||||
if !strings.Contains(filteredYaml, "locale") {
|
||||
t.Error("Filtered YAML should contain non-default locale field")
|
||||
}
|
||||
|
||||
// Full output should contain default fields
|
||||
if !strings.Contains(fullYaml, "darkMode") {
|
||||
t.Error("Full YAML should contain darkMode field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigYaml_CommentsOnOff(t *testing.T) {
|
||||
// Use the actual settings source directory since comments work there
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "en",
|
||||
},
|
||||
}
|
||||
|
||||
// Test with comments=true using the real source
|
||||
withComments, err := GenerateConfigYaml(config, true, true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml with comments=true failed: %v", err)
|
||||
}
|
||||
|
||||
// Test with comments=false using the real source
|
||||
withoutComments, err := GenerateConfigYaml(config, false, true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml with comments=false failed: %v", err)
|
||||
}
|
||||
|
||||
// Output with comments should be longer
|
||||
if len(withComments) <= len(withoutComments) {
|
||||
t.Errorf("YAML with comments (%d chars) should be longer than without comments (%d chars)", len(withComments), len(withoutComments))
|
||||
}
|
||||
|
||||
// With comments should contain '#' characters
|
||||
if !strings.Contains(withComments, "#") {
|
||||
t.Error("YAML with comments should contain '#' characters")
|
||||
}
|
||||
|
||||
// Without comments should not contain '#' characters
|
||||
if strings.Contains(withoutComments, "#") {
|
||||
t.Error("YAML without comments should not contain '#' characters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
|
||||
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
// Comprehensive test with all features
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "es", // Non-default
|
||||
DarkMode: true, // Default
|
||||
DisableOfficePreviewExt: ".docx .xlsx", // This is deprecated
|
||||
},
|
||||
Auth: Auth{
|
||||
Key: "secret123", // This is secret
|
||||
AdminUsername: "admin", // This is secret
|
||||
AdminPassword: "password", // This is secret
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
showComments bool
|
||||
showFull bool
|
||||
filterDeprecated bool
|
||||
expectSecret bool
|
||||
expectDeprecated bool
|
||||
expectFull bool
|
||||
expectComments bool
|
||||
}{
|
||||
{
|
||||
name: "API mode: show all including deprecated",
|
||||
showComments: false,
|
||||
showFull: true,
|
||||
filterDeprecated: false,
|
||||
expectSecret: true, // secrets should be hidden
|
||||
expectDeprecated: true, // deprecated should be shown
|
||||
expectFull: true, // all fields shown
|
||||
expectComments: false, // no comments
|
||||
},
|
||||
{
|
||||
name: "Static config mode: filter deprecated",
|
||||
showComments: true,
|
||||
showFull: true,
|
||||
filterDeprecated: true,
|
||||
expectSecret: true, // secrets should be hidden
|
||||
expectDeprecated: false, // deprecated should be filtered
|
||||
expectFull: true, // all non-deprecated fields shown
|
||||
expectComments: true, // comments included
|
||||
},
|
||||
{
|
||||
name: "Filtered output: only changes",
|
||||
showComments: false,
|
||||
showFull: false,
|
||||
filterDeprecated: false,
|
||||
expectSecret: true, // secrets should be hidden
|
||||
expectDeprecated: false, // might not appear if matches default
|
||||
expectFull: false, // only non-default fields
|
||||
expectComments: false, // no comments
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
yamlOutput, err := GenerateConfigYaml(config, tt.showComments, tt.showFull, tt.filterDeprecated)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml failed: %v", err)
|
||||
}
|
||||
|
||||
// Check secret hiding
|
||||
if tt.expectSecret {
|
||||
if !strings.Contains(yamlOutput, "**hidden**") {
|
||||
t.Error("Expected secrets to be hidden with **hidden**")
|
||||
}
|
||||
if strings.Contains(yamlOutput, "secret123") {
|
||||
t.Error("Secret values should not appear in output")
|
||||
}
|
||||
}
|
||||
|
||||
// Check deprecated field filtering
|
||||
hasDeprecated := strings.Contains(yamlOutput, "disableOfficePreviewExt")
|
||||
if tt.expectDeprecated && !hasDeprecated {
|
||||
t.Error("Expected deprecated field to be present")
|
||||
}
|
||||
if !tt.expectDeprecated && hasDeprecated && tt.filterDeprecated {
|
||||
t.Error("Expected deprecated field to be filtered out")
|
||||
}
|
||||
|
||||
// Check comments
|
||||
hasComments := strings.Contains(yamlOutput, "#")
|
||||
if tt.expectComments && !hasComments {
|
||||
t.Error("Expected comments to be present")
|
||||
}
|
||||
if !tt.expectComments && hasComments {
|
||||
t.Error("Expected no comments")
|
||||
}
|
||||
|
||||
// Check string quoting - all string values should be quoted
|
||||
lines := strings.Split(yamlOutput, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, ":") && !strings.HasSuffix(strings.TrimSpace(line), ":") {
|
||||
// This is a key-value line
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
value := strings.TrimSpace(parts[1])
|
||||
// Skip comments and empty values
|
||||
if commentIdx := strings.Index(value, "#"); commentIdx >= 0 {
|
||||
// If comment starts at the beginning, there's no value, just a comment
|
||||
if commentIdx == 0 {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(value[:commentIdx])
|
||||
}
|
||||
// Check if it's a number (should NOT be quoted)
|
||||
if reNumber.MatchString(value) {
|
||||
if strings.HasPrefix(value, "\"") {
|
||||
t.Errorf("Numeric value should not be quoted in line: %s (value: '%s')", line, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a boolean (should NOT be quoted)
|
||||
if value == "true" || value == "false" {
|
||||
if strings.HasPrefix(value, "\"") {
|
||||
t.Errorf("Boolean value should not be quoted in line: %s (value: '%s')", line, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip arrays, objects, and empty values
|
||||
if value == "" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") || strings.Contains(value, ":") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else should be a quoted string
|
||||
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
|
||||
t.Errorf("String value should be quoted in line: %s (value: '%s')", line, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Generated YAML for %s:\n%s", tt.name, yamlOutput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigYaml_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Settings
|
||||
desc string
|
||||
}{
|
||||
{
|
||||
name: "empty_config",
|
||||
config: &Settings{},
|
||||
desc: "Empty config should generate valid YAML",
|
||||
},
|
||||
{
|
||||
name: "only_defaults",
|
||||
config: &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
DarkMode: true, // default value
|
||||
Locale: "en", // default value
|
||||
},
|
||||
},
|
||||
desc: "Config with only default values",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test all combinations
|
||||
for _, showComments := range []bool{false, true} {
|
||||
for _, showFull := range []bool{false, true} {
|
||||
for _, filterDeprecated := range []bool{false, true} {
|
||||
yamlOutput, err := GenerateConfigYaml(tt.config, showComments, showFull, filterDeprecated)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml failed for %s (comments=%v, full=%v, filterDeprecated=%v): %v",
|
||||
tt.desc, showComments, showFull, filterDeprecated, err)
|
||||
}
|
||||
|
||||
// Basic validation - should produce valid YAML structure
|
||||
if yamlOutput == "" {
|
||||
t.Errorf("Generated empty YAML for %s", tt.desc)
|
||||
}
|
||||
|
||||
// Should not contain obvious error messages (but allow "error" in log levels etc)
|
||||
if strings.Contains(yamlOutput, "Error:") || strings.Contains(yamlOutput, "ERROR:") {
|
||||
t.Errorf("YAML contains error for %s", tt.desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ type Settings struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
MinSearchLength int `json:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
|
||||
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
|
||||
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
|
||||
Socket string `json:"socket"` // socket to listen on
|
||||
TLSKey string `json:"tlsKey"` // path to TLS key
|
||||
@@ -52,9 +54,9 @@ type Integrations struct {
|
||||
// onlyoffice secret is stored in the local.json file
|
||||
// docker exec <containerID> /var/www/onlyoffice/documentserver/npm/json -f /etc/onlyoffice/documentserver/local.json 'services.CoAuthoring.secret.session.string'
|
||||
type OnlyOffice struct {
|
||||
Url string `json:"url" validate:"required"` // The URL to the OnlyOffice Document Server, needs to be accessible to the user.
|
||||
InternalUrl string `json:"internalUrl"` // An optional internal address that the filebrowser server can use to communicate with the OnlyOffice Document Server, could be useful to bypass proxy.
|
||||
Secret string `json:"secret" validate:"required"`
|
||||
Url string `json:"url" validate:"required"` // The URL to the OnlyOffice Document Server, needs to be accessible to the user.
|
||||
InternalUrl string `json:"internalUrl"` // An optional internal address that the filebrowser server can use to communicate with the OnlyOffice Document Server, could be useful to bypass proxy.
|
||||
Secret string `json:"secret" validate:"required"` // secret: authentication key for OnlyOffice integration
|
||||
}
|
||||
|
||||
type Media struct {
|
||||
@@ -73,21 +75,22 @@ type LogConfig struct {
|
||||
type Source struct {
|
||||
Path string `json:"path" validate:"required"` // file system path. (Can be relative)
|
||||
Name string `json:"name"` // display name
|
||||
Config SourceConfig `json:"config"`
|
||||
Config SourceConfig `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
type SourceConfig struct {
|
||||
Private bool `json:"private"` // designate as source as private -- currently just means no sharing permitted.
|
||||
Disabled bool `json:"disabled"` // disable the source, this is useful so you don't need to remove it from the config file
|
||||
IndexingInterval uint32 `json:"indexingIntervalMinutes"` // optional manual overide interval in minutes to re-index the source
|
||||
DisableIndexing bool `json:"disableIndexing"` // disable the indexing of this source
|
||||
MaxWatchers int `json:"maxWatchers"` // number of concurrent watchers to use for this source, currently not supported
|
||||
NeverWatchPaths []string `json:"neverWatchPaths"` // paths that get initially once. Useful for folders that rarely change contents (without source path prefix)
|
||||
Exclude ExcludeIndexFilter `json:"exclude"` // exclude files and folders from indexing, if include is not set
|
||||
Include IncludeIndexFilter `json:"include"` // include files and folders from indexing, if exclude is not set
|
||||
DefaultUserScope string `json:"defaultUserScope"` // default "/" should match folders under path
|
||||
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
|
||||
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user
|
||||
DenyByDefault bool `json:"denyByDefault,omitempty"` // deny access unless an "allow" access rule was specifically created.
|
||||
Private bool `json:"private"` // designate as source as private -- currently just means no sharing permitted.
|
||||
Disabled bool `json:"disabled,omitempty"` // disable the source, this is useful so you don't need to remove it from the config file
|
||||
IndexingInterval uint32 `json:"indexingIntervalMinutes,omitempty"` // optional manual overide interval in minutes to re-index the source
|
||||
DisableIndexing bool `json:"disableIndexing,omitempty"` // disable the indexing of this source
|
||||
MaxWatchers int `json:"maxWatchers"` // number of concurrent watchers to use for this source, currently not supported
|
||||
NeverWatchPaths []string `json:"neverWatchPaths"` // paths that get initially once. Useful for folders that rarely change contents (without source path prefix)
|
||||
Exclude ExcludeIndexFilter `json:"exclude"` // exclude files and folders from indexing, if include is not set
|
||||
Include IncludeIndexFilter `json:"include"` // include files and folders from indexing, if exclude is not set
|
||||
DefaultUserScope string `json:"defaultUserScope"` // default "/" should match folders under path
|
||||
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
|
||||
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user
|
||||
}
|
||||
|
||||
type IncludeIndexFilter struct {
|
||||
@@ -113,19 +116,24 @@ type Frontend struct {
|
||||
ExternalLinks []ExternalLink `json:"externalLinks"`
|
||||
DisableNavButtons bool `json:"disableNavButtons"` // disable the nav buttons in the sidebar
|
||||
Styling StylingConfig `json:"styling"`
|
||||
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
|
||||
Description string `json:"description"` // description that shows up in html head meta description
|
||||
}
|
||||
|
||||
type StylingConfig struct {
|
||||
CustomCSS string `json:"customCSS"` // if a valid path to a css file is provided, it will be applied for all users. (eg. "reduce-rounded-corners.css")
|
||||
LightBackground string `json:"lightBackground"` // specify a valid CSS color property value to use as the background color in light mode
|
||||
DarkBackground string `json:"darkBackground"` // Specify a valid CSS color property value to use as the background color in dark mode
|
||||
CustomThemes map[string]CustomTheme `json:"customThemes"` // A list of custom css files that each user can select to override the default styling. if "default" is key name then it will be the default option.
|
||||
CustomThemeOptions map[string]CustomTheme `json:"-"` // not exposed
|
||||
CustomCSS string `json:"customCSS"` // if a valid path to a css file is provided, it will be applied for all users. (eg. "reduce-rounded-corners.css")
|
||||
CustomCSSRaw string `json:"-"` // The css raw content to use for the custom css.
|
||||
LightBackground string `json:"lightBackground"` // specify a valid CSS color property value to use as the background color in light mode
|
||||
DarkBackground string `json:"darkBackground"` // Specify a valid CSS color property value to use as the background color in dark mode
|
||||
CustomThemes map[string]CustomTheme `json:"customThemes"` // A list of custom css files that each user can select to override the default styling. if "default" is key name then it will be the default option.
|
||||
// In-memory (not exposed to config)
|
||||
CustomThemeOptions map[string]CustomTheme `json:"-"` // not exposed
|
||||
}
|
||||
|
||||
type CustomTheme struct {
|
||||
Description string `json:"description"` // The description of the theme to display in the UI.
|
||||
CSS string `json:"css,omitempty"` // The css file path and filename to use for the theme.
|
||||
CssRaw string `json:"-"` // The css raw content to use for the theme.
|
||||
}
|
||||
|
||||
type ExternalLink struct {
|
||||
@@ -151,8 +159,8 @@ type UserDefaults struct {
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // comma separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // comma separated list of file extensions to disable viewing for
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
|
||||
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
|
||||
Preview users.Preview `json:"preview"`
|
||||
@@ -166,4 +174,5 @@ type UserDefaults struct {
|
||||
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
|
||||
}
|
||||
@@ -74,17 +74,21 @@ func FallbackColor(val, defaultColor string) string {
|
||||
}
|
||||
|
||||
func addCustomTheme(name, description, cssFilePath string) {
|
||||
// Store only file path in config (for YAML export)
|
||||
Config.Frontend.Styling.CustomThemes[name] = CustomTheme{
|
||||
Description: description,
|
||||
CSS: cssFilePath, // Store file path, not content
|
||||
}
|
||||
|
||||
// Load CSS content for runtime use (frontend consumption)
|
||||
cssContent := ""
|
||||
if cssFilePath != "" {
|
||||
cssContent = readCustomCSS(cssFilePath)
|
||||
}
|
||||
Config.Frontend.Styling.CustomThemes[name] = CustomTheme{
|
||||
Description: description,
|
||||
CSS: cssContent,
|
||||
}
|
||||
Config.Frontend.Styling.CustomThemeOptions[name] = CustomTheme{
|
||||
Description: description,
|
||||
CSS: cssFilePath,
|
||||
CssRaw: cssContent, // Store loaded content
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,4 +12,5 @@ var (
|
||||
SearchResultsCache = cache.NewCache(15*time.Second, 1*time.Hour)
|
||||
OnlyOfficeCache = cache.NewCache(48*time.Hour, 1*time.Hour)
|
||||
JwtCache = cache.NewCache(1*time.Hour, 72*time.Hour)
|
||||
MediaCache = cache.NewCache(24 * time.Hour) // subtitles get cached for 24 hours
|
||||
)
|
||||
@@ -121,3 +121,10 @@ func NonNilSlice[T any](in []T) []T {
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func Ternary[T any](cond bool, vtrue, vfalse T) T {
|
||||
if cond {
|
||||
return vtrue
|
||||
}
|
||||
return vfalse
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
"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/users"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
@@ -52,9 +53,10 @@ type FrontendRuleSet struct {
|
||||
}
|
||||
|
||||
type FrontendAccessRule struct {
|
||||
DenyAll bool `json:"denyAll,omitempty"`
|
||||
Deny FrontendRuleSet `json:"deny"`
|
||||
Allow FrontendRuleSet `json:"allow"`
|
||||
DenyAll bool `json:"denyAll,omitempty"`
|
||||
Deny FrontendRuleSet `json:"deny"`
|
||||
Allow FrontendRuleSet `json:"allow"`
|
||||
SourceDenyDefault bool `json:"sourceDenyDefault"`
|
||||
}
|
||||
|
||||
// GroupMap maps group names to a set of usernames.
|
||||
@@ -134,6 +136,12 @@ func NewStorage(db *storm.DB, usersStore *users.Storage) *Storage {
|
||||
return s
|
||||
}
|
||||
|
||||
// ClearCache clears the access cache (useful for testing)
|
||||
func ClearCache() {
|
||||
// Recreate the cache to clear it
|
||||
accessCache = cache.NewCache(1 * time.Minute)
|
||||
}
|
||||
|
||||
// getOrCreateRuleNL ensures a rule exists for the given source and index path.
|
||||
// The caller must hold the lock.
|
||||
func (s *Storage) getOrCreateRuleNL(sourcePath, indexPath string) *AccessRule {
|
||||
@@ -168,6 +176,7 @@ func (s *Storage) DenyUser(sourcePath, indexPath, username string) error {
|
||||
}
|
||||
rule.Deny.Users[username] = struct{}{}
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, false)
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
@@ -187,6 +196,7 @@ func (s *Storage) AllowUser(sourcePath, indexPath, username string) error {
|
||||
}
|
||||
rule.Allow.Users[username] = struct{}{}
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, false)
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
@@ -204,6 +214,7 @@ func (s *Storage) DenyGroup(sourcePath, indexPath, groupname string) error {
|
||||
}
|
||||
rule.Deny.Groups[groupname] = struct{}{}
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, false)
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
@@ -221,6 +232,7 @@ func (s *Storage) AllowGroup(sourcePath, indexPath, groupname string) error {
|
||||
}
|
||||
rule.Allow.Groups[groupname] = struct{}{}
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, false)
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
@@ -234,11 +246,13 @@ func (s *Storage) DenyAll(sourcePath, indexPath string) error {
|
||||
}
|
||||
rule.DenyAll = true
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, false)
|
||||
return s.SaveToDB()
|
||||
}
|
||||
|
||||
// Permitted checks if a username is permitted for a given sourcePath and indexPath, recursively checking parent directories.
|
||||
func (s *Storage) Permitted(sourcePath, indexPath, username string) bool {
|
||||
|
||||
// Get current version for the sourcePath
|
||||
versionKey := "version:" + sourcePath
|
||||
version := 0
|
||||
@@ -269,6 +283,19 @@ func (s *Storage) computePermitted(sourcePath, indexPath, username string) bool
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// No rules found anywhere in the path hierarchy
|
||||
// Check if the source has DenyByDefault configured (acts like a root-level denyAll rule)
|
||||
sourceInfo, ok := settings.Config.Server.SourceMap[sourcePath]
|
||||
if !ok {
|
||||
logger.Errorf("source %s not found in config during access check", sourcePath)
|
||||
return false
|
||||
}
|
||||
|
||||
if sourceInfo.Config.DenyByDefault {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -285,28 +312,27 @@ func (s *Storage) permittedAtExactPath(sourcePath, indexPath, username string) (
|
||||
if !ok {
|
||||
return true, false
|
||||
}
|
||||
return s.permittedRule(rule, username), true
|
||||
return s.permittedRule(rule, username, sourcePath), true
|
||||
}
|
||||
|
||||
// permittedRule contains the old Permitted logic, but operates on a rule and username only.
|
||||
func (s *Storage) permittedRule(rule *AccessRule, username string) bool {
|
||||
// Check user deny
|
||||
func (s *Storage) permittedRule(rule *AccessRule, username string, sourcePath string) bool {
|
||||
// Check user deny first - this always takes precedence
|
||||
if _, found := rule.Deny.Users[username]; found {
|
||||
return false
|
||||
}
|
||||
if rule.DenyAll {
|
||||
return false
|
||||
}
|
||||
// Check group deny
|
||||
for group := range rule.Deny.Groups {
|
||||
if s.isUserInGroup(username, group) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// If any allow is present, user must be in at least one
|
||||
|
||||
// Check allow rules - these can override DenyAll
|
||||
hasUserAllow := len(rule.Allow.Users) > 0
|
||||
hasGroupAllow := len(rule.Allow.Groups) > 0
|
||||
if hasUserAllow || hasGroupAllow {
|
||||
// If user is in any allow list, permit them (overrides DenyAll)
|
||||
if hasUserAllow {
|
||||
if _, found := rule.Allow.Users[username]; found {
|
||||
return true
|
||||
@@ -319,18 +345,30 @@ func (s *Storage) permittedRule(rule *AccessRule, username string) bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
// Fallback to deny rules
|
||||
if _, found := rule.Deny.Users[username]; found {
|
||||
return false
|
||||
}
|
||||
for group := range rule.Deny.Groups {
|
||||
if s.isUserInGroup(username, group) {
|
||||
|
||||
// User is not in any allow list
|
||||
// Check DenyByDefault to determine behavior
|
||||
sourceInfo, ok := settings.Config.Server.SourceMap[sourcePath]
|
||||
if ok && sourceInfo.Config.DenyByDefault {
|
||||
// DenyByDefault=true: allow lists are exclusive, deny users not on the list
|
||||
return false
|
||||
} else {
|
||||
// DenyByDefault=false: allow lists are additive, allow users not on the list
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check DenyAll - only applies if no allow rules exist
|
||||
if rule.DenyAll {
|
||||
return false
|
||||
}
|
||||
|
||||
// No specific rules apply - check source DenyByDefault setting
|
||||
sourceInfo, ok := settings.Config.Server.SourceMap[sourcePath]
|
||||
if ok && sourceInfo.Config.DenyByDefault {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -350,7 +388,16 @@ func (s *Storage) isUserInGroup(username, group string) bool {
|
||||
func (s *Storage) GetFrontendRules(sourcePath, indexPath string) (FrontendAccessRule, bool) {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
|
||||
// Get source configuration
|
||||
sourceDenyDefault := false
|
||||
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
|
||||
if sourceExists {
|
||||
sourceDenyDefault = sourceInfo.Config.DenyByDefault
|
||||
}
|
||||
|
||||
frontendRules := FrontendAccessRule{
|
||||
SourceDenyDefault: sourceDenyDefault,
|
||||
Deny: FrontendRuleSet{
|
||||
Users: make([]string, 0),
|
||||
Groups: make([]string, 0),
|
||||
@@ -388,6 +435,14 @@ func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule,
|
||||
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
|
||||
// Get source configuration
|
||||
sourceDenyDefault := false
|
||||
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
|
||||
if sourceExists {
|
||||
sourceDenyDefault = sourceInfo.Config.DenyByDefault
|
||||
}
|
||||
|
||||
// Return a copy to avoid external mutation
|
||||
frontendRules := make(map[string]FrontendAccessRule, len(s.AllRules))
|
||||
rules, ok := s.AllRules[sourcePath]
|
||||
@@ -397,7 +452,8 @@ func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule,
|
||||
for indexPath, rule := range rules {
|
||||
// Convert AccessRule to FrontendAccessRule
|
||||
frontendRules[indexPath] = FrontendAccessRule{
|
||||
DenyAll: rule.DenyAll,
|
||||
DenyAll: rule.DenyAll,
|
||||
SourceDenyDefault: sourceDenyDefault,
|
||||
Deny: FrontendRuleSet{
|
||||
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
|
||||
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
|
||||
@@ -739,6 +795,14 @@ func (s *Storage) RemoveAllRulesForGroup(groupname string) error {
|
||||
func (s *Storage) GetRulesForUser(sourcePath, username string) map[string]FrontendAccessRule {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
|
||||
// Get source configuration
|
||||
sourceDenyDefault := false
|
||||
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
|
||||
if sourceExists {
|
||||
sourceDenyDefault = sourceInfo.Config.DenyByDefault
|
||||
}
|
||||
|
||||
userRules := make(map[string]FrontendAccessRule)
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
@@ -756,7 +820,8 @@ func (s *Storage) GetRulesForUser(sourcePath, username string) map[string]Fronte
|
||||
}
|
||||
if userHasRule {
|
||||
userRules[indexPath] = FrontendAccessRule{
|
||||
DenyAll: rule.DenyAll,
|
||||
DenyAll: rule.DenyAll,
|
||||
SourceDenyDefault: sourceDenyDefault,
|
||||
Deny: FrontendRuleSet{
|
||||
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
|
||||
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
|
||||
@@ -775,6 +840,14 @@ func (s *Storage) GetRulesForUser(sourcePath, username string) map[string]Fronte
|
||||
func (s *Storage) GetRulesForGroup(sourcePath, groupname string) map[string]FrontendAccessRule {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
|
||||
// Get source configuration
|
||||
sourceDenyDefault := false
|
||||
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
|
||||
if sourceExists {
|
||||
sourceDenyDefault = sourceInfo.Config.DenyByDefault
|
||||
}
|
||||
|
||||
groupRules := make(map[string]FrontendAccessRule)
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
@@ -792,7 +865,8 @@ func (s *Storage) GetRulesForGroup(sourcePath, groupname string) map[string]Fron
|
||||
}
|
||||
if groupHasRule {
|
||||
groupRules[indexPath] = FrontendAccessRule{
|
||||
DenyAll: rule.DenyAll,
|
||||
DenyAll: rule.DenyAll,
|
||||
SourceDenyDefault: sourceDenyDefault,
|
||||
Deny: FrontendRuleSet{
|
||||
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
|
||||
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
|
||||
@@ -811,6 +885,14 @@ func (s *Storage) GetRulesForGroup(sourcePath, groupname string) map[string]Fron
|
||||
func (s *Storage) GetAllRulesByUsers(sourcePath string) map[string]map[string]FrontendAccessRule {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
|
||||
// Get source configuration
|
||||
sourceDenyDefault := false
|
||||
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
|
||||
if sourceExists {
|
||||
sourceDenyDefault = sourceInfo.Config.DenyByDefault
|
||||
}
|
||||
|
||||
allUserRules := make(map[string]map[string]FrontendAccessRule)
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
@@ -823,7 +905,8 @@ func (s *Storage) GetAllRulesByUsers(sourcePath string) map[string]map[string]Fr
|
||||
continue
|
||||
}
|
||||
frontendRule := FrontendAccessRule{
|
||||
DenyAll: rule.DenyAll,
|
||||
DenyAll: rule.DenyAll,
|
||||
SourceDenyDefault: sourceDenyDefault,
|
||||
Deny: FrontendRuleSet{
|
||||
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
|
||||
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
|
||||
@@ -853,6 +936,14 @@ func (s *Storage) GetAllRulesByUsers(sourcePath string) map[string]map[string]Fr
|
||||
func (s *Storage) GetAllRulesByGroups(sourcePath string) map[string]map[string]FrontendAccessRule {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
|
||||
// Get source configuration
|
||||
sourceDenyDefault := false
|
||||
sourceInfo, sourceExists := settings.Config.Server.SourceMap[sourcePath]
|
||||
if sourceExists {
|
||||
sourceDenyDefault = sourceInfo.Config.DenyByDefault
|
||||
}
|
||||
|
||||
allGroupRules := make(map[string]map[string]FrontendAccessRule)
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
@@ -865,7 +956,8 @@ func (s *Storage) GetAllRulesByGroups(sourcePath string) map[string]map[string]F
|
||||
continue
|
||||
}
|
||||
frontendRule := FrontendAccessRule{
|
||||
DenyAll: rule.DenyAll,
|
||||
DenyAll: rule.DenyAll,
|
||||
SourceDenyDefault: sourceDenyDefault,
|
||||
Deny: FrontendRuleSet{
|
||||
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
|
||||
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
boltusers "github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
@@ -29,7 +30,28 @@ func createTestUser(t *testing.T, userStore *users.Storage, username string) {
|
||||
}
|
||||
}
|
||||
|
||||
func setupTestSources() {
|
||||
// Setup default test sources with allow-by-default behavior
|
||||
settings.Config.Server.SourceMap = map[string]settings.Source{
|
||||
"mnt/storage": {
|
||||
Path: "mnt/storage",
|
||||
Name: "storage",
|
||||
Config: settings.SourceConfig{
|
||||
DenyByDefault: false,
|
||||
},
|
||||
},
|
||||
"mnt/open": {
|
||||
Path: "mnt/open",
|
||||
Name: "open",
|
||||
Config: settings.SourceConfig{
|
||||
DenyByDefault: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermitted_UserBlacklist(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
createTestUser(t, userStore, "bob")
|
||||
@@ -49,6 +71,7 @@ func TestPermitted_UserBlacklist(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPermitted_UserWhitelist(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
createTestUser(t, userStore, "bob")
|
||||
@@ -63,11 +86,12 @@ func TestPermitted_UserWhitelist(t *testing.T) {
|
||||
t.Error("bob should be permitted (allowed)")
|
||||
}
|
||||
if !s.Permitted("mnt/storage", "/vip", "alice") {
|
||||
t.Error("alice should be permitted (not denied)")
|
||||
t.Error("alice should be permitted (default allow behavior when DenyByDefault=false)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermitted_GroupBlacklist(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
createTestUser(t, userStore, "bob")
|
||||
@@ -88,6 +112,7 @@ func TestPermitted_GroupBlacklist(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPermitted_GroupWhitelist(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
createTestUser(t, userStore, "bob")
|
||||
@@ -103,11 +128,12 @@ func TestPermitted_GroupWhitelist(t *testing.T) {
|
||||
t.Error("bob should be permitted (in allowed group)")
|
||||
}
|
||||
if !s.Permitted("mnt/storage", "/vip", "alice") {
|
||||
t.Error("alice should be permitted (not in denied group)")
|
||||
t.Error("alice should be permitted (default allow behavior when DenyByDefault=false)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermitted_NoRule(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, _ := createTestStorage(t)
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
@@ -119,6 +145,7 @@ func TestPermitted_NoRule(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPermitted_CombinedRules(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
createTestUser(t, userStore, "bob")
|
||||
@@ -163,6 +190,7 @@ func TestPermitted_CombinedRules(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPermitted_DenyAll(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
createTestUser(t, userStore, "bob")
|
||||
@@ -182,12 +210,12 @@ func TestPermitted_DenyAll(t *testing.T) {
|
||||
t.Error("bob should not be permitted (deny all)")
|
||||
}
|
||||
|
||||
// Test that Allow rule is overridden by DenyAll
|
||||
// Test that Allow rule overrides DenyAll
|
||||
if err = s.AllowUser("mnt/storage", "/private", "alice"); err != nil {
|
||||
t.Errorf("AllowUser failed: %v", err)
|
||||
}
|
||||
if s.Permitted("mnt/storage", "/private", "alice") {
|
||||
t.Error("alice should not be permitted (deny all overrides allow)")
|
||||
if !s.Permitted("mnt/storage", "/private", "alice") {
|
||||
t.Error("alice should be permitted (allow overrides deny all)")
|
||||
}
|
||||
|
||||
// Test removing DenyAll
|
||||
@@ -204,8 +232,176 @@ func TestPermitted_DenyAll(t *testing.T) {
|
||||
t.Error("alice should be permitted after removing deny all")
|
||||
}
|
||||
|
||||
// Bob should be permitted as he is not on any list, and there is an allow list which he is not on, but he is not on deny either.
|
||||
// Bob should be permitted because DenyByDefault=false makes allow lists additive (not exclusive)
|
||||
if !s.Permitted("mnt/storage", "/private", "bob") {
|
||||
t.Error("bob should be permitted after removing deny all (not on allow list but not denied)")
|
||||
t.Error("bob should be permitted after removing deny all (DenyByDefault=false makes allow lists additive)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermitted_DenyByDefault(t *testing.T) {
|
||||
// Clear access cache to prevent test pollution
|
||||
access.ClearCache()
|
||||
|
||||
// Create isolated storage for this test
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
createTestUser(t, userStore, "bob")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Setup test configuration with DenyByDefault enabled for one source
|
||||
originalSourceMap := settings.Config.Server.SourceMap
|
||||
defer func() {
|
||||
settings.Config.Server.SourceMap = originalSourceMap
|
||||
access.ClearCache()
|
||||
}()
|
||||
|
||||
settings.Config.Server.SourceMap = map[string]settings.Source{
|
||||
"mnt/storage": {
|
||||
Path: "mnt/storage",
|
||||
Name: "storage",
|
||||
Config: settings.SourceConfig{
|
||||
DenyByDefault: true,
|
||||
},
|
||||
},
|
||||
"mnt/open": {
|
||||
Path: "mnt/open",
|
||||
Name: "open",
|
||||
Config: settings.SourceConfig{
|
||||
DenyByDefault: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Test DenyByDefault: When no rules exist, users should be denied
|
||||
if s.Permitted("mnt/storage", "/private", "alice") {
|
||||
t.Error("alice should not be permitted (DenyByDefault is true and no rules exist)")
|
||||
}
|
||||
if s.Permitted("mnt/storage", "/private", "bob") {
|
||||
t.Error("bob should not be permitted (DenyByDefault is true and no rules exist)")
|
||||
}
|
||||
|
||||
// Test that explicit allow rules override DenyByDefault
|
||||
if err := s.AllowUser("mnt/storage", "/allowed", "alice"); err != nil {
|
||||
t.Errorf("AllowUser failed: %v", err)
|
||||
}
|
||||
if !s.Permitted("mnt/storage", "/allowed", "alice") {
|
||||
t.Error("alice should be permitted (explicit allow rule overrides DenyByDefault)")
|
||||
}
|
||||
if s.Permitted("mnt/storage", "/allowed", "bob") {
|
||||
t.Error("bob should not be permitted (not in allow list and DenyByDefault is true)")
|
||||
}
|
||||
|
||||
// Test that explicit deny rules work with DenyByDefault
|
||||
if err := s.DenyUser("mnt/storage", "/denied", "alice"); err != nil {
|
||||
t.Errorf("DenyUser failed: %v", err)
|
||||
}
|
||||
if s.Permitted("mnt/storage", "/denied", "alice") {
|
||||
t.Error("alice should not be permitted (explicit deny rule)")
|
||||
}
|
||||
if s.Permitted("mnt/storage", "/denied", "bob") {
|
||||
t.Error("bob should not be permitted (DenyByDefault is true)")
|
||||
}
|
||||
|
||||
// Test that DenyByDefault doesn't affect sources where it's disabled
|
||||
if !s.Permitted("mnt/open", "/public", "alice") {
|
||||
t.Error("alice should be permitted in open source (DenyByDefault is false)")
|
||||
}
|
||||
if !s.Permitted("mnt/open", "/public", "bob") {
|
||||
t.Error("bob should be permitted in open source (DenyByDefault is false)")
|
||||
}
|
||||
|
||||
// Test recursive path checking with DenyByDefault
|
||||
// Allow alice at parent path, should work for child paths too
|
||||
if err := s.AllowUser("mnt/storage", "/parent", "alice"); err != nil {
|
||||
t.Errorf("AllowUser failed: %v", err)
|
||||
}
|
||||
if !s.Permitted("mnt/storage", "/parent/child", "alice") {
|
||||
t.Error("alice should be permitted for child path (inherits from parent allow rule)")
|
||||
}
|
||||
if s.Permitted("mnt/storage", "/parent/child", "bob") {
|
||||
t.Error("bob should not be permitted for child path (DenyByDefault and not in allow list)")
|
||||
}
|
||||
|
||||
// Test that group rules work with DenyByDefault
|
||||
_ = s.AddUserToGroup("vip", "alice")
|
||||
if err := s.AllowGroup("mnt/storage", "/vip", "vip"); err != nil {
|
||||
t.Errorf("AllowGroup failed: %v", err)
|
||||
}
|
||||
if !s.Permitted("mnt/storage", "/vip", "alice") {
|
||||
t.Error("alice should be permitted (in allowed group)")
|
||||
}
|
||||
if s.Permitted("mnt/storage", "/vip", "bob") {
|
||||
t.Error("bob should not be permitted (not in allowed group and DenyByDefault is true)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermitted_DenyByDefaultWithDenyAll(t *testing.T) {
|
||||
// Clear access cache to prevent test pollution
|
||||
access.ClearCache()
|
||||
|
||||
// Create isolated storage for this test
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
createTestUser(t, userStore, "bob")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Setup test configuration with DenyByDefault enabled
|
||||
originalSourceMap := settings.Config.Server.SourceMap
|
||||
defer func() {
|
||||
settings.Config.Server.SourceMap = originalSourceMap
|
||||
access.ClearCache()
|
||||
}()
|
||||
|
||||
settings.Config.Server.SourceMap = map[string]settings.Source{
|
||||
"mnt/storage": {
|
||||
Path: "mnt/storage",
|
||||
Name: "storage",
|
||||
Config: settings.SourceConfig{
|
||||
DenyByDefault: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Test that explicit DenyAll rule also works when DenyByDefault is enabled
|
||||
if err = s.DenyAll("mnt/storage", "/restricted"); err != nil {
|
||||
t.Errorf("DenyAll failed: %v", err)
|
||||
}
|
||||
if s.Permitted("mnt/storage", "/restricted", "alice") {
|
||||
t.Error("alice should not be permitted (DenyAll rule)")
|
||||
}
|
||||
if s.Permitted("mnt/storage", "/restricted", "bob") {
|
||||
t.Error("bob should not be permitted (DenyAll rule)")
|
||||
}
|
||||
|
||||
// Test that Allow rule overrides DenyAll even with DenyByDefault
|
||||
if err = s.AllowUser("mnt/storage", "/restricted", "alice"); err != nil {
|
||||
t.Errorf("AllowUser failed: %v", err)
|
||||
}
|
||||
if !s.Permitted("mnt/storage", "/restricted", "alice") {
|
||||
t.Error("alice should be permitted (allow overrides DenyAll)")
|
||||
}
|
||||
|
||||
// Remove DenyAll and test that DenyByDefault takes effect
|
||||
removed, err := s.RemoveDenyAll("mnt/storage", "/restricted")
|
||||
if err != nil {
|
||||
t.Errorf("RemoveDenyAll failed: %v", err)
|
||||
}
|
||||
if !removed {
|
||||
t.Error("RemoveDenyAll should have removed the rule")
|
||||
}
|
||||
|
||||
// After removing DenyAll, alice should be permitted due to Allow rule
|
||||
if !s.Permitted("mnt/storage", "/restricted", "alice") {
|
||||
t.Error("alice should be permitted after removing DenyAll (has allow rule)")
|
||||
}
|
||||
// Bob should not be permitted due to DenyByDefault
|
||||
if s.Permitted("mnt/storage", "/restricted", "bob") {
|
||||
t.Error("bob should not be permitted (DenyByDefault is true and no allow rule)")
|
||||
}
|
||||
}
|
||||
@@ -4,29 +4,31 @@ import "sync"
|
||||
|
||||
type CommonShare struct {
|
||||
//AllowEdit bool `json:"allowEdit,omitempty"`
|
||||
AllowUpload bool `json:"allowUpload,omitempty"`
|
||||
DisablingFileViewer bool `json:"disableFileViewer,omitempty"`
|
||||
DownloadsLimit int `json:"downloadsLimit,omitempty"`
|
||||
ShareTheme string `json:"shareTheme,omitempty"`
|
||||
DisableAnonymous bool `json:"disableAnonymous,omitempty"`
|
||||
MaxBandwidth int `json:"maxBandwidth,omitempty"`
|
||||
DisableThumbnails bool `json:"disableThumbnails,omitempty"`
|
||||
KeepAfterExpiration bool `json:"keepAfterExpiration,omitempty"`
|
||||
AllowedUsernames []string `json:"allowedUsernames,omitempty"`
|
||||
ThemeColor string `json:"themeColor,omitempty"`
|
||||
Banner string `json:"banner,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Favicon string `json:"favicon,omitempty"`
|
||||
QuickDownload bool `json:"quickDownload,omitempty"`
|
||||
HideNavButtons bool `json:"hideNavButtons,omitempty"`
|
||||
DisableSidebar bool `json:"disableSidebar"`
|
||||
ViewMode string `json:"viewMode,omitempty"`
|
||||
Source string `json:"source,omitempty"` // backend source is path to maintain between name changes
|
||||
Path string `json:"path,omitempty"`
|
||||
DownloadURL string `json:"downloadURL,omitempty"`
|
||||
DisableShareCard bool `json:"disableShareCard,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
AllowUpload bool `json:"allowUpload,omitempty"`
|
||||
DisableFileViewer bool `json:"disableFileViewer,omitempty"`
|
||||
DownloadsLimit int `json:"downloadsLimit,omitempty"`
|
||||
ShareTheme string `json:"shareTheme,omitempty"`
|
||||
DisableAnonymous bool `json:"disableAnonymous,omitempty"`
|
||||
MaxBandwidth int `json:"maxBandwidth,omitempty"`
|
||||
DisableThumbnails bool `json:"disableThumbnails,omitempty"`
|
||||
KeepAfterExpiration bool `json:"keepAfterExpiration,omitempty"`
|
||||
AllowedUsernames []string `json:"allowedUsernames,omitempty"`
|
||||
ThemeColor string `json:"themeColor,omitempty"`
|
||||
Banner string `json:"banner,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Favicon string `json:"favicon,omitempty"`
|
||||
QuickDownload bool `json:"quickDownload,omitempty"`
|
||||
HideNavButtons bool `json:"hideNavButtons,omitempty"`
|
||||
DisableSidebar bool `json:"disableSidebar"`
|
||||
ViewMode string `json:"viewMode,omitempty"`
|
||||
Source string `json:"source,omitempty"` // backend source is path to maintain between name changes
|
||||
Path string `json:"path,omitempty"`
|
||||
DownloadURL string `json:"downloadURL,omitempty"`
|
||||
DisableShareCard bool `json:"disableShareCard,omitempty"`
|
||||
EnforceDarkLightMode string `json:"enforceDarkLightMode,omitempty"` // "dark" or "light"
|
||||
EnableOnlyOffice bool `json:"enableOnlyOffice,omitempty"`
|
||||
EnableOnlyOfficeEditing bool `json:"enableOnlyOfficeEditing,omitempty"`
|
||||
}
|
||||
type CreateBody struct {
|
||||
CommonShare
|
||||
|
||||
@@ -71,17 +71,6 @@ func NewStorage(back StorageBackend, usersStore *users.Storage) *Storage {
|
||||
}
|
||||
}
|
||||
|
||||
// setUsername populates the Link.Username from the users store using UserID, if available.
|
||||
func (s *Storage) setUsername(l *Link) {
|
||||
if l == nil || s.users == nil {
|
||||
return
|
||||
}
|
||||
user, err := s.users.Get(l.UserID)
|
||||
if err == nil && user != nil {
|
||||
l.Username = user.Username
|
||||
}
|
||||
}
|
||||
|
||||
// All wraps StorageBackend.All and handles expiry.
|
||||
func (s *Storage) All() ([]*Link, error) {
|
||||
links, err := s.back.All()
|
||||
@@ -92,10 +81,7 @@ func (s *Storage) All() ([]*Link, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// warm cache: avoid DB calls under lock
|
||||
for _, l := range filtered {
|
||||
s.setUsername(l)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
for i, l := range filtered {
|
||||
if l == nil {
|
||||
@@ -121,10 +107,7 @@ func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// inject username outside lock
|
||||
for _, l := range filtered {
|
||||
s.setUsername(l)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
for i, l := range filtered {
|
||||
if l == nil {
|
||||
@@ -166,7 +149,7 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
|
||||
return nil, errors.ErrNotExist
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.setUsername(link)
|
||||
|
||||
s.shareCache[hash] = link
|
||||
s.mu.Unlock()
|
||||
return link, nil
|
||||
@@ -177,7 +160,7 @@ func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
|
||||
l, err := s.back.GetPermanent(path, source, id)
|
||||
if err == nil && l != nil {
|
||||
s.mu.Lock()
|
||||
s.setUsername(l)
|
||||
|
||||
s.shareCache[l.Hash] = l
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -194,9 +177,6 @@ func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, l := range filtered {
|
||||
s.setUsername(l)
|
||||
}
|
||||
s.mu.Lock()
|
||||
for i, l := range filtered {
|
||||
if l == nil {
|
||||
@@ -222,9 +202,6 @@ func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, l := range filtered {
|
||||
s.setUsername(l)
|
||||
}
|
||||
s.mu.Lock()
|
||||
for i, l := range filtered {
|
||||
if l == nil {
|
||||
|
||||
@@ -49,6 +49,7 @@ type Preview struct {
|
||||
Office bool `json:"office"` // show preview image for office files
|
||||
PopUp bool `json:"popup"` // show larger popup preview when hovering
|
||||
AutoplayMedia bool `json:"autoplayMedia"` // autoplay media files in preview
|
||||
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable html5 media player and use the default media player
|
||||
}
|
||||
|
||||
// User describes a user.
|
||||
@@ -99,10 +100,11 @@ type NonAdminEditable struct {
|
||||
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // comma separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // comma separated list of file extensions to disable viewing for
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
|
||||
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
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
@@ -110,10 +112,6 @@ type FileLoading struct {
|
||||
ChunkSize int `json:"uploadChunkSizeMb"`
|
||||
}
|
||||
|
||||
var AnonymousUser = User{
|
||||
Username: "anonymous", // temp user not registered
|
||||
}
|
||||
|
||||
func CleanUsername(s string) string {
|
||||
// Remove any trailing space to avoid ending on -
|
||||
s = strings.Trim(s, " ")
|
||||
|
||||
@@ -138,3 +138,14 @@ func handleSourceUpdates() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Shutdown() {
|
||||
userClientsMu.Lock()
|
||||
defer userClientsMu.Unlock()
|
||||
|
||||
for _, clientChannels := range userClients {
|
||||
for _, ch := range clientChannels {
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// SubtitleTrack represents a subtitle track (embedded or external file)
|
||||
type SubtitleTrack struct {
|
||||
Name string `json:"name"` // filename for external, or descriptive name for embedded
|
||||
Language string `json:"language,omitempty"` // language code
|
||||
Title string `json:"title,omitempty"` // title/description
|
||||
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
|
||||
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
|
||||
Content string `json:"content,omitempty"` // subtitle content
|
||||
IsFile bool `json:"isFile"` // true for external files, false for embedded streams
|
||||
}
|
||||
|
||||
// FFProbeOutput represents the JSON output from ffprobe
|
||||
type FFProbeOutput struct {
|
||||
Streams []struct {
|
||||
Index int `json:"index"`
|
||||
CodecType string `json:"codec_type"`
|
||||
CodecName string `json:"codec_name"`
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
Disposition map[string]int `json:"disposition,omitempty"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
|
||||
// DetectAllSubtitles finds both embedded and external subtitle tracks
|
||||
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []SubtitleTrack {
|
||||
key := "all_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
|
||||
|
||||
// Check cache first
|
||||
if cached, ok := utils.MediaCache.Get(key).([]SubtitleTrack); ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
var allSubtitles []SubtitleTrack
|
||||
|
||||
// First, get embedded subtitles
|
||||
embeddedSubs := detectEmbeddedSubtitles(videoPath)
|
||||
allSubtitles = append(allSubtitles, embeddedSubs...)
|
||||
|
||||
// Then, get external subtitle files
|
||||
externalSubs := detectExternalSubtitles(videoPath, parentDir)
|
||||
allSubtitles = append(allSubtitles, externalSubs...)
|
||||
|
||||
// Cache the complete list
|
||||
utils.MediaCache.Set(key, allSubtitles)
|
||||
|
||||
return allSubtitles
|
||||
}
|
||||
|
||||
// detectEmbeddedSubtitles uses ffprobe to find embedded subtitle tracks
|
||||
func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
|
||||
cmd := exec.Command("ffprobe",
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_streams",
|
||||
"-select_streams", "s",
|
||||
realPath)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
logger.Debug("ffprobe failed for file: " + realPath + ", error: " + err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
var probeOutput FFProbeOutput
|
||||
if err := json.Unmarshal(output, &probeOutput); err != nil {
|
||||
logger.Debug("failed to parse ffprobe output for file: " + realPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
var subtitles []SubtitleTrack
|
||||
for _, stream := range probeOutput.Streams {
|
||||
if stream.CodecType == "subtitle" {
|
||||
track := SubtitleTrack{
|
||||
Index: &stream.Index,
|
||||
Codec: stream.CodecName,
|
||||
IsFile: false, // This is an embedded subtitle
|
||||
}
|
||||
|
||||
// Extract language and title from tags
|
||||
if stream.Tags != nil {
|
||||
if lang, ok := stream.Tags["language"]; ok {
|
||||
track.Language = lang
|
||||
}
|
||||
if title, ok := stream.Tags["title"]; ok {
|
||||
track.Title = title
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a descriptive name
|
||||
if track.Title != "" {
|
||||
track.Name = track.Title
|
||||
} else if track.Language != "" {
|
||||
track.Name = "Embedded (" + track.Language + ")"
|
||||
} else {
|
||||
track.Name = "Embedded Subtitle " + strconv.Itoa(stream.Index)
|
||||
}
|
||||
|
||||
subtitles = append(subtitles, track)
|
||||
}
|
||||
}
|
||||
return subtitles
|
||||
}
|
||||
|
||||
// detectExternalSubtitles finds external subtitle files in the same directory
|
||||
func detectExternalSubtitles(videoPath string, parentDir string) []SubtitleTrack {
|
||||
var subtitles []SubtitleTrack
|
||||
|
||||
// Get the base name of the video (without extension)
|
||||
videoBaseName := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
|
||||
|
||||
// Common subtitle extensions
|
||||
subtitleExts := []string{".srt", ".vtt", ".lrc", ".sbv", ".ass", ".ssa", ".sub", ".smi"}
|
||||
|
||||
// Look for subtitle files with matching base name
|
||||
for _, ext := range subtitleExts {
|
||||
subtitlePath := filepath.Join(parentDir, videoBaseName+ext)
|
||||
if _, err := os.Stat(subtitlePath); err == nil {
|
||||
// File exists
|
||||
track := SubtitleTrack{
|
||||
Name: filepath.Base(subtitlePath),
|
||||
IsFile: true, // This is an external file
|
||||
}
|
||||
|
||||
// Try to infer language from filename patterns like "video.en.srt"
|
||||
parts := strings.Split(videoBaseName, ".")
|
||||
if len(parts) > 1 {
|
||||
// Check if the last part before extension looks like a language code
|
||||
lastPart := parts[len(parts)-1]
|
||||
if len(lastPart) == 2 || len(lastPart) == 3 {
|
||||
track.Language = lastPart
|
||||
}
|
||||
}
|
||||
|
||||
subtitles = append(subtitles, track)
|
||||
}
|
||||
}
|
||||
|
||||
return subtitles
|
||||
}
|
||||
|
||||
// ExtractSubtitleContent extracts subtitle content without service management
|
||||
func ExtractSubtitleContent(videoPath string, streamIndex int) (string, error) {
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-i", videoPath,
|
||||
"-map", fmt.Sprintf("0:%d", streamIndex),
|
||||
"-c:s", "webvtt",
|
||||
"-f", "webvtt",
|
||||
"-") // output to stdout
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("subtitle extraction failed: %v", err)
|
||||
}
|
||||
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// LoadAndConvertSubtitleFile loads a subtitle file and converts it to WebVTT format
|
||||
func LoadAndConvertSubtitleFile(subtitlePath string) (string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(subtitlePath))
|
||||
|
||||
if ext == ".vtt" {
|
||||
// Already WebVTT, just read it
|
||||
content, err := os.ReadFile(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
if ext == ".srt" {
|
||||
// Convert SRT to WebVTT using ffmpeg
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-i", subtitlePath,
|
||||
"-c:s", "webvtt",
|
||||
"-f", "webvtt",
|
||||
"-") // output to stdout
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to convert SRT to WebVTT: %v", err)
|
||||
}
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// For other formats, try to read as plain text and hope for the best
|
||||
// This is a fallback - ideally you'd want format-specific converters
|
||||
content, err := os.ReadFile(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Basic conversion wrapper for non-VTT formats
|
||||
vttHeader := "WEBVTT\n\n"
|
||||
return vttHeader + string(content), nil
|
||||
}
|
||||
|
||||
// ExtractSingleSubtitle extracts content for a specific subtitle track by array index
|
||||
func ExtractSingleSubtitle(videoPath string, parentDir string, trackIndex int, modtime time.Time) (SubtitleTrack, error) {
|
||||
// Get all subtitle tracks
|
||||
allTracks := DetectAllSubtitles(videoPath, parentDir, modtime)
|
||||
|
||||
if trackIndex >= len(allTracks) {
|
||||
return SubtitleTrack{}, fmt.Errorf("subtitle track %d not found (only %d tracks available)", trackIndex, len(allTracks))
|
||||
}
|
||||
|
||||
track := allTracks[trackIndex]
|
||||
|
||||
// Load content based on type
|
||||
if track.IsFile {
|
||||
// Load external subtitle file
|
||||
subtitlePath := filepath.Join(parentDir, track.Name)
|
||||
content, err := LoadAndConvertSubtitleFile(subtitlePath)
|
||||
if err != nil {
|
||||
return SubtitleTrack{}, fmt.Errorf("failed to load external subtitle: %v", err)
|
||||
}
|
||||
track.Content = content
|
||||
} else {
|
||||
// Extract embedded subtitle content
|
||||
if track.Index == nil {
|
||||
return SubtitleTrack{}, fmt.Errorf("embedded subtitle track has no stream index")
|
||||
}
|
||||
content, err := ExtractSubtitleContent(videoPath, *track.Index)
|
||||
if err != nil {
|
||||
return SubtitleTrack{}, fmt.Errorf("failed to extract embedded subtitle: %v", err)
|
||||
}
|
||||
track.Content = content
|
||||
}
|
||||
|
||||
return track, nil
|
||||
}
|
||||
|
||||
// LoadAllSubtitleContent loads the actual content for all detected subtitle tracks
|
||||
func LoadAllSubtitleContent(videoPath string, subtitles []SubtitleTrack, modtime time.Time) error {
|
||||
for idx := range subtitles {
|
||||
subtitle := &subtitles[idx]
|
||||
|
||||
// Check if content is already cached
|
||||
contentKey := fmt.Sprintf("subtitle_content:%s:%d:%s", videoPath, idx, modtime.Format(time.RFC3339))
|
||||
if cached, ok := utils.MediaCache.Get(contentKey).(string); ok {
|
||||
subtitle.Content = cached
|
||||
continue
|
||||
}
|
||||
|
||||
var content string
|
||||
var err error
|
||||
|
||||
if subtitle.IsFile {
|
||||
// Load external subtitle file content and convert to WebVTT
|
||||
subtitlePath := filepath.Join(filepath.Dir(videoPath), subtitle.Name)
|
||||
content, err = LoadAndConvertSubtitleFile(subtitlePath)
|
||||
if err != nil {
|
||||
logger.Debug("failed to read/convert subtitle file " + subtitlePath + ": " + err.Error())
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// Load embedded subtitle content
|
||||
if subtitle.Index == nil {
|
||||
logger.Debug("embedded subtitle has no stream index")
|
||||
continue
|
||||
}
|
||||
content, err = ExtractSubtitleContent(videoPath, *subtitle.Index)
|
||||
if err != nil {
|
||||
logger.Debug("failed to extract embedded subtitle: " + err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
subtitle.Content = content
|
||||
// Cache the content for future requests
|
||||
utils.MediaCache.Set(contentKey, content)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// VideoService handles video preview operations with ffmpeg
|
||||
type VideoService struct {
|
||||
ffmpegPath string
|
||||
ffprobePath string
|
||||
debug bool
|
||||
semaphore chan struct{}
|
||||
}
|
||||
|
||||
// NewVideoService creates a new video service instance
|
||||
func NewVideoService(ffmpegPath, ffprobePath string, maxConcurrent int, debug bool) *VideoService {
|
||||
return &VideoService{
|
||||
ffmpegPath: ffmpegPath,
|
||||
ffprobePath: ffprobePath,
|
||||
debug: debug,
|
||||
semaphore: make(chan struct{}, maxConcurrent),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *VideoService) acquire(ctx context.Context) error {
|
||||
select {
|
||||
case s.semaphore <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *VideoService) release() {
|
||||
<-s.semaphore
|
||||
}
|
||||
|
||||
// GenerateVideoPreview generates a single preview image from a video using ffmpeg.
|
||||
// videoPath: path to the input video file.
|
||||
// outputPath: path where the generated preview image will be saved (e.g., "/tmp/preview.jpg").
|
||||
// seekTime: how many seconds into the video to seek before capturing the frame.
|
||||
func (s *VideoService) GenerateVideoPreview(videoPath, outputPath string, percentageSeek int) ([]byte, error) {
|
||||
if err := s.acquire(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.release()
|
||||
|
||||
// Step 1: Get video duration from the container format
|
||||
probeCmd := exec.Command(
|
||||
s.ffprobePath,
|
||||
"-v", "error",
|
||||
// Use format=duration for better compatibility
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
videoPath,
|
||||
)
|
||||
|
||||
var probeOut bytes.Buffer
|
||||
probeCmd.Stdout = &probeOut
|
||||
if s.debug {
|
||||
probeCmd.Stderr = os.Stderr
|
||||
}
|
||||
if err := probeCmd.Run(); err != nil {
|
||||
logger.Errorf("ffprobe command failed on file '%v' : %v", videoPath, err)
|
||||
return nil, fmt.Errorf("ffprobe failed: %w", err)
|
||||
}
|
||||
|
||||
durationStr := strings.TrimSpace(probeOut.String())
|
||||
if durationStr == "" || durationStr == "N/A" {
|
||||
logger.Errorf("could not determine video duration for file '%v' using duration info '%v'", videoPath, durationStr)
|
||||
return nil, fmt.Errorf("could not determine video duration")
|
||||
}
|
||||
|
||||
durationFloat, err := strconv.ParseFloat(durationStr, 64)
|
||||
if err != nil {
|
||||
// The original error you saw would be caught here if "N/A" was still the output
|
||||
return nil, fmt.Errorf("invalid duration: %v", err)
|
||||
}
|
||||
|
||||
if durationFloat <= 0 {
|
||||
return nil, fmt.Errorf("video duration must be positive")
|
||||
}
|
||||
|
||||
// Step 2: Get the duration of the video in whole seconds
|
||||
duration := int(durationFloat)
|
||||
|
||||
// Step 3: Calculate seek time based on percentageSeek (percentage value)
|
||||
seekSeconds := duration * percentageSeek / 100
|
||||
|
||||
// Step 4: Convert seekSeconds to string for ffmpeg command
|
||||
seekTime := strconv.Itoa(seekSeconds)
|
||||
|
||||
// Step 5: Extract frame at seek time
|
||||
cmd := exec.Command(
|
||||
s.ffmpegPath,
|
||||
"-ss", seekTime,
|
||||
"-i", videoPath,
|
||||
"-frames:v", "1",
|
||||
"-q:v", "10",
|
||||
"-y", // overwrite output
|
||||
outputPath,
|
||||
)
|
||||
|
||||
if s.debug {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
}
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg command failed on file '%v' : %w", videoPath, err)
|
||||
}
|
||||
return os.ReadFile(outputPath)
|
||||
}
|
||||
|
||||
// GetVideoDuration extracts the duration of a video file using ffprobe
|
||||
func GetVideoDuration(ffprobePath string, videoPath string) (float64, error) {
|
||||
cmd := exec.Command(ffprobePath,
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
videoPath)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ffprobe failed: %w", err)
|
||||
}
|
||||
|
||||
durationStr := strings.TrimSpace(string(output))
|
||||
if durationStr == "" || durationStr == "N/A" {
|
||||
return 0, fmt.Errorf("could not determine video duration")
|
||||
}
|
||||
|
||||
duration, err := strconv.ParseFloat(durationStr, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration: %v", err)
|
||||
}
|
||||
|
||||
return duration, nil
|
||||
}
|
||||
+55
-44
@@ -1,6 +1,6 @@
|
||||
module github.com/gtsteffaniak/filebrowser/backend
|
||||
|
||||
go 1.24.2
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
@@ -24,44 +24,53 @@ require (
|
||||
golang.org/x/mod v0.27.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/sys v0.35.0
|
||||
golang.org/x/time v0.11.0
|
||||
golang.org/x/time v0.12.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
|
||||
4d63.com/gochecknoglobals v0.2.2 // indirect
|
||||
github.com/4meepo/tagalign v1.4.2 // indirect
|
||||
github.com/Abirdcfly/dupword v0.1.3 // indirect
|
||||
codeberg.org/chavacava/garif v0.2.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
|
||||
dev.gaijin.team/go/golib v0.6.0 // indirect
|
||||
github.com/4meepo/tagalign v1.4.3 // indirect
|
||||
github.com/Abirdcfly/dupword v0.1.6 // indirect
|
||||
github.com/AlwxSin/noinlineerr v1.0.5 // indirect
|
||||
github.com/Antonboom/errname v1.1.0 // indirect
|
||||
github.com/Antonboom/nilnil v1.1.0 // indirect
|
||||
github.com/Antonboom/testifylint v1.6.1 // indirect
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect
|
||||
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.3.1 // indirect
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.17.2 // indirect
|
||||
github.com/air-verse/air v1.62.0 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 // 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.0.0 // 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
|
||||
github.com/ashanbrown/forbidigo v1.6.0 // indirect
|
||||
github.com/ashanbrown/makezero v1.2.0 // indirect
|
||||
github.com/ashanbrown/forbidigo/v2 v2.1.0 // indirect
|
||||
github.com/ashanbrown/makezero/v2 v2.0.1 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
||||
github.com/bep/golibsass v1.2.0 // indirect
|
||||
github.com/bkielbasa/cyclop v1.2.3 // indirect
|
||||
github.com/blizzy78/varnamelen v0.8.0 // indirect
|
||||
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
|
||||
github.com/bombsimon/wsl/v5 v5.1.1 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/breml/bidichk v0.3.3 // indirect
|
||||
github.com/breml/errchkjson v0.4.1 // indirect
|
||||
github.com/butuzov/ireturn v0.4.0 // indirect
|
||||
github.com/butuzov/mirror v1.3.0 // indirect
|
||||
github.com/catenacyber/perfsprint v0.9.1 // indirect
|
||||
github.com/ccojocar/zxcvbn-go v1.0.2 // indirect
|
||||
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charithe/durationcheck v0.0.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
@@ -69,11 +78,11 @@ require (
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/chavacava/garif v0.1.0 // indirect
|
||||
github.com/ckaznocha/intrange v0.3.1 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/curioswitch/go-reassign v0.3.0 // indirect
|
||||
github.com/daixiang0/gci v0.13.6 // indirect
|
||||
github.com/daixiang0/gci v0.13.7 // indirect
|
||||
github.com/dave/dst v0.27.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/denis-tingaikin/go-header v0.5.0 // indirect
|
||||
@@ -85,9 +94,9 @@ require (
|
||||
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.5.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fzipp/gocyclo v0.6.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||
github.com/ghostiam/protogetter v0.3.15 // indirect
|
||||
github.com/go-critic/go-critic v0.13.0 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
@@ -105,20 +114,22 @@ require (
|
||||
github.com/go-toolsmith/astp v1.1.0 // indirect
|
||||
github.com/go-toolsmith/strparse v1.1.0 // indirect
|
||||
github.com/go-toolsmith/typep v1.1.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/gofrs/flock v0.12.1 // indirect
|
||||
github.com/golang/geo v0.0.0-20250812090027-44f18bf34695 // indirect
|
||||
github.com/gohugoio/hugo v0.147.6 // indirect
|
||||
github.com/golang/geo v0.0.0-20250825151631-54d70cc7cb31 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
|
||||
github.com/golangci/go-printf-func-name v0.1.0 // indirect
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.1.6 // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.4.0 // indirect
|
||||
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 // indirect
|
||||
github.com/golangci/misspell v0.6.0 // indirect
|
||||
github.com/golangci/plugin-module-register v0.1.1 // indirect
|
||||
github.com/golangci/misspell v0.7.0 // indirect
|
||||
github.com/golangci/plugin-module-register v0.1.2 // indirect
|
||||
github.com/golangci/revgrep v0.8.0 // indirect
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
|
||||
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
|
||||
github.com/gordonklaus/ineffassign v0.1.0 // indirect
|
||||
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
|
||||
@@ -131,9 +142,9 @@ require (
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/hexops/gotextdiff v1.0.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jgautheron/goconst v1.8.1 // indirect
|
||||
github.com/jgautheron/goconst v1.8.2 // indirect
|
||||
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
|
||||
github.com/jjti/go-spancheck v0.6.4 // indirect
|
||||
github.com/jjti/go-spancheck v0.6.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/julz/importas v0.2.0 // indirect
|
||||
github.com/jupiterrider/ffi v0.5.1 // indirect
|
||||
@@ -143,18 +154,19 @@ require (
|
||||
github.com/kulti/thelper v0.6.3 // indirect
|
||||
github.com/kunwardeep/paralleltest v1.0.14 // indirect
|
||||
github.com/lasiar/canonicalheader v1.1.2 // indirect
|
||||
github.com/ldez/exptostd v0.4.3 // indirect
|
||||
github.com/ldez/gomoddirectives v0.6.1 // indirect
|
||||
github.com/ldez/grignotin v0.9.0 // indirect
|
||||
github.com/ldez/exptostd v0.4.4 // indirect
|
||||
github.com/ldez/gomoddirectives v0.7.0 // indirect
|
||||
github.com/ldez/grignotin v0.10.0 // indirect
|
||||
github.com/ldez/tagliatelle v0.7.1 // indirect
|
||||
github.com/ldez/usetesting v0.4.3 // indirect
|
||||
github.com/ldez/usetesting v0.5.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/leonklingele/grouper v1.1.2 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/macabu/inamedparam v0.2.0 // indirect
|
||||
github.com/magiconair/properties v1.8.6 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/manuelarte/funcorder v0.2.1 // indirect
|
||||
github.com/manuelarte/embeddedstructfieldcheck v0.3.0 // indirect
|
||||
github.com/manuelarte/funcorder v0.5.0 // indirect
|
||||
github.com/maratori/testableexamples v1.0.0 // indirect
|
||||
github.com/maratori/testpackage v1.1.1 // indirect
|
||||
github.com/matoous/godox v1.1.0 // indirect
|
||||
@@ -162,16 +174,15 @@ require (
|
||||
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.9.0 // indirect
|
||||
github.com/mgechev/revive v1.11.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
|
||||
github.com/moricho/tparallel v0.3.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/nakabonne/nestif v0.3.1 // indirect
|
||||
github.com/nishanths/exhaustive v0.12.0 // indirect
|
||||
github.com/nishanths/predeclared v0.2.2 // indirect
|
||||
github.com/nunnatsa/ginkgolinter v0.19.1 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/nunnatsa/ginkgolinter v0.20.0 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
@@ -192,18 +203,18 @@ require (
|
||||
github.com/ryancurrah/gomodguard v1.4.1 // indirect
|
||||
github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
|
||||
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
|
||||
github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect
|
||||
github.com/securego/gosec/v2 v2.22.3 // indirect
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
|
||||
github.com/securego/gosec/v2 v2.22.7 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/sivchari/containedctx v1.0.3 // indirect
|
||||
github.com/sonatard/noctx v0.1.0 // indirect
|
||||
github.com/sonatard/noctx v0.4.0 // indirect
|
||||
github.com/sourcegraph/go-diff v0.7.0 // indirect
|
||||
github.com/spf13/cast v1.5.0 // indirect
|
||||
github.com/spf13/cast v1.8.0 // indirect
|
||||
github.com/spf13/cobra v1.9.1 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/spf13/pflag v1.0.7 // indirect
|
||||
github.com/spf13/viper v1.12.0 // indirect
|
||||
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
|
||||
github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect
|
||||
@@ -211,6 +222,7 @@ require (
|
||||
github.com/subosito/gotenv v1.4.1 // indirect
|
||||
github.com/swaggo/files v1.0.1 // indirect
|
||||
github.com/tdakkota/asciicheck v0.4.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.1 // indirect
|
||||
github.com/tetafro/godot v1.5.1 // indirect
|
||||
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect
|
||||
github.com/timonwong/loggercheck v0.11.0 // indirect
|
||||
@@ -220,7 +232,7 @@ require (
|
||||
github.com/ultraware/whitespace v0.2.0 // indirect
|
||||
github.com/urfave/cli/v2 v2.3.0 // indirect
|
||||
github.com/uudashr/gocognit v1.2.0 // indirect
|
||||
github.com/uudashr/iface v1.3.1 // indirect
|
||||
github.com/uudashr/iface v1.4.1 // indirect
|
||||
github.com/xen0n/gosmopolitan v1.3.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yagipy/maintidx v1.0.0 // indirect
|
||||
@@ -228,20 +240,18 @@ require (
|
||||
github.com/ykadowak/zerologlint v0.1.5 // indirect
|
||||
gitlab.com/bosi/decorder v0.4.2 // indirect
|
||||
go-simpler.org/musttag v0.13.1 // indirect
|
||||
go-simpler.org/sloglint v0.11.0 // indirect
|
||||
go-simpler.org/sloglint v0.11.1 // indirect
|
||||
go.augendre.info/arangolint v0.2.0 // indirect
|
||||
go.augendre.info/fatcontext v0.8.0 // indirect
|
||||
go.etcd.io/bbolt v1.4.2 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
go.uber.org/zap v1.24.0 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated // indirect
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
@@ -252,6 +262,7 @@ require (
|
||||
)
|
||||
|
||||
tool (
|
||||
github.com/air-verse/air
|
||||
github.com/golangci/golangci-lint/v2/cmd/golangci-lint
|
||||
github.com/swaggo/swag/cmd/swag
|
||||
)
|
||||
+212
-117
@@ -34,17 +34,29 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
|
||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||
codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY=
|
||||
codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y=
|
||||
dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI=
|
||||
dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo=
|
||||
dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
|
||||
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
|
||||
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
|
||||
github.com/Abirdcfly/dupword v0.1.3/go.mod h1:8VbB2t7e10KRNdwTVoxdBaxla6avbhGzb8sCTygUMhw=
|
||||
github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8=
|
||||
github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c=
|
||||
github.com/Abirdcfly/dupword v0.1.6 h1:qeL6u0442RPRe3mcaLcbaCi2/Y/hOcdtw6DE9odjz9c=
|
||||
github.com/Abirdcfly/dupword v0.1.6/go.mod h1:s+BFMuL/I4YSiFv29snqyjwzDp4b65W2Kvy+PKzZ6cw=
|
||||
github.com/AlwxSin/noinlineerr v1.0.5 h1:RUjt63wk1AYWTXtVXbSqemlbVTb23JOSRiNsshj7TbY=
|
||||
github.com/AlwxSin/noinlineerr v1.0.5/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc=
|
||||
github.com/Antonboom/errname v1.1.0 h1:A+ucvdpMwlo/myWrkHEUEBWc/xuXdud23S8tmTb/oAE=
|
||||
github.com/Antonboom/errname v1.1.0/go.mod h1:O1NMrzgUcVBGIfi3xlVuvX8Q/VP/73sseCaAppfjqZw=
|
||||
github.com/Antonboom/nilnil v1.1.0 h1:jGxJxjgYS3VUUtOTNk8Z1icwT5ESpLH/426fjmQG+ng=
|
||||
github.com/Antonboom/nilnil v1.1.0/go.mod h1:b7sAlogQjFa1wV8jUW3o4PMzDVFLbTux+xnQdvzdcIE=
|
||||
github.com/Antonboom/testifylint v1.6.1 h1:6ZSytkFWatT8mwZlmRCHkWz1gPi+q6UBSbieji2Gj/o=
|
||||
github.com/Antonboom/testifylint v1.6.1/go.mod h1:k+nEkathI2NFjKO6HvwmSrbzUcQ6FAnbZV+ZRrnXPLI=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
@@ -53,8 +65,6 @@ github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
|
||||
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 h1:sHglBQTwgx+rWPdisA5ynNEsoARbiCBOyGcJM4/OzsM=
|
||||
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs=
|
||||
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 h1:Sz1JIXEcSfhz7fUi7xHnhpIE0thVASYjvosApmHuD2k=
|
||||
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1/go.mod h1:n/LSCXNuIYqVfBlVXyHfMQkZDdp1/mmxfSjADd3z1Zg=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
|
||||
@@ -63,14 +73,16 @@ github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsu
|
||||
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.62.0 h1:6CoXL4MAX9dc4xAzLfjMcDfbBoGmW5VjuuTV/1+bI+M=
|
||||
github.com/air-verse/air v1.62.0/go.mod h1:EO+jWuetL10tS9raffwg8WEV0t0KUeucRRaf9ii86dA=
|
||||
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.17.2 h1:Rm81SCZ2mPoH+Q8ZCc/9YvzPUN/E7HgPiPJD8SLV6GI=
|
||||
github.com/alecthomas/chroma/v2 v2.17.2/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
|
||||
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
@@ -80,30 +92,60 @@ 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.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw=
|
||||
github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE=
|
||||
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=
|
||||
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
|
||||
github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
|
||||
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
|
||||
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
|
||||
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
|
||||
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
|
||||
github.com/ashanbrown/forbidigo v1.6.0 h1:D3aewfM37Yb3pxHujIPSpTf6oQk9sc9WZi8gerOIVIY=
|
||||
github.com/ashanbrown/forbidigo v1.6.0/go.mod h1:Y8j9jy9ZYAEHXdu723cUlraTqbzjKF1MUyfOKL+AjcU=
|
||||
github.com/ashanbrown/makezero v1.2.0 h1:/2Lp1bypdmK9wDIq7uWBlDF1iMUpIIS4A+pF6C9IEUU=
|
||||
github.com/ashanbrown/makezero v1.2.0/go.mod h1:dxlPhHbDMC6N6xICzFBSK+4njQDdK8euNO0qjQMtGY4=
|
||||
github.com/ashanbrown/forbidigo/v2 v2.1.0 h1:NAxZrWqNUQiDz19FKScQ/xvwzmij6BiOw3S0+QUQ+Hs=
|
||||
github.com/ashanbrown/forbidigo/v2 v2.1.0/go.mod h1:0zZfdNAuZIL7rSComLGthgc/9/n2FqspBOH90xlCHdA=
|
||||
github.com/ashanbrown/makezero/v2 v2.0.1 h1:r8GtKetWOgoJ4sLyUx97UTwyt2dO7WkGFHizn/Lo8TY=
|
||||
github.com/ashanbrown/makezero/v2 v2.0.1/go.mod h1:kKU4IMxmYW1M4fiEHMb2vc5SFoPzXvgbMR9gIp5pjSw=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
|
||||
github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bep/gitmap v1.6.0 h1:sDuQMm9HoTL0LtlrfxjbjgAg2wHQd4nkMup2FInYzhA=
|
||||
github.com/bep/gitmap v1.6.0/go.mod h1:n+3W1f/rot2hynsqEGxGMErPRgT41n9CkGuzPvz9cIw=
|
||||
github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA=
|
||||
github.com/bep/goat v0.5.0/go.mod h1:Md9x7gRxiWKs85yHlVTvHQw9rg86Bm+Y4SuYE8CTH7c=
|
||||
github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7xg=
|
||||
github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU=
|
||||
github.com/bep/golibsass v1.2.0 h1:nyZUkKP/0psr8nT6GR2cnmt99xS93Ji82ZD9AgOK6VI=
|
||||
github.com/bep/golibsass v1.2.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA=
|
||||
github.com/bep/goportabletext v0.1.0 h1:8dqym2So1cEqVZiBa4ZnMM1R9l/DnC1h4ONg4J5kujw=
|
||||
github.com/bep/goportabletext v0.1.0/go.mod h1:6lzSTsSue75bbcyvVc0zqd1CdApuT+xkZQ6Re5DzZFg=
|
||||
github.com/bep/gowebp v0.4.0 h1:QihuVnvIKbRoeBNQkN0JPMM8ClLmD6V2jMftTFwSK3Q=
|
||||
github.com/bep/gowebp v0.4.0/go.mod h1:95gtYkAA8iIn1t3HkAPurRCVGV/6NhgaHJ1urz0iIwc=
|
||||
github.com/bep/imagemeta v0.12.0 h1:ARf+igs5B7pf079LrqRnwzQ/wEB8Q9v4NSDRZO1/F5k=
|
||||
github.com/bep/imagemeta v0.12.0/go.mod h1:23AF6O+4fUi9avjiydpKLStUNtJr5hJB4rarG18JpN8=
|
||||
github.com/bep/lazycache v0.8.0 h1:lE5frnRjxaOFbkPZ1YL6nijzOPPz6zeXasJq8WpG4L8=
|
||||
github.com/bep/lazycache v0.8.0/go.mod h1:BQ5WZepss7Ko91CGdWz8GQZi/fFnCcyWupv8gyTeKwk=
|
||||
github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ=
|
||||
github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0=
|
||||
github.com/bep/overlayfs v0.10.0 h1:wS3eQ6bRsLX+4AAmwGjvoFSAQoeheamxofFiJ2SthSE=
|
||||
github.com/bep/overlayfs v0.10.0/go.mod h1:ouu4nu6fFJaL0sPzNICzxYsBeWwrjiTdFZdK4lI3tro=
|
||||
github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI=
|
||||
github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
|
||||
github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w=
|
||||
github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo=
|
||||
github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M=
|
||||
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
|
||||
github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
|
||||
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
|
||||
github.com/bombsimon/wsl/v5 v5.1.1 h1:cQg5KJf9FlctAH4cpL9vLKnziYknoCMCdqXl0wjl72Q=
|
||||
github.com/bombsimon/wsl/v5 v5.1.1/go.mod h1:Gp8lD04z27wm3FANIUPZycXp+8huVsn0oxc+n4qfV9I=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
@@ -117,8 +159,8 @@ github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc=
|
||||
github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI=
|
||||
github.com/catenacyber/perfsprint v0.9.1 h1:5LlTp4RwTooQjJCvGEFV6XksZvWE7wCOUvjD2z0vls0=
|
||||
github.com/catenacyber/perfsprint v0.9.1/go.mod h1:q//VWC2fWbcdSLEY1R3l8n0zQCDPdE4IjZwyY1HMunM=
|
||||
github.com/ccojocar/zxcvbn-go v1.0.2 h1:na/czXU8RrhXO4EZme6eQJLR4PzcGsahsBOAwU6I3Vg=
|
||||
github.com/ccojocar/zxcvbn-go v1.0.2/go.mod h1:g1qkXtUSvHP8lhHp5GrSmTz6uWALGRMQdw6Qnz/hi60=
|
||||
github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc=
|
||||
github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
@@ -136,13 +178,13 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc=
|
||||
github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs=
|
||||
github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
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.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
|
||||
@@ -150,10 +192,12 @@ github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmr
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs=
|
||||
github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88=
|
||||
github.com/daixiang0/gci v0.13.6 h1:RKuEOSkGpSadkGbvZ6hJ4ddItT3cVZ9Vn9Rybk6xjl8=
|
||||
github.com/daixiang0/gci v0.13.6/go.mod h1:12etP2OniiIdP4q+kjUGrC/rUagga7ODbqsom5Eo5Yk=
|
||||
github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ=
|
||||
github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih0brVzQ=
|
||||
github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY=
|
||||
github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc=
|
||||
github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo=
|
||||
@@ -163,6 +207,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8=
|
||||
github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
|
||||
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
|
||||
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
|
||||
@@ -190,22 +236,29 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=
|
||||
github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A=
|
||||
github.com/evanw/esbuild v0.25.3 h1:4JKyUsm/nHDhpxis4IyWXAi8GiyTwG1WdEp6OhGVE8U=
|
||||
github.com/evanw/esbuild v0.25.3/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/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=
|
||||
github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo=
|
||||
github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
|
||||
github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
|
||||
github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
|
||||
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
|
||||
github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
|
||||
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gen2brain/go-fitz v1.24.15 h1:sJNB1MOWkqnzzENPHggFpgxTwW0+S5WF/rM5wUBpJWo=
|
||||
github.com/gen2brain/go-fitz v1.24.15/go.mod h1:SftkiVbTHqF141DuiLwBBM65zP7ig6AVDQpf2WlHamo=
|
||||
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
|
||||
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/ghostiam/protogetter v0.3.15 h1:1KF5sXel0HE48zh1/vn0Loiw25A9ApyseLzQuif1mLY=
|
||||
github.com/ghostiam/protogetter v0.3.15/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA=
|
||||
github.com/go-critic/go-critic v0.13.0 h1:kJzM7wzltQasSUXtYyTl6UaPVySO6GkaR1thFnJ6afY=
|
||||
@@ -227,8 +280,8 @@ github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vb
|
||||
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.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
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.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA=
|
||||
github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
@@ -269,10 +322,12 @@ github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQi
|
||||
github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ=
|
||||
github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus=
|
||||
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
|
||||
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
|
||||
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
@@ -280,13 +335,29 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e h1:QArsSubW7eDh8APMXkByjQWvuljwPGAGQpJEFn0F0wY=
|
||||
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e/go.mod h1:3Ltoo9Banwq0gOtcOwxuHG6omk+AwsQPADyw2vQYOJQ=
|
||||
github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
|
||||
github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
|
||||
github.com/gohugoio/httpcache v0.7.0 h1:ukPnn04Rgvx48JIinZvZetBfHaWE7I01JR2Q2RrQ3Vs=
|
||||
github.com/gohugoio/httpcache v0.7.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
|
||||
github.com/gohugoio/hugo v0.147.6 h1:rL4rnus/5qzj4+FoA+JMzsVvFJ2YZdVIH6pbuCB2P84=
|
||||
github.com/gohugoio/hugo v0.147.6/go.mod h1:Sb2COQPDPYG+tRSpePtzKytiuVDqkBivEhgIew1QbNo=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.3.0 h1:gj49kTR5Z4Hnm0ZaQrgPVazL3DUkppw+x6XhHCmh+Wk=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.3.0/go.mod h1:IMMj7xiUbLt1YNJ6m7AM4cnsX4cFnnfkleO/lBHGzUg=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1 h1:nUzXfRTszLliZuN0JTKeunXTRaiFX6ksaWP0puLLYAY=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1/go.mod h1:Wy8ThAA8p2/w1DY05vEzq6EIeI2mzDjvHsu7ULBVwog=
|
||||
github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XGDc=
|
||||
github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4=
|
||||
github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo=
|
||||
github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
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-20250812090027-44f18bf34695 h1:W3KSdht88NFfM71hd6Y4j2uiW81/aoqjPrMVoV/ph1E=
|
||||
github.com/golang/geo v0.0.0-20250812090027-44f18bf34695/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
|
||||
github.com/golang/geo v0.0.0-20250825151631-54d70cc7cb31 h1:226lwSa0uZO7sd7QU88n43nDIqGoc+bOh0vSO3Q/byU=
|
||||
github.com/golang/geo v0.0.0-20250825151631-54d70cc7cb31/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
|
||||
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=
|
||||
@@ -324,16 +395,19 @@ github.com/golangci/go-printf-func-name v0.1.0 h1:dVokQP+NMTO7jwO4bwsRwLWeudOVUP
|
||||
github.com/golangci/go-printf-func-name v0.1.0/go.mod h1:wqhWFH5mUdJQhweRnldEywnR5021wTdZSNgwYceV14s=
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
|
||||
github.com/golangci/golangci-lint/v2 v2.1.6 h1:LXqShFfAGM5BDzEOWD2SL1IzJAgUOqES/HRBsfKjI+w=
|
||||
github.com/golangci/golangci-lint/v2 v2.1.6/go.mod h1:EPj+fgv4TeeBq3TcqaKZb3vkiV5dP4hHHKhXhEhzci8=
|
||||
github.com/golangci/golangci-lint v1.64.8 h1:y5TdeVidMtBGG32zgSC7ZXTFNHrsJkDnpO4ItB3Am+I=
|
||||
github.com/golangci/golangci-lint/v2 v2.4.0 h1:qz6O6vr7kVzXJqyvHjHSz5fA3D+PM8v96QU5gxZCNWM=
|
||||
github.com/golangci/golangci-lint/v2 v2.4.0/go.mod h1:Oq7vuAf6L1iNL34uHDcsIF6Mnc0amOPdsT3/GlpHD+I=
|
||||
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 h1:AkK+w9FZBXlU/xUmBtSJN1+tAI4FIvy5WtnUnY8e4p8=
|
||||
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95/go.mod h1:k9mmcyWKSTMcPPvQUCfRWWQ9VHJ1U9Dc0R7kaXAgtnQ=
|
||||
github.com/golangci/misspell v0.6.0 h1:JCle2HUTNWirNlDIAUO44hUsKhOFqGPoC4LZxlaSXDs=
|
||||
github.com/golangci/misspell v0.6.0/go.mod h1:keMNyY6R9isGaSAu+4Q8NMBwMPkh15Gtc8UCVoDtAWo=
|
||||
github.com/golangci/plugin-module-register v0.1.1 h1:TCmesur25LnyJkpsVrupv1Cdzo+2f7zX0H6Jkw1Ol6c=
|
||||
github.com/golangci/plugin-module-register v0.1.1/go.mod h1:TTpqoB6KkwOJMV8u7+NyXMrkwwESJLOkfl9TxR1DGFc=
|
||||
github.com/golangci/misspell v0.7.0 h1:4GOHr/T1lTW0hhR4tgaaV1WS/lJ+ncvYCoFKmqJsj0c=
|
||||
github.com/golangci/misspell v0.7.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
|
||||
github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
|
||||
github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
|
||||
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
|
||||
github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k=
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM=
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s=
|
||||
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM=
|
||||
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
@@ -348,7 +422,6 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
@@ -362,8 +435,8 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
|
||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18=
|
||||
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
@@ -386,6 +459,8 @@ github.com/gtsteffaniak/go-cache v0.0.0-20250521142451-edc77dfcb063 h1:CoLbeqTmM
|
||||
github.com/gtsteffaniak/go-cache v0.0.0-20250521142451-edc77dfcb063/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
|
||||
github.com/gtsteffaniak/go-logger v0.1.2 h1:0g6UxxNwU+Q+7IVRw+Yn7AuYsIK0ukiTgbzjTLm+Uy4=
|
||||
github.com/gtsteffaniak/go-logger v0.1.2/go.mod h1:U3ZkdAclcxXNMvoQLq+zDkqcTmdqhX3hTf+6AuTo6v0=
|
||||
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
|
||||
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
|
||||
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo=
|
||||
github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
@@ -404,14 +479,16 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
|
||||
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
|
||||
github.com/jgautheron/goconst v1.8.1 h1:PPqCYp3K/xlOj5JmIe6O1Mj6r1DbkdbLtR3AJuZo414=
|
||||
github.com/jgautheron/goconst v1.8.1/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako=
|
||||
github.com/jgautheron/goconst v1.8.2 h1:y0XF7X8CikZ93fSNT6WBTb/NElBu9IjaY7CCYQrCMX4=
|
||||
github.com/jgautheron/goconst v1.8.2/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako=
|
||||
github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs=
|
||||
github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c=
|
||||
github.com/jjti/go-spancheck v0.6.4 h1:Tl7gQpYf4/TMU7AT84MN83/6PutY21Nb9fuQjFTpRRc=
|
||||
github.com/jjti/go-spancheck v0.6.4/go.mod h1:yAEYdKJ2lRkDA8g7X+oKUHXOWVAXSBJRv04OhF+QUjk=
|
||||
github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8=
|
||||
github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||
@@ -450,18 +527,20 @@ github.com/kulti/thelper v0.6.3 h1:ElhKf+AlItIu+xGnI990no4cE2+XaSu1ULymV2Yulxs=
|
||||
github.com/kulti/thelper v0.6.3/go.mod h1:DsqKShOvP40epevkFrvIwkCMNYxMeTNjdWL4dqWHZ6I=
|
||||
github.com/kunwardeep/paralleltest v1.0.14 h1:wAkMoMeGX/kGfhQBPODT/BL8XhK23ol/nuQ3SwFaUw8=
|
||||
github.com/kunwardeep/paralleltest v1.0.14/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
|
||||
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
|
||||
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
|
||||
github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4=
|
||||
github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI=
|
||||
github.com/ldez/exptostd v0.4.3 h1:Ag1aGiq2epGePuRJhez2mzOpZ8sI9Gimcb4Sb3+pk9Y=
|
||||
github.com/ldez/exptostd v0.4.3/go.mod h1:iZBRYaUmcW5jwCR3KROEZ1KivQQp6PHXbDPk9hqJKCQ=
|
||||
github.com/ldez/gomoddirectives v0.6.1 h1:Z+PxGAY+217f/bSGjNZr/b2KTXcyYLgiWI6geMBN2Qc=
|
||||
github.com/ldez/gomoddirectives v0.6.1/go.mod h1:cVBiu3AHR9V31em9u2kwfMKD43ayN5/XDgr+cdaFaKs=
|
||||
github.com/ldez/grignotin v0.9.0 h1:MgOEmjZIVNn6p5wPaGp/0OKWyvq42KnzAt/DAb8O4Ow=
|
||||
github.com/ldez/grignotin v0.9.0/go.mod h1:uaVTr0SoZ1KBii33c47O1M8Jp3OP3YDwhZCmzT9GHEk=
|
||||
github.com/ldez/exptostd v0.4.4 h1:58AtQjnLcT/tI5W/1KU7xE/O7zW9RAWB6c/ScQAnfus=
|
||||
github.com/ldez/exptostd v0.4.4/go.mod h1:QfdzPw6oHjFVdNV7ILoPu5sw3OZ3OG1JS0I5JN3J4Js=
|
||||
github.com/ldez/gomoddirectives v0.7.0 h1:EOx8Dd56BZYSez11LVgdj025lKwlP0/E5OLSl9HDwsY=
|
||||
github.com/ldez/gomoddirectives v0.7.0/go.mod h1:wR4v8MN9J8kcwvrkzrx6sC9xe9Cp68gWYCsda5xvyGc=
|
||||
github.com/ldez/grignotin v0.10.0 h1:NQPeh1E/Eza4F0exCeC1WkpnLvgUcQDT8MQ1vOLML0E=
|
||||
github.com/ldez/grignotin v0.10.0/go.mod h1:oR4iCKUP9fwoeO6vCQeD7M5SMxCT6xdVas4vg0h1LaI=
|
||||
github.com/ldez/tagliatelle v0.7.1 h1:bTgKjjc2sQcsgPiT902+aadvMjCeMHrY7ly2XKFORIk=
|
||||
github.com/ldez/tagliatelle v0.7.1/go.mod h1:3zjxUpsNB2aEZScWiZTHrAXOl1x25t3cRmzfK1mlo2I=
|
||||
github.com/ldez/usetesting v0.4.3 h1:pJpN0x3fMupdTf/IapYjnkhiY1nSTN+pox1/GyBRw3k=
|
||||
github.com/ldez/usetesting v0.4.3/go.mod h1:eEs46T3PpQ+9RgN9VjpY6qWdiw2/QmfiDeWmdZdrjIQ=
|
||||
github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc=
|
||||
github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY=
|
||||
@@ -474,12 +553,18 @@ github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamh
|
||||
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/manuelarte/funcorder v0.2.1 h1:7QJsw3qhljoZ5rH0xapIvjw31EcQeFbF31/7kQ/xS34=
|
||||
github.com/manuelarte/funcorder v0.2.1/go.mod h1:BQQ0yW57+PF9ZpjpeJDKOffEsQbxDFKW8F8zSMe/Zd0=
|
||||
github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE=
|
||||
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
|
||||
github.com/manuelarte/embeddedstructfieldcheck v0.3.0 h1:VhGqK8gANDvFYDxQkjPbv7/gDJtsGU9k6qj/hC2hgso=
|
||||
github.com/manuelarte/embeddedstructfieldcheck v0.3.0/go.mod h1:LSo/IQpPfx1dXMcX4ibZCYA7Yy6ayZHIaOGM70+1Wy8=
|
||||
github.com/manuelarte/funcorder v0.5.0 h1:llMuHXXbg7tD0i/LNw8vGnkDTHFpTnWqKPI85Rknc+8=
|
||||
github.com/manuelarte/funcorder v0.5.0/go.mod h1:Yt3CiUQthSBMBxjShjdXMexmzpP8YGvGLjrxJNkO2hA=
|
||||
github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI=
|
||||
github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE=
|
||||
github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04=
|
||||
github.com/maratori/testpackage v1.1.1/go.mod h1:s4gRK/ym6AMrqpOa/kEbQTV4Q4jb7WeLZzVhVVVOQMc=
|
||||
github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
|
||||
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
|
||||
github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4=
|
||||
github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs=
|
||||
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
||||
@@ -488,42 +573,51 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
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.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
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/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.9.0 h1:8LaA62XIKrb8lM6VsBSQ92slt/o92z5+hTw3CmrvSrM=
|
||||
github.com/mgechev/revive v1.9.0/go.mod h1:LAPq3+MgOf7GcL5PlWIkHb0PT7XH4NuC2LdWymhb9Mo=
|
||||
github.com/mgechev/revive v1.11.0 h1:b/gLLpBE427o+Xmd8G58gSA+KtBwxWinH/A565Awh0w=
|
||||
github.com/mgechev/revive v1.11.0/go.mod h1:tI0oLF/2uj+InHCBLrrqfTKfjtFTBCFFfG05auyzgdw=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI=
|
||||
github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=
|
||||
github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc=
|
||||
github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
|
||||
github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
|
||||
github.com/niklasfasching/go-org v1.7.0 h1:vyMdcMWWTe/XmANk19F4k8XGBYg0GQ/gJGMimOjGMek=
|
||||
github.com/niklasfasching/go-org v1.7.0/go.mod h1:WuVm4d45oePiE0eX25GqTDQIt/qPW1T9DGkRscqLW5o=
|
||||
github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg=
|
||||
github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
|
||||
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
|
||||
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
|
||||
github.com/nunnatsa/ginkgolinter v0.19.1 h1:mjwbOlDQxZi9Cal+KfbEJTCz327OLNfwNvoZ70NJ+c4=
|
||||
github.com/nunnatsa/ginkgolinter v0.19.1/go.mod h1:jkQ3naZDmxaZMXPWaS9rblH+i+GWXQCaS/JFIWcOH2s=
|
||||
github.com/nunnatsa/ginkgolinter v0.20.0 h1:OmWLkAFO2HUTYcU6mprnKud1Ey5pVdiVNYGO5HVicx8=
|
||||
github.com/nunnatsa/ginkgolinter v0.20.0/go.mod h1:dCIuFlTPfQerXgGUju3VygfAFPdC5aE1mdacCDKDJcQ=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0=
|
||||
github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM=
|
||||
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
|
||||
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
|
||||
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
|
||||
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
|
||||
@@ -531,10 +625,14 @@ github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJ
|
||||
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
|
||||
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
|
||||
github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -596,14 +694,14 @@ github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9f
|
||||
github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ=
|
||||
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.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw=
|
||||
github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
|
||||
github.com/sashamelentyev/usestdlibvars v1.28.0 h1:jZnudE2zKCtYlGzLVreNp5pmCdOxXUzwsMDBkR21cyQ=
|
||||
github.com/sashamelentyev/usestdlibvars v1.28.0/go.mod h1:9nl0jgOfHKWNFS43Ojw0i7aRoS4j6EBye3YBhmAIRF8=
|
||||
github.com/securego/gosec/v2 v2.22.3 h1:mRrCNmRF2NgZp4RJ8oJ6yPJ7G4x6OCiAXHd8x4trLRc=
|
||||
github.com/securego/gosec/v2 v2.22.3/go.mod h1:42M9Xs0v1WseinaB/BmNGO8AVqG8vRfhC2686ACY48k=
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
|
||||
github.com/securego/gosec/v2 v2.22.7 h1:8/9P+oTYI4yIpAzccQKVsg1/90Po+JzGtAhqoHImDeM=
|
||||
github.com/securego/gosec/v2 v2.22.7/go.mod h1:510TFNDMrIPytokyHQAVLvPeDr41Yihn2ak8P+XQfNE=
|
||||
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=
|
||||
@@ -616,21 +714,22 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
|
||||
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
|
||||
github.com/sonatard/noctx v0.1.0 h1:JjqOc2WN16ISWAjAk8M5ej0RfExEXtkEyExl2hLW+OM=
|
||||
github.com/sonatard/noctx v0.1.0/go.mod h1:0RvBxqY8D4j9cTTTWE8ylt2vqj2EPI8fHmrxHdsaZ2c=
|
||||
github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
|
||||
github.com/sonatard/noctx v0.4.0/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.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
|
||||
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
|
||||
github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
|
||||
github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
|
||||
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ=
|
||||
github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI=
|
||||
github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0=
|
||||
@@ -639,18 +738,12 @@ github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8B
|
||||
github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
|
||||
@@ -663,12 +756,20 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/tdakkota/asciicheck v0.4.1 h1:bm0tbcmi0jezRA2b5kg4ozmMuGAFotKI3RZfrhfovg8=
|
||||
github.com/tdakkota/asciicheck v0.4.1/go.mod h1:0k7M3rCfRXb0Z6bwgvkEIMleKH3kXNz9UqJ9Xuqopr8=
|
||||
github.com/tdewolff/minify/v2 v2.23.5 h1:/P548KcpTkIOUvNg22zN83/GiaYSOIrbqtoue4I7kYM=
|
||||
github.com/tdewolff/minify/v2 v2.23.5/go.mod h1:2RI9tiIrzJU1Z5EasXEPaI1MqobRyxKHOOgrRkq5oEw=
|
||||
github.com/tdewolff/parse/v2 v2.8.1 h1:J5GSHru6o3jF1uLlEKVXkDxxcVx6yzOlIVIotK4w2po=
|
||||
github.com/tdewolff/parse/v2 v2.8.1/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA=
|
||||
github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0=
|
||||
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag=
|
||||
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY=
|
||||
github.com/tetafro/godot v1.5.1 h1:PZnjCol4+FqaEzvZg5+O8IY2P3hfY9JzRBNPv1pEDS4=
|
||||
github.com/tetafro/godot v1.5.1/go.mod h1:cCdPtEndkmqqrhiCfkmxDodMQJ/f3L1BCNskCUZdTwk=
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk=
|
||||
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460=
|
||||
github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M=
|
||||
@@ -685,8 +786,8 @@ 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/iface v1.3.1 h1:bA51vmVx1UIhiIsQFSNq6GZ6VPTk3WNMZgRiCe9R29U=
|
||||
github.com/uudashr/iface v1.3.1/go.mod h1:4QvspiRd3JLPAEXBQ9AiZpLbJlrWWgRChOKDJEuQTdg=
|
||||
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=
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
|
||||
github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
|
||||
@@ -706,34 +807,38 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo=
|
||||
github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo=
|
||||
gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8=
|
||||
go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ=
|
||||
go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28=
|
||||
go-simpler.org/musttag v0.13.1 h1:lw2sJyu7S1X8lc8zWUAdH42y+afdcCnHhWpnkWvd6vU=
|
||||
go-simpler.org/musttag v0.13.1/go.mod h1:8r450ehpMLQgvpb6sg+hV5Ur47eH6olp/3yEanfG97k=
|
||||
go-simpler.org/sloglint v0.11.0 h1:JlR1X4jkbeaffiyjLtymeqmGDKBDO1ikC6rjiuFAOco=
|
||||
go-simpler.org/sloglint v0.11.0/go.mod h1:CFDO8R1i77dlciGfPEPvYke2ZMx4eyGiEIWkyeW2Pvw=
|
||||
go-simpler.org/sloglint v0.11.1 h1:xRbPepLT/MHPTCA6TS/wNfZrDzkGvCCqUv4Bdwc3H7s=
|
||||
go-simpler.org/sloglint v0.11.1/go.mod h1:2PowwiCOK8mjiF+0KGifVOT8ZsCNiFzvfyJeJOIt8MQ=
|
||||
go.augendre.info/arangolint v0.2.0 h1:2NP/XudpPmfBhQKX4rMk+zDYIj//qbt4hfZmSSTcpj8=
|
||||
go.augendre.info/arangolint v0.2.0/go.mod h1:Vx4KSJwu48tkE+8uxuf0cbBnAPgnt8O1KWiT7bljq7w=
|
||||
go.augendre.info/fatcontext v0.8.0 h1:2dfk6CQbDGeu1YocF59Za5Pia7ULeAM6friJ3LP7lmk=
|
||||
go.augendre.info/fatcontext v0.8.0/go.mod h1:oVJfMgwngMsHO+KB2MdgzcO+RvtNdiCEOlWvSFtax/s=
|
||||
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
|
||||
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
|
||||
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
@@ -755,12 +860,12 @@ 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-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
||||
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b h1:QoALfVG9rhQ/M7vYDScfPdWjGL9dlsVVM5VGh7aKoAA=
|
||||
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||
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-20250210185358-939b2ce775ac h1:TSSpLIG4v+p0rPv1pNOQtl1I8knsO4S9trOxNMOLVP4=
|
||||
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b h1:KdrhdYPDUvJTvrDK9gdjfFd6JTk8vA1WJoldYSi0kHo=
|
||||
golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b/go.mod h1:LKZHyeOpPuZcMgxeHjJp4p5yvxrCX1xDvH10zYHhjjQ=
|
||||
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.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||
@@ -787,9 +892,7 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.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.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
@@ -831,10 +934,8 @@ golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qx
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
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=
|
||||
@@ -908,13 +1009,11 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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.2.0/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=
|
||||
@@ -924,9 +1023,7 @@ golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
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.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
@@ -937,9 +1034,7 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
@@ -947,8 +1042,8 @@ golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
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.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
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=
|
||||
@@ -1000,9 +1095,7 @@ golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
|
||||
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.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
@@ -1129,6 +1222,8 @@ mvdan.cc/gofumpt v0.8.0/go.mod h1:vEYnSzyGPmjvFkqJWtXkh79UwPWP9/HMxQdGEXZHjpg=
|
||||
mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 h1:WjUu4yQoT5BHT1w8Zu56SP8367OuBV5jvo+4Ulppyf8=
|
||||
mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4/go.mod h1:rthT7OuvRbaGcd5ginj6dA2oLE7YNlta9qhBNNdCaLE=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
|
||||
|
||||
@@ -23,7 +23,7 @@ type GroupListResponse struct {
|
||||
// @Param path query string false "Index path (e.g. /secret)"
|
||||
// @Param user query string false "Username to filter rules for"
|
||||
// @Param group query string false "Group name to filter rules for"
|
||||
// @Success 200 {object} object "Varies based on query. Can be access.FrontendAccessRule, []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule"
|
||||
// @Success 200 {object} object "Varies based on query. Can be access.FrontendAccessRule (when source and path specified), []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule"
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/access [get]
|
||||
|
||||
@@ -76,11 +76,12 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
|
||||
return nil, err
|
||||
}
|
||||
if config.Auth.Methods.ProxyAuth.CreateUser {
|
||||
err = storage.CreateUser(users.User{
|
||||
user := users.User{
|
||||
LoginMethod: users.LoginMethodProxy,
|
||||
Username: proxyUser,
|
||||
Permissions: settings.Config.UserDefaults.Permissions,
|
||||
})
|
||||
}
|
||||
settings.ApplyUserDefaults(&user)
|
||||
err = storage.CreateUser(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -71,7 +71,11 @@ func sseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
|
||||
return http.StatusInternalServerError, fmt.Errorf("error sending broadcast: %v, user: %s", err, username)
|
||||
}
|
||||
|
||||
case msg := <-sendChan:
|
||||
case msg, ok := <-sendChan:
|
||||
if !ok {
|
||||
logger.Debugf("SSE channel closed for user: %s, SessionId: %s", username, sessionId)
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
if err := msgr.sendEvent(msg.EventType, msg.Message); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error sending targeted message: %v, user: %s", err, username)
|
||||
}
|
||||
|
||||
+49
-70
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/events"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
// http-swagger middleware
|
||||
)
|
||||
@@ -23,9 +25,6 @@ import (
|
||||
//go:embed embed/*
|
||||
var assets embed.FS
|
||||
|
||||
// Boolean flag to determine whether to use the embedded FS or not
|
||||
var embeddedFS = os.Getenv("FILEBROWSER_NO_EMBEDED") != "true"
|
||||
|
||||
// Custom dirFS to handle both embedded and non-embedded file systems
|
||||
type dirFS struct {
|
||||
http.Dir
|
||||
@@ -45,7 +44,18 @@ var (
|
||||
func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete chan struct{}) {
|
||||
store = storage
|
||||
config = &settings.Config
|
||||
var err error
|
||||
|
||||
// Dev mode enables development features like template hot-reloading
|
||||
devMode := os.Getenv("FILEBROWSER_DEVMODE") == "true"
|
||||
_, err := os.Stat("http/dist")
|
||||
// In dev mode, always use filesystem assets. Otherwise, check if http/dist exists
|
||||
var embeddedFS bool
|
||||
if devMode {
|
||||
embeddedFS = false
|
||||
} else {
|
||||
embeddedFS = os.IsNotExist(err)
|
||||
}
|
||||
|
||||
// --- START: ADD THIS DECRYPTION LOGIC ---
|
||||
if embeddedFS {
|
||||
// Embedded mode: Serve files from the embedded assets
|
||||
@@ -57,8 +67,20 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
assetFs = dirFS{Dir: http.Dir("http/dist")}
|
||||
}
|
||||
|
||||
// In development mode, we want to reload the templates on each request.
|
||||
// In production (embedded), we parse them once.
|
||||
templates := template.New("").Funcs(template.FuncMap{
|
||||
"marshal": func(v interface{}) (string, error) {
|
||||
a, err := json.Marshal(v)
|
||||
return string(a), err
|
||||
},
|
||||
})
|
||||
if !devMode {
|
||||
templates = template.Must(templates.ParseFS(assetFs, "public/index.html"))
|
||||
}
|
||||
templateRenderer = &TemplateRenderer{
|
||||
templates: template.Must(template.ParseFS(assetFs, "public/index.html")),
|
||||
templates: templates,
|
||||
devMode: devMode,
|
||||
}
|
||||
|
||||
router := http.NewServeMux()
|
||||
@@ -95,6 +117,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
api.HandleFunc("PATCH /resources", withUser(resourcePatchHandler))
|
||||
api.HandleFunc("GET /raw", withUser(rawHandler))
|
||||
api.HandleFunc("GET /preview", withUser(previewHandler))
|
||||
api.HandleFunc("GET /media/subtitles", withUser(subtitlesHandler))
|
||||
if version.Version == "testing" || version.Version == "untracked" {
|
||||
api.HandleFunc("GET /inspectIndex", inspectIndex)
|
||||
api.HandleFunc("GET /mockData", mockData)
|
||||
@@ -106,21 +129,28 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
api.HandleFunc("GET /access/groups", withAdmin(groupGetHandler))
|
||||
api.HandleFunc("POST /access/group", withAdmin(groupPostHandler))
|
||||
api.HandleFunc("DELETE /access/group", withAdmin(groupDeleteHandler))
|
||||
|
||||
// Share management routes (require permission)
|
||||
api.HandleFunc("GET /shares", withPermShare(shareListHandler))
|
||||
api.HandleFunc("GET /share/direct", withPermShare(shareDirectDownloadHandler))
|
||||
api.HandleFunc("GET /share", withPermShare(shareGetHandler))
|
||||
api.HandleFunc("POST /share", withPermShare(sharePostHandler))
|
||||
api.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
|
||||
|
||||
// Create API sub-router for public API endpoints
|
||||
publicAPI := http.NewServeMux()
|
||||
// NEW PUBLIC ROUTES - All publicly accessible endpoints
|
||||
// Share management routes (require permission but are publicly accessible)
|
||||
publicRoutes.HandleFunc("GET /shares", withPermShare(shareListHandler))
|
||||
publicRoutes.HandleFunc("GET /share/direct", withPermShare(shareDirectDownloadHandler))
|
||||
publicRoutes.HandleFunc("GET /share", withPermShare(shareGetHandler))
|
||||
publicRoutes.HandleFunc("POST /share", withPermShare(sharePostHandler))
|
||||
publicRoutes.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
|
||||
// Public API routes (hash-based authentication)
|
||||
publicAPI.HandleFunc("GET /raw", withHashFile(publicRawHandler))
|
||||
publicAPI.HandleFunc("GET /preview", withHashFile(publicPreviewHandler))
|
||||
publicAPI.HandleFunc("GET /shared", withHashFile(publicShareHandler))
|
||||
publicAPI.HandleFunc("GET /resources", withHashFile(publicShareHandler))
|
||||
publicAPI.HandleFunc("GET /users", withUser(userGetHandler))
|
||||
publicAPI.HandleFunc("POST /onlyoffice/callback", withHashFile(onlyofficeCallbackHandler))
|
||||
publicAPI.HandleFunc("GET /onlyoffice/config", withHashFile(onlyofficeClientConfigGetHandler))
|
||||
|
||||
// Settings routes
|
||||
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
|
||||
api.HandleFunc("GET /settings/config", withAdmin(settingsConfigHandler))
|
||||
|
||||
// Events routes
|
||||
api.HandleFunc("GET /events", withUser(func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
@@ -131,63 +161,9 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
api.HandleFunc("GET /jobs/{action}/{target}", withUser(getJobsHandler))
|
||||
|
||||
api.HandleFunc("GET /onlyoffice/config", withUser(onlyofficeClientConfigGetHandler))
|
||||
publicAPI.HandleFunc("GET /onlyoffice/config", withHashFile(onlyofficeClientConfigGetHandler))
|
||||
api.HandleFunc("POST /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
|
||||
publicAPI.HandleFunc("POST /onlyoffice/callback", withHashFile(onlyofficeCallbackHandler))
|
||||
api.HandleFunc("GET /onlyoffice/getToken", withUser(onlyofficeGetTokenHandler))
|
||||
publicAPI.HandleFunc("GET /onlyoffice/getToken", withHashFile(onlyofficeGetTokenHandler))
|
||||
|
||||
api.HandleFunc("GET /search", withUser(searchHandler))
|
||||
|
||||
// Share routes (DEPRECATED - maintain for backwards compatibility)
|
||||
// These will redirect to the new /public/shares endpoints
|
||||
api.HandleFunc("GET /shares", func(w http.ResponseWriter, r *http.Request) {
|
||||
newURL := config.Server.BaseURL + "public/shares"
|
||||
if r.URL.RawQuery != "" {
|
||||
newURL += "?" + r.URL.RawQuery
|
||||
}
|
||||
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
|
||||
})
|
||||
api.HandleFunc("GET /share", func(w http.ResponseWriter, r *http.Request) {
|
||||
newURL := config.Server.BaseURL + "public/share"
|
||||
if r.URL.RawQuery != "" {
|
||||
newURL += "?" + r.URL.RawQuery
|
||||
}
|
||||
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
|
||||
})
|
||||
api.HandleFunc("POST /share", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, config.Server.BaseURL+"public/share", http.StatusPermanentRedirect)
|
||||
})
|
||||
api.HandleFunc("DELETE /share", func(w http.ResponseWriter, r *http.Request) {
|
||||
newURL := config.Server.BaseURL + "public/share"
|
||||
if r.URL.RawQuery != "" {
|
||||
newURL += "?" + r.URL.RawQuery
|
||||
}
|
||||
http.Redirect(w, r, newURL, http.StatusPermanentRedirect)
|
||||
})
|
||||
|
||||
api.HandleFunc("GET /public/raw", func(w http.ResponseWriter, r *http.Request) {
|
||||
newURL := config.Server.BaseURL + "public/api/raw"
|
||||
if r.URL.RawQuery != "" {
|
||||
newURL += "?" + r.URL.RawQuery
|
||||
}
|
||||
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
|
||||
})
|
||||
api.HandleFunc("GET /public/share", func(w http.ResponseWriter, r *http.Request) {
|
||||
newURL := config.Server.BaseURL + "public/api/shared"
|
||||
if r.URL.RawQuery != "" {
|
||||
newURL += "?" + r.URL.RawQuery
|
||||
}
|
||||
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
|
||||
})
|
||||
api.HandleFunc("GET /public/preview", func(w http.ResponseWriter, r *http.Request) {
|
||||
newURL := config.Server.BaseURL + "public/api/preview"
|
||||
if r.URL.RawQuery != "" {
|
||||
newURL += "?" + r.URL.RawQuery
|
||||
}
|
||||
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
|
||||
})
|
||||
|
||||
// Mount the public API sub-router
|
||||
publicRoutes.Handle("/api/", http.StripPrefix("/api", publicAPI))
|
||||
|
||||
@@ -203,11 +179,11 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
// New frontend share route handler - handle share page and any subpaths
|
||||
publicRoutes.HandleFunc("GET /share/", withOrWithoutUser(indexHandler))
|
||||
|
||||
// Public static assets (needed for shared pages to load CSS/JS/images)
|
||||
publicRoutes.HandleFunc("GET /static/", staticFilesHandler)
|
||||
|
||||
// Static and index file handlers
|
||||
router.HandleFunc(fmt.Sprintf("GET %vstatic/", config.Server.BaseURL), staticFilesHandler)
|
||||
staticPrefix := config.Server.BaseURL + "static/"
|
||||
router.Handle(staticPrefix, http.StripPrefix(staticPrefix, http.HandlerFunc(staticFilesHandler)))
|
||||
publicRoutes.Handle("GET /static/", http.StripPrefix("/static/", http.HandlerFunc(staticFilesHandler)))
|
||||
|
||||
router.HandleFunc(config.Server.BaseURL, withOrWithoutUser(indexHandler))
|
||||
router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler)
|
||||
router.Handle(fmt.Sprintf("%vswagger/", config.Server.BaseURL), withUser(swaggerHandler))
|
||||
@@ -280,6 +256,9 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
<-ctx.Done()
|
||||
logger.Info("Shutting down HTTP server...")
|
||||
|
||||
// Close all SSE sessions
|
||||
events.Shutdown()
|
||||
|
||||
// Persist in-memory state before shutting down the HTTP server
|
||||
if store != nil {
|
||||
if store.Share != nil {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
)
|
||||
|
||||
// subtitlesHandler handles subtitle extraction requests
|
||||
// @Summary Extract embedded subtitles
|
||||
// @Description Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content
|
||||
// @Tags Subtitles
|
||||
// @Accept json
|
||||
// @Produce text/vtt
|
||||
// @Param path query string true "Index path to the video file"
|
||||
// @Param source query string true "Source name for the desired source"
|
||||
// @Param index query int false "Stream index for embedded subtitle extraction, defaults to 0"
|
||||
// @Success 200 {string} string "Raw WebVTT subtitle content"
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 404 {object} map[string]string "Resource not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/media/subtitles [get]
|
||||
func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
indexParam := r.URL.Query().Get("index")
|
||||
|
||||
if indexParam == "" {
|
||||
indexParam = "0" // default to first subtitle stream
|
||||
}
|
||||
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
source, err = url.QueryUnescape(source)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
|
||||
}
|
||||
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
}
|
||||
realPath, _, err := idx.GetRealPath(userscope, path)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
metadata, exists := idx.GetMetadataInfo(userscope, true)
|
||||
if !exists {
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
index, err := strconv.Atoi(indexParam)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid index parameter: %v", err)
|
||||
}
|
||||
parentDir := filepath.Dir(realPath)
|
||||
subtitle, err := ffmpeg.ExtractSingleSubtitle(realPath, parentDir, index, metadata.ModTime)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to extract subtitle: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", "inline")
|
||||
w.Header().Set("Cache-Control", "private")
|
||||
http.ServeContent(w, r, fmt.Sprintf("%s-%d.vtt", subtitle.Name, index),
|
||||
time.Now(), bytes.NewReader([]byte(subtitle.Content)))
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
+45
-10
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
@@ -27,6 +28,7 @@ type requestContext struct {
|
||||
fileInfo iteminfo.ExtendedFileInfo
|
||||
token string
|
||||
share *share.Link
|
||||
shareValid bool
|
||||
ctx context.Context
|
||||
MaxBandwidth int
|
||||
}
|
||||
@@ -50,6 +52,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
// Get the file link by hash
|
||||
link, err := store.Share.GetByHash(hash)
|
||||
if err != nil {
|
||||
data.share = &share.Link{}
|
||||
return http.StatusNotFound, fmt.Errorf("share hash not found")
|
||||
}
|
||||
if link.DisableAnonymous && data.user.Username == "anonymous" {
|
||||
@@ -77,21 +80,35 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
return http.StatusForbidden, fmt.Errorf("the target source is private")
|
||||
}
|
||||
// Get file information with options
|
||||
getContent := r.URL.Query().Get("content") == "true"
|
||||
if link.DisableFileViewer || link.Downloads >= link.DownloadsLimit {
|
||||
getContent = false
|
||||
}
|
||||
file, err := FileInfoFasterFunc(iteminfo.FileOptions{
|
||||
Path: utils.JoinPathAsUnix(link.Path, path),
|
||||
Source: link.Source,
|
||||
Modify: false,
|
||||
Expand: true,
|
||||
Content: r.URL.Query().Get("content") == "true",
|
||||
Content: getContent,
|
||||
})
|
||||
file.Token = link.Token
|
||||
if err != nil {
|
||||
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
|
||||
return errToStatus(err), fmt.Errorf("error fetching share from server")
|
||||
}
|
||||
file.Token = link.Token
|
||||
file.Source = ""
|
||||
file.Hash = link.Hash
|
||||
if !link.EnableOnlyOffice || !link.DisableFileViewer {
|
||||
file.OnlyOfficeId = ""
|
||||
}
|
||||
if getContent && file.Content != "" {
|
||||
link.Mu.Lock()
|
||||
link.Downloads++
|
||||
link.Mu.Unlock()
|
||||
}
|
||||
file.Path = "/" + strings.TrimPrefix(strings.TrimPrefix(file.Path, link.Path), "/")
|
||||
// Set the file info in the `data` object
|
||||
data.fileInfo = file
|
||||
data.fileInfo = *file
|
||||
// Call the next handler with the data
|
||||
return fn(w, r, data)
|
||||
})
|
||||
@@ -114,9 +131,14 @@ func withAdminHelper(fn handleFunc) handleFunc {
|
||||
func withOrWithoutUserHelper(fn handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||
var link *share.Link
|
||||
var isShareRequest bool
|
||||
var shareHash string
|
||||
|
||||
hash := r.URL.Query().Get("hash")
|
||||
if hash != "" {
|
||||
// Get the file link by hash
|
||||
isShareRequest = true
|
||||
shareHash = hash
|
||||
link, _ = store.Share.GetByHash(hash)
|
||||
} else {
|
||||
prefix := config.Server.BaseURL + "public/share/"
|
||||
@@ -128,6 +150,8 @@ func withOrWithoutUserHelper(fn handleFunc) handleFunc {
|
||||
remaining = remaining[:idx]
|
||||
}
|
||||
if remaining != "" {
|
||||
isShareRequest = true
|
||||
shareHash = remaining
|
||||
var err error
|
||||
link, err = store.Share.GetByHash(remaining)
|
||||
if err != nil {
|
||||
@@ -138,26 +162,37 @@ func withOrWithoutUserHelper(fn handleFunc) handleFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a share request, always create a share context (even if invalid)
|
||||
if isShareRequest {
|
||||
if link != nil {
|
||||
data.share = link
|
||||
data.shareValid = true
|
||||
} else {
|
||||
// Create an empty share with just the hash for invalid shares
|
||||
data.share = &share.Link{Hash: shareHash}
|
||||
data.shareValid = false
|
||||
}
|
||||
}
|
||||
|
||||
// Try to authenticate user first
|
||||
status, err := withUserHelper(nil)(w, r, data)
|
||||
if err == nil && status < 400 {
|
||||
if link != nil {
|
||||
data.share = link
|
||||
if data.share != nil && data.shareValid {
|
||||
if data.user != nil {
|
||||
data.user.CustomTheme = link.ShareTheme
|
||||
data.user.CustomTheme = data.share.ShareTheme
|
||||
}
|
||||
}
|
||||
return fn(w, r, data)
|
||||
}
|
||||
// Only fall back to anonymous if authentication actually failed
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
data.user = &users.AnonymousUser
|
||||
data.user = &users.User{Username: "anonymous"}
|
||||
settings.ApplyUserDefaults(data.user)
|
||||
// If user authentication failed, call the handler without user context
|
||||
// Clear any user data that might have been partially set
|
||||
data.token = ""
|
||||
if link != nil {
|
||||
data.share = link
|
||||
data.user.CustomTheme = link.ShareTheme
|
||||
if data.share != nil && data.shareValid {
|
||||
data.user.CustomTheme = data.share.ShareTheme
|
||||
}
|
||||
// Call the handler function without user context
|
||||
return fn(w, r, data)
|
||||
|
||||
@@ -43,8 +43,8 @@ func mockFileInfoFaster(t *testing.T) {
|
||||
t.Cleanup(func() { FileInfoFasterFunc = originalFileInfoFaster })
|
||||
|
||||
// Mock the function to skip execution
|
||||
FileInfoFasterFunc = func(opts iteminfo.FileOptions) (iteminfo.ExtendedFileInfo, error) {
|
||||
return iteminfo.ExtendedFileInfo{
|
||||
FileInfoFasterFunc = func(opts iteminfo.FileOptions) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return &iteminfo.ExtendedFileInfo{
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
Path: opts.Path,
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
|
||||
@@ -292,11 +292,11 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
|
||||
if config.Auth.Methods.OidcAuth.AdminGroup == "" {
|
||||
isAdmin = config.UserDefaults.Permissions.Admin
|
||||
}
|
||||
user = &users.User{}
|
||||
user = &users.User{
|
||||
Username: username,
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
}
|
||||
settings.ApplyUserDefaults(user)
|
||||
user.Username = username
|
||||
user.LoginMethod = users.LoginMethodOidc
|
||||
user.Permissions = settings.Config.UserDefaults.Permissions
|
||||
if isAdmin {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
|
||||
+222
-100
@@ -38,68 +38,100 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
if settings.Config.Integrations.OnlyOffice.Url == "" {
|
||||
return http.StatusInternalServerError, errors.New("only-office integration must be configured in settings")
|
||||
}
|
||||
encodedUrl := r.URL.Query().Get("url")
|
||||
// Decode the URL-encoded path
|
||||
givenUrl, err := url.QueryUnescape(encodedUrl)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
|
||||
// Extract clean parameters from request
|
||||
source := r.URL.Query().Get("source")
|
||||
path := r.URL.Query().Get("path")
|
||||
|
||||
// Validate required parameters
|
||||
if (path == "" || source == "") && d.fileInfo.Hash == "" {
|
||||
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, path)
|
||||
return http.StatusBadRequest, errors.New("missing required parameters: path + source/hash are required")
|
||||
}
|
||||
// get path from url
|
||||
pathParts := strings.Split(givenUrl, "/api/raw?files=")
|
||||
origPathParts := strings.Split(encodedUrl, "/api/raw?files=")
|
||||
encodedPath := origPathParts[len(origPathParts)-1]
|
||||
sourceFile := pathParts[len(pathParts)-1]
|
||||
sourceSplit := strings.Split(sourceFile, "::")
|
||||
if len(sourceSplit) != 2 {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid url path %v", givenUrl)
|
||||
}
|
||||
source := sourceSplit[0]
|
||||
path := sourceSplit[1]
|
||||
urlFirst := pathParts[0]
|
||||
if settings.Config.Server.InternalUrl != "" {
|
||||
urlFirst = settings.Config.Server.InternalUrl + settings.Config.Server.BaseURL
|
||||
whatToReplace := strings.Split(givenUrl, "/api/raw")[0] + "/"
|
||||
givenUrl = strings.Replace(givenUrl, whatToReplace, urlFirst, 1)
|
||||
}
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
|
||||
Path: utils.JoinPathAsUnix(userscope, path),
|
||||
Modify: d.user.Permissions.Modify,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Debugf("onlyofficeClientConfigGetHandler: failed to get file info for file source %v, path %v: %v", source, path, err)
|
||||
return errToStatus(err), err
|
||||
}
|
||||
id, err := getOnlyOfficeId(source, fileInfo.Path)
|
||||
if err != nil {
|
||||
logger.Debugf("getOnlyOfficeId failed for file source %v, path %v: %v", source, fileInfo.Path, err)
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
split := strings.Split(fileInfo.Name, ".")
|
||||
fileType := split[len(split)-1]
|
||||
theme := "light"
|
||||
if d.user.DarkMode {
|
||||
theme = "dark"
|
||||
themeMode := utils.Ternary(d.user.DarkMode, "dark", "light")
|
||||
|
||||
if d.fileInfo.Hash == "" {
|
||||
// Build file info based on whether this is a share or regular request
|
||||
// Regular user request - need to resolve scope
|
||||
userScope, scopeErr := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if scopeErr != nil {
|
||||
logger.Errorf("OnlyOffice: source %s not available for user %s: %v", source, d.user.Username, scopeErr)
|
||||
return http.StatusForbidden, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
resolvedPath := utils.JoinPathAsUnix(userScope, path)
|
||||
logger.Debugf("OnlyOffice user request: resolved path=%s", resolvedPath)
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
|
||||
Path: resolvedPath,
|
||||
Modify: d.user.Permissions.Modify,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("OnlyOffice: failed to get file info for source=%s, path=%s: %v", source, resolvedPath, err)
|
||||
return errToStatus(err), err
|
||||
}
|
||||
d.fileInfo = *fileInfo
|
||||
} else {
|
||||
// is a share, use the file info from the share middleware
|
||||
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
logger.Error("OnlyOffice: source from share not found")
|
||||
return http.StatusInternalServerError, fmt.Errorf("source not found for share")
|
||||
}
|
||||
source = sourceInfo.Name
|
||||
// path is index path, so we build from share path
|
||||
path = utils.JoinPathAsUnix(d.share.Path, path)
|
||||
if d.share.EnforceDarkLightMode == "dark" {
|
||||
themeMode = "dark"
|
||||
}
|
||||
if d.share.EnforceDarkLightMode == "light" {
|
||||
themeMode = "light"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Determine file type and editing permissions
|
||||
fileType := getFileExtension(d.fileInfo.Name)
|
||||
canEdit := iteminfo.CanEditOnlyOffice(d.user.Permissions.Modify, fileType)
|
||||
mode := "view"
|
||||
if canEdit {
|
||||
mode = "edit"
|
||||
canEditMode := utils.Ternary(canEdit, "edit", "view")
|
||||
if d.fileInfo.Hash != "" {
|
||||
if d.share.EnableOnlyOfficeEditing {
|
||||
canEditMode = "edit"
|
||||
}
|
||||
}
|
||||
callbackURL := fmt.Sprintf("%v/api/onlyoffice/callback?files=%v&auth=%v", strings.TrimSuffix(urlFirst, "/"), encodedPath, d.token)
|
||||
// For shares, we need to keep track of the original relative path for the callback URL
|
||||
var callbackPath string
|
||||
if d.fileInfo.Hash != "" {
|
||||
// For shares, use the original path parameter (relative to share)
|
||||
callbackPath = r.URL.Query().Get("path")
|
||||
} else {
|
||||
// For regular requests, use the processed path
|
||||
callbackPath = path
|
||||
}
|
||||
|
||||
// Generate document ID for OnlyOffice
|
||||
documentId, err := getOnlyOfficeId(source, path)
|
||||
if err != nil {
|
||||
logger.Errorf("OnlyOffice: failed to generate document ID for source=%s, path=%s: %v", source, path, err)
|
||||
return http.StatusNotFound, fmt.Errorf("failed to generate document ID: %v", err)
|
||||
}
|
||||
|
||||
// Build download URL that OnlyOffice server will use
|
||||
downloadURL := buildOnlyOfficeDownloadURL(source, callbackPath, d.fileInfo.Hash, d.token)
|
||||
|
||||
// Build callback URL for OnlyOffice to notify us of changes
|
||||
callbackURL := buildOnlyOfficeCallbackURL(source, callbackPath, d.fileInfo.Hash, d.token)
|
||||
|
||||
// Build OnlyOffice client configuration
|
||||
clientConfig := map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"fileType": fileType,
|
||||
"key": id,
|
||||
"title": fileInfo.Name,
|
||||
"url": givenUrl + "&auth=" + d.token,
|
||||
"key": documentId,
|
||||
"title": d.fileInfo.Name,
|
||||
"url": downloadURL,
|
||||
"permissions": map[string]interface{}{
|
||||
"edit": canEdit,
|
||||
"edit": canEditMode,
|
||||
"download": true,
|
||||
"print": true,
|
||||
},
|
||||
@@ -113,78 +145,194 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
"customization": map[string]interface{}{
|
||||
"autosave": true,
|
||||
"forcesave": true,
|
||||
"uiTheme": theme,
|
||||
"uiTheme": themeMode,
|
||||
},
|
||||
"lang": d.user.Locale,
|
||||
"mode": mode,
|
||||
"mode": canEditMode,
|
||||
},
|
||||
}
|
||||
|
||||
// Sign configuration with JWT if secret is configured
|
||||
if settings.Config.Integrations.OnlyOffice.Secret != "" {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(clientConfig))
|
||||
signature, err := token.SignedString([]byte(settings.Config.Integrations.OnlyOffice.Secret))
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to sign JWT")
|
||||
logger.Errorf("OnlyOffice: failed to sign JWT: %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to sign configuration")
|
||||
}
|
||||
clientConfig["token"] = signature
|
||||
}
|
||||
|
||||
return renderJSON(w, r, clientConfig)
|
||||
}
|
||||
|
||||
// getFileExtension extracts file extension from filename
|
||||
func getFileExtension(filename string) string {
|
||||
parts := strings.Split(filename, ".")
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
// buildOnlyOfficeDownloadURL constructs the download URL that OnlyOffice server will use to fetch the file
|
||||
func buildOnlyOfficeDownloadURL(source, path, hash, token string) string {
|
||||
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
|
||||
baseURL := settings.Config.Server.BaseURL
|
||||
if settings.Config.Server.InternalUrl != "" {
|
||||
baseURL = settings.Config.Server.InternalUrl + settings.Config.Server.BaseURL
|
||||
}
|
||||
|
||||
var downloadURL string
|
||||
if hash != "" {
|
||||
// Share download URL - don't expose source name, just use the path relative to share
|
||||
filesParam := url.QueryEscape(path)
|
||||
downloadURL = fmt.Sprintf("%s/public/api/raw?files=%s&hash=%s&token=%s&auth=%s",
|
||||
strings.TrimSuffix(baseURL, "/"), filesParam, hash, token, token)
|
||||
} else {
|
||||
// Regular download URL - include source for non-share requests
|
||||
filesParam := url.QueryEscape(source + "::" + path)
|
||||
downloadURL = fmt.Sprintf("%s/api/raw?files=%s&auth=%s",
|
||||
strings.TrimSuffix(baseURL, "/"), filesParam, token)
|
||||
}
|
||||
|
||||
return downloadURL
|
||||
}
|
||||
|
||||
// buildOnlyOfficeCallbackURL constructs the callback URL that OnlyOffice server will use to notify us of changes
|
||||
func buildOnlyOfficeCallbackURL(source, path, hash, token string) string {
|
||||
baseURL := settings.Config.Server.BaseURL
|
||||
if settings.Config.Server.InternalUrl != "" {
|
||||
baseURL = settings.Config.Server.InternalUrl + settings.Config.Server.BaseURL
|
||||
}
|
||||
|
||||
var callbackURL string
|
||||
if hash != "" {
|
||||
// Share callback URL - use public API and don't expose source, use path relative to share
|
||||
params := url.Values{}
|
||||
params.Set("hash", hash)
|
||||
params.Set("path", path) // This should be the path relative to the share, not the full filesystem path
|
||||
params.Set("auth", token)
|
||||
|
||||
callbackURL = fmt.Sprintf("%s/public/api/onlyoffice/callback?%s",
|
||||
strings.TrimSuffix(baseURL, "/"), params.Encode())
|
||||
} else {
|
||||
// Regular callback URL - include source for non-share requests
|
||||
params := url.Values{}
|
||||
params.Set("source", source)
|
||||
params.Set("path", path)
|
||||
params.Set("auth", token)
|
||||
|
||||
callbackURL = fmt.Sprintf("%s/api/onlyoffice/callback?%s",
|
||||
strings.TrimSuffix(baseURL, "/"), params.Encode())
|
||||
}
|
||||
|
||||
return callbackURL
|
||||
}
|
||||
|
||||
func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Parse OnlyOffice callback data
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
var data OnlyOfficeCallback
|
||||
err = json.Unmarshal(body, &data)
|
||||
if err != nil {
|
||||
logger.Errorf("OnlyOffice callback: failed to read request body: %v", err)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
encodedPath := r.URL.Query().Get("files")
|
||||
pathParts := strings.Split(encodedPath, "::")
|
||||
if len(pathParts) < 2 {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
source := pathParts[0]
|
||||
// Decode the URL-encoded path
|
||||
path, err := url.QueryUnescape(pathParts[1])
|
||||
var data OnlyOfficeCallback
|
||||
err = json.Unmarshal(body, &data)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
logger.Errorf("OnlyOffice callback: failed to parse JSON: %v", err)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// Extract clean parameters from query string
|
||||
source := r.URL.Query().Get("source")
|
||||
path := r.URL.Query().Get("path")
|
||||
|
||||
// Validate required parameters
|
||||
if (path == "" || source == "") && d.fileInfo.Hash == "" {
|
||||
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, path)
|
||||
return http.StatusBadRequest, errors.New("missing required parameters: path + source/hash are required")
|
||||
}
|
||||
|
||||
if d.fileInfo.Hash == "" {
|
||||
// Regular user request - need to resolve scope
|
||||
userScope, scopeErr := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if scopeErr != nil {
|
||||
logger.Errorf("OnlyOffice callback: source %s not available for user %s: %v", source, d.user.Username, scopeErr)
|
||||
return http.StatusForbidden, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
path = utils.JoinPathAsUnix(userScope, path)
|
||||
} else {
|
||||
// is a share, use the file info from the share middleware
|
||||
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
logger.Error("OnlyOffice: source from share not found")
|
||||
return http.StatusInternalServerError, fmt.Errorf("source not found for share")
|
||||
}
|
||||
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 {
|
||||
// Refer to only-office documentation
|
||||
// Refer to OnlyOffice documentation:
|
||||
// - https://api.onlyoffice.com/editors/coedit
|
||||
// - https://api.onlyoffice.com/editors/callback
|
||||
//
|
||||
// When the document is fully closed by all editors,
|
||||
// then the document key should no longer be re-used.
|
||||
// the document key should no longer be re-used.
|
||||
logger.Debugf("OnlyOffice: document closed, cleaning up document ID for source=%s, path=%s", source, path)
|
||||
deleteOfficeId(source, path)
|
||||
}
|
||||
|
||||
// Handle document save operations
|
||||
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
|
||||
data.Status == onlyOfficeStatusForceSaveWhileDocumentStillOpen {
|
||||
|
||||
// Verify user has modify permissions
|
||||
if !d.user.Permissions.Modify {
|
||||
logger.Warningf("OnlyOffice callback: user %s lacks modify permissions for source=%s, path=%s",
|
||||
d.user.Username, source, path)
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
|
||||
// Download the updated document from OnlyOffice server
|
||||
doc, err := http.Get(data.URL)
|
||||
if err != nil {
|
||||
logger.Errorf("OnlyOffice callback: failed to download updated document: %v", err)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer doc.Body.Close()
|
||||
|
||||
// Resolve file path for writing (same logic as in config handler)
|
||||
var resolvedPath string
|
||||
if d.fileInfo.Hash == "" {
|
||||
// Regular user request - need to resolve scope
|
||||
userScope, scopeErr := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if scopeErr != nil {
|
||||
logger.Errorf("OnlyOffice callback: source %s not available for user %s: %v",
|
||||
source, d.user.Username, scopeErr)
|
||||
return http.StatusForbidden, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
resolvedPath = utils.JoinPathAsUnix(userScope, path)
|
||||
}
|
||||
|
||||
// Write the updated document
|
||||
fileOpts := iteminfo.FileOptions{
|
||||
Path: path,
|
||||
Path: resolvedPath,
|
||||
Source: source,
|
||||
}
|
||||
writeErr := files.WriteFile(fileOpts, doc.Body)
|
||||
if writeErr != nil {
|
||||
logger.Errorf("OnlyOffice callback: failed to write updated document: %v", writeErr)
|
||||
return http.StatusInternalServerError, writeErr
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Return success response to OnlyOffice server
|
||||
resp := map[string]int{
|
||||
"error": 0,
|
||||
}
|
||||
@@ -215,29 +363,3 @@ func deleteOfficeId(source, path string) {
|
||||
realpath, _, _ := idx.GetRealPath(path)
|
||||
utils.OnlyOfficeCache.Delete(realpath)
|
||||
}
|
||||
|
||||
func onlyofficeGetTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// get config from body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var payload map[string]interface{}
|
||||
// marshall to struct
|
||||
err = json.Unmarshal(body, &payload)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
if settings.Config.Integrations.OnlyOffice.Secret != "" {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(payload))
|
||||
ss, err := token.SignedString([]byte(settings.Config.Integrations.OnlyOffice.Secret))
|
||||
if err != nil {
|
||||
return 500, errors.New("could not generate a new jwt")
|
||||
}
|
||||
return renderJSON(w, r, map[string]string{"token": ss})
|
||||
}
|
||||
return 400, fmt.Errorf("bad request")
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
d.fileInfo = fileInfo
|
||||
d.fileInfo = *fileInfo
|
||||
return previewHelperFunc(w, r, d)
|
||||
}
|
||||
|
||||
|
||||
+29
-6
@@ -7,6 +7,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
|
||||
@@ -37,16 +38,40 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
d.share.Mu.Unlock()
|
||||
encodedFiles := r.URL.Query().Get("files")
|
||||
|
||||
// Decode the URL-encoded path
|
||||
f, err := url.QueryUnescape(encodedFiles)
|
||||
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
|
||||
f, err := url.PathUnescape(encodedFiles)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
|
||||
// Get the actual source name from the share's source mapping
|
||||
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
return http.StatusInternalServerError, fmt.Errorf("source not found for share")
|
||||
}
|
||||
actualSourceName := sourceInfo.Name
|
||||
|
||||
fileList := []string{}
|
||||
for _, file := range strings.Split(f, "||") {
|
||||
filePath := utils.JoinPathAsUnix(d.share.Path, file)
|
||||
fileList = append(fileList, d.fileInfo.Source+"::"+filePath)
|
||||
// Check if file already contains source prefix (source::path format)
|
||||
if strings.Contains(file, "::") {
|
||||
splitFile := strings.SplitN(file, "::", 2)
|
||||
if len(splitFile) == 2 {
|
||||
source := splitFile[0]
|
||||
path := splitFile[1]
|
||||
// Join the share path with the requested path
|
||||
filePath := utils.JoinPathAsUnix(d.share.Path, path)
|
||||
fileList = append(fileList, source+"::"+filePath)
|
||||
} else {
|
||||
// Fallback: treat as plain path
|
||||
filePath := utils.JoinPathAsUnix(d.share.Path, file)
|
||||
fileList = append(fileList, actualSourceName+"::"+filePath)
|
||||
}
|
||||
} else {
|
||||
// Plain path without source prefix - use the actual source name from share
|
||||
filePath := utils.JoinPathAsUnix(d.share.Path, file)
|
||||
fileList = append(fileList, actualSourceName+"::"+filePath)
|
||||
}
|
||||
}
|
||||
|
||||
var status int
|
||||
@@ -59,8 +84,6 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
}
|
||||
|
||||
func publicShareHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// disable onlyoffice for public share
|
||||
d.fileInfo.OnlyOfficeId = ""
|
||||
return renderJSON(w, r, d.fileInfo)
|
||||
}
|
||||
|
||||
|
||||
+16
-3
@@ -104,6 +104,7 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
|
||||
}
|
||||
path = utils.JoinPathAsUnix(userScope, path)
|
||||
}
|
||||
// For shares, the path is already correctly resolved by publicRawHandler
|
||||
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
@@ -173,15 +174,20 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
|
||||
}
|
||||
return addSingleFile(filePath, relPath, zipWriter, tarWriter)
|
||||
})
|
||||
} else {
|
||||
// For a single file, use the base name as the archive path
|
||||
return addSingleFile(realPath, baseName, zipWriter, tarWriter)
|
||||
}
|
||||
|
||||
// For a single file, use the base name as the archive path
|
||||
return addSingleFile(realPath, baseName, zipWriter, tarWriter)
|
||||
}
|
||||
|
||||
func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWriter *tar.Writer) error {
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
// If we get "is a directory" error, this is likely a symlink to a directory
|
||||
// that wasn't properly detected. Skip it gracefully.
|
||||
if strings.Contains(err.Error(), "is a directory") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
@@ -191,6 +197,11 @@ func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWrite
|
||||
return err
|
||||
}
|
||||
|
||||
// Double-check if this is actually a directory (in case of symlinks)
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if tarWriter != nil {
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
@@ -240,6 +251,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
}
|
||||
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
|
||||
}
|
||||
// For shares, the path is already correctly resolved by publicRawHandler
|
||||
idx := indexing.GetIndex(firstFileSource)
|
||||
if idx == nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", firstFileSource)
|
||||
@@ -395,6 +407,7 @@ func computeArchiveSize(fileList []string, d *requestContext) (int64, error) {
|
||||
}
|
||||
path = utils.JoinPathAsUnix(userScope, path)
|
||||
}
|
||||
// For shares, the path is already correctly resolved by publicRawHandler
|
||||
realPath, isDir, err := idx.GetRealPath(path)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
|
||||
@@ -85,6 +85,10 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
scopePath := utils.JoinPathAsUnix(userscope, path)
|
||||
getContent := r.URL.Query().Get("content") == "true"
|
||||
if d.share != nil && d.share.DisableFileViewer {
|
||||
getContent = false
|
||||
}
|
||||
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
|
||||
Access: store.Access,
|
||||
Username: d.user.Username,
|
||||
@@ -92,7 +96,7 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
Modify: d.user.Permissions.Modify,
|
||||
Source: source,
|
||||
Expand: true,
|
||||
Content: r.URL.Query().Get("content") == "true",
|
||||
Content: getContent,
|
||||
})
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
@@ -172,7 +176,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
}
|
||||
|
||||
// delete thumbnails
|
||||
preview.DelThumbs(r.Context(), fileInfo)
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath))
|
||||
if err != nil {
|
||||
@@ -265,7 +269,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
}
|
||||
// On the first chunk, check for conflicts or handle override
|
||||
if offset == 0 {
|
||||
var fileInfo iteminfo.ExtendedFileInfo
|
||||
var fileInfo *iteminfo.ExtendedFileInfo
|
||||
fileInfo, err = files.FileInfoFaster(fileOpts)
|
||||
if err == nil { // File exists
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
@@ -274,7 +278,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
return http.StatusConflict, nil
|
||||
}
|
||||
// If overriding, delete existing thumbnails
|
||||
preview.DelThumbs(r.Context(), fileInfo)
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,7 +341,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
return http.StatusConflict, nil
|
||||
}
|
||||
|
||||
preview.DelThumbs(r.Context(), fileInfo)
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
}
|
||||
err = files.WriteFile(fileOpts, r.Body)
|
||||
if err != nil {
|
||||
@@ -567,7 +571,7 @@ func patchAction(ctx context.Context, params patchActionParams) error {
|
||||
}
|
||||
|
||||
// delete thumbnails
|
||||
preview.DelThumbs(ctx, fileInfo)
|
||||
preview.DelThumbs(ctx, *fileInfo)
|
||||
return files.MoveResource(params.isSrcDir, params.isDstDir, params.srcIndex, params.dstIndex, params.src, params.dst, store.Share)
|
||||
default:
|
||||
return fmt.Errorf("unsupported action %s: %w", params.action, errors.ErrInvalidRequestParams)
|
||||
|
||||
@@ -64,6 +64,9 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
if len(query) < settings.Config.Server.MinSearchLength {
|
||||
return http.StatusBadRequest, fmt.Errorf("query is too short, minimum length is %d", settings.Config.Server.MinSearchLength)
|
||||
}
|
||||
searchScope := strings.TrimPrefix(unencodedScope, ".")
|
||||
// Retrieve the User-Agent and X-Auth headers from the request
|
||||
sessionId := r.Header.Get("SessionId")
|
||||
|
||||
@@ -2,6 +2,8 @@ package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
)
|
||||
|
||||
// settingsGetHandler retrieves the current system settings.
|
||||
@@ -34,3 +36,36 @@ func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
}
|
||||
return renderJSON(w, r, config)
|
||||
}
|
||||
|
||||
// settingsConfigHandler returns the current system settings as YAML.
|
||||
// @Summary Get system settings as YAML
|
||||
// @Description Returns the current running configuration settings as YAML format with optional comments and filtering.
|
||||
// @Tags Settings
|
||||
// @Accept json
|
||||
// @Produce text/plain
|
||||
// @Param full query boolean false "Show all values (true) or only non-default values (false, default)"
|
||||
// @Param comments query boolean false "Include comments in YAML (true) or plain YAML (false, default)"
|
||||
// @Success 200 {string} string "System settings in YAML format"
|
||||
// @Router /api/settings/config [get]
|
||||
func settingsConfigHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Parse query parameters
|
||||
fullParam := r.URL.Query().Get("full")
|
||||
commentsParam := r.URL.Query().Get("comments")
|
||||
|
||||
showFull := fullParam == "true"
|
||||
showComments := commentsParam == "true"
|
||||
|
||||
// Generate YAML using the existing generator function (don't filter deprecated fields in API)
|
||||
yamlOutput, err := settings.GenerateConfigYaml(config, showComments, showFull, false)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// Set content type and write response
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if _, err := w.Write([]byte(yamlOutput)); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
+82
-13
@@ -17,9 +17,35 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
)
|
||||
|
||||
// ShareResponse represents a share with computed username field and download URL
|
||||
type ShareResponse struct {
|
||||
*share.Link
|
||||
Username string `json:"username,omitempty"`
|
||||
DownloadURL string `json:"downloadURL,omitempty"`
|
||||
}
|
||||
|
||||
// convertToShareResponse converts shares to response format with usernames
|
||||
func convertToShareResponse(r *http.Request, shares []*share.Link) ([]*ShareResponse, error) {
|
||||
responses := make([]*ShareResponse, len(shares))
|
||||
for i, s := range shares {
|
||||
user, err := store.Users.Get(s.UserID)
|
||||
username := ""
|
||||
if err == nil {
|
||||
username = user.Username
|
||||
}
|
||||
responses[i] = &ShareResponse{
|
||||
Link: s,
|
||||
Username: username,
|
||||
DownloadURL: getDownloadURL(r, s.Hash),
|
||||
}
|
||||
}
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
// shareListHandler returns a list of all share links.
|
||||
// @Summary List share links
|
||||
// @Description Returns a list of share links for the current user, or all links if the user is an admin.
|
||||
@@ -41,7 +67,11 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
shares = utils.NonNilSlice(shares)
|
||||
return renderJSON(w, r, shares)
|
||||
sharesWithUsernames, err := convertToShareResponse(r, shares)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
return renderJSON(w, r, sharesWithUsernames)
|
||||
}
|
||||
|
||||
// shareGetsHandler retrieves share links for a specific resource path.
|
||||
@@ -74,16 +104,17 @@ func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
scopePath := utils.JoinPathAsUnix(userscope, path)
|
||||
s, err := store.Share.Gets(scopePath, sourceInfo.Path, d.user.ID)
|
||||
if err == errors.ErrNotExist || len(s) == 0 {
|
||||
return renderJSON(w, r, []*share.Link{})
|
||||
}
|
||||
// Overwrite the Source field with the source name from the query for each link
|
||||
for _, link := range s {
|
||||
link.DownloadURL = getDownloadURL(r, link.Hash)
|
||||
return renderJSON(w, r, []*ShareResponse{})
|
||||
}
|
||||
// DownloadURL will be set in convertToShareResponse
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error getting share info from server")
|
||||
}
|
||||
return renderJSON(w, r, s)
|
||||
sharesWithUsernames, err := convertToShareResponse(r, s)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
return renderJSON(w, r, sharesWithUsernames)
|
||||
}
|
||||
|
||||
// shareDeleteHandler deletes a specific share link by its hash.
|
||||
@@ -190,11 +221,21 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
body.Path = s.Path
|
||||
body.Source = s.Source
|
||||
s.CommonShare = body.CommonShare
|
||||
|
||||
if err = store.Share.Save(s); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
return renderJSON(w, r, s)
|
||||
// Convert to ShareResponse format with username
|
||||
var user *users.User
|
||||
user, err = store.Users.Get(s.UserID)
|
||||
username := ""
|
||||
if err == nil {
|
||||
username = user.Username
|
||||
}
|
||||
response := &ShareResponse{
|
||||
Link: s,
|
||||
Username: username,
|
||||
}
|
||||
return renderJSON(w, r, response)
|
||||
}
|
||||
|
||||
source, ok := config.Server.NameToSource[body.Source]
|
||||
@@ -243,10 +284,21 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
CommonShare: body.CommonShare,
|
||||
}
|
||||
|
||||
if err := store.Share.Save(s); err != nil {
|
||||
if err = store.Share.Save(s); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
return renderJSON(w, r, s)
|
||||
// Convert to ShareResponse format with username
|
||||
var user *users.User
|
||||
user, err = store.Users.Get(s.UserID)
|
||||
username := ""
|
||||
if err == nil {
|
||||
username = user.Username
|
||||
}
|
||||
response := &ShareResponse{
|
||||
Link: s,
|
||||
Username: username,
|
||||
}
|
||||
return renderJSON(w, r, response)
|
||||
}
|
||||
|
||||
// DirectDownloadResponse represents the response for direct download endpoint
|
||||
@@ -410,9 +462,26 @@ func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *reque
|
||||
func getDownloadURL(r *http.Request, hash string) string {
|
||||
var downloadURL string
|
||||
if config.Server.ExternalUrl != "" {
|
||||
downloadURL = fmt.Sprintf("%spublic/api/raw?hash=%s", config.Server.ExternalUrl, hash)
|
||||
downloadURL = fmt.Sprintf("%s%spublic/api/raw?hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, hash)
|
||||
} else {
|
||||
downloadURL = fmt.Sprintf("%s://%s%spublic/api/raw?hash=%s", getScheme(r), r.Host, config.Server.BaseURL, hash)
|
||||
// Prefer X-Forwarded-Host for proxy support
|
||||
var host string
|
||||
var scheme string
|
||||
|
||||
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
// Use X-Forwarded-Proto if available, otherwise default to https for proxied requests
|
||||
if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" {
|
||||
scheme = forwardedProto
|
||||
} else {
|
||||
scheme = "https"
|
||||
}
|
||||
} else {
|
||||
// Fallback to simple approach
|
||||
host = r.Host
|
||||
scheme = getScheme(r)
|
||||
}
|
||||
downloadURL = fmt.Sprintf("%s://%s%spublic/api/raw?hash=%s", scheme, host, config.Server.BaseURL, hash)
|
||||
}
|
||||
return downloadURL
|
||||
}
|
||||
|
||||
+96
-46
@@ -17,10 +17,19 @@ var templateRenderer *TemplateRenderer
|
||||
|
||||
type TemplateRenderer struct {
|
||||
templates *template.Template
|
||||
devMode bool
|
||||
}
|
||||
|
||||
// Render renders a template document with headers and data
|
||||
func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data interface{}) error {
|
||||
// If in dev mode, reload templates on every render.
|
||||
if t.devMode {
|
||||
var err error
|
||||
t.templates, err = t.templates.ParseFS(assetFs, "public/index.html")
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reloading template: %w", err)
|
||||
}
|
||||
}
|
||||
// Set headers
|
||||
w.Header().Set("Cache-Control", "no-cache, private, max-age=0")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
@@ -39,71 +48,107 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
}
|
||||
userSelectedTheme := ""
|
||||
if d.user != nil {
|
||||
theme, ok := config.Frontend.Styling.CustomThemes[d.user.CustomTheme]
|
||||
theme, ok := config.Frontend.Styling.CustomThemeOptions[d.user.CustomTheme]
|
||||
if ok {
|
||||
userSelectedTheme = theme.CSS
|
||||
userSelectedTheme = theme.CssRaw
|
||||
}
|
||||
}
|
||||
|
||||
defaultThemeColor := "#455a64"
|
||||
staticURL := config.Server.BaseURL + "static"
|
||||
publicStaticURL := config.Server.BaseURL + "public/static"
|
||||
|
||||
// Use custom favicon if configured and validated, otherwise fall back to default
|
||||
var favicon string
|
||||
if config.Frontend.Favicon != "" {
|
||||
favicon = staticURL + "/favicon"
|
||||
} else {
|
||||
// Default favicon
|
||||
favicon = staticURL + "/img/icons/favicon-256x256.png"
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"title": config.Frontend.Name,
|
||||
"customCSS": config.Frontend.Styling.CustomCSS,
|
||||
"customCSS": config.Frontend.Styling.CustomCSSRaw,
|
||||
"userSelectedTheme": userSelectedTheme,
|
||||
"lightBackground": config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": config.Frontend.Styling.DarkBackground,
|
||||
"staticURL": staticURL,
|
||||
"baseURL": config.Server.BaseURL,
|
||||
"favicon": staticURL + "/img/icons/favicon-256x256.png",
|
||||
"color": "var(--primaryColor)",
|
||||
"favicon": favicon,
|
||||
"color": defaultThemeColor,
|
||||
"winIcon": staticURL + "/img/icons/mstile-144x144.png",
|
||||
"appIcon": staticURL + "/img/icons/android-chrome-256x256.png",
|
||||
"description": "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server",
|
||||
"description": config.Frontend.Description,
|
||||
}
|
||||
shareOverrides := map[string]interface{}{
|
||||
"isShare": false,
|
||||
"banner": "",
|
||||
"title": "",
|
||||
"quickDownload": false,
|
||||
"description": "",
|
||||
"themeColor": "",
|
||||
"disableThumbnails": false,
|
||||
"viewMode": "list",
|
||||
"disableFileViewer": false,
|
||||
"disableShareCard": false,
|
||||
"disableSidebar": false,
|
||||
shareProps := map[string]interface{}{
|
||||
"isShare": false,
|
||||
"isValid": false,
|
||||
"banner": "",
|
||||
"title": "",
|
||||
"quickDownload": false,
|
||||
"description": "",
|
||||
"themeColor": "",
|
||||
"disableThumbnails": false,
|
||||
"viewMode": "list",
|
||||
"disableFileViewer": false,
|
||||
"disableShareCard": false,
|
||||
"disableSidebar": false,
|
||||
"isPasswordProtected": false,
|
||||
"hash": "",
|
||||
}
|
||||
disableNavButtons := settings.Config.Frontend.DisableNavButtons
|
||||
if d.share != nil {
|
||||
disableNavButtons = disableNavButtons || d.share.HideNavButtons
|
||||
shareOverrides["viewMode"] = d.share.ViewMode
|
||||
shareOverrides["banner"] = d.share.Banner
|
||||
shareOverrides["title"] = d.share.Title
|
||||
shareOverrides["description"] = d.share.Description
|
||||
shareOverrides["themeColor"] = d.share.ThemeColor
|
||||
shareOverrides["quickDownload"] = d.share.QuickDownload
|
||||
shareOverrides["disableThumbnails"] = d.share.DisableThumbnails
|
||||
shareOverrides["disableFileViewer"] = d.share.DisablingFileViewer
|
||||
shareOverrides["disableShareCard"] = d.share.DisableShareCard
|
||||
shareOverrides["disableSidebar"] = d.share.DisableSidebar
|
||||
shareOverrides["isShare"] = true
|
||||
if d.share.Favicon != "" {
|
||||
if strings.HasPrefix(d.share.Favicon, "http") {
|
||||
data["favicon"] = d.share.Favicon
|
||||
} else {
|
||||
data["favicon"] = staticURL + "/" + d.share.Favicon
|
||||
shareProps["isShare"] = true
|
||||
shareProps["isValid"] = d.shareValid
|
||||
shareProps["hash"] = d.share.Hash
|
||||
|
||||
if d.shareValid {
|
||||
disableNavButtons = disableNavButtons || d.share.HideNavButtons
|
||||
shareProps["viewMode"] = d.share.ViewMode
|
||||
shareProps["banner"] = d.share.Banner
|
||||
shareProps["title"] = d.share.Title
|
||||
shareProps["description"] = d.share.Description
|
||||
shareProps["themeColor"] = d.share.ThemeColor
|
||||
shareProps["quickDownload"] = d.share.QuickDownload
|
||||
shareProps["disableThumbnails"] = d.share.DisableThumbnails
|
||||
shareProps["disableFileViewer"] = d.share.DisableFileViewer
|
||||
shareProps["disableShareCard"] = d.share.DisableShareCard
|
||||
shareProps["disableSidebar"] = d.share.DisableSidebar
|
||||
shareProps["isPasswordProtected"] = d.share.PasswordHash != ""
|
||||
shareProps["downloadURL"] = getDownloadURL(r, d.share.Hash)
|
||||
shareProps["enforceDarkLightMode"] = d.share.EnforceDarkLightMode
|
||||
shareProps["enableOnlyOffice"] = d.share.EnableOnlyOffice
|
||||
shareProps["enableOnlyOfficeEditing"] = d.share.EnableOnlyOfficeEditing
|
||||
if d.share.Favicon != "" {
|
||||
if strings.HasPrefix(d.share.Favicon, "http") {
|
||||
data["favicon"] = d.share.Favicon
|
||||
} else {
|
||||
data["favicon"] = publicStaticURL + "/" + d.share.Favicon
|
||||
}
|
||||
}
|
||||
if d.share.Description != "" {
|
||||
data["description"] = d.share.Description
|
||||
}
|
||||
if d.share.Title != "" {
|
||||
data["title"] = d.share.Title
|
||||
}
|
||||
}
|
||||
if d.share.Description != "" {
|
||||
data["description"] = d.share.Description
|
||||
}
|
||||
if d.share.Title != "" {
|
||||
data["title"] = d.share.Title
|
||||
|
||||
// base url could be different for routes behind proxy
|
||||
data["staticURL"] = publicStaticURL
|
||||
// Use custom favicon for shares too if configured
|
||||
if config.Frontend.Favicon != "" {
|
||||
data["favicon"] = publicStaticURL + "/favicon"
|
||||
} else {
|
||||
data["favicon"] = publicStaticURL + "/img/icons/favicon-256x256.png"
|
||||
}
|
||||
data["winIcon"] = publicStaticURL + "/img/icons/mstile-144x144.png"
|
||||
data["appIcon"] = publicStaticURL + "/img/icons/android-chrome-256x256.png"
|
||||
}
|
||||
// variables consumed by frontend as json
|
||||
data["globalVars"] = map[string]interface{}{
|
||||
"name": config.Frontend.Name,
|
||||
"minSearchLength": config.Server.MinSearchLength,
|
||||
"disableExternal": config.Frontend.DisableDefaultLinks,
|
||||
"darkMode": settings.Config.UserDefaults.DarkMode,
|
||||
"baseURL": config.Server.BaseURL,
|
||||
@@ -124,7 +169,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"updateAvailable": utils.GetUpdateAvailableUrl(),
|
||||
"disableNavButtons": disableNavButtons,
|
||||
"userSelectableThemes": config.Frontend.Styling.CustomThemeOptions,
|
||||
"share": shareOverrides,
|
||||
"share": shareProps,
|
||||
}
|
||||
jsonVars, err := json.Marshal(data["globalVars"])
|
||||
if err != nil {
|
||||
@@ -143,10 +188,15 @@ func staticFilesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
const maxAge = 86400 // 1 day
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%v", maxAge))
|
||||
w.Header().Set("Content-Security-Policy", `default-src 'self'; style-src 'unsafe-inline';`)
|
||||
// Remove "/static/" from the request path
|
||||
adjustedPath := strings.TrimPrefix(r.URL.Path, fmt.Sprintf("%vstatic/", config.Server.BaseURL))
|
||||
adjustedCompressed := adjustedPath + ".gz"
|
||||
if strings.HasSuffix(adjustedPath, ".js") {
|
||||
|
||||
// Handle custom favicon if configured and requested
|
||||
if r.URL.Path == "favicon" && config.Frontend.Favicon != "" {
|
||||
http.ServeFile(w, r, config.Frontend.Favicon)
|
||||
return
|
||||
}
|
||||
|
||||
adjustedCompressed := r.URL.Path + ".gz"
|
||||
if strings.HasSuffix(r.URL.Path, ".js") {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8") // Set the correct MIME type for JavaScript files
|
||||
}
|
||||
// Check if the gzipped version of the file exists
|
||||
@@ -159,7 +209,7 @@ func staticFilesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
} else {
|
||||
// Otherwise, serve the regular file
|
||||
http.StripPrefix(fmt.Sprintf("%vstatic/", config.Server.BaseURL), http.FileServer(http.FS(assetFs))).ServeHTTP(w, r)
|
||||
http.FileServer(http.FS(assetFs)).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
)
|
||||
|
||||
type ItemInfo struct {
|
||||
@@ -28,13 +29,14 @@ type FileInfo struct {
|
||||
// extra calculated fields can be added here
|
||||
type ExtendedFileInfo struct {
|
||||
FileInfo
|
||||
Content string `json:"content,omitempty"` // text content of a file, if requested
|
||||
Subtitles []string `json:"subtitles,omitempty"` // subtitles for video files
|
||||
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
|
||||
Token string `json:"token,omitempty"` // token for the file -- used for sharing
|
||||
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
|
||||
Source string `json:"source"` // associated index source for the file
|
||||
RealPath string `json:"-"`
|
||||
Content string `json:"content,omitempty"` // text content of a file, if requested
|
||||
Subtitles []ffmpeg.SubtitleTrack `json:"subtitles,omitempty"` // subtitles for video files
|
||||
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
|
||||
Token string `json:"token,omitempty"` // token for the file -- used for sharing
|
||||
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
|
||||
Source string `json:"source,omitempty"` // associated index source for the file
|
||||
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
|
||||
RealPath string `json:"-"`
|
||||
}
|
||||
|
||||
// FileOptions are the options when getting a file info.
|
||||
|
||||
@@ -8,29 +8,43 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
type SubtitleTrack struct {
|
||||
Name string `json:"name"` // filename for external, or descriptive name for embedded
|
||||
Language string `json:"language,omitempty"` // language code
|
||||
Title string `json:"title,omitempty"` // title/description
|
||||
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
|
||||
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
|
||||
IsFile bool `json:"isFile"` // true for external files, false for embedded
|
||||
}
|
||||
|
||||
type FFProbeOutput struct {
|
||||
Streams []struct {
|
||||
Index int `json:"index"`
|
||||
CodecType string `json:"codec_type"`
|
||||
CodecName string `json:"codec_name"`
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
Disposition map[string]int `json:"disposition,omitempty"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
|
||||
// detects subtitles for video files.
|
||||
func (i *ExtendedFileInfo) DetectSubtitles(parentInfo *FileInfo) {
|
||||
if !strings.HasPrefix(i.Type, "video") {
|
||||
logger.Debug("subtitles are not supported for this file : " + i.Name)
|
||||
return
|
||||
}
|
||||
ext := filepath.Ext(i.Name)
|
||||
baseWithoutExt := strings.TrimSuffix(i.Name, ext)
|
||||
for _, f := range parentInfo.Files {
|
||||
baseName := strings.TrimSuffix(i.Name, ext)
|
||||
if baseName != baseWithoutExt {
|
||||
continue
|
||||
}
|
||||
// Use unified subtitle detection that finds both embedded and external
|
||||
parentDir := filepath.Dir(i.RealPath)
|
||||
i.Subtitles = ffmpeg.DetectAllSubtitles(i.RealPath, parentDir, i.ModTime)
|
||||
}
|
||||
|
||||
for _, subtitleExt := range SubtitleExts {
|
||||
if strings.HasSuffix(f.Name, subtitleExt) {
|
||||
i.Subtitles = append(i.Subtitles, f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
// LoadSubtitleContent loads the actual content for all detected subtitle tracks
|
||||
func (i *ExtendedFileInfo) LoadSubtitleContent() error {
|
||||
return ffmpeg.LoadAllSubtitleContent(i.RealPath, i.Subtitles, i.ModTime)
|
||||
}
|
||||
|
||||
func (info *FileInfo) SortItems() {
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
)
|
||||
|
||||
// GenerateVideoPreview generates a single preview image from a video using ffmpeg.
|
||||
@@ -21,69 +15,8 @@ func (s *Service) GenerateVideoPreview(videoPath, outputPath string, percentageS
|
||||
return nil, err
|
||||
}
|
||||
defer s.release()
|
||||
// Step 1: Get video duration from the container format
|
||||
probeCmd := exec.Command(
|
||||
s.ffprobePath,
|
||||
"-v", "error",
|
||||
// Use format=duration for better compatibility
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
videoPath,
|
||||
)
|
||||
|
||||
var probeOut bytes.Buffer
|
||||
probeCmd.Stdout = &probeOut
|
||||
if s.debug {
|
||||
probeCmd.Stderr = os.Stderr
|
||||
}
|
||||
if err := probeCmd.Run(); err != nil {
|
||||
logger.Errorf("ffprobe command failed on file '%v' : %v", videoPath, err)
|
||||
return nil, fmt.Errorf("ffprobe failed: %w", err)
|
||||
}
|
||||
|
||||
durationStr := strings.TrimSpace(probeOut.String())
|
||||
if durationStr == "" || durationStr == "N/A" {
|
||||
logger.Errorf("could not determine video duration for file '%v' using duration info '%v'", videoPath, durationStr)
|
||||
return nil, fmt.Errorf("could not determine video duration")
|
||||
}
|
||||
|
||||
durationFloat, err := strconv.ParseFloat(durationStr, 64)
|
||||
if err != nil {
|
||||
// The original error you saw would be caught here if "N/A" was still the output
|
||||
return nil, fmt.Errorf("invalid duration: %v", err)
|
||||
}
|
||||
|
||||
if durationFloat <= 0 {
|
||||
return nil, fmt.Errorf("video duration must be positive")
|
||||
}
|
||||
|
||||
// The rest of your function remains the same...
|
||||
// Step 2: Get the duration of the video in whole seconds
|
||||
duration := int(durationFloat)
|
||||
|
||||
// Step 3: Calculate seek time based on percentageSeek (percentage value)
|
||||
seekSeconds := duration * percentageSeek / 100
|
||||
|
||||
// Step 4: Convert seekSeconds to string for ffmpeg command
|
||||
seekTime := strconv.Itoa(seekSeconds)
|
||||
// Step 5: Extract frame at seek time
|
||||
cmd := exec.Command(
|
||||
s.ffmpegPath,
|
||||
"-ss", seekTime,
|
||||
"-i", videoPath,
|
||||
"-frames:v", "1",
|
||||
"-q:v", "10",
|
||||
"-y", // overwrite output
|
||||
outputPath,
|
||||
)
|
||||
|
||||
if s.debug {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
}
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg command failed on file '%v' : %w", videoPath, err)
|
||||
}
|
||||
return os.ReadFile(outputPath)
|
||||
// Create a temporary video service for this operation
|
||||
videoSvc := ffmpeg.NewVideoService(s.ffmpegPath, s.ffprobePath, 1, s.debug)
|
||||
return videoSvc.GenerateVideoPreview(videoPath, outputPath, percentageSeek)
|
||||
}
|
||||
+183
-18
@@ -56,7 +56,7 @@ const docTemplate = `{
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Varies based on query. Can be access.FrontendAccessRule, []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule",
|
||||
"description": "Varies based on query. Can be access.FrontendAccessRule (when source and path specified), []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule",
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
@@ -854,6 +854,87 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/media/subtitles": {
|
||||
"get": {
|
||||
"description": "Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"text/vtt"
|
||||
],
|
||||
"tags": [
|
||||
"Subtitles"
|
||||
],
|
||||
"summary": "Extract embedded subtitles",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Index path to the video file",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source name for the desired source",
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Stream index for embedded subtitle extraction, defaults to 0",
|
||||
"name": "index",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Raw WebVTT subtitle content",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Resource not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/preview": {
|
||||
"get": {
|
||||
"description": "Returns a preview image based on the requested path and size.",
|
||||
@@ -1616,6 +1697,43 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/settings/config": {
|
||||
"get": {
|
||||
"description": "Returns the current running configuration settings as YAML format with optional comments and filtering.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"text/plain"
|
||||
],
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Get system settings as YAML",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Show all values (true) or only non-default values (false, default)",
|
||||
"name": "full",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Include comments in YAML (true) or plain YAML (false, default)",
|
||||
"name": "comments",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "System settings in YAML format",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/share": {
|
||||
"get": {
|
||||
"description": "Retrieves all share links associated with a specific resource path for the current user.",
|
||||
@@ -2486,26 +2604,26 @@ const docTemplate = `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"adminPassword": {
|
||||
"description": "the password of the admin user. If not set, the default is \"admin\".",
|
||||
"description": "secret: the password of the admin user. If not set, the default is \"admin\".",
|
||||
"type": "string"
|
||||
},
|
||||
"adminUsername": {
|
||||
"description": "the username of the admin user. If not set, the default is \"admin\".",
|
||||
"description": "secret: the username of the admin user. If not set, the default is \"admin\".",
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"description": "the key used to sign the JWT tokens. If not set, a random key will be generated.",
|
||||
"description": "secret: the key used to sign the JWT tokens. If not set, a random key will be generated.",
|
||||
"type": "string"
|
||||
},
|
||||
"methods": {
|
||||
"$ref": "#/definitions/settings.LoginMethods"
|
||||
},
|
||||
"tokenExpirationHours": {
|
||||
"description": "the number of hours until the token expires. Default is 2 hours.",
|
||||
"description": "time in hours each web UI session token is valid for. Default is 2 hours.",
|
||||
"type": "integer"
|
||||
},
|
||||
"totpSecret": {
|
||||
"description": "secret used to encrypt TOTP secrets",
|
||||
"description": "secret: secret used to encrypt TOTP secrets",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
@@ -2602,6 +2720,10 @@ const docTemplate = `{
|
||||
"settings.Frontend": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"description": "description that shows up in html head meta description",
|
||||
"type": "string"
|
||||
},
|
||||
"disableDefaultLinks": {
|
||||
"description": "disable default links in the sidebar",
|
||||
"type": "boolean"
|
||||
@@ -2620,6 +2742,10 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/settings.ExternalLink"
|
||||
}
|
||||
},
|
||||
"favicon": {
|
||||
"description": "path to a favicon to use for the frontend",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "display name",
|
||||
"type": "string"
|
||||
@@ -2723,11 +2849,11 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"clientId": {
|
||||
"description": "client id of the OIDC application",
|
||||
"description": "secret: client id of the OIDC application",
|
||||
"type": "string"
|
||||
},
|
||||
"clientSecret": {
|
||||
"description": "client secret of the OIDC application",
|
||||
"description": "secret: client secret of the OIDC application",
|
||||
"type": "string"
|
||||
},
|
||||
"createUser": {
|
||||
@@ -2776,6 +2902,7 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"secret": {
|
||||
"description": "secret: authentication key for OnlyOffice integration",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
@@ -2885,6 +3012,10 @@ const docTemplate = `{
|
||||
"description": "disable type detection by header, useful if filesystem is slow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"disableUpdateCheck": {
|
||||
"description": "disables backend update check service",
|
||||
"type": "boolean"
|
||||
},
|
||||
"externalUrl": {
|
||||
"description": "used by share links if set (eg. http://mydomain.com)",
|
||||
"type": "string"
|
||||
@@ -2903,6 +3034,10 @@ const docTemplate = `{
|
||||
"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: 3)",
|
||||
"type": "integer"
|
||||
},
|
||||
"numImageProcessors": {
|
||||
"description": "number of concurrent image processing jobs used to create previews, default is number of cpu cores available.",
|
||||
"type": "integer"
|
||||
@@ -2985,6 +3120,10 @@ const docTemplate = `{
|
||||
"description": "default \"/\" should match folders under path",
|
||||
"type": "string"
|
||||
},
|
||||
"denyByDefault": {
|
||||
"description": "deny access unless an \"allow\" access rule was specifically created.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"disableIndexing": {
|
||||
"description": "disable the indexing of this source",
|
||||
"type": "boolean"
|
||||
@@ -3069,6 +3208,10 @@ const docTemplate = `{
|
||||
"description": "when false, the date is relative, when true, the date is an exact timestamp",
|
||||
"type": "boolean"
|
||||
},
|
||||
"debugOffice": {
|
||||
"description": "debug onlyoffice editor",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deleteWithoutConfirming": {
|
||||
"description": "delete files without confirmation",
|
||||
"type": "boolean"
|
||||
@@ -3082,7 +3225,7 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"disablePreviewExt": {
|
||||
"description": "comma separated list of file extensions to disable preview for",
|
||||
"description": "space separated list of file extensions to disable preview for",
|
||||
"type": "string"
|
||||
},
|
||||
"disableQuickToggles": {
|
||||
@@ -3102,7 +3245,7 @@ const docTemplate = `{
|
||||
"type": "boolean"
|
||||
},
|
||||
"disableViewingExt": {
|
||||
"description": "comma separated list of file extensions to disable viewing for",
|
||||
"description": "space separated list of file extensions to disable viewing for",
|
||||
"type": "string"
|
||||
},
|
||||
"editorQuickSave": {
|
||||
@@ -3213,6 +3356,16 @@ const docTemplate = `{
|
||||
"downloadsLimit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"enableOnlyOffice": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enableOnlyOfficeEditing": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enforceDarkLightMode": {
|
||||
"description": "\"dark\" or \"light\"",
|
||||
"type": "string"
|
||||
},
|
||||
"expires": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3256,9 +3409,6 @@ const docTemplate = `{
|
||||
"unit": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"viewMode": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -3307,6 +3457,16 @@ const docTemplate = `{
|
||||
"downloadsLimit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"enableOnlyOffice": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enableOnlyOfficeEditing": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enforceDarkLightMode": {
|
||||
"description": "\"dark\" or \"light\"",
|
||||
"type": "string"
|
||||
},
|
||||
"expire": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -3354,9 +3514,6 @@ const docTemplate = `{
|
||||
"userID": {
|
||||
"type": "integer"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"viewMode": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -3436,6 +3593,10 @@ const docTemplate = `{
|
||||
"description": "autoplay media files in preview",
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultMediaPlayer": {
|
||||
"description": "disable html5 media player and use the default media player",
|
||||
"type": "boolean"
|
||||
},
|
||||
"disableHideSidebar": {
|
||||
"description": "disable the hide sidebar preview for previews and editors",
|
||||
"type": "boolean"
|
||||
@@ -3509,6 +3670,10 @@ const docTemplate = `{
|
||||
"description": "when false, the date is relative, when true, the date is an exact timestamp",
|
||||
"type": "boolean"
|
||||
},
|
||||
"debugOffice": {
|
||||
"description": "debug onlyoffice editor",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deleteWithoutConfirming": {
|
||||
"description": "delete files without confirmation",
|
||||
"type": "boolean"
|
||||
@@ -3522,7 +3687,7 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"disablePreviewExt": {
|
||||
"description": "comma separated list of file extensions to disable preview for",
|
||||
"description": "space separated list of file extensions to disable preview for",
|
||||
"type": "string"
|
||||
},
|
||||
"disableQuickToggles": {
|
||||
@@ -3541,7 +3706,7 @@ const docTemplate = `{
|
||||
"type": "boolean"
|
||||
},
|
||||
"disableViewingExt": {
|
||||
"description": "comma separated list of file extensions to disable viewing for",
|
||||
"description": "space separated list of file extensions to disable viewing for",
|
||||
"type": "string"
|
||||
},
|
||||
"editorQuickSave": {
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Varies based on query. Can be access.FrontendAccessRule, []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule",
|
||||
"description": "Varies based on query. Can be access.FrontendAccessRule (when source and path specified), []access.PrincipalRule, map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule",
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
@@ -843,6 +843,87 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/media/subtitles": {
|
||||
"get": {
|
||||
"description": "Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"text/vtt"
|
||||
],
|
||||
"tags": [
|
||||
"Subtitles"
|
||||
],
|
||||
"summary": "Extract embedded subtitles",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Index path to the video file",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source name for the desired source",
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Stream index for embedded subtitle extraction, defaults to 0",
|
||||
"name": "index",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Raw WebVTT subtitle content",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Resource not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/preview": {
|
||||
"get": {
|
||||
"description": "Returns a preview image based on the requested path and size.",
|
||||
@@ -1605,6 +1686,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/settings/config": {
|
||||
"get": {
|
||||
"description": "Returns the current running configuration settings as YAML format with optional comments and filtering.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"text/plain"
|
||||
],
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Get system settings as YAML",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Show all values (true) or only non-default values (false, default)",
|
||||
"name": "full",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Include comments in YAML (true) or plain YAML (false, default)",
|
||||
"name": "comments",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "System settings in YAML format",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/share": {
|
||||
"get": {
|
||||
"description": "Retrieves all share links associated with a specific resource path for the current user.",
|
||||
@@ -2475,26 +2593,26 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"adminPassword": {
|
||||
"description": "the password of the admin user. If not set, the default is \"admin\".",
|
||||
"description": "secret: the password of the admin user. If not set, the default is \"admin\".",
|
||||
"type": "string"
|
||||
},
|
||||
"adminUsername": {
|
||||
"description": "the username of the admin user. If not set, the default is \"admin\".",
|
||||
"description": "secret: the username of the admin user. If not set, the default is \"admin\".",
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"description": "the key used to sign the JWT tokens. If not set, a random key will be generated.",
|
||||
"description": "secret: the key used to sign the JWT tokens. If not set, a random key will be generated.",
|
||||
"type": "string"
|
||||
},
|
||||
"methods": {
|
||||
"$ref": "#/definitions/settings.LoginMethods"
|
||||
},
|
||||
"tokenExpirationHours": {
|
||||
"description": "the number of hours until the token expires. Default is 2 hours.",
|
||||
"description": "time in hours each web UI session token is valid for. Default is 2 hours.",
|
||||
"type": "integer"
|
||||
},
|
||||
"totpSecret": {
|
||||
"description": "secret used to encrypt TOTP secrets",
|
||||
"description": "secret: secret used to encrypt TOTP secrets",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
@@ -2591,6 +2709,10 @@
|
||||
"settings.Frontend": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"description": "description that shows up in html head meta description",
|
||||
"type": "string"
|
||||
},
|
||||
"disableDefaultLinks": {
|
||||
"description": "disable default links in the sidebar",
|
||||
"type": "boolean"
|
||||
@@ -2609,6 +2731,10 @@
|
||||
"$ref": "#/definitions/settings.ExternalLink"
|
||||
}
|
||||
},
|
||||
"favicon": {
|
||||
"description": "path to a favicon to use for the frontend",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "display name",
|
||||
"type": "string"
|
||||
@@ -2712,11 +2838,11 @@
|
||||
"type": "string"
|
||||
},
|
||||
"clientId": {
|
||||
"description": "client id of the OIDC application",
|
||||
"description": "secret: client id of the OIDC application",
|
||||
"type": "string"
|
||||
},
|
||||
"clientSecret": {
|
||||
"description": "client secret of the OIDC application",
|
||||
"description": "secret: client secret of the OIDC application",
|
||||
"type": "string"
|
||||
},
|
||||
"createUser": {
|
||||
@@ -2765,6 +2891,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"secret": {
|
||||
"description": "secret: authentication key for OnlyOffice integration",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
@@ -2874,6 +3001,10 @@
|
||||
"description": "disable type detection by header, useful if filesystem is slow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"disableUpdateCheck": {
|
||||
"description": "disables backend update check service",
|
||||
"type": "boolean"
|
||||
},
|
||||
"externalUrl": {
|
||||
"description": "used by share links if set (eg. http://mydomain.com)",
|
||||
"type": "string"
|
||||
@@ -2892,6 +3023,10 @@
|
||||
"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: 3)",
|
||||
"type": "integer"
|
||||
},
|
||||
"numImageProcessors": {
|
||||
"description": "number of concurrent image processing jobs used to create previews, default is number of cpu cores available.",
|
||||
"type": "integer"
|
||||
@@ -2974,6 +3109,10 @@
|
||||
"description": "default \"/\" should match folders under path",
|
||||
"type": "string"
|
||||
},
|
||||
"denyByDefault": {
|
||||
"description": "deny access unless an \"allow\" access rule was specifically created.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"disableIndexing": {
|
||||
"description": "disable the indexing of this source",
|
||||
"type": "boolean"
|
||||
@@ -3058,6 +3197,10 @@
|
||||
"description": "when false, the date is relative, when true, the date is an exact timestamp",
|
||||
"type": "boolean"
|
||||
},
|
||||
"debugOffice": {
|
||||
"description": "debug onlyoffice editor",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deleteWithoutConfirming": {
|
||||
"description": "delete files without confirmation",
|
||||
"type": "boolean"
|
||||
@@ -3071,7 +3214,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"disablePreviewExt": {
|
||||
"description": "comma separated list of file extensions to disable preview for",
|
||||
"description": "space separated list of file extensions to disable preview for",
|
||||
"type": "string"
|
||||
},
|
||||
"disableQuickToggles": {
|
||||
@@ -3091,7 +3234,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"disableViewingExt": {
|
||||
"description": "comma separated list of file extensions to disable viewing for",
|
||||
"description": "space separated list of file extensions to disable viewing for",
|
||||
"type": "string"
|
||||
},
|
||||
"editorQuickSave": {
|
||||
@@ -3202,6 +3345,16 @@
|
||||
"downloadsLimit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"enableOnlyOffice": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enableOnlyOfficeEditing": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enforceDarkLightMode": {
|
||||
"description": "\"dark\" or \"light\"",
|
||||
"type": "string"
|
||||
},
|
||||
"expires": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3245,9 +3398,6 @@
|
||||
"unit": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"viewMode": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -3296,6 +3446,16 @@
|
||||
"downloadsLimit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"enableOnlyOffice": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enableOnlyOfficeEditing": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enforceDarkLightMode": {
|
||||
"description": "\"dark\" or \"light\"",
|
||||
"type": "string"
|
||||
},
|
||||
"expire": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -3343,9 +3503,6 @@
|
||||
"userID": {
|
||||
"type": "integer"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"viewMode": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -3425,6 +3582,10 @@
|
||||
"description": "autoplay media files in preview",
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultMediaPlayer": {
|
||||
"description": "disable html5 media player and use the default media player",
|
||||
"type": "boolean"
|
||||
},
|
||||
"disableHideSidebar": {
|
||||
"description": "disable the hide sidebar preview for previews and editors",
|
||||
"type": "boolean"
|
||||
@@ -3498,6 +3659,10 @@
|
||||
"description": "when false, the date is relative, when true, the date is an exact timestamp",
|
||||
"type": "boolean"
|
||||
},
|
||||
"debugOffice": {
|
||||
"description": "debug onlyoffice editor",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deleteWithoutConfirming": {
|
||||
"description": "delete files without confirmation",
|
||||
"type": "boolean"
|
||||
@@ -3511,7 +3676,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"disablePreviewExt": {
|
||||
"description": "comma separated list of file extensions to disable preview for",
|
||||
"description": "space separated list of file extensions to disable preview for",
|
||||
"type": "string"
|
||||
},
|
||||
"disableQuickToggles": {
|
||||
@@ -3530,7 +3695,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"disableViewingExt": {
|
||||
"description": "comma separated list of file extensions to disable viewing for",
|
||||
"description": "space separated list of file extensions to disable viewing for",
|
||||
"type": "string"
|
||||
},
|
||||
"editorQuickSave": {
|
||||
|
||||
@@ -140,22 +140,25 @@ definitions:
|
||||
settings.Auth:
|
||||
properties:
|
||||
adminPassword:
|
||||
description: the password of the admin user. If not set, the default is "admin".
|
||||
description: 'secret: the password of the admin user. If not set, the default
|
||||
is "admin".'
|
||||
type: string
|
||||
adminUsername:
|
||||
description: the username of the admin user. If not set, the default is "admin".
|
||||
description: 'secret: the username of the admin user. If not set, the default
|
||||
is "admin".'
|
||||
type: string
|
||||
key:
|
||||
description: the key used to sign the JWT tokens. If not set, a random key
|
||||
will be generated.
|
||||
description: 'secret: the key used to sign the JWT tokens. If not set, a random
|
||||
key will be generated.'
|
||||
type: string
|
||||
methods:
|
||||
$ref: '#/definitions/settings.LoginMethods'
|
||||
tokenExpirationHours:
|
||||
description: the number of hours until the token expires. Default is 2 hours.
|
||||
description: time in hours each web UI session token is valid for. Default
|
||||
is 2 hours.
|
||||
type: integer
|
||||
totpSecret:
|
||||
description: secret used to encrypt TOTP secrets
|
||||
description: 'secret: secret used to encrypt TOTP secrets'
|
||||
type: string
|
||||
type: object
|
||||
settings.CustomTheme:
|
||||
@@ -229,6 +232,9 @@ definitions:
|
||||
type: object
|
||||
settings.Frontend:
|
||||
properties:
|
||||
description:
|
||||
description: description that shows up in html head meta description
|
||||
type: string
|
||||
disableDefaultLinks:
|
||||
description: disable default links in the sidebar
|
||||
type: boolean
|
||||
@@ -242,6 +248,9 @@ definitions:
|
||||
items:
|
||||
$ref: '#/definitions/settings.ExternalLink'
|
||||
type: array
|
||||
favicon:
|
||||
description: path to a favicon to use for the frontend
|
||||
type: string
|
||||
name:
|
||||
description: display name
|
||||
type: string
|
||||
@@ -316,10 +325,10 @@ definitions:
|
||||
description: if set, users in this group will be granted admin privileges.
|
||||
type: string
|
||||
clientId:
|
||||
description: client id of the OIDC application
|
||||
description: 'secret: client id of the OIDC application'
|
||||
type: string
|
||||
clientSecret:
|
||||
description: client secret of the OIDC application
|
||||
description: 'secret: client secret of the OIDC application'
|
||||
type: string
|
||||
createUser:
|
||||
description: create user if not exists
|
||||
@@ -357,6 +366,7 @@ definitions:
|
||||
to bypass proxy.
|
||||
type: string
|
||||
secret:
|
||||
description: 'secret: authentication key for OnlyOffice integration'
|
||||
type: string
|
||||
url:
|
||||
description: The URL to the OnlyOffice Document Server, needs to be accessible
|
||||
@@ -440,6 +450,9 @@ definitions:
|
||||
disableTypeDetectionByHeader:
|
||||
description: disable type detection by header, useful if filesystem is slow.
|
||||
type: boolean
|
||||
disableUpdateCheck:
|
||||
description: disables backend update check service
|
||||
type: boolean
|
||||
externalUrl:
|
||||
description: used by share links if set (eg. http://mydomain.com)
|
||||
type: string
|
||||
@@ -455,6 +468,10 @@ definitions:
|
||||
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:
|
||||
3)'
|
||||
type: integer
|
||||
numImageProcessors:
|
||||
description: number of concurrent image processing jobs used to create previews,
|
||||
default is number of cpu cores available.
|
||||
@@ -515,6 +532,9 @@ definitions:
|
||||
defaultUserScope:
|
||||
description: default "/" should match folders under path
|
||||
type: string
|
||||
denyByDefault:
|
||||
description: deny access unless an "allow" access rule was specifically created.
|
||||
type: boolean
|
||||
disableIndexing:
|
||||
description: disable the indexing of this source
|
||||
type: boolean
|
||||
@@ -582,6 +602,9 @@ definitions:
|
||||
description: when false, the date is relative, when true, the date is an exact
|
||||
timestamp
|
||||
type: boolean
|
||||
debugOffice:
|
||||
description: debug onlyoffice editor
|
||||
type: boolean
|
||||
deleteWithoutConfirming:
|
||||
description: delete files without confirmation
|
||||
type: boolean
|
||||
@@ -592,7 +615,7 @@ definitions:
|
||||
description: list of file extensions to disable onlyoffice editor for
|
||||
type: string
|
||||
disablePreviewExt:
|
||||
description: comma separated list of file extensions to disable preview for
|
||||
description: space separated list of file extensions to disable preview for
|
||||
type: string
|
||||
disableQuickToggles:
|
||||
description: disable the quick toggles in the sidebar
|
||||
@@ -607,7 +630,7 @@ definitions:
|
||||
description: disable update notifications banner for admin users
|
||||
type: boolean
|
||||
disableViewingExt:
|
||||
description: comma separated list of file extensions to disable viewing for
|
||||
description: space separated list of file extensions to disable viewing for
|
||||
type: string
|
||||
editorQuickSave:
|
||||
description: show quick save button in editor
|
||||
@@ -687,6 +710,13 @@ definitions:
|
||||
type: string
|
||||
downloadsLimit:
|
||||
type: integer
|
||||
enableOnlyOffice:
|
||||
type: boolean
|
||||
enableOnlyOfficeEditing:
|
||||
type: boolean
|
||||
enforceDarkLightMode:
|
||||
description: '"dark" or "light"'
|
||||
type: string
|
||||
expires:
|
||||
type: string
|
||||
favicon:
|
||||
@@ -716,8 +746,6 @@ definitions:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
viewMode:
|
||||
type: string
|
||||
type: object
|
||||
@@ -750,6 +778,13 @@ definitions:
|
||||
type: integer
|
||||
downloadsLimit:
|
||||
type: integer
|
||||
enableOnlyOffice:
|
||||
type: boolean
|
||||
enableOnlyOfficeEditing:
|
||||
type: boolean
|
||||
enforceDarkLightMode:
|
||||
description: '"dark" or "light"'
|
||||
type: string
|
||||
expire:
|
||||
type: integer
|
||||
favicon:
|
||||
@@ -785,8 +820,6 @@ definitions:
|
||||
type: string
|
||||
userID:
|
||||
type: integer
|
||||
username:
|
||||
type: string
|
||||
viewMode:
|
||||
type: string
|
||||
type: object
|
||||
@@ -840,6 +873,9 @@ definitions:
|
||||
autoplayMedia:
|
||||
description: autoplay media files in preview
|
||||
type: boolean
|
||||
defaultMediaPlayer:
|
||||
description: disable html5 media player and use the default media player
|
||||
type: boolean
|
||||
disableHideSidebar:
|
||||
description: disable the hide sidebar preview for previews and editors
|
||||
type: boolean
|
||||
@@ -892,6 +928,9 @@ definitions:
|
||||
description: when false, the date is relative, when true, the date is an exact
|
||||
timestamp
|
||||
type: boolean
|
||||
debugOffice:
|
||||
description: debug onlyoffice editor
|
||||
type: boolean
|
||||
deleteWithoutConfirming:
|
||||
description: delete files without confirmation
|
||||
type: boolean
|
||||
@@ -902,7 +941,7 @@ definitions:
|
||||
description: deprecated
|
||||
type: string
|
||||
disablePreviewExt:
|
||||
description: comma separated list of file extensions to disable preview for
|
||||
description: space separated list of file extensions to disable preview for
|
||||
type: string
|
||||
disableQuickToggles:
|
||||
description: disable the quick toggles in the sidebar
|
||||
@@ -916,7 +955,7 @@ definitions:
|
||||
description: disable update notifications
|
||||
type: boolean
|
||||
disableViewingExt:
|
||||
description: comma separated list of file extensions to disable viewing for
|
||||
description: space separated list of file extensions to disable viewing for
|
||||
type: string
|
||||
editorQuickSave:
|
||||
description: show quick save button in editor
|
||||
@@ -1083,8 +1122,9 @@ paths:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Varies based on query. Can be access.FrontendAccessRule, []access.PrincipalRule,
|
||||
map[string][]access.PrincipalRule, or map[string]access.FrontendAccessRule
|
||||
description: Varies based on query. Can be access.FrontendAccessRule (when
|
||||
source and path specified), []access.PrincipalRule, map[string][]access.PrincipalRule,
|
||||
or map[string]access.FrontendAccessRule
|
||||
schema:
|
||||
type: object
|
||||
"400":
|
||||
@@ -1554,6 +1594,61 @@ paths:
|
||||
summary: User login
|
||||
tags:
|
||||
- Auth
|
||||
/api/media/subtitles:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Extracts embedded subtitle content from video files by stream index
|
||||
and returns raw WebVTT content
|
||||
parameters:
|
||||
- description: Index path to the video file
|
||||
in: query
|
||||
name: path
|
||||
required: true
|
||||
type: string
|
||||
- description: Source name for the desired source
|
||||
in: query
|
||||
name: source
|
||||
required: true
|
||||
type: string
|
||||
- description: Stream index for embedded subtitle extraction, defaults to 0
|
||||
in: query
|
||||
name: index
|
||||
type: integer
|
||||
produces:
|
||||
- text/vtt
|
||||
responses:
|
||||
"200":
|
||||
description: Raw WebVTT subtitle content
|
||||
schema:
|
||||
type: string
|
||||
"400":
|
||||
description: Bad request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: Resource not found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Extract embedded subtitles
|
||||
tags:
|
||||
- Subtitles
|
||||
/api/preview:
|
||||
get:
|
||||
consumes:
|
||||
@@ -2077,6 +2172,31 @@ paths:
|
||||
summary: Get system settings
|
||||
tags:
|
||||
- Settings
|
||||
/api/settings/config:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the current running configuration settings as YAML format
|
||||
with optional comments and filtering.
|
||||
parameters:
|
||||
- description: Show all values (true) or only non-default values (false, default)
|
||||
in: query
|
||||
name: full
|
||||
type: boolean
|
||||
- description: Include comments in YAML (true) or plain YAML (false, default)
|
||||
in: query
|
||||
name: comments
|
||||
type: boolean
|
||||
produces:
|
||||
- text/plain
|
||||
responses:
|
||||
"200":
|
||||
description: System settings in YAML format
|
||||
schema:
|
||||
type: string
|
||||
summary: Get system settings as YAML
|
||||
tags:
|
||||
- Settings
|
||||
/api/share:
|
||||
get:
|
||||
consumes:
|
||||
|
||||
@@ -1,740 +0,0 @@
|
||||
diff --git a/CHANGELOG.md b/CHANGELOG.md
|
||||
index 92478a24..5085e058 100644
|
||||
--- a/CHANGELOG.md
|
||||
+++ b/CHANGELOG.md
|
||||
@@ -11,17 +11,16 @@ All notable changes to this project will be documented in this file. For commit
|
||||
|
||||
**New Features**:
|
||||
- New access control system that works like shares. You can add new allow / deny rules for users/groups for specific paths on specific sources.
|
||||
- #- The concept of 'groups', either automatically from OIDC groups claim or defined https://github.com/gtsteffaniak/filebrowser/issues/545
|
||||
+ - The concept of 'groups', either automatically from OIDC groups claim or defined https://github.com/gtsteffaniak/filebrowser/issues/545
|
||||
- share view changes -- now aligns with the standard listing view. This means files can be viewed and edited (if permission allows) just like a normal listing.
|
||||
- many share links customization enhancements
|
||||
- #- only share to certain authenticated users https://github.com/gtsteffaniak/filebrowser/issues/656 https://github.com/gtsteffaniak/filebrowser/issues/985
|
||||
- #- one-time download links
|
||||
- #- customize share theme, banner image, and icons https://github.com/gtsteffaniak/filebrowser/issues/827 https://github.com/gtsteffaniak/filebrowser/issues/1029
|
||||
- #- share link info customization https://github.com/gtsteffaniak/filebrowser/issues/792 https://github.com/gtsteffaniak/filebrowser/issues/841 https://github.com/gtsteffaniak/filebrowser/issues/520
|
||||
- #- "shared with me" and "share history" https://github.com/gtsteffaniak/filebrowser/issues/943
|
||||
+ - only share to certain authenticated users https://github.com/gtsteffaniak/filebrowser/issues/656 https://github.com/gtsteffaniak/filebrowser/issues/985
|
||||
+ - one-time download links
|
||||
+ - customize share theme, banner image, and icons https://github.com/gtsteffaniak/filebrowser/issues/827 https://github.com/gtsteffaniak/filebrowser/issues/1029
|
||||
+ - share link info customization https://github.com/gtsteffaniak/filebrowser/issues/792 https://github.com/gtsteffaniak/filebrowser/issues/841 https://github.com/gtsteffaniak/filebrowser/issues/520
|
||||
+ - "shared with me" and "share history" https://github.com/gtsteffaniak/filebrowser/issues/943
|
||||
- upload to share links https://github.com/gtsteffaniak/filebrowser/issues/661
|
||||
- share link public changes https://github.com/gtsteffaniak/filebrowser/issues/473
|
||||
- -
|
||||
- public/private folder logic https://github.com/gtsteffaniak/filebrowser/issues/505
|
||||
|
||||
**BugFixes**:
|
||||
diff --git a/backend/http/middleware.go b/backend/http/middleware.go
|
||||
index 5ba397fe..595f00e1 100644
|
||||
--- a/backend/http/middleware.go
|
||||
+++ b/backend/http/middleware.go
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
- "net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -44,14 +43,8 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
|
||||
// Middleware to handle file requests by hash and pass it to the handler
|
||||
func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||
+ path := r.URL.Query().Get("path")
|
||||
hash := r.URL.Query().Get("hash")
|
||||
- encodedPath := r.URL.Query().Get("path")
|
||||
- // Decode the URL-encoded path
|
||||
- path, err := url.QueryUnescape(encodedPath)
|
||||
- if err != nil {
|
||||
- return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
- }
|
||||
- data.path = path
|
||||
// Get the file link by hash
|
||||
link, err := store.Share.GetByHash(hash)
|
||||
if err != nil {
|
||||
@@ -71,6 +64,11 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
return status, fmt.Errorf("could not authenticate share request")
|
||||
}
|
||||
}
|
||||
+ data.path = strings.TrimSuffix(link.Path, "/") + "/" + strings.TrimPrefix(path, "/")
|
||||
+ if path == "" || path == "/" {
|
||||
+ data.path = link.Path
|
||||
+ }
|
||||
+
|
||||
source, ok := config.Server.SourceMap[link.Source]
|
||||
if !ok {
|
||||
return http.StatusNotFound, fmt.Errorf("source not found")
|
||||
diff --git a/backend/http/middleware_test.go b/backend/http/middleware_test.go
|
||||
index ff7c173e..eb257951 100644
|
||||
--- a/backend/http/middleware_test.go
|
||||
+++ b/backend/http/middleware_test.go
|
||||
@@ -155,20 +155,6 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
},
|
||||
expectedStatusCode: 0, // zero means 200 on helpers
|
||||
},
|
||||
- {
|
||||
- name: "Private share, valid password when token exists",
|
||||
- share: &share.Link{
|
||||
- Hash: "pw_and_token_hash",
|
||||
- UserID: 1,
|
||||
- PasswordHash: passwordBcrypt,
|
||||
- Token: "some_random_token",
|
||||
- Source: "/srv",
|
||||
- },
|
||||
- extraHeaders: map[string]string{
|
||||
- "X-SHARE-PASSWORD": "password",
|
||||
- },
|
||||
- expectedStatusCode: 0, // zero means 200 on helpers
|
||||
- },
|
||||
{
|
||||
name: "Private share, no auth provided",
|
||||
share: &share.Link{
|
||||
diff --git a/backend/http/public.go b/backend/http/public.go
|
||||
index ef65dfa6..0e641fbd 100644
|
||||
--- a/backend/http/public.go
|
||||
+++ b/backend/http/public.go
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
+ "github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
+ "github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
+ "github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
|
||||
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
|
||||
@@ -49,7 +52,7 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
}
|
||||
|
||||
func publicShareHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
- d.fileInfo.Path = "/" + strings.TrimPrefix(d.fileInfo.Path, d.share.Path)
|
||||
+ d.fileInfo.Path = strings.TrimPrefix(d.fileInfo.Path, d.share.Path)
|
||||
return renderJSON(w, r, d.fileInfo)
|
||||
}
|
||||
|
||||
@@ -91,5 +94,24 @@ func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
if config.Server.DisablePreviews {
|
||||
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
|
||||
}
|
||||
+ path := r.URL.Query().Get("path")
|
||||
+ var err error
|
||||
+ if path == "" {
|
||||
+ return http.StatusBadRequest, fmt.Errorf("invalid request path")
|
||||
+ }
|
||||
+ fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
|
||||
+ Path: utils.JoinPathAsUnix(d.share.Path, path),
|
||||
+ Modify: false, // TODO make this configurable
|
||||
+ Source: d.fileInfo.Source,
|
||||
+ Expand: true,
|
||||
+ })
|
||||
+ if err != nil {
|
||||
+ logger.Debugf("public preview handler: error getting file info: %v", err)
|
||||
+ return 400, fmt.Errorf("file not found")
|
||||
+ }
|
||||
+ if fileInfo.Type == "directory" {
|
||||
+ return http.StatusBadRequest, fmt.Errorf("can't create preview for directory")
|
||||
+ }
|
||||
+ d.fileInfo = fileInfo
|
||||
return previewHelperFunc(w, r, d)
|
||||
}
|
||||
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
|
||||
index 9399a292..2ef5f38e 100644
|
||||
--- a/frontend/src/api/index.js
|
||||
+++ b/frontend/src/api/index.js
|
||||
@@ -7,3 +7,6 @@ import search from "./search";
|
||||
|
||||
// Note: shareApi has been consolidated into publicApi
|
||||
export { filesApi, publicApi, usersApi, settingsApi, search, accessApi };
|
||||
+
|
||||
+// For backward compatibility, export publicApi as shareApi as well
|
||||
+export { publicApi as shareApi };
|
||||
diff --git a/frontend/src/api/public.js b/frontend/src/api/public.js
|
||||
index 1dcb4125..d94713c5 100644
|
||||
--- a/frontend/src/api/public.js
|
||||
+++ b/frontend/src/api/public.js
|
||||
@@ -2,7 +2,6 @@ import { fetchURL, fetchJSON, adjustedData } from "./utils";
|
||||
import { notify } from "@/notify";
|
||||
import { getApiPath, removePrefix } from "@/utils/url.js";
|
||||
import { externalUrl, baseURL } from "@/utils/constants";
|
||||
-import { state } from "@/store";
|
||||
|
||||
// ============================================================================
|
||||
// PUBLIC API ENDPOINTS (hash-based authentication)
|
||||
@@ -13,8 +12,7 @@ export async function fetchPub(path, hash, password = "", content = false) {
|
||||
const params = {
|
||||
path,
|
||||
hash,
|
||||
- ...(content && { content: 'true' }),
|
||||
- ...(state.share.token && { token: state.share.token })
|
||||
+ ...(content && { content: 'true' })
|
||||
}
|
||||
const apiPath = getApiPath("public/api/shared", params);
|
||||
const response = await fetch(apiPath, {
|
||||
@@ -24,17 +22,7 @@ export async function fetchPub(path, hash, password = "", content = false) {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
- const error = new Error(response.statusText);
|
||||
- // attempt to marshal json response
|
||||
- let data = null;
|
||||
- try {
|
||||
- data = await response.json()
|
||||
- } catch (e) {
|
||||
- // ignore
|
||||
- }
|
||||
- if (data) {
|
||||
- error.message = data.message;
|
||||
- }
|
||||
+ const error = new Error("Failed to connect to the server.");
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
@@ -57,14 +45,13 @@ export function getDownloadURL(share, files) {
|
||||
}
|
||||
|
||||
// Generate a preview URL for public shares
|
||||
-export function getPreviewURL(path) {
|
||||
+export function getPreviewURL(hash, path) {
|
||||
try {
|
||||
const params = {
|
||||
path: encodeURIComponent(path),
|
||||
size: 'small',
|
||||
- hash: state.share.hash,
|
||||
- inline: 'true',
|
||||
- ...(state.share.token && { token: state.share.token })
|
||||
+ hash: hash,
|
||||
+ inline: 'true'
|
||||
}
|
||||
const apiPath = getApiPath('public/api/preview', params)
|
||||
return window.origin + apiPath
|
||||
diff --git a/frontend/src/api/share.js b/frontend/src/api/share.js
|
||||
new file mode 100644
|
||||
index 00000000..729b7599
|
||||
--- /dev/null
|
||||
+++ b/frontend/src/api/share.js
|
||||
@@ -0,0 +1,66 @@
|
||||
+import { fetchURL, fetchJSON, adjustedData } from "./utils";
|
||||
+import { notify } from "@/notify";
|
||||
+import { getApiPath } from "@/utils/url.js";
|
||||
+import { externalUrl } from "@/utils/constants";
|
||||
+
|
||||
+export async function list() {
|
||||
+ const apiPath = getApiPath("api/shares");
|
||||
+ return fetchJSON(apiPath);
|
||||
+}
|
||||
+
|
||||
+export async function get(path, source) {
|
||||
+ try {
|
||||
+ const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
|
||||
+ const apiPath = getApiPath("api/share",params);
|
||||
+ let data = fetchJSON(apiPath);
|
||||
+ return adjustedData(data, path);
|
||||
+ } catch (err) {
|
||||
+ notify.showError(err.message || "Error fetching data");
|
||||
+ throw err;
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+export async function remove(hash) {
|
||||
+ const params = { hash };
|
||||
+ const apiPath = getApiPath("api/share",params);
|
||||
+ await fetchURL(apiPath, {
|
||||
+ method: "DELETE",
|
||||
+ });
|
||||
+}
|
||||
+
|
||||
+export async function create(path, source, password = "", expires = "", unit = "hours") {
|
||||
+ const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
|
||||
+ const apiPath = getApiPath("api/share",params);
|
||||
+ let body = "{}";
|
||||
+ if (password != "" || expires !== "" || unit !== "hours") {
|
||||
+ body = JSON.stringify({ password: password, expires: expires, unit: unit });
|
||||
+ }
|
||||
+ return fetchJSON(apiPath, {
|
||||
+ method: "POST",
|
||||
+ body: body,
|
||||
+ });
|
||||
+}
|
||||
+
|
||||
+export function getShareURL(share) {
|
||||
+ if (externalUrl) {
|
||||
+ const apiPath = getApiPath(`share/${share.hash}`)
|
||||
+ return externalUrl + apiPath
|
||||
+ }
|
||||
+ return window.origin+getApiPath(`share/${share.hash}`);
|
||||
+}
|
||||
+
|
||||
+export function getPreviewURL(hash, path) {
|
||||
+ try {
|
||||
+ const params = {
|
||||
+ path: encodeURIComponent(path),
|
||||
+ size: 'small',
|
||||
+ hash: hash,
|
||||
+ inline: 'true'
|
||||
+ }
|
||||
+ const apiPath = getApiPath('api/public/preview', params)
|
||||
+ return window.origin + apiPath
|
||||
+ } catch (err) {
|
||||
+ notify.showError(err.message || 'Error getting preview URL')
|
||||
+ throw err
|
||||
+ }
|
||||
+}
|
||||
diff --git a/frontend/src/components/files/ListingItem.vue b/frontend/src/components/files/ListingItem.vue
|
||||
index 29c2749f..93848c1c 100644
|
||||
--- a/frontend/src/components/files/ListingItem.vue
|
||||
+++ b/frontend/src/components/files/ListingItem.vue
|
||||
@@ -67,7 +67,7 @@ import { enableThumbs } from "@/utils/constants";
|
||||
import downloadFiles from "@/utils/download";
|
||||
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
-import { filesApi,publicApi } from "@/api";
|
||||
+import { filesApi, shareApi } from "@/api";
|
||||
import * as upload from "@/utils/upload";
|
||||
import { state, getters, mutations } from "@/store"; // Import your custom store
|
||||
import { url } from "@/utils";
|
||||
@@ -162,8 +162,8 @@ export default {
|
||||
// @ts-ignore
|
||||
let path = url.removeTrailingSlash(state.req.path) + "/" + this.name;
|
||||
if (getters.isShare()) {
|
||||
- let urlPath = getters.getSharePath(this.name) ;
|
||||
- return publicApi.getPreviewURL(urlPath);
|
||||
+ let urlPath = getters.getSharePath() + "/" + this.name;
|
||||
+ return shareApi.getPreviewURL(state.share.hash, urlPath, state.req.modified);
|
||||
}
|
||||
// @ts-ignore
|
||||
return filesApi.getPreviewURL(state.req.source, path, this.modified);
|
||||
@@ -226,7 +226,7 @@ export default {
|
||||
},
|
||||
getUrl() {
|
||||
if (this.hash) {
|
||||
- return baseURL + "public/share/" + this.hash + "/" + url.encodedPath(this.path);
|
||||
+ return baseURL + "share/" + this.hash + url.encodedPath(this.path);
|
||||
}
|
||||
if (serverHasMultipleSources) {
|
||||
return baseURL + "files/" + encodeURIComponent(this.source) + url.encodedPath(this.path);
|
||||
diff --git a/frontend/src/components/prompts/ActionApi.vue b/frontend/src/components/prompts/ActionApi.vue
|
||||
index f2ebaa3f..1a381565 100644
|
||||
--- a/frontend/src/components/prompts/ActionApi.vue
|
||||
+++ b/frontend/src/components/prompts/ActionApi.vue
|
||||
@@ -90,3 +90,20 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
+
|
||||
+<style>
|
||||
+/* Basic styling for the prompt */
|
||||
+.card.floating {
|
||||
+ padding: 20px;
|
||||
+}
|
||||
+.card-content {
|
||||
+ margin-bottom: 16px;
|
||||
+}
|
||||
+.card-action {
|
||||
+ display: flex;
|
||||
+ justify-content: flex-end;
|
||||
+}
|
||||
+.card-action .button {
|
||||
+ margin-left: 8px;
|
||||
+}
|
||||
+</style>
|
||||
diff --git a/frontend/src/components/prompts/Share.vue b/frontend/src/components/prompts/Share.vue
|
||||
index be1ad5ad..8689100d 100644
|
||||
--- a/frontend/src/components/prompts/Share.vue
|
||||
+++ b/frontend/src/components/prompts/Share.vue
|
||||
@@ -2,11 +2,10 @@
|
||||
<div class="card-title">
|
||||
<h2>{{ $t("buttons.share") }}</h2>
|
||||
</div>
|
||||
- <div class="card-content">
|
||||
- <div aria-label="share-path" class="searchContext"> {{ $t('search.path') }} {{ subpath }}</div>
|
||||
- <p> {{ $t('share.notice') }} </p>
|
||||
-
|
||||
- <div v-if="listing">
|
||||
+ <div aria-label="share-path" class="searchContext"> {{ $t('search.path') }} {{ subpath }}</div>
|
||||
+ <p> {{ $t('share.notice') }} </p>
|
||||
+ <template v-if="listing">
|
||||
+ <div class="card-content">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
@@ -46,7 +45,20 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
- <div v-else>
|
||||
+ <div class="card-action">
|
||||
+ <button class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('buttons.close')"
|
||||
+ :title="$t('buttons.close')">
|
||||
+ {{ $t("buttons.close") }}
|
||||
+ </button>
|
||||
+ <button class="button button--flat button--blue" @click="() => switchListing()" :aria-label="$t('buttons.new')"
|
||||
+ :title="$t('buttons.new')">
|
||||
+ {{ $t("buttons.new") }}
|
||||
+ </button>
|
||||
+ </div>
|
||||
+ </template>
|
||||
+
|
||||
+ <template v-else>
|
||||
+ <div class="card-content">
|
||||
<p>{{ $t("settings.shareDuration") }}</p>
|
||||
<div class="form-flex-group">
|
||||
<input class="form-grow input flat-right" v-focus type="number" max="2147483647" min="1" @keyup.enter="submit" v-model.trim="time" />
|
||||
@@ -58,7 +70,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<p>{{ $t("prompts.optionalPassword") }}</p>
|
||||
- <input class="input" type="password" autocomplete="off" v-model.trim="password" />
|
||||
+ <input class="input" type="password" v-model.trim="password" />
|
||||
|
||||
<div class="settings-items">
|
||||
<ToggleSwitch class="item" v-model="readOnly" :name="'Read-only access'" />
|
||||
@@ -80,32 +92,23 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
- </div>
|
||||
|
||||
- <div class="card-action">
|
||||
- <button v-if="listing" class="button button--flat button--grey" @click="closeHovers" :aria-label="$t('buttons.close')"
|
||||
- :title="$t('buttons.close')">
|
||||
- {{ $t("buttons.close") }}
|
||||
- </button>
|
||||
- <button v-if="listing" class="button button--flat button--blue" @click="() => switchListing()" :aria-label="$t('buttons.new')"
|
||||
- :title="$t('buttons.new')">
|
||||
- {{ $t("buttons.new") }}
|
||||
- </button>
|
||||
-
|
||||
- <button v-if="!listing" class="button button--flat button--grey" @click="() => switchListing()" :aria-label="$t('buttons.cancel')"
|
||||
- :title="$t('buttons.cancel')">
|
||||
- {{ $t("buttons.cancel") }}
|
||||
- </button>
|
||||
- <button v-if="!listing" class="button button--flat button--blue" @click="submit" aria-label="Share-Confirm"
|
||||
- :title="$t('buttons.share')">
|
||||
- {{ $t("buttons.share") }}
|
||||
- </button>
|
||||
- </div>
|
||||
+ <div class="card-action">
|
||||
+ <button class="button button--flat button--grey" @click="() => switchListing()" :aria-label="$t('buttons.cancel')"
|
||||
+ :title="$t('buttons.cancel')">
|
||||
+ {{ $t("buttons.cancel") }}
|
||||
+ </button>
|
||||
+ <button class="button button--flat button--blue" @click="submit" aria-label="Share-Confirm"
|
||||
+ :title="$t('buttons.share')">
|
||||
+ {{ $t("buttons.share") }}
|
||||
+ </button>
|
||||
+ </div>
|
||||
+ </template>
|
||||
</template>
|
||||
<script>
|
||||
import { notify } from "@/notify";
|
||||
import { state, getters, mutations } from "@/store";
|
||||
-import { publicApi } from "@/api";
|
||||
+import { shareApi, publicApi } from "@/api";
|
||||
import Clipboard from "clipboard";
|
||||
import { fromNow } from "@/utils/moment";
|
||||
import { buildItemUrl } from "@/utils/url";
|
||||
@@ -188,7 +191,7 @@ export default {
|
||||
|
||||
try {
|
||||
// get last element of the path
|
||||
- const links = await publicApi.get(this.subpath, this.source);
|
||||
+ const links = await shareApi.get(this.subpath, this.source);
|
||||
this.links = links;
|
||||
} catch (err) {
|
||||
notify.showError(err);
|
||||
@@ -215,9 +218,9 @@ export default {
|
||||
let isPermanent = !this.time || this.time == 0;
|
||||
let res = null;
|
||||
if (isPermanent) {
|
||||
- res = await publicApi.create(this.subpath, this.source, this.password);
|
||||
+ res = await shareApi.create(this.subpath, this.source, this.password);
|
||||
} else {
|
||||
- res = await publicApi.create(
|
||||
+ res = await shareApi.create(
|
||||
this.subpath,
|
||||
this.source,
|
||||
this.password,
|
||||
@@ -241,7 +244,7 @@ export default {
|
||||
async deleteLink(event, link) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
- await publicApi.remove(link.hash);
|
||||
+ await shareApi.remove(link.hash);
|
||||
this.links = this.links.filter((item) => item.hash !== link.hash);
|
||||
if (this.links.length === 0) {
|
||||
this.listing = false;
|
||||
@@ -254,7 +257,7 @@ export default {
|
||||
return fromNow(time, state.user.locale)
|
||||
},
|
||||
buildLink(share) {
|
||||
- return publicApi.getShareURL(share);
|
||||
+ return shareApi.getShareURL(share);
|
||||
},
|
||||
hasDownloadLink() {
|
||||
if (state.isSearchActive) {
|
||||
diff --git a/frontend/src/components/sidebar/General.vue b/frontend/src/components/sidebar/General.vue
|
||||
index 58f02c10..0e030aeb 100644
|
||||
--- a/frontend/src/components/sidebar/General.vue
|
||||
+++ b/frontend/src/components/sidebar/General.vue
|
||||
@@ -159,7 +159,7 @@ export default {
|
||||
isStickySidebar: () => getters.isStickySidebar(),
|
||||
isMobile: () => getters.isMobile(),
|
||||
isListingView: () => getters.currentView() == "listingView",
|
||||
- user: () => (state.user),
|
||||
+ user: () => (console.log(state.user), state.user),
|
||||
isDarkMode: () => getters.isDarkMode(),
|
||||
showSources: () => !getters.isShare(),
|
||||
currentPrompt: () => getters.currentPrompt(),
|
||||
diff --git a/frontend/src/css/dashboard.css b/frontend/src/css/dashboard.css
|
||||
index 2e1fbc8f..77da5938 100644
|
||||
--- a/frontend/src/css/dashboard.css
|
||||
+++ b/frontend/src/css/dashboard.css
|
||||
@@ -226,10 +226,6 @@ body.rtl .card-title>*:first-child {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
-.card-content h3 {
|
||||
- text-align: center;
|
||||
-}
|
||||
-
|
||||
.card h2 {
|
||||
font-weight: 500;
|
||||
}
|
||||
diff --git a/frontend/src/store/getters.js b/frontend/src/store/getters.js
|
||||
index cb062702..d97e7eed 100644
|
||||
--- a/frontend/src/store/getters.js
|
||||
+++ b/frontend/src/store/getters.js
|
||||
@@ -201,9 +201,10 @@ export const getters = {
|
||||
sharePathBase: () => {
|
||||
return '/public/share/' + getters.shareHash() + '/'
|
||||
},
|
||||
- getSharePath: (subPath = "") => {
|
||||
+ getSharePath: () => {
|
||||
let urlPath = getters.routePath('public/share')
|
||||
- return "/" + removeLeadingSlash(urlPath.split(state.share.hash)[1]) + "/" + subPath
|
||||
+ let parts = urlPath.split('/')
|
||||
+ return "/" + removeLeadingSlash(parts.slice(2).join('/'))
|
||||
},
|
||||
currentView: () => {
|
||||
let listingView = ''
|
||||
@@ -288,6 +289,7 @@ export const getters = {
|
||||
},
|
||||
|
||||
currentPromptName: () => {
|
||||
+ console.log("currentPromptName", state.prompts);
|
||||
// Ensure state.prompts is an array
|
||||
if (!Array.isArray(state.prompts) || state.prompts.length === 0) {
|
||||
return ""
|
||||
diff --git a/frontend/src/store/mutations.js b/frontend/src/store/mutations.js
|
||||
index 8dc0b19f..b2b13461 100644
|
||||
--- a/frontend/src/store/mutations.js
|
||||
+++ b/frontend/src/store/mutations.js
|
||||
@@ -191,6 +191,7 @@ export const mutations = {
|
||||
emitStateChanged();
|
||||
},
|
||||
showHover: (value) => {
|
||||
+ console.log("showHover", value);
|
||||
if (typeof value === "object") {
|
||||
state.prompts.push({
|
||||
name: value?.name,
|
||||
@@ -206,6 +207,7 @@ export const mutations = {
|
||||
props: value?.props,
|
||||
});
|
||||
}
|
||||
+ console.log("showHover", state.prompts);
|
||||
emitStateChanged();
|
||||
},
|
||||
setLoading: (loadType, status) => {
|
||||
@@ -221,6 +223,7 @@ export const mutations = {
|
||||
emitStateChanged();
|
||||
},
|
||||
setCurrentUser: (value) => {
|
||||
+ console.log('setCurrentUser', value);
|
||||
try {
|
||||
// If value is null or undefined, emit state change and exit early
|
||||
if (!value) {
|
||||
@@ -243,6 +246,7 @@ export const mutations = {
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
+ console.log('setCurrentUser', state.user);
|
||||
emitStateChanged();
|
||||
},
|
||||
setJWT: (value) => {
|
||||
@@ -303,6 +307,7 @@ export const mutations = {
|
||||
if (!state.user) {
|
||||
state.user = {};
|
||||
}
|
||||
+ console.log('updateCurrentUser', value);
|
||||
// Store previous state for comparison
|
||||
const previousUser = { ...state.user };
|
||||
|
||||
@@ -315,12 +320,14 @@ export const mutations = {
|
||||
i18n.default.locale = state.user.locale;
|
||||
localStorage.setItem("userLocale", state.user.locale);
|
||||
}
|
||||
+ console.log('updateCurrentUser2', state.user);
|
||||
// Update localStorage if stickySidebar exists
|
||||
if (state.user.stickySidebar && getters.currentView() == "listingView") {
|
||||
state.multiButtonState = "menu";
|
||||
} else if (state.showSidebar) {
|
||||
state.multiButtonState = "back";
|
||||
}
|
||||
+ console.log('updateCurrentUser3', state.user);
|
||||
// Update users if there's any change in state.user
|
||||
if (JSON.stringify(state.user) !== JSON.stringify(previousUser)) {
|
||||
// Only update the properties that were actually provided in the input
|
||||
diff --git a/frontend/src/views/Files.vue b/frontend/src/views/Files.vue
|
||||
index 4c69ecd7..6f8b892b 100644
|
||||
--- a/frontend/src/views/Files.vue
|
||||
+++ b/frontend/src/views/Files.vue
|
||||
@@ -9,9 +9,8 @@
|
||||
<div class="card-title">
|
||||
<h2>{{ $t("general.password") }}</h2>
|
||||
</div>
|
||||
- <div class="card-content form-flex-group">
|
||||
+ <div class="card-content">
|
||||
<input
|
||||
- class="input share-password"
|
||||
v-focus
|
||||
type="password"
|
||||
:placeholder="$t('general.password')"
|
||||
@@ -149,10 +148,8 @@ export default {
|
||||
});
|
||||
// Add glow effect
|
||||
element.classList.add('scroll-glow');
|
||||
- console.log("scrollToHash", element)
|
||||
// Remove glow effect after animation completes
|
||||
setTimeout(() => {
|
||||
- console.log("scrollToHash remove glow", element)
|
||||
element.classList.remove('scroll-glow');
|
||||
}, 1000);
|
||||
}
|
||||
@@ -211,15 +208,19 @@ export default {
|
||||
this.shareHash = parts[1]
|
||||
this.shareSubPath = "/" + parts.slice(2).join("/");
|
||||
|
||||
+ // Store share data in state for use by components
|
||||
+ mutations.setShareData({
|
||||
+ hash: this.shareHash,
|
||||
+ token: this.shareToken,
|
||||
+ subPath: this.shareSubPath,
|
||||
+ });
|
||||
+
|
||||
// Handle password
|
||||
- if (this.sharePassword === "") {
|
||||
+ if (this.sharePassword === "" || this.sharePassword === null) {
|
||||
this.sharePassword = localStorage.getItem("sharepass:" + this.shareHash);
|
||||
} else {
|
||||
localStorage.setItem("sharepass:" + this.shareHash, this.sharePassword);
|
||||
}
|
||||
- if (this.sharePassword === null) {
|
||||
- this.sharePassword = "";
|
||||
- }
|
||||
|
||||
mutations.resetSelected();
|
||||
mutations.setMultiple(false);
|
||||
@@ -229,15 +230,11 @@ export default {
|
||||
let file = await publicApi.fetchPub(this.shareSubPath, this.shareHash, this.sharePassword);
|
||||
file.hash = this.shareHash;
|
||||
this.shareToken = file.token;
|
||||
- // Store share data in state for use by components
|
||||
- mutations.setShareData({
|
||||
- hash: this.shareHash,
|
||||
- token: this.shareToken,
|
||||
- subPath: this.shareSubPath,
|
||||
- });
|
||||
+
|
||||
// If not a directory, fetch content for preview components
|
||||
if (file.type != "directory") {
|
||||
const content = !getters.fileViewingDisabled(file.name);
|
||||
+
|
||||
file = await publicApi.fetchPub(this.shareSubPath, this.shareHash, this.sharePassword, content);
|
||||
file.hash = this.shareHash;
|
||||
this.shareToken = file.token;
|
||||
@@ -324,7 +321,7 @@ export default {
|
||||
};
|
||||
</script>
|
||||
|
||||
-<style>
|
||||
+<style scoped>
|
||||
.share__wrong__password {
|
||||
color: #ff4757;
|
||||
text-align: center;
|
||||
@@ -351,8 +348,4 @@ export default {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
-
|
||||
-.share-password {
|
||||
- width: 100%;
|
||||
-}
|
||||
</style>
|
||||
diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue
|
||||
index 4547e3d2..38d75dc1 100644
|
||||
--- a/frontend/src/views/Settings.vue
|
||||
+++ b/frontend/src/views/Settings.vue
|
||||
@@ -122,4 +122,7 @@ export default {
|
||||
background-color: var(--surfaceSecondary);
|
||||
}
|
||||
|
||||
+.card-content {
|
||||
+ padding: 1em;
|
||||
+}
|
||||
</style>
|
||||
diff --git a/frontend/src/views/settings/Profile.vue b/frontend/src/views/settings/Profile.vue
|
||||
index f65fe30f..e34e405a 100644
|
||||
--- a/frontend/src/views/settings/Profile.vue
|
||||
+++ b/frontend/src/views/settings/Profile.vue
|
||||
@@ -428,6 +428,9 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
+.card-content h3 {
|
||||
+ text-align: center;
|
||||
+}
|
||||
|
||||
#disablePreviews,
|
||||
#disableViewing,
|
||||
diff --git a/frontend/src/views/settings/Shares.vue b/frontend/src/views/settings/Shares.vue
|
||||
index cd28af70..6d39166e 100644
|
||||
--- a/frontend/src/views/settings/Shares.vue
|
||||
+++ b/frontend/src/views/settings/Shares.vue
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
<script>
|
||||
import { notify } from "@/notify";
|
||||
-import { publicApi } from "@/api";
|
||||
+import { shareApi } from "@/api";
|
||||
import { state, mutations, getters } from "@/store";
|
||||
import Clipboard from "clipboard";
|
||||
import Errors from "@/views/Errors.vue";
|
||||
@@ -70,7 +70,7 @@ export default {
|
||||
async created() {
|
||||
mutations.setLoading("shares", true);
|
||||
try {
|
||||
- let links = await publicApi.list();
|
||||
+ let links = await shareApi.list();
|
||||
if (links.length === 0) {
|
||||
this.links = [];
|
||||
return;
|
||||
@@ -115,7 +115,7 @@ export default {
|
||||
mutations.closeHovers();
|
||||
|
||||
try {
|
||||
- publicApi.remove(link.hash);
|
||||
+ shareApi.remove(link.hash);
|
||||
this.links = this.links.filter((item) => item.hash !== link.hash);
|
||||
notify.showSuccess(this.$t("settings.shareDeleted"));
|
||||
} catch (e) {
|
||||
@@ -128,7 +128,7 @@ export default {
|
||||
return fromNow(time);
|
||||
},
|
||||
buildLink(share) {
|
||||
- return publicApi.getShareURL(share);
|
||||
+ return shareApi.getShareURL(share);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
frontend
|
||||
@@ -8,11 +8,10 @@
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build && cp -r dist/* ../backend/http/embed",
|
||||
"build-windows": "vite build && robocopy dist ../backend/http/embed /e",
|
||||
"build": "vite build --outDir ../backend/http/dist && cp -r ../backend/http/dist/* ../backend/http/embed",
|
||||
"build-windows": "vite build --outDir ../backend/http/dist && robocopy ../backend/http/dist ../backend/http/embed /e",
|
||||
"build-docker": "vite build",
|
||||
"watch": "vite build --watch",
|
||||
"watch": "vite build --watch --outDir ../backend/http/dist",
|
||||
"typecheck": "vue-tsc -p ./tsconfig.json --noEmit",
|
||||
"lint": "eslint --ext .js,.vue,ts src",
|
||||
"lint:fix": "eslint --fix src/",
|
||||
@@ -22,6 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@onlyoffice/document-editor-vue": "^1.4.0",
|
||||
"@skjnldsv/vue-plyr": "^7.5.0",
|
||||
"ace-builds": "^1.24.2",
|
||||
"axios": "^1.7.9",
|
||||
"clipboard": "^2.0.4",
|
||||
@@ -31,6 +31,7 @@
|
||||
"file-loader": "^6.2.0",
|
||||
"glob": "^9.3.5",
|
||||
"highlight.js": "^11.11.1",
|
||||
"jsmediatags": "github:Kurami32/jsmediatags",
|
||||
"mammoth": "^1.9.1",
|
||||
"marked": "^15.0.6",
|
||||
"normalize.css": "^8.0.1",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
server:
|
||||
minSearchLength: 3 # minimum length of search query to begin searching (default: 3)
|
||||
disableUpdateCheck: false # disables backend update check service
|
||||
numImageProcessors: 4 # number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
|
||||
socket: "" # socket to listen on
|
||||
tlsKey: "" # path to TLS key
|
||||
@@ -21,6 +23,7 @@ server:
|
||||
- path: "/relative/or/absolute/path" # file system path. (Can be relative) validate:required
|
||||
name: "backend" # display name
|
||||
config:
|
||||
denyByDefault: false # deny access unless an "allow" access rule was specifically created.
|
||||
private: false # designate as source as private -- currently just means no sharing permitted.
|
||||
disabled: false # disable the source, this is useful so you don't need to remove it from the config file
|
||||
indexingIntervalMinutes: 0 # optional manual overide interval in minutes to re-index the source
|
||||
@@ -47,7 +50,7 @@ server:
|
||||
cacheDir: "tmp" # path to the cache directory, used for thumbnails and other cached files
|
||||
maxArchiveSize: 50 # max pre-archive combined size of files/folder that are allowed to be archived (in GB)
|
||||
auth:
|
||||
tokenExpirationHours: 2 # the number of hours until the token expires. Default is 2 hours.
|
||||
tokenExpirationHours: 2 # time in hours each web UI session token is valid for. Default is 2 hours.
|
||||
methods:
|
||||
proxy: # validate:omitempty
|
||||
enabled: false
|
||||
@@ -66,8 +69,8 @@ auth:
|
||||
enforcedOtp: false # if set to true, TOTP is enforced for all password users users. Otherwise, users can choose to enable TOTP.
|
||||
oidc: # validate:omitempty
|
||||
enabled: false # whether to enable OIDC authentication
|
||||
clientId: "" # client id of the OIDC application
|
||||
clientSecret: "" # client secret of the OIDC application
|
||||
clientId: "**hidden**" # secret: client id of the OIDC application
|
||||
clientSecret: "**hidden**" # secret: client secret of the OIDC application
|
||||
issuerUrl: "" # authorization URL of the OIDC provider
|
||||
scopes: "openid email profile" # scopes to request from the OIDC provider
|
||||
userIdentifier: "preferred_username" # the field value to use as the username. Default is "preferred_username", can also be "email" or "username", or "phone"
|
||||
@@ -76,10 +79,10 @@ auth:
|
||||
createUser: false # create user if not exists
|
||||
adminGroup: "" # if set, users in this group will be granted admin privileges.
|
||||
groupsClaim: "groups" # the JSON field name to read groups from. Default is "groups"
|
||||
key: "" # the key used to sign the JWT tokens. If not set, a random key will be generated.
|
||||
adminUsername: "admin" # the username of the admin user. If not set, the default is "admin".
|
||||
adminPassword: "admin" # the password of the admin user. If not set, the default is "admin".
|
||||
totpSecret: "" # secret used to encrypt TOTP secrets
|
||||
key: "**hidden**" # secret: the key used to sign the JWT tokens. If not set, a random key will be generated.
|
||||
adminUsername: "**hidden**" # secret: the username of the admin user. If not set, the default is "admin".
|
||||
adminPassword: "**hidden**" # secret: the password of the admin user. If not set, the default is "admin".
|
||||
totpSecret: "**hidden**" # secret: secret used to encrypt TOTP secrets
|
||||
frontend:
|
||||
name: "FileBrowser Quantum" # display name
|
||||
disableDefaultLinks: false # disable default links in the sidebar
|
||||
@@ -90,70 +93,21 @@ frontend:
|
||||
url: "https://github.com/gtsteffaniak/filebrowser/releases/" # the url to link to validate:required
|
||||
- text: "Help" # the text to display on the link validate:required
|
||||
title: "" # the title to display on hover
|
||||
url: "https://github.com/gtsteffaniak/filebrowser/wiki" # the url to link to validate:required
|
||||
url: "help prompt" # the url to link to validate:required
|
||||
disableNavButtons: false # disable the nav buttons in the sidebar
|
||||
styling:
|
||||
customCSS: "" # if a valid path to a css file is provided, it will be applied for all users. (eg. "reduce-rounded-corners.css")
|
||||
lightBackground: "#f5f5f5" # specify a valid CSS color property value to use as the background color in light mode
|
||||
darkBackground: "#141D24" # Specify a valid CSS color property value to use as the background color in dark mode
|
||||
customThemes: # A list of custom css files that each user can select to override the default styling. if "default" is key name then it will be the default option.
|
||||
alternative:
|
||||
description: Reduce rounded corners
|
||||
css: |-
|
||||
.card,
|
||||
#listingView.gallery .item,
|
||||
#listingView.normal .item,
|
||||
.button,
|
||||
button,
|
||||
.button-action,
|
||||
.input,
|
||||
input,
|
||||
#listingView.gallery .item img,
|
||||
#listingView .item img,
|
||||
.sizeInputWrapper,
|
||||
.sizeInput,
|
||||
#search-input,
|
||||
.floating-tooltip,
|
||||
.settings-items>.item,
|
||||
.button-group {
|
||||
border-radius: 0.25em !important;
|
||||
}
|
||||
|
||||
#listingView.compact .header {
|
||||
border-bottom-left-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
}
|
||||
|
||||
#listingView.list .item:last-child,
|
||||
#results {
|
||||
border-bottom-left-radius: 0.25em !important;
|
||||
border-bottom-right-radius: 0.25em !important;
|
||||
}
|
||||
|
||||
#listingView.list .item:first-child {
|
||||
border-top-left-radius: 0.25em !important;
|
||||
border-top-right-radius: 0.25em !important;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li:last-child a {
|
||||
border-top-right-radius: 0.25em !important;
|
||||
border-bottom-right-radius: 0.25em !important;
|
||||
}
|
||||
#breadcrumbs ul li:first-child a {
|
||||
border-top-left-radius: 0.25em !important;
|
||||
border-bottom-left-radius: 0.25em !important;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.icon {
|
||||
border-radius: 0.1em !important;
|
||||
}
|
||||
default:
|
||||
description: The default theme
|
||||
css: ""
|
||||
description: "The default theme" # The description of the theme to display in the UI.
|
||||
css: "" # The css file path and filename to use for the theme.
|
||||
alternative:
|
||||
description: "Reduce rounded corners" # The description of the theme to display in the UI.
|
||||
css: "reduce-rounded-corners.css" # The css file path and filename to use for the theme.
|
||||
favicon: "" # path to a favicon to use for the frontend
|
||||
description: "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server" # description that shows up in html head meta description
|
||||
userDefaults:
|
||||
editorQuickSave: false # show quick save button in editor
|
||||
hideSidebarFileActions: false # hide the file actions in the sidebar
|
||||
@@ -169,8 +123,8 @@ userDefaults:
|
||||
gallerySize: 3 # 0-9 - the size of the gallery thumbnails
|
||||
themeColor: "var(--blue)" # theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
quickDownload: false # show icon to download in one click
|
||||
disablePreviewExt: "" # comma separated list of file extensions to disable preview for
|
||||
disableViewingExt: "" # comma separated list of file extensions to disable viewing for
|
||||
disablePreviewExt: "" # space separated list of file extensions to disable preview for
|
||||
disableViewingExt: "" # space separated list of file extensions to disable viewing for
|
||||
lockPassword: false # disable the user from changing their password
|
||||
disableSettings: false # disable the user from viewing the settings page
|
||||
preview:
|
||||
@@ -182,6 +136,7 @@ userDefaults:
|
||||
office: false
|
||||
popup: false
|
||||
autoplayMedia: false
|
||||
defaultMediaPlayer: false
|
||||
permissions:
|
||||
api: false
|
||||
admin: false
|
||||
@@ -194,14 +149,14 @@ userDefaults:
|
||||
fileLoading: # upload and download settings
|
||||
maxConcurrentUpload: 10
|
||||
uploadChunkSizeMb: 10
|
||||
disableOfficePreviewExt: "" # deprecated: use disablePreviewExt instead
|
||||
disableOnlyOfficeExt: "" # 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
|
||||
integrations:
|
||||
office: # validate:omitempty
|
||||
url: "" # The URL to the OnlyOffice Document Server, needs to be accessible to the user. validate:required
|
||||
internalUrl: "" # An optional internal address that the filebrowser server can use to communicate with the OnlyOffice Document Server, could be useful to bypass proxy.
|
||||
secret: "" # validate:required
|
||||
secret: "**hidden**" # secret: authentication key for OnlyOffice integration validate:required
|
||||
media: # validate:omitempty
|
||||
ffmpegPath: "" # path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
|
||||
@@ -50,9 +50,15 @@ async function translateText(text, targetLanguage, keyPath = '') {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (/\{[^}]+\}/.test(text)) {
|
||||
console.log(`Skipping translation for placeholder string: "${text}"`);
|
||||
return text;
|
||||
const hasPlaceholders = /\{[^}]+\}/.test(text);
|
||||
let textToTranslate = text;
|
||||
const options = {};
|
||||
|
||||
if (hasPlaceholders) {
|
||||
console.log(`Found placeholder in: "${text}". Wrapping for translation.`);
|
||||
textToTranslate = text.replace(/(\{[^}]+\})/g, '<ph>$1</ph>');
|
||||
options.tagHandling = 'xml';
|
||||
options.ignoreTags = ['ph'];
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -60,12 +66,19 @@ async function translateText(text, targetLanguage, keyPath = '') {
|
||||
|
||||
let deeplTargetLang = deeplLangMap[targetLanguage.toLowerCase()] || targetLanguage.toUpperCase();
|
||||
|
||||
const result = await translator.translateText(text, masterLanguageCode, deeplTargetLang);
|
||||
const result = await translator.translateText(textToTranslate, masterLanguageCode, deeplTargetLang, options);
|
||||
|
||||
// Delay to avoid rate-limiting
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
return result.text;
|
||||
let translatedText = result.text;
|
||||
|
||||
if (hasPlaceholders) {
|
||||
// Unwrap the placeholders. The translator might add spaces around tags.
|
||||
translatedText = translatedText.replace(/<ph>\s*(\{[^}]+\})\s*<\/ph>/g, '$1');
|
||||
}
|
||||
|
||||
return translatedText;
|
||||
|
||||
} catch (err) {
|
||||
console.error(`⚠️ Translation failed for "${text}" (${keyPath}):`, err?.message || err);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fetchURL, adjustedData } from './utils'
|
||||
import { getApiPath, doubleEncode } from '@/utils/url.js'
|
||||
import { getApiPath, doubleEncode, getPublicApiPath } from '@/utils/url.js'
|
||||
import { mutations } from '@/store'
|
||||
import { notify } from '@/notify'
|
||||
import { externalUrl } from '@/utils/constants'
|
||||
@@ -265,3 +265,17 @@ export async function sources() {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function GetOfficeConfig(req) {
|
||||
const params = {
|
||||
path: encodeURIComponent(req.path),
|
||||
...(req.hash && { hash: encodeURIComponent(req.hash) }),
|
||||
...(req.source && { source: encodeURIComponent(req.source) })
|
||||
}
|
||||
let apiPath = getApiPath('api/onlyoffice/config', params)
|
||||
if (req.hash) {
|
||||
apiPath = getPublicApiPath('onlyoffice/config', params)
|
||||
}
|
||||
const res = await fetchURL(apiPath)
|
||||
return await res.json()
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import * as publicApi from "./public";
|
||||
import * as usersApi from "./users";
|
||||
import * as settingsApi from "./settings";
|
||||
import * as accessApi from "./access";
|
||||
import * as shareApi from "./share";
|
||||
import search from "./search";
|
||||
|
||||
// Note: shareApi has been consolidated into publicApi
|
||||
export { filesApi, publicApi, usersApi, settingsApi, search, accessApi };
|
||||
export { filesApi, publicApi, usersApi, settingsApi, shareApi, search, accessApi };
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchURL, fetchJSON, adjustedData } from "./utils";
|
||||
import { adjustedData } from "./utils";
|
||||
import { notify } from "@/notify";
|
||||
import { getApiPath } from "@/utils/url.js";
|
||||
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
|
||||
import { externalUrl } from "@/utils/constants";
|
||||
import { state } from "@/store";
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function fetchPub(path, hash, password = "", content = false) {
|
||||
...(content && { content: 'true' }),
|
||||
...(state.share.token && { token: state.share.token })
|
||||
}
|
||||
const apiPath = getApiPath("public/api/shared", params);
|
||||
const apiPath = getPublicApiPath("resources", params);
|
||||
const response = await fetch(apiPath, {
|
||||
headers: {
|
||||
"X-SHARE-PASSWORD": password ? encodeURIComponent(password) : "",
|
||||
@@ -57,13 +57,15 @@ export async function fetchPub(path, hash, password = "", content = false) {
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getDownloadURL(share, files, inline=false) {
|
||||
// Join files array with || delimiter and then URL encode
|
||||
const filesParam = Array.isArray(files) ? files.join('||') : files;
|
||||
const params = {
|
||||
files: files,
|
||||
files: encodeURIComponent(filesParam),
|
||||
hash: share.hash,
|
||||
token: share.token,
|
||||
...(inline && { inline: 'true' })
|
||||
}
|
||||
const apiPath = getApiPath("public/api/raw", params);
|
||||
const apiPath = getPublicApiPath("raw", params);
|
||||
return window.origin + apiPath
|
||||
}
|
||||
|
||||
@@ -81,7 +83,7 @@ export function getPreviewURL(path,size="small") {
|
||||
inline: 'true',
|
||||
...(state.share.token && { token: state.share.token })
|
||||
}
|
||||
const apiPath = getApiPath('public/api/preview', params)
|
||||
const apiPath = getPublicApiPath('preview', params)
|
||||
return window.origin + apiPath
|
||||
} catch (/** @type {any} */ err) {
|
||||
notify.showError(err.message || 'Error getting preview URL')
|
||||
@@ -89,60 +91,6 @@ export function getPreviewURL(path,size="small") {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SHARE MANAGEMENT API (permission-based authentication)
|
||||
// ============================================================================
|
||||
|
||||
// List all shares
|
||||
export async function list() {
|
||||
const apiPath = getApiPath("public/shares");
|
||||
return fetchJSON(apiPath);
|
||||
}
|
||||
|
||||
// Get share information
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {string} source
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function get(path, source) {
|
||||
try {
|
||||
const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
|
||||
const apiPath = getApiPath("public/share", params);
|
||||
let data = await fetchJSON(apiPath);
|
||||
return adjustedData(data);
|
||||
} catch (/** @type {any} */ err) {
|
||||
notify.showError(err.message || "Error fetching data");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove/delete a share
|
||||
/**
|
||||
* @param {string} hash
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function remove(hash) {
|
||||
const params = { hash };
|
||||
const apiPath = getApiPath("public/share", params);
|
||||
await fetchURL(apiPath, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new share
|
||||
/**
|
||||
* @param {Record<string, any>} bodyObj
|
||||
* @returns {Promise<Share>}
|
||||
*/
|
||||
export async function create(bodyObj = {}) {
|
||||
const apiPath = getApiPath("public/share");
|
||||
return fetchJSON(apiPath, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(bodyObj || {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Get the shareable URL for a share
|
||||
/**
|
||||
* @param {{ hash: string }} share
|
||||
@@ -155,21 +103,3 @@ export function getShareURL(share) {
|
||||
}
|
||||
return window.origin + getApiPath(`public/share/${share.hash}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} Share
|
||||
* @property {string} hash
|
||||
* @property {string} path
|
||||
* @property {string} source
|
||||
* @property {number} expire
|
||||
* @property {number} downloadsLimit
|
||||
* @property {number} maxBandwidth
|
||||
* @property {string} shareTheme
|
||||
* @property {boolean} disableAnonymous
|
||||
* @property {boolean} disableThumbnails
|
||||
* @property {boolean} keepAfterExpiration
|
||||
* @property {string[]} allowedUsernames
|
||||
* @property {string} viewMode
|
||||
* @property {string} token
|
||||
* @property {boolean} inline
|
||||
*/
|
||||
@@ -13,3 +13,11 @@ export async function update(settings) {
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
|
||||
export function getConfig(showFull = false, showComments = false) {
|
||||
const params = {};
|
||||
if (showFull) params.full = "true";
|
||||
if (showComments) params.comments = "true";
|
||||
const path = getApiPath("api/settings/config", params);
|
||||
return fetchURL(path);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { fetchURL, fetchJSON, adjustedData } from "./utils";
|
||||
import { notify } from "@/notify";
|
||||
import { getApiPath } from "@/utils/url.js";
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// SHARE MANAGEMENT API (permission-based authentication)
|
||||
// ============================================================================
|
||||
|
||||
// List all shares
|
||||
export async function list() {
|
||||
const apiPath = getApiPath("api/shares");
|
||||
return fetchJSON(apiPath);
|
||||
}
|
||||
|
||||
// Get share information
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {string} source
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function get(path, source) {
|
||||
try {
|
||||
const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
|
||||
const apiPath = getApiPath("api/share", params);
|
||||
let data = await fetchJSON(apiPath);
|
||||
return adjustedData(data);
|
||||
} catch (/** @type {any} */ err) {
|
||||
notify.showError(err.message || "Error fetching data");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove/delete a share
|
||||
/**
|
||||
* @param {string} hash
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function remove(hash) {
|
||||
const params = { hash };
|
||||
const apiPath = getApiPath("api/share", params);
|
||||
await fetchURL(apiPath, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new share
|
||||
/**
|
||||
* @param {Record<string, any>} bodyObj
|
||||
* @returns {Promise<Share>}
|
||||
*/
|
||||
export async function create(bodyObj = {}) {
|
||||
const apiPath = getApiPath("api/share");
|
||||
return fetchJSON(apiPath, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(bodyObj || {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} Share
|
||||
* @property {string} hash
|
||||
* @property {string} path
|
||||
* @property {string} source
|
||||
* @property {number} expire
|
||||
* @property {number} downloadsLimit
|
||||
* @property {number} maxBandwidth
|
||||
* @property {string} shareTheme
|
||||
* @property {boolean} disableAnonymous
|
||||
* @property {boolean} disableThumbnails
|
||||
* @property {boolean} keepAfterExpiration
|
||||
* @property {string[]} allowedUsernames
|
||||
* @property {string} viewMode
|
||||
* @property {string} token
|
||||
* @property {boolean} inline
|
||||
*/
|
||||
@@ -1,9 +1,10 @@
|
||||
import { fetchURL, fetchJSON } from '@/api/utils'
|
||||
import { getApiPath } from '@/utils/url.js'
|
||||
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
|
||||
import { notify } from '@/notify' // Import notify for error handling
|
||||
import { setNewToken } from '@/utils/auth.js' // Import setNewToken for token management
|
||||
import { shareInfo } from '@/utils/constants'
|
||||
|
||||
export async function getAllUsers () {
|
||||
export async function getAllUsers() {
|
||||
try {
|
||||
const apiPath = getApiPath('api/users')
|
||||
return await fetchJSON(apiPath)
|
||||
@@ -13,7 +14,7 @@ export async function getAllUsers () {
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateOTP (username, password) {
|
||||
export async function generateOTP(username, password) {
|
||||
const params = { username }
|
||||
try {
|
||||
let apiPath = getApiPath('api/auth/otp/generate', params)
|
||||
@@ -30,7 +31,7 @@ export async function generateOTP (username, password) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyOtp (username, password, otp) {
|
||||
export async function verifyOtp(username, password, otp) {
|
||||
const params = { username }
|
||||
try {
|
||||
let apiPath = getApiPath('api/auth/otp/verify', params)
|
||||
@@ -91,7 +92,10 @@ export async function login(username, password, recaptcha, otp) {
|
||||
}
|
||||
export async function get(id) {
|
||||
try {
|
||||
const apiPath = getApiPath('api/users', { id: id })
|
||||
let apiPath = getApiPath('api/users', { id: id })
|
||||
if (shareInfo.isShare) {
|
||||
apiPath = getPublicApiPath('users', { id: id })
|
||||
}
|
||||
return await fetchJSON(apiPath)
|
||||
} catch (err) {
|
||||
notify.showError(err.message || `Failed to fetch user with ID: ${id}`)
|
||||
@@ -148,7 +152,7 @@ export function deleteApiKey (params) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function create (user) {
|
||||
export async function create(user) {
|
||||
try {
|
||||
const apiPath = getApiPath('api/users')
|
||||
const res = await fetchURL(apiPath, {
|
||||
@@ -190,7 +194,7 @@ export async function update(user, which = ['all']) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function remove (id) {
|
||||
export async function remove(id) {
|
||||
try {
|
||||
const apiPath = getApiPath('api/users', { id: id })
|
||||
await fetchURL(apiPath, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { state } from "@/store";
|
||||
import { renew, logout } from "@/utils/auth";
|
||||
import { notify } from "@/notify";
|
||||
import { shareInfo } from "@/utils/constants";
|
||||
|
||||
export async function fetchURL(url, opts, auth = true) {
|
||||
opts = opts || {};
|
||||
@@ -34,7 +35,7 @@ export async function fetchURL(url, opts, auth = true) {
|
||||
let error = new Error(await res.text());
|
||||
error.status = res.status;
|
||||
|
||||
if (auth && res.status == 401) {
|
||||
if (auth && res.status == 401 && !shareInfo.isShare) {
|
||||
logout();
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="isCardView" class="gallery-size card">
|
||||
<div v-if="showGallerySize" class="gallery-size card">
|
||||
{{ $t("files.size") }}<span class="sr-only">:</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<input
|
||||
v-model="gallerySize"
|
||||
@@ -69,8 +69,8 @@ export default {
|
||||
addPadding() {
|
||||
return getters.isStickySidebar() || getters.isShare();
|
||||
},
|
||||
isCardView() {
|
||||
return getters.isCardView();
|
||||
showGallerySize() {
|
||||
return getters.isCardView() && state.req?.items?.length > 0;
|
||||
},
|
||||
items() {
|
||||
const req = state.req;
|
||||
|
||||
@@ -14,15 +14,25 @@
|
||||
:class="{ 'dark-mode': isDarkMode, 'centered': centered }"
|
||||
:key="showCreate ? 'create-mode' : 'normal-mode'"
|
||||
>
|
||||
<div v-if="selectedCount > 0" class="button selected-count-header">
|
||||
<span>{{ selectedCount }} {{ $t("prompts.selected") }} </span>
|
||||
<div class="context-menu-header">
|
||||
<div
|
||||
class="action button clickable"
|
||||
v-if="!isSearchActive && userPerms.modify && !isShare"
|
||||
@click="toggleShowCreate"
|
||||
>
|
||||
<i v-if="!showCreate" class="material-icons">add</i>
|
||||
<i v-if="showCreate" 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"
|
||||
>
|
||||
<span>{{ selectedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<action
|
||||
v-if="!showCreate && !isSearchActive && userPerms.modify && !isShare"
|
||||
icon="add"
|
||||
:label="$t('buttons.new')"
|
||||
@action="startShowCreate"
|
||||
/>
|
||||
<hr class="divider">
|
||||
<action
|
||||
v-if="showCreate && !isSearchActive && userPerms.modify"
|
||||
icon="create_new_folder"
|
||||
@@ -73,6 +83,12 @@
|
||||
:label="$t('buttons.copyFile')"
|
||||
show="copy"
|
||||
/>
|
||||
<action
|
||||
v-if="!showCreate && selectedCount == 1 && isSearchActive"
|
||||
icon="folder"
|
||||
:label="$t('buttons.openParentFolder')"
|
||||
@action="openParentFolder"
|
||||
/>
|
||||
<action
|
||||
v-if="!showCreate && selectedCount > 0 && userPerms.modify"
|
||||
icon="forward"
|
||||
@@ -117,6 +133,7 @@
|
||||
:class="{ 'dark-mode': isDarkMode }"
|
||||
>
|
||||
<action v-if="showGoToRaw" icon="open_in_new" :label="$t('buttons.openFile')" @action="goToRaw()" />
|
||||
<action v-if="shouldShowParentFolder()" icon="folder" :label="$t('buttons.openParentFolder')" @action="openParentFolder" />
|
||||
<action v-if="isPreview" icon="file_download" :label="$t('buttons.download')" @action="startDownload" />
|
||||
<action v-if="showEdit" icon="edit" :label="$t('buttons.edit')" @action="edit()" />
|
||||
<action v-if="showSave" icon="save" :label="$t('buttons.save')" @action="save()" />
|
||||
@@ -134,6 +151,7 @@ import buttons from "@/utils/buttons";
|
||||
import { notify } from "@/notify";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
import { filesApi, publicApi } from "@/api";
|
||||
import { url } from "@/utils";
|
||||
export default {
|
||||
name: "ContextMenu",
|
||||
components: {
|
||||
@@ -258,8 +276,19 @@ export default {
|
||||
modify: state.user?.permissions?.modify,
|
||||
};
|
||||
},
|
||||
currentPrompt() {
|
||||
return getters.currentPrompt();
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentPrompt: {
|
||||
handler(prompt) {
|
||||
if (prompt && prompt.name === "ContextMenu") {
|
||||
this.setPositions();
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
hasOverflowItems: {
|
||||
handler(hasItems) {
|
||||
mutations.setContextMenuHasItems(hasItems);
|
||||
@@ -287,6 +316,22 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hideTooltip() {
|
||||
mutations.hideTooltip();
|
||||
},
|
||||
showTooltip(event, text) {
|
||||
mutations.showTooltip({
|
||||
content: text,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
toggleShowCreate() {
|
||||
this.showCreate = !this.showCreate;
|
||||
},
|
||||
shouldShowParentFolder() {
|
||||
return this.isPreview && state.req.path != "/";
|
||||
},
|
||||
showAccessHover() {
|
||||
mutations.showHover({
|
||||
name: "access",
|
||||
@@ -405,7 +450,8 @@ export default {
|
||||
},
|
||||
startDownload() {
|
||||
mutations.closeHovers();
|
||||
downloadFiles(state.selected);
|
||||
const items = state.selected.length > 0 ? state.selected : [state.req];
|
||||
downloadFiles(items);
|
||||
},
|
||||
goToRaw() {
|
||||
if (getters.isShare()) {
|
||||
@@ -447,6 +493,14 @@ export default {
|
||||
},
|
||||
});
|
||||
},
|
||||
openParentFolder() {
|
||||
const item = state.selected.length > 0 ? state.selected[0] : state.req;
|
||||
let parentPath = url.removeLastDir(item.path);
|
||||
if (parentPath == "") {
|
||||
parentPath = "/";
|
||||
}
|
||||
url.goToItem(state.req.source, parentPath);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -507,4 +561,10 @@ export default {
|
||||
height: 0 !important;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.context-menu-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -38,7 +38,7 @@
|
||||
<div class="inputWrapper" style="display: flex">
|
||||
<select
|
||||
v-if="multipleSources"
|
||||
class="searchContext input"
|
||||
class="searchContext button input"
|
||||
aria-label="search-path"
|
||||
v-model="selectedSource"
|
||||
:value="selectedSource"
|
||||
@@ -50,7 +50,7 @@
|
||||
</select>
|
||||
|
||||
<!-- Formatted display of selected value -->
|
||||
<div class="searchContext">{{ contextText }}</div>
|
||||
<div class="searchContext">{{ $t("search.searchContext", { context: getContext }) }}</div>
|
||||
</div>
|
||||
|
||||
<div id="result-list">
|
||||
@@ -87,7 +87,7 @@
|
||||
v-model="smallerThan"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="number"
|
||||
:placeholder="$t('search.number')"
|
||||
/><p>MB</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</div>
|
||||
<div class="sizeInputWrapper">
|
||||
@@ -96,7 +96,7 @@
|
||||
class="sizeInput"
|
||||
v-model="largerThan"
|
||||
type="number"
|
||||
placeholder="number"
|
||||
:placeholder="$t('search.number')"
|
||||
/><p>MB</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,9 +146,8 @@ import { search } from "@/api";
|
||||
import { getters, mutations, state } from "@/store";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { url } from "@/utils/";
|
||||
|
||||
import Icon from "@/components/files/Icon.vue";
|
||||
import { serverHasMultipleSources, baseURL } from "@/utils/constants";
|
||||
import { serverHasMultipleSources, baseURL, minSearchLength } from "@/utils/constants";
|
||||
|
||||
var boxes = {
|
||||
folder: { label: "folders", icon: "folder" },
|
||||
@@ -170,22 +169,22 @@ export default {
|
||||
return {
|
||||
largerThan: "",
|
||||
smallerThan: "",
|
||||
noneMessage: "Start typing 3 or more characters to begin searching.",
|
||||
noneMessage: this.$t("search.typeToSearch", { minSearchLength: minSearchLength }),
|
||||
searchTypes: "",
|
||||
isTypeSelectDisabled: false,
|
||||
showHelp: false,
|
||||
folderSelect: [
|
||||
{ label: "Only Folders", value: "type:folder" },
|
||||
{ label: "Only Files", value: "type:file" },
|
||||
{ label: this.$t("search.onlyFolders"), value: "type:folder" },
|
||||
{ label: this.$t("search.onlyFiles"), value: "type:file" },
|
||||
],
|
||||
typeSelect: [
|
||||
{ label: "Photos", value: "type:image" },
|
||||
{ label: "Audio", value: "type:audio" },
|
||||
{ label: "Videos", value: "type:video" },
|
||||
{ label: "Documents", value: "type:doc" },
|
||||
{ label: "Archives", value: "type:archive" },
|
||||
{ label: this.$t("search.photos"), value: "type:image" },
|
||||
{ label: this.$t("search.audio"), value: "type:audio" },
|
||||
{ label: this.$t("search.videos"), value: "type:video" },
|
||||
{ label: this.$t("search.documents"), value: "type:doc" },
|
||||
{ label: this.$t("search.archives"), value: "type:archive" },
|
||||
],
|
||||
toggleOptionButton: [{ label: "Show Options" }],
|
||||
toggleOptionButton: [{ label: this.$t("search.showOptions") }],
|
||||
value: "",
|
||||
ongoing: false,
|
||||
results: [],
|
||||
@@ -295,9 +294,7 @@ export default {
|
||||
if (this.ongoing) {
|
||||
return "";
|
||||
}
|
||||
return this.value === ""
|
||||
? this.$t("search.typeToSearch")
|
||||
: this.$t("search.pressToSearch");
|
||||
return this.$t("search.typeToSearch", { minSearchLength: minSearchLength })
|
||||
},
|
||||
isRunning() {
|
||||
return this.ongoing;
|
||||
@@ -306,13 +303,15 @@ export default {
|
||||
return this.showHelp;
|
||||
},
|
||||
activeStates() {
|
||||
// Create a Set of combined `name` and `type` keys for efficient lookup
|
||||
const selectedSet = new Set(
|
||||
state.selected.map((item) => `${item.name}:${item.type}`)
|
||||
);
|
||||
const result = this.results.map((s) => selectedSet.has(`${s.name}:${s.type}`));
|
||||
// Build a map of active states for the `results` array
|
||||
return result;
|
||||
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);
|
||||
}
|
||||
|
||||
const selectedPaths = new Set(selectedItems.map((item) => item.path));
|
||||
return this.results.map((result) => selectedPaths.has(result.path));
|
||||
},
|
||||
sourceInfo() {
|
||||
return state.sources.info;
|
||||
@@ -328,9 +327,6 @@ export default {
|
||||
return "/"; // if searching on non-current source, search the whole thing
|
||||
}
|
||||
},
|
||||
contextText() {
|
||||
return `Search Context: ${this.getContext}`; // FIX: Use `this.getContext` as a property, not a function call
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
openContext(event) {
|
||||
@@ -487,9 +483,9 @@ export default {
|
||||
if (event != undefined) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (this.value === "" || this.value.length < 3) {
|
||||
if (this.value === "" || this.value.length < minSearchLength) {
|
||||
this.ongoing = false;
|
||||
this.noneMessage = "Not enough characters to search (min 3)";
|
||||
this.noneMessage = this.$t("search.notEnoughCharacters", { minSearchLength: minSearchLength });
|
||||
return;
|
||||
}
|
||||
let searchTypesFull = this.searchTypes;
|
||||
@@ -508,7 +504,7 @@ export default {
|
||||
|
||||
this.ongoing = false;
|
||||
if (this.results.length == 0) {
|
||||
this.noneMessage = "No results found in indexed search.";
|
||||
this.noneMessage = this.$t("search.noResults");
|
||||
}
|
||||
},
|
||||
toggleHelp() {
|
||||
@@ -519,17 +515,13 @@ export default {
|
||||
},
|
||||
addSelected(event, s) {
|
||||
const pathParts = url.removeTrailingSlash(s.path).split("/");
|
||||
let path = this.getContext + s.path;
|
||||
if (this.getContext === "/") {
|
||||
path = s.path;
|
||||
}
|
||||
let path = this.getContext + url.removeTrailingSlash(s.path);
|
||||
const modifiedItem = {
|
||||
name: pathParts.pop(),
|
||||
path: path,
|
||||
size: s.size,
|
||||
type: s.type,
|
||||
source: this.selectedSource || state.sources.current,
|
||||
fullPath: path,
|
||||
};
|
||||
mutations.resetSelected();
|
||||
mutations.addSelected(modifiedItem);
|
||||
@@ -538,7 +530,10 @@ export default {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
.sizeInputWrapper {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.main-input {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -551,7 +546,6 @@ export default {
|
||||
border-left: 1px solid gray;
|
||||
border-right: 1px solid gray;
|
||||
word-wrap: break-word;
|
||||
height: -webkit-fill-available;
|
||||
}
|
||||
|
||||
.searchContext.input {
|
||||
@@ -562,7 +556,16 @@ export default {
|
||||
width: 25%;
|
||||
min-width: 7em;
|
||||
max-width: 15em;
|
||||
height: -webkit-fill-available;
|
||||
}
|
||||
|
||||
.searchContext.input option {
|
||||
background: grey;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.searchContext.input option:hover {
|
||||
background: var(--primaryColor);
|
||||
color: white;
|
||||
}
|
||||
|
||||
#results > #result-list {
|
||||
@@ -670,9 +673,8 @@ export default {
|
||||
/* IE and Edge */
|
||||
}
|
||||
|
||||
.search-entry {
|
||||
cursor: pointer;
|
||||
border-radius: 0.25em;
|
||||
.search-entry:hover {
|
||||
background-color: var(--alt-background);
|
||||
}
|
||||
|
||||
.search-entry.active {
|
||||
@@ -864,4 +866,72 @@ body.rtl #search .boxes h3 {
|
||||
padding-right: 0.5em;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
#search {
|
||||
min-width: unset;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
#search.active {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#search-input {
|
||||
transition: 1s ease all;
|
||||
}
|
||||
|
||||
#search.active #search-input {
|
||||
border-bottom: 3px solid rgba(0, 0, 0, 0.075);
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(6px);
|
||||
height: 4em;
|
||||
}
|
||||
|
||||
#search.active>div {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
#search.active #result {
|
||||
height: 100vh;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
#search.active #result>p>i {
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
display: table;
|
||||
}
|
||||
|
||||
#search.active #result ul li a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: .3em 0;
|
||||
margin-right: .3em;
|
||||
}
|
||||
|
||||
#search-input>.action,
|
||||
#search-input>i {
|
||||
margin-right: 0.3em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#result-list {
|
||||
width:100vw !important;
|
||||
max-width: 100vw !important;
|
||||
left: 0;
|
||||
top: 4em;
|
||||
-webkit-box-direction: normal;
|
||||
-ms-flex-direction: column;
|
||||
overflow: scroll;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -3,14 +3,14 @@
|
||||
ref="tooltipRef"
|
||||
v-show="tooltip.show"
|
||||
class="floating-tooltip fb-shadow"
|
||||
:class="{ 'dark-mode': isDarkMode }"
|
||||
:class="{ 'dark-mode': isDarkMode, 'pointer-enabled': tooltip.pointerEvents }"
|
||||
:style="tooltipStyle"
|
||||
v-html="tooltip.content"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { state } from "@/store";
|
||||
import { state,getters } from "@/store";
|
||||
|
||||
export default {
|
||||
name: "Tooltip",
|
||||
@@ -26,13 +26,27 @@ export default {
|
||||
return state.tooltip;
|
||||
},
|
||||
isDarkMode() {
|
||||
return state.user.darkMode;
|
||||
return getters.isDarkMode();
|
||||
},
|
||||
tooltipStyle() {
|
||||
return {
|
||||
const style = {
|
||||
top: `${this.adjustedY}px`,
|
||||
left: `${this.adjustedX}px`,
|
||||
};
|
||||
|
||||
// Add custom width if specified
|
||||
if (this.tooltip.width) {
|
||||
style.maxWidth = this.tooltip.width;
|
||||
style.width = this.tooltip.width;
|
||||
}
|
||||
|
||||
// Add max height and scrolling for viewport overflow
|
||||
if (this.tooltip.width || this.tooltip.pointerEvents) {
|
||||
style.maxHeight = '80vh';
|
||||
style.overflowY = 'auto';
|
||||
}
|
||||
|
||||
return style;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@@ -53,6 +67,10 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* @param {number} x - X coordinate
|
||||
* @param {number} y - Y coordinate
|
||||
*/
|
||||
updatePosition(x, y) {
|
||||
this.$nextTick(() => {
|
||||
const tooltipEl = this.$refs.tooltipRef;
|
||||
@@ -101,6 +119,16 @@ export default {
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.floating-tooltip.pointer-enabled {
|
||||
pointer-events: auto;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.floating-tooltip.pointer-enabled a {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tooltip-info-icon {
|
||||
font-size: 1em !important;
|
||||
padding: 0.1em !important;
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
} from "@/utils/constants";
|
||||
import { getTypeInfo } from "@/utils/mimetype";
|
||||
import { mutations, state } from "@/store";
|
||||
import { shareOverrides } from "@/utils/constants";
|
||||
import { shareInfo } from "@/utils/constants";
|
||||
|
||||
// NEW: Define placeholder and error image URLs for easy configuration
|
||||
const PLACEHOLDER_URL = baseURL + "static/img/placeholder.png"; // A generic loading placeholder
|
||||
@@ -76,7 +76,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
hasPreviewImage() {
|
||||
if (shareOverrides.disableThumbnails) {
|
||||
if (shareInfo.disableThumbnails) {
|
||||
return false;
|
||||
}
|
||||
return this.isPreviewImg && this.imageState !== 'error' && !this.disablePreviewExt && !this.officeFileDisabled
|
||||
|
||||
@@ -75,7 +75,7 @@ import * as upload from "@/utils/upload";
|
||||
import { state, getters, mutations } from "@/store"; // Import your custom store
|
||||
import { url } from "@/utils";
|
||||
import Icon from "@/components/files/Icon.vue";
|
||||
import { baseURL, serverHasMultipleSources, shareOverrides } from "@/utils/constants";
|
||||
import { baseURL, serverHasMultipleSources, shareInfo } from "@/utils/constants";
|
||||
|
||||
export default {
|
||||
name: "item",
|
||||
@@ -115,7 +115,7 @@ export default {
|
||||
// @ts-ignore
|
||||
if (getters.isShare()) {
|
||||
// @ts-ignore
|
||||
return shareOverrides.quickDownload && !this.isDir;
|
||||
return shareInfo.quickDownload && !this.isDir;
|
||||
}
|
||||
// @ts-ignore
|
||||
return state.user?.quickDownload && !this.galleryView && !this.isDir;
|
||||
@@ -465,19 +465,16 @@ export default {
|
||||
) {
|
||||
mutations.resetSelected();
|
||||
}
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
mutations.setLastSelectedIndex(this.index);
|
||||
},
|
||||
open() {
|
||||
if (this.hash) {
|
||||
const shareHash = this.hash;
|
||||
url.goToItem(this.source, this.path, "", shareHash);
|
||||
return;
|
||||
}
|
||||
// @ts-ignore
|
||||
const previousHash = state.req.items[this.index].name;
|
||||
url.goToItem(this.source, this.path, previousHash);
|
||||
const previousHistoryItem = {
|
||||
name: state.req.items[this.index].name,
|
||||
source: state.req.source,
|
||||
path: state.req.path,
|
||||
};
|
||||
url.goToItem(this.source, this.path, previousHistoryItem);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+12
-12
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div class="share card">
|
||||
<div class="share__box">
|
||||
<div v-if="shareOverrides.banner" class="banner">
|
||||
<div v-if="shareInfo.banner" class="banner">
|
||||
<img :src="getShareBanner" />
|
||||
</div>
|
||||
<div v-if="shareOverrides.title" class="share__box__element">
|
||||
<h3>{{ shareOverrides.title }}</h3>
|
||||
<div v-if="shareInfo.title" class="share__box__element">
|
||||
<h3>{{ shareInfo.title }}</h3>
|
||||
</div>
|
||||
<div v-if="shareOverrides.description" class="share__box__element">
|
||||
<p>{{ shareOverrides.description }}</p>
|
||||
<div v-if="shareInfo.description" class="share__box__element">
|
||||
<p>{{ shareInfo.description }}</p>
|
||||
</div>
|
||||
<hr v-if="shareOverrides.banner || shareOverrides.title || shareOverrides.description" />
|
||||
<hr v-if="shareInfo.banner || shareInfo.title || shareInfo.description" />
|
||||
<div class="share__box__element">
|
||||
<strong>{{ $t("prompts.displayName") }}</strong> {{ req.name }}
|
||||
</div>
|
||||
@@ -38,7 +38,7 @@ import { state, getters } from "@/store";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { getTypeInfo } from "@/utils/mimetype";
|
||||
import QrcodeVue from "qrcode.vue";
|
||||
import { shareOverrides } from "@/utils/constants";
|
||||
import { shareInfo } from "@/utils/constants";
|
||||
|
||||
export default {
|
||||
name: "ShareInfo",
|
||||
@@ -61,13 +61,13 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
getShareBanner() {
|
||||
if (this.shareOverrides.banner.startsWith("http")) {
|
||||
return this.shareOverrides.banner;
|
||||
if (this.shareInfo.banner.startsWith("http")) {
|
||||
return this.shareInfo.banner;
|
||||
}
|
||||
return publicApi.getDownloadURL(state.share, [this.shareOverrides.banner]);
|
||||
return publicApi.getDownloadURL(state.share, [this.shareInfo.banner]);
|
||||
},
|
||||
shareOverrides() {
|
||||
return shareOverrides;
|
||||
shareInfo() {
|
||||
return shareInfo;
|
||||
},
|
||||
req() {
|
||||
return state.req;
|
||||
@@ -16,11 +16,24 @@
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>{{ $t("prompts.source", { suffix: ":" }) }} {{ currentSource }}</p>
|
||||
<div aria-label="access-path" class="searchContext clickable" @click="startPathEdit">
|
||||
<div aria-label="access-path" class="searchContext clickable button" @click="startPathEdit">
|
||||
{{ $t("search.path") }} {{ currentPath }}
|
||||
</div>
|
||||
<!-- Default behavior banner -->
|
||||
<div class="card item">
|
||||
<div class="card-content banner-content">
|
||||
<i class="material-icons">{{ sourceDenyDefault ? 'block' : 'check_circle' }}</i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
{{ $t("access.defaultBehavior", { suffix: ":" }) }} {{ sourceDenyDefault ? $t("access.deny") : $t("access.allow")
|
||||
}}
|
||||
<i class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('access.defaultBehaviorDescription'))" @mouseleave="hideTooltip">
|
||||
help
|
||||
</i>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- Add Form -->
|
||||
<div class="form-flex-group" >
|
||||
<div class="form-flex-group">
|
||||
<select class="input flat-right form-compact" v-model="addType">
|
||||
<option value="user">{{ $t("general.user") }}</option>
|
||||
<option value="group">{{ $t("general.group") }}</option>
|
||||
@@ -30,7 +43,8 @@
|
||||
<option value="deny">{{ $t("access.deny") }}</option>
|
||||
<option value="allow">{{ $t("access.allow") }}</option>
|
||||
</select>
|
||||
<input v-if="addType !== 'all'" class="input flat-right flat-left form-grow form-compact" v-model="addName" :placeholder="$t('access.enterName')" list="group-suggestions" />
|
||||
<input v-if="addType !== 'all'" class="input flat-right flat-left form-grow form-compact" v-model="addName"
|
||||
:placeholder="$t('access.enterName')" list="group-suggestions" />
|
||||
<datalist id="group-suggestions">
|
||||
<option v-for="group in groups" :key="group" :value="group"></option>
|
||||
</datalist>
|
||||
@@ -48,7 +62,8 @@
|
||||
</tr>
|
||||
<tr v-for="entry in entries" :key="entry.type + '-' + entry.name">
|
||||
<td>{{ entry.allow ? $t("access.allow") : $t("access.deny") }}</td>
|
||||
<td>{{ entry.type === 'user' ? $t("general.user") : (entry.type === 'group' ? $t("general.group") : $t('access.all')) }}</td>
|
||||
<td>{{ entry.type === 'user' ? $t("general.user") : (entry.type === 'group' ? $t("general.group") :
|
||||
$t('access.all')) }}</td>
|
||||
<td>{{ entry.name }}</td>
|
||||
<td>
|
||||
<button @click="deleteAccess(entry)" class="action" :aria-label="$t('buttons.delete')"
|
||||
@@ -62,11 +77,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-action">
|
||||
<button v-if="!isEditingPath" @click="closeHovers" class="button button--flat button--grey" :aria-label="$t('buttons.close')"
|
||||
:title="$t('buttons.close')">
|
||||
{{ $t("buttons.close") }}
|
||||
</button>
|
||||
</div>
|
||||
<button v-if="!isEditingPath" @click="closeHovers" class="button button--flat button--grey"
|
||||
:aria-label="$t('buttons.close')" :title="$t('buttons.close')">
|
||||
{{ $t("buttons.close") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -90,6 +105,7 @@ export default {
|
||||
currentSource: this.sourceName,
|
||||
tempSource: this.sourceName,
|
||||
rule: { denyAll: false, deny: { users: [], groups: [] }, allow: { users: [], groups: [] } },
|
||||
sourceDenyDefault: false,
|
||||
addType: "user",
|
||||
addListType: "deny",
|
||||
addName: "",
|
||||
@@ -171,10 +187,14 @@ export default {
|
||||
},
|
||||
async fetchRule() {
|
||||
try {
|
||||
this.rule = await accessApi.get(this.currentSource, this.currentPath);
|
||||
const response = await accessApi.get(this.currentSource, this.currentPath);
|
||||
// Handle new API response structure - now sourceDenyDefault is part of the rule
|
||||
this.rule = response;
|
||||
this.sourceDenyDefault = response.sourceDenyDefault || false;
|
||||
} catch (e) {
|
||||
notify.showError(e);
|
||||
this.rule = { denyAll: false, deny: { users: [], groups: [] }, allow: { users: [], groups: [] } };
|
||||
this.sourceDenyDefault = false;
|
||||
}
|
||||
},
|
||||
/**
|
||||
@@ -225,6 +245,16 @@ export default {
|
||||
} else {
|
||||
this.$emit('close');
|
||||
}
|
||||
},
|
||||
showTooltip(event, text) {
|
||||
mutations.showTooltip({
|
||||
content: text,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
hideTooltip() {
|
||||
mutations.hideTooltip();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -234,4 +264,16 @@ export default {
|
||||
.form-flex-group {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0.25em !important;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.searchContext {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
</style>
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<!-- Duration Input -->
|
||||
<p>{{ $t('api.tokenDuration') }}</p>
|
||||
<div class="inputWrapper">
|
||||
<div class="sizeInputWrapper">
|
||||
<input class="sizeInput roundedInputLeft input" v-model.number="duration" type="number" min="1"
|
||||
:placeholder="$t('api.durationNumberPlaceholder')" />
|
||||
<select v-model="unit" class="roundedInputRight input">
|
||||
@@ -97,3 +97,8 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.sizeInputWrapper {
|
||||
display: flex !important;
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,7 @@
|
||||
<p v-else>
|
||||
{{ $t("prompts.deleteMessageMultiple", { count: selectedCount }) }}
|
||||
</p>
|
||||
<div style="display: grid" aria-label="delete-path" class="searchContext">
|
||||
<div style="display: grid" aria-label="delete-path" class="searchContext button">
|
||||
<span v-for="(item, index) in nav" :key="index"> {{ item }} </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
|
||||
<div>Source: {{ sourcePath.source }} </div> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<div aria-label="filelist-path" class="searchContext clickable">{{$t('search.path')}} {{ sourcePath.path }}</div>
|
||||
<div aria-label="filelist-path" class="searchContext button clickable">{{$t('search.path')}} {{ sourcePath.path }}</div>
|
||||
<ul class="file-list">
|
||||
<li
|
||||
@click="itemClick"
|
||||
|
||||
@@ -4,16 +4,15 @@
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p>{{ $t("help.description") }} <a class="link" href="https://github.com/gtsteffaniak/filebrowser/wiki" target="_blank">{{ $t("help.wiki") }}</a></p>
|
||||
<ul>
|
||||
<li><strong>F1</strong> - {{ $t("help.f1") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<li><strong>F2</strong> - {{ $t("help.f2") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<li><strong>DEL</strong> - {{ $t("help.del") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<li><strong>ESC</strong> - {{ $t("help.esc") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<li><strong>CTRL + S</strong> - {{ $t("help.ctrl.s") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<li><strong>CTRL + F</strong> - {{ $t("help.ctrl.f") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<li><strong>CTRL + Click</strong> - {{ $t("help.ctrl.click") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<li><strong>Click</strong> - {{ $t("help.click") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<li><strong>Double click</strong> - {{ $t("help.doubleClick") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<li><strong>CTRL + D</strong> - {{ $t("help.ctrl.d") }}</li> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="card-title">
|
||||
<h2>{{ $t("general.password") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<div v-if="showWrongCredentials" class="share__wrong__password">
|
||||
{{ $t("login.wrongCredentials") }}
|
||||
</div>
|
||||
<div class="form-flex-group">
|
||||
<input
|
||||
class="input share-password"
|
||||
v-focus
|
||||
type="password"
|
||||
:placeholder="$t('general.password')"
|
||||
v-model="password"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-action">
|
||||
<button
|
||||
class="button button--flat"
|
||||
@click="submit"
|
||||
:aria-label="$t('buttons.submit')"
|
||||
:title="$t('buttons.submit')"
|
||||
>
|
||||
{{ $t("buttons.submit") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mutations } from "@/store";
|
||||
|
||||
export default {
|
||||
name: "password",
|
||||
props: {
|
||||
submitCallback: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
showWrongCredentials: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
initialPassword: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
password: this.initialPassword,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
mutations.closeHovers();
|
||||
this.submitCallback(this.password);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.share__wrong__password {
|
||||
color: #ff4757;
|
||||
text-align: center;
|
||||
padding: 1em 0;
|
||||
}
|
||||
|
||||
.share-password {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -30,6 +30,7 @@ import UserEdit from "./UserEdit.vue";
|
||||
import buttons from "@/utils/buttons";
|
||||
import Totp from "./Totp.vue";
|
||||
import Access from "./Access.vue";
|
||||
import Password from "./Password.vue";
|
||||
import { state, getters, mutations } from "@/store"; // Import your custom store
|
||||
|
||||
export default {
|
||||
@@ -56,6 +57,7 @@ export default {
|
||||
CreateApi,
|
||||
ActionApi,
|
||||
Access,
|
||||
Password,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p>{{ $t("prompts.renameMessage", { filename: oldName }) }}</p>
|
||||
<input class="input" :class="{ 'form-invalid': !validateFileName(name) }" v-focus type="text"
|
||||
@keyup.enter="submit" v-model.trim="name" />
|
||||
<p v-if="!validateFileName(name) && name.length > 0" class="validation-error">
|
||||
{{ $t("prompts.invalidName") }}
|
||||
<p>{{ $t("prompts.renameMessage") }}</p>
|
||||
<input class="input" :class="{ 'form-invalid': !validation.valid }" v-focus type="text" @keyup.enter="submit"
|
||||
v-model.trim="name" />
|
||||
<p v-if="!validation.valid && name.length > 0" class="validation-error">
|
||||
<span v-if="validation.reason === 'conflict'">
|
||||
{{ $t("prompts.renameMessageConflict", { filename: name }) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t("prompts.renameMessageInvalid") }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +22,7 @@
|
||||
:title="$t('buttons.cancel')">
|
||||
{{ $t("buttons.cancel") }}
|
||||
</button>
|
||||
<button @click="submit" class="button button--flat" :disabled="!validateFileName(name) || name.length === 0"
|
||||
<button @click="submit" class="button button--flat" :disabled="!validation.valid || name.length === 0"
|
||||
type="submit" :aria-label="$t('buttons.rename')" :title="$t('buttons.rename')">
|
||||
{{ $t("buttons.rename") }}
|
||||
</button>
|
||||
@@ -25,7 +30,7 @@
|
||||
</template>
|
||||
<script>
|
||||
import { filesApi } from "@/api";
|
||||
import { mutations } from "@/store";
|
||||
import { mutations, state } from "@/store";
|
||||
import { notify } from "@/notify";
|
||||
|
||||
export default {
|
||||
@@ -49,6 +54,9 @@ export default {
|
||||
oldName() {
|
||||
return this.item?.name || "";
|
||||
},
|
||||
validation() {
|
||||
return this.validateFileName(this.name);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
@@ -56,24 +64,51 @@ export default {
|
||||
*/
|
||||
validateFileName(value) {
|
||||
if (value === "") {
|
||||
return false;
|
||||
return { valid: true };
|
||||
}
|
||||
// Check for forbidden characters: forward slash and backslash
|
||||
const forbiddenChars = /[/\\]/;
|
||||
return !forbiddenChars.test(value);
|
||||
|
||||
const isFolder = this.item.type === "directory";
|
||||
|
||||
// Check for forbidden characters in file names
|
||||
if (!isFolder) {
|
||||
const forbiddenChars = /[/\\]/;
|
||||
if (forbiddenChars.test(value)) {
|
||||
return { valid: false, reason: 'invalidChar' };
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the item already exists
|
||||
for (const item of state.req.items) {
|
||||
if (item.name.toLowerCase() === value.toLowerCase()) {
|
||||
if (isFolder === (item.type === "directory")) {
|
||||
return { valid: false, reason: 'conflict' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
},
|
||||
async submit() {
|
||||
// Validate before submitting
|
||||
if (!this.validateFileName(this.name) || this.name.length === 0) {
|
||||
notify.showError(this.$t("prompts.invalidName"));
|
||||
// remove trailing slashes
|
||||
if (this.name.endsWith("/") || this.name.endsWith("\\")) {
|
||||
this.name = this.name.substring(0, this.name.length - 1);
|
||||
}
|
||||
if (!this.validation.valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newPath = this.item.path.substring(0, this.item.path.lastIndexOf("/"));
|
||||
newPath = `${newPath}/${this.name}`;
|
||||
try {
|
||||
if (this.name === this.item.name) {
|
||||
mutations.closeHovers();
|
||||
return;
|
||||
}
|
||||
|
||||
const items = [{
|
||||
from: this.item.path,
|
||||
fromSource: this.item.source,
|
||||
to: this.item.path.replace(/[^/]+$/, this.name),
|
||||
to: newPath,
|
||||
toSource: this.item.source,
|
||||
}];
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<h2>{{ $t("buttons.share") }}</h2>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div aria-label="share-path" class="searchContext"> {{ $t('search.path') }} {{ item.path }}</div>
|
||||
<div aria-label="share-path" class="searchContext button"> {{ $t('search.path') }} {{ item.path }}</div>
|
||||
<p> {{ $t('share.notice') }} </p>
|
||||
|
||||
<div v-if="listing">
|
||||
@@ -80,7 +80,7 @@
|
||||
<ToggleSwitch class="item" v-model="allowEdit" :name="'Allow modifications'" />
|
||||
<ToggleSwitch class="item" v-model="allowUpload" :name="'Allow uploading'" />
|
||||
-->
|
||||
<ToggleSwitch class="item" v-model="disablingFileViewer" :name="'Disable File Viewer'" />
|
||||
<ToggleSwitch class="item" v-model="disableFileViewer" :name="'Disable File Viewer'" />
|
||||
<ToggleSwitch
|
||||
class="item"
|
||||
v-model="quickDownload"
|
||||
@@ -89,6 +89,25 @@
|
||||
/>
|
||||
<ToggleSwitch class="item" v-model="disableAnonymous" :name="$t('share.disableAnonymous')" :description="$t('share.disableAnonymousDescription')" />
|
||||
<ToggleSwitch class="item" v-model="enableAllowedUsernames" :name="$t('share.enableAllowedUsernames')" :description="$t('share.enableAllowedUsernamesDescription')" />
|
||||
|
||||
<ToggleSwitch v-if="onlyOfficeAvailable" class="item" v-model="enableOnlyOffice" :name="$t('share.enableOnlyOffice')" :description="$t('share.enableOnlyOfficeDescription')" />
|
||||
<ToggleSwitch v-if="onlyOfficeAvailable" class="item" v-model="enableOnlyOfficeEditing" :name="$t('share.enableOnlyOfficeEditing')" :description="$t('share.enableOnlyOfficeEditingDescription')" />
|
||||
<p>
|
||||
{{ $t("share.enforceDarkLightMode") }}
|
||||
<i
|
||||
class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip($event, $t('share.enforceDarkLightModeDescription'))"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
help
|
||||
</i>
|
||||
</p>
|
||||
<select class="input" v-model="enforceDarkLightMode">
|
||||
<option value="default">{{ $t("share.default") }}</option>
|
||||
<option value="dark">{{ $t("share.dark") }}</option>
|
||||
<option value="light">{{ $t("share.light") }}</option>
|
||||
</select>
|
||||
|
||||
<div v-if="enableAllowedUsernames" class="item">
|
||||
<input class="input" type="text" v-model.trim="allowedUsernames" :placeholder="$t('share.allowedUsernamesPlaceholder')" />
|
||||
</div>
|
||||
@@ -111,26 +130,7 @@
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="advanced-toggle">
|
||||
<button
|
||||
class="button button--flat button--block"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
:aria-expanded="showAdvanced ? 'true' : 'false'"
|
||||
aria-controls="advanced-settings"
|
||||
:aria-label="showAdvanced ? $t('buttons.showLess') : $t('buttons.showMore')"
|
||||
:title="showAdvanced ? $t('buttons.showLess') : $t('buttons.showMore')"
|
||||
>
|
||||
{{ showAdvanced ? $t('buttons.showLess') : $t('buttons.showMore') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<transition
|
||||
name="expand"
|
||||
@before-enter="beforeEnter"
|
||||
@enter="enter"
|
||||
@leave="leave"
|
||||
>
|
||||
<div id="advanced-settings" v-show="showAdvanced">
|
||||
<SettingsItem :title="$t('buttons.showMore')" :collapsable="true" :start-collapsed="true">
|
||||
<div class="settings-items">
|
||||
<ToggleSwitch class="item" v-model="keepAfterExpiration" :name="$t('share.keepAfterExpiration')" :description="$t('share.keepAfterExpirationDescription')" />
|
||||
<ToggleSwitch class="item" v-model="disableThumbnails" :name="$t('share.disableThumbnails')" :description="$t('share.disableThumbnailsDescription')" />
|
||||
@@ -222,8 +222,7 @@
|
||||
</i>
|
||||
</p>
|
||||
<input class="input" type="text" v-model.trim="favicon" />
|
||||
</div>
|
||||
</transition>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -250,18 +249,21 @@
|
||||
<script>
|
||||
import { notify } from "@/notify";
|
||||
import { state, getters, mutations } from "@/store";
|
||||
import { publicApi } from "@/api";
|
||||
import { publicApi, shareApi } from "@/api";
|
||||
import Clipboard from "clipboard";
|
||||
import { fromNow } from "@/utils/moment";
|
||||
import { buildItemUrl } from "@/utils/url";
|
||||
import { buildItemUrl, fixDownloadURL } from "@/utils/url";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import { userSelectableThemes } from "@/utils/constants";
|
||||
import SettingsItem from "@/components/settings/SettingsItem.vue";
|
||||
import { userSelectableThemes, externalUrl, onlyOfficeUrl } from "@/utils/constants";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
//import ViewMode from "@/components/settings/ViewMode.vue";
|
||||
|
||||
export default {
|
||||
name: "share",
|
||||
components: {
|
||||
ToggleSwitch,
|
||||
SettingsItem,
|
||||
//ViewMode,
|
||||
},
|
||||
props: {
|
||||
@@ -299,7 +301,7 @@ export default {
|
||||
disableAnonymous: false,
|
||||
allowUpload: false,
|
||||
maxBandwidth: "",
|
||||
disablingFileViewer: false,
|
||||
disableFileViewer: false,
|
||||
disableThumbnails: false,
|
||||
enableAllowedUsernames: false,
|
||||
allowedUsernames: "",
|
||||
@@ -309,15 +311,20 @@ export default {
|
||||
title: "",
|
||||
description: "",
|
||||
favicon: "",
|
||||
showAdvanced: false,
|
||||
quickDownload: false,
|
||||
disableNavButtons: false,
|
||||
disableShareCard: false,
|
||||
disableSidebar: false,
|
||||
enforceDarkLightMode: "default",
|
||||
enableOnlyOffice: false,
|
||||
enableOnlyOfficeEditing: false,
|
||||
//viewMode: "normal",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
onlyOfficeAvailable() {
|
||||
return onlyOfficeUrl !== "";
|
||||
},
|
||||
availableThemes() {
|
||||
return userSelectableThemes || {};
|
||||
},
|
||||
@@ -376,6 +383,7 @@ export default {
|
||||
this.shareTheme = this.link.shareTheme || "default";
|
||||
this.disableAnonymous = this.link.disableAnonymous || false;
|
||||
this.disableThumbnails = this.link.disableThumbnails || false;
|
||||
this.disableFileViewer = this.link.disableFileViewer || false;
|
||||
this.enableAllowedUsernames = Array.isArray(this.link.allowedUsernames) && this.link.allowedUsernames.length > 0;
|
||||
this.allowedUsernames = this.enableAllowedUsernames ? this.link.allowedUsernames.join(", ") : "";
|
||||
this.keepAfterExpiration = this.link.keepAfterExpiration || false;
|
||||
@@ -388,6 +396,9 @@ export default {
|
||||
this.disableNavButtons = this.link.hideNavButtons || false;
|
||||
this.disableShareCard = this.link.disableShareCard || false;
|
||||
this.disableSidebar = this.link.disableSidebar || false;
|
||||
this.enforceDarkLightMode = this.link.enforceDarkLightMode || "default";
|
||||
this.enableOnlyOffice = this.link.enableOnlyOffice || false;
|
||||
this.enableOnlyOfficeEditing = this.link.enableOnlyOfficeEditing || false;
|
||||
//this.viewMode = this.link.viewMode || "normal";
|
||||
}
|
||||
},
|
||||
@@ -399,7 +410,7 @@ export default {
|
||||
}
|
||||
try {
|
||||
// get last element of the path
|
||||
const links = await publicApi.get(this.item.path, this.item.source);
|
||||
const links = await shareApi.get(this.item.path, this.item.source);
|
||||
this.links = links;
|
||||
} catch (err) {
|
||||
notify.showError(err);
|
||||
@@ -418,45 +429,6 @@ export default {
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* @param {Element} el
|
||||
*/
|
||||
beforeEnter(el) {
|
||||
const element = /** @type {HTMLElement} */ (el);
|
||||
element.style.height = '0';
|
||||
element.style.opacity = '0';
|
||||
},
|
||||
/**
|
||||
* @param {Element} el
|
||||
* @param {() => void} done
|
||||
*/
|
||||
enter(el, done) {
|
||||
const element = /** @type {HTMLElement} */ (el);
|
||||
element.style.transition = '';
|
||||
element.style.height = '0';
|
||||
element.style.opacity = '0';
|
||||
void element.offsetHeight;
|
||||
element.style.transition = 'height 0.3s, opacity 0.3s';
|
||||
element.style.height = element.scrollHeight + 'px';
|
||||
element.style.opacity = '1';
|
||||
setTimeout(() => {
|
||||
element.style.height = 'auto';
|
||||
done();
|
||||
}, 300);
|
||||
},
|
||||
/**
|
||||
* @param {Element} el
|
||||
* @param {() => void} done
|
||||
*/
|
||||
leave(el, done) {
|
||||
const element = /** @type {HTMLElement} */ (el);
|
||||
element.style.transition = 'height 0.3s, opacity 0.3s';
|
||||
element.style.height = element.scrollHeight + 'px';
|
||||
void element.offsetHeight;
|
||||
element.style.height = '0';
|
||||
element.style.opacity = '0';
|
||||
setTimeout(done, 300);
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} event
|
||||
* @param {string} text
|
||||
@@ -491,7 +463,7 @@ export default {
|
||||
maxBandwidth: this.maxBandwidth ? parseInt(this.maxBandwidth) : 0,
|
||||
downloadsLimit: this.downloadsLimit ? parseInt(this.downloadsLimit) : 0,
|
||||
shareTheme: this.shareTheme,
|
||||
disablingFileViewer: this.disablingFileViewer,
|
||||
disableFileViewer: this.disableFileViewer,
|
||||
disableThumbnails: this.disableThumbnails,
|
||||
allowedUsernames: this.enableAllowedUsernames ? this.allowedUsernames.split(',').map(u => u.trim()) : [],
|
||||
keepAfterExpiration: this.keepAfterExpiration,
|
||||
@@ -505,19 +477,23 @@ export default {
|
||||
hideNavButtons: this.disableNavButtons,
|
||||
disableShareCard: this.disableShareCard,
|
||||
disableSidebar: this.disableSidebar,
|
||||
enforceDarkLightMode: this.enforceDarkLightMode,
|
||||
enableOnlyOffice: this.enableOnlyOffice,
|
||||
enableOnlyOfficeEditing: this.enableOnlyOfficeEditing,
|
||||
};
|
||||
if (this.isEditMode) {
|
||||
payload.hash = this.link.hash;
|
||||
}
|
||||
|
||||
const res = await publicApi.create(payload);
|
||||
const res = await shareApi.create(payload);
|
||||
|
||||
if (!this.isEditMode) {
|
||||
this.links.push(res);
|
||||
this.sort();
|
||||
} else {
|
||||
// reload page to see changes
|
||||
window.location.reload();
|
||||
// emit event to reload shares in settings view
|
||||
eventBus.emit('sharesChanged');
|
||||
mutations.closeHovers();
|
||||
}
|
||||
|
||||
this.time = "";
|
||||
@@ -536,8 +512,10 @@ export default {
|
||||
async deleteLink(event, link) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await publicApi.remove(link.hash);
|
||||
await shareApi.remove(link.hash);
|
||||
this.links = this.links.filter((item) => item.hash !== link.hash);
|
||||
// emit event to reload shares in settings view
|
||||
eventBus.emit('sharesChanged');
|
||||
if (this.links.length === 0) {
|
||||
this.listing = false;
|
||||
}
|
||||
@@ -565,8 +543,8 @@ export default {
|
||||
* @param {Share} share
|
||||
*/
|
||||
buildDownloadLink(share) {
|
||||
if (share.downloadURL) {
|
||||
return share.downloadURL;
|
||||
if (share.downloadURL && externalUrl == "") {
|
||||
return this.fixDownloadURL(share.downloadURL);
|
||||
}
|
||||
return publicApi.getDownloadURL(share, [this.item.name]);
|
||||
},
|
||||
@@ -585,15 +563,15 @@ export default {
|
||||
|
||||
this.listing = !this.listing;
|
||||
},
|
||||
fixDownloadURL(downloadUrl) {
|
||||
return fixDownloadURL(downloadUrl);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.advanced-toggle {
|
||||
margin-top: 1em;
|
||||
}
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -611,15 +589,4 @@ export default {
|
||||
.input {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.expand-enter,
|
||||
.expand-leave-to {
|
||||
height: 0 !important;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title button" :class="{ 'unclickable': !collapsable }"
|
||||
@click="collapsable ? toggleCollapse() : null">
|
||||
<h3>{{ title }}</h3>
|
||||
<i v-if="collapsable" class="material-symbols-outlined collapse-icon" :class="{ 'rotated': !actuallyCollapsed }">
|
||||
expand_more
|
||||
</i>
|
||||
</div>
|
||||
|
||||
<transition name="expand" @before-enter="beforeEnter" @enter="enter" @leave="leave">
|
||||
<div v-show="!actuallyCollapsed" class="settings-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SettingsItem',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
collapsable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
startCollapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
forceCollapsed: {
|
||||
type: Boolean,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isCollapsed: this.startCollapsed
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
actuallyCollapsed() {
|
||||
// If forceCollapsed is explicitly set, use that, otherwise use internal state
|
||||
return this.forceCollapsed !== null ? this.forceCollapsed : this.isCollapsed;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
forceCollapsed(newVal) {
|
||||
if (newVal !== null) {
|
||||
this.isCollapsed = newVal;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleCollapse() {
|
||||
if (this.forceCollapsed !== null) {
|
||||
// Emit event for parent to handle accordion logic
|
||||
this.$emit('toggle', this.title);
|
||||
} else {
|
||||
// Handle internally as before
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {Element} el
|
||||
*/
|
||||
beforeEnter(el) {
|
||||
const element = /** @type {HTMLElement} */ (el);
|
||||
element.style.height = '0';
|
||||
element.style.opacity = '0';
|
||||
},
|
||||
/**
|
||||
* @param {Element} el
|
||||
* @param {() => void} done
|
||||
*/
|
||||
enter(el, done) {
|
||||
const element = /** @type {HTMLElement} */ (el);
|
||||
element.style.transition = '';
|
||||
element.style.height = '0';
|
||||
element.style.opacity = '0';
|
||||
void element.offsetHeight;
|
||||
element.style.transition = 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
element.style.height = element.scrollHeight + 'px';
|
||||
element.style.opacity = '1';
|
||||
setTimeout(() => {
|
||||
element.style.height = 'auto';
|
||||
done();
|
||||
}, 300);
|
||||
},
|
||||
/**
|
||||
* @param {Element} el
|
||||
* @param {() => void} done
|
||||
*/
|
||||
leave(el, done) {
|
||||
const element = /** @type {HTMLElement} */ (el);
|
||||
element.style.transition = 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
element.style.height = element.scrollHeight + 'px';
|
||||
void element.offsetHeight;
|
||||
element.style.height = '0';
|
||||
element.style.opacity = '0';
|
||||
setTimeout(done, 300);
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
|
||||
.settings-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 1em;
|
||||
background: var(--alt-background) !important;
|
||||
color: var(--textPrimary) !important;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.settings-group-title h3 {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--textSecondary);
|
||||
}
|
||||
|
||||
.collapse-icon.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
overflow: hidden;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter,
|
||||
.expand-leave-to {
|
||||
height: 0 !important;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.settings-group-title.unclickable {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* Prevent content height issues during animation */
|
||||
.settings-content .input,
|
||||
.settings-content .settings-items {
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="card-wrapper user-card">
|
||||
<div
|
||||
v-if="settingsAllowed && user.username !== 'anonymous'"
|
||||
@click="navigateTo('/settings#profile-main')"
|
||||
@click="navigateTo('/settings','#profile-main')"
|
||||
class="inner-card"
|
||||
>
|
||||
<button
|
||||
@@ -48,10 +48,12 @@
|
||||
@click="toggleClick"
|
||||
@mouseenter="showTooltip($event, $t('index.toggleClick'))"
|
||||
@mouseleave="hideTooltip"
|
||||
v-if="!isInvalidShare"
|
||||
>
|
||||
<i class="material-icons">ads_click</i>
|
||||
</div>
|
||||
<div
|
||||
v-if="darkModeTogglePossible"
|
||||
class="clickable"
|
||||
:class="{ active: user?.darkMode }"
|
||||
@click="toggleDarkMode"
|
||||
@@ -139,7 +141,7 @@
|
||||
|
||||
<script>
|
||||
import * as auth from "@/utils/auth";
|
||||
import { signup, disableExternal, noAuth, loginPage } from "@/utils/constants";
|
||||
import { signup, disableExternal, noAuth, loginPage, shareInfo } from "@/utils/constants";
|
||||
import ProgressBar from "@/components/ProgressBar.vue";
|
||||
import { state, getters, mutations } from "@/store"; // Import your custom store
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
|
||||
@@ -156,11 +158,12 @@ export default {
|
||||
computed: {
|
||||
disableQuickToggles: () => state.user.disableQuickToggles,
|
||||
hasSourceInfo: () => state.sources.hasSourceInfo,
|
||||
hideSidebarFileActions: () => state.user.hideSidebarFileActions,
|
||||
hideSidebarFileActions: () => state.user.hideSidebarFileActions || getters.isInvalidShare(),
|
||||
settingsAllowed: () => !state.user.disableSettings,
|
||||
isSettings: () => getters.isSettings(),
|
||||
isStickySidebar: () => getters.isStickySidebar(),
|
||||
isMobile: () => getters.isMobile(),
|
||||
isInvalidShare: () => getters.isInvalidShare(),
|
||||
isListingView: () => getters.currentView() == "listingView",
|
||||
user: () => (state.user),
|
||||
isDarkMode: () => getters.isDarkMode(),
|
||||
@@ -176,6 +179,7 @@ export default {
|
||||
sourceInfo: () => state.sources.info,
|
||||
activeSource: () => state.sources.current,
|
||||
realtimeActive: () => state.realtimeActive,
|
||||
darkModeTogglePossible: () => shareInfo.enforceDarkLightMode != "dark" && shareInfo.enforceDarkLightMode != "light",
|
||||
},
|
||||
watch: {
|
||||
route() {
|
||||
@@ -216,17 +220,13 @@ export default {
|
||||
}
|
||||
mutations.updateCurrentUser({ stickySidebar: !state.user.stickySidebar });
|
||||
},
|
||||
navigateTo(path) {
|
||||
const hashIndex = path.indexOf("#");
|
||||
if (hashIndex !== -1) {
|
||||
// Extract the hash
|
||||
const hash = path.substring(hashIndex);
|
||||
// Remove the hash from the path
|
||||
const cleanPath = path.substring(0, hashIndex);
|
||||
this.$router.push({ path: cleanPath, hash: hash }, () => {});
|
||||
} else {
|
||||
this.$router.push({ path: path }, () => {});
|
||||
}
|
||||
navigateTo(path,hash) {
|
||||
mutations.setPreviousHistoryItem({
|
||||
name: state.req.name,
|
||||
source: state.req.source,
|
||||
path: state.req.path,
|
||||
});
|
||||
this.$router.push({ path: path, hash: hash });
|
||||
mutations.closeHovers();
|
||||
},
|
||||
navigateToLogin() {
|
||||
@@ -404,6 +404,7 @@ button.action {
|
||||
.quick-toggles .active {
|
||||
background-color: var(--primaryColor) !important;
|
||||
border-radius: 10em;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.inner-card {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="share-sidebar">
|
||||
<div class="share-sidebar__content">
|
||||
<ShareInfo
|
||||
<ShareInfoCard
|
||||
v-if="!disableShareCard"
|
||||
:hash="hash"
|
||||
:token="token"
|
||||
@@ -12,13 +12,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ShareInfo from "@/components/files/ShareInfo.vue";
|
||||
import { shareOverrides } from "@/utils/constants";
|
||||
import ShareInfoCard from "@/components/files/ShareInfoCard.vue";
|
||||
import { shareInfo } from "@/utils/constants";
|
||||
|
||||
export default {
|
||||
name: "SidebarShare",
|
||||
components: {
|
||||
ShareInfo,
|
||||
ShareInfoCard,
|
||||
},
|
||||
props: {
|
||||
hash: {
|
||||
@@ -36,7 +36,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
disableShareCard() {
|
||||
return shareOverrides.disableShareCard;
|
||||
return shareInfo.disableShareCard;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,12 +9,21 @@
|
||||
</div>
|
||||
<SidebarSettings v-if="isSettings"></SidebarSettings>
|
||||
<SidebarGeneral v-if="!isSettings"></SidebarGeneral>
|
||||
<SidebarShare v-if="isShare"></SidebarShare>
|
||||
<SidebarShare v-if="isValidShare"></SidebarShare>
|
||||
|
||||
<div class="buffer"></div>
|
||||
<div v-if="!isSettings" class="credits">
|
||||
<span v-for="item in externalLinks" :key="item.title">
|
||||
<a :href="item.url" target="_blank" :title="item.title">{{ item.text }}</a>
|
||||
<a
|
||||
v-if="item.url === 'help prompt'"
|
||||
href="#"
|
||||
@click.prevent="help"
|
||||
:title="$t('sidebar.help')"
|
||||
>{{ $t("sidebar.help") }}</a
|
||||
>
|
||||
<a v-else :href="item.url" target="_blank" :title="item.title">{{
|
||||
item.text
|
||||
}}</a>
|
||||
</span>
|
||||
<span v-if="name != ''">
|
||||
<h4 style="margin: 0">{{ name }}</h4>
|
||||
@@ -48,7 +57,7 @@ export default {
|
||||
mutations.setSeenUpdate(localStorage.getItem("seenUpdate"));
|
||||
},
|
||||
computed: {
|
||||
isShare: () => getters.isShare(),
|
||||
isValidShare: () => getters.isValidShare(),
|
||||
releaseUrl: () => updateAvailable,
|
||||
isDarkMode: () => getters.isDarkMode(),
|
||||
isLoggedIn: () => getters.isLoggedIn(),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
:root {
|
||||
--blue: #2196f3;
|
||||
--dark-blue: #1E88E5;
|
||||
--red: #F44336;
|
||||
--red: #f44336;
|
||||
--dark-red: #D32F2F;
|
||||
--moon-grey: #f2f2f2;
|
||||
|
||||
--icon-red: #da4453;
|
||||
--icon-orange: #f47750;
|
||||
--icon-yellow: #fdbc4b;
|
||||
|
||||
@@ -179,13 +179,13 @@ input.sizeInput:disabled {
|
||||
border-radius: 1em;
|
||||
margin: .5em;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
background-color: #f5f5f5;
|
||||
padding: .25em;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
border: 1px solid #ccc;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@ input.sizeInput:disabled {
|
||||
backdrop-filter: invert(.1);
|
||||
height: 2em;
|
||||
width: 5em;
|
||||
|
||||
}
|
||||
|
||||
.roundedInputLeft {
|
||||
@@ -206,10 +207,10 @@ input.sizeInput:disabled {
|
||||
border-top-right-radius: 0;
|
||||
width: auto;
|
||||
background-color: var(--surfaceSecondary);
|
||||
min-width: 50%;
|
||||
}
|
||||
|
||||
.roundedInputRight {
|
||||
padding: 1em;
|
||||
border-radius: 1em;
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
|
||||
@@ -224,7 +224,7 @@ body.rtl .card-title>*:first-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.card .card-content.full {
|
||||
.card-content.full {
|
||||
padding-bottom: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -102,9 +102,10 @@ body.rtl #listingView {
|
||||
}
|
||||
|
||||
#listingView.compact .item:hover,
|
||||
#listingView.list .item:hover {
|
||||
#listingView.list .item:hover,
|
||||
.search-entry:hover {
|
||||
width: 98% !important;
|
||||
margin-left: 0.5em !important;
|
||||
margin-left: 1% !important;
|
||||
padding-left: 0.5em !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,68 +50,7 @@
|
||||
body.rtl .dashboard .row {
|
||||
margin-right: unset;
|
||||
}
|
||||
#search {
|
||||
min-width: unset;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
#search.active {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
#search-input {
|
||||
transition: 1s ease all;
|
||||
}
|
||||
#search.active #search-input {
|
||||
border-bottom: 3px solid rgba(0, 0, 0, 0.075);
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(6px);
|
||||
height: 4em;
|
||||
}
|
||||
|
||||
#search.active>div {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
#search.active #result {
|
||||
height: 100vh;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
#search.active #result>p>i {
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
display: table;
|
||||
}
|
||||
|
||||
#search.active #result ul li a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: .3em 0;
|
||||
margin-right: .3em;
|
||||
}
|
||||
|
||||
#search-input>.action,
|
||||
#search-input>i {
|
||||
margin-right: 0.3em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#result-list {
|
||||
width:100vw !important;
|
||||
max-width: 100vw !important;
|
||||
left: 0;
|
||||
top: 4em;
|
||||
-webkit-box-direction: normal;
|
||||
-ms-flex-direction: column;
|
||||
overflow: scroll;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user