Compare commits

..
110 changed files with 2991 additions and 1434 deletions

No files matched your search

+44
View File
@@ -5,6 +5,9 @@ on:
branches:
- "main"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright
@@ -30,3 +33,44 @@ jobs:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
create_beta_release:
needs: [test_playwright]
if: startsWith(github.event.head_commit.message, 'Beta/v')
name: Create Beta Release
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
fetch-depth: 0
- name: Extract version from commit message
id: extract_version
shell: bash
run: |
tag_name=$(echo "${{ github.event.head_commit.message }}" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
if [ -z "$tag_name" ] || [ "$tag_name" = "-beta" ]; then
echo "Could not extract version from: ${{ github.event.head_commit.message }}"
exit 1
fi
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
- name: Tag release commit on main
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
TAG="${{ steps.extract_version.outputs.tag_name }}"
git tag -f "$TAG" "${{ github.sha }}"
git push origin "refs/tags/$TAG" --force
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_version.outputs.tag_name }}
prerelease: false
make_latest: false
draft: false
generate_release_notes: true
overwrite_existing: true
name: ${{ steps.extract_version.outputs.tag_name }}
+2 -5
View File
@@ -22,11 +22,8 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- uses: golangci/golangci-lint-action@v7
with:
version: 'v2.1.6'
working-directory: backend
install-mode: goinstall
- working-directory: backend
run: go tool golangci-lint run --path-prefix=backend
format-backend:
runs-on: ubuntu-24.04-arm
steps:
-66
View File
@@ -1,66 +0,0 @@
name: beta release
on:
push:
branches:
- "beta/v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright - regular
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go build -o filebrowser .
- name: Build
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
create_release_tag:
needs: [ test_playwright ]
name: Create Release
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
- name: Extract branch name
shell: bash
run: |
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
id: extract_branch
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
prerelease: false
make_latest: false
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.tag_name }}
+33
View File
@@ -2,6 +2,39 @@
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).
## v2.0.3
**Security**:
- [High] Stored XSS via HTML preview: strip `<script>` from sanitized srcdoc, remove `allow-same-origin` from the preview iframe sandbox, set HttpOnly on the session cookie, and add a nonce-based `script-src` CSP on the SPA shell (GHSA-vvm6-jwrf-hgmg) -- thanks @qrn12580
- [Moderate] Non-admin users could PATCH privileged fields on their own account (scopes, permissions, etc.), allowing privilege escalation; restores v1 non-admin field guard on PATCH /api/users (GHSA-p5cc-4p84-c4m2) -- thanks @Chri6s
**New Features**:
- Added option to globally disable the "Install App" message via `frontend.disablePWAInstall`
- PWA improvements for installed mobile apps: dedicated maskable icons (192/512), manifest and splash colors that follow the instance default theme, runtime `theme-color` sync on dark-mode toggle, and edge-to-edge safe-area layout for notched devices (#2625) (#2869) -- thanks @APatenaude
**Bugfixes**:
- Fixed Fuji `.raf` thumbnail preview by extracting the camera-embedded JPEG from the RAF header (regression for files where TIFF-based raw extraction does not apply).
- Image previews for unsupported decodable formats now return HTTP 415 immediately instead of attempting JPEG decode and returning HTTP 500.
- OIDC: preserve the verified ID-token user identifier when falling back to the UserInfo endpoint for missing groups, so login no longer fails with HTTP 500 when UserInfo returns groups but omits the configured identifier.
- LDAP: restore `memberOf` as the default `groupsClaim` when unset, fixing group-based admin and login authorization for existing LDAP configs after the v2.0.2 default changed to `groups`.
- "Install app" message reappears after being cleared on device.
- Restored upload chunk size `0` to disable chunking as documented ([#2202](https://github.com/gtsteffaniak/filebrowser/issues/2202)).
- Fixed iOS 26 / WebKit multi-chunk upload stall by isolating chunk connections and returning partial chunk JSON responses ([#2734](https://github.com/gtsteffaniak/filebrowser/issues/2734)).
- Sidebar folder links with custom names, icons, or styles no longer disappear after restart; multiple shortcuts to different folders on the same source are preserved ([#2809](https://github.com/gtsteffaniak/filebrowser/issues/2809)).
- Adding a source to a user via scopes now auto-adds a matching sidebar link; removing a source keeps the link (shown disabled) so users can delete it manually.
- A password reset via CLI returns user to a password method user.
- Authentication login methods are consistently enforced without modifying existing account settings.
## v2.0.2
**Bugfixes**:
- OIDC: `groupsClaim` value is always included in requested scopes and falls back to the UserInfo endpoint when the ID token omits the groups claim.
- Windows: fix backslash duplication in navigation URLs, folder sizes showing 4 KB, and download/preview failures (#2815, #2816)
- Wrong extension in the 'new database was created popup (#2817) (#2824)
- External subtitles fail to load on public video shares due to authenticated subtitle endpoint (#2822) (#2827)
- OnlyOffice is inaccessible on password-protected shares (#2811)
- FFmpeg 9.0 incorrectly detected as below minimum 5.0.0 on Windows (#2820) -- thanks @yzxcj797
## v2.0.1
**Security**:
+10 -4
View File
@@ -115,7 +115,7 @@ func setUser(username, password string, asAdmin bool) error {
return nil
}
if user.LoginMethod != users.LoginMethodPassword {
return fmt.Errorf("user %s is not allowed to login with password authentication, cannot set password", username)
logger.Infof("Converting user %s from %s to password login method", username, user.LoginMethod)
}
user.TOTPSecret = ""
user.TOTPNonce = ""
@@ -139,6 +139,9 @@ func promoteUser(username string) error {
if err != nil {
return fmt.Errorf("user %s not found", username)
}
if user.LoginMethod != users.LoginMethodPassword {
return fmt.Errorf("user %s cannot be promoted: only password-login users can be granted admin via CLI", username)
}
if user.Permissions.Admin {
fmt.Printf("user %s is already an admin\n", username)
return nil
@@ -238,11 +241,14 @@ func createConfig(configpath string, noInput bool) error {
}
for {
config.Server.DatabaseV2.Path = askQuestion(reader, "What should the file name and path be for the database?", "./database.db")
if strings.HasSuffix(config.Server.DatabaseV2.Path, ".db") {
// "database.db" is the reserved legacy-bolt filename and is rejected
// at startup (see backend/cmd/migrate.go), so the wizard must not
// suggest it as the default.
config.Server.DatabaseV2.Path = askQuestion(reader, "What should the file name and path be for the database?", "./filebrowser.sqlite")
if strings.HasSuffix(config.Server.DatabaseV2.Path, ".sqlite") {
break
}
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .db", config.Server.DatabaseV2.Path)
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .sqlite", config.Server.DatabaseV2.Path)
}
config.Frontend.Name = askQuestion(reader, "What should the application brand name be?", "FileBrowser Quantum")
config.Auth.AdminUsername = askQuestion(reader, "What should the default admin username be?", "admin")
+101
View File
@@ -0,0 +1,101 @@
package cmd
import (
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func setupCLIUserTest(t *testing.T) {
t.Helper()
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.InitializeCLI("../../../_docker/src/noauth/backend/config.yaml")
settings.Env.IsPlaywright = true
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
store, _, err := state.Open(dbPath)
if err != nil {
t.Fatal(err)
}
app.MustWireServices(store)
t.Cleanup(func() { _ = state.Close() })
}
func TestSetUserConvertsOIDCToPassword(t *testing.T) {
setupCLIUserTest(t)
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, ""); err != nil {
t.Fatal(err)
}
if err := setUser("graham", "newpassword", false); err != nil {
t.Fatalf("setUser: %v", err)
}
loaded, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
if loaded.LoginMethod != users.LoginMethodPassword {
t.Fatalf("loginMethod = %q, want password", loaded.LoginMethod)
}
}
func TestPromoteUserRejectsNonPasswordLoginMethod(t *testing.T) {
setupCLIUserTest(t)
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, ""); err != nil {
t.Fatal(err)
}
err := promoteUser("graham")
if err == nil {
t.Fatal("promoteUser: expected error for oidc user")
}
if !strings.Contains(err.Error(), "password-login") {
t.Fatalf("promoteUser err = %q, want password-login restriction", err.Error())
}
}
func TestPromoteUserAllowsPasswordLoginMethod(t *testing.T) {
setupCLIUserTest(t)
passwordUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodPassword,
},
}
if err := state.CreateUser(passwordUser, "secret"); err != nil {
t.Fatal(err)
}
if err := promoteUser("graham"); err != nil {
t.Fatalf("promoteUser: %v", err)
}
loaded, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
if !loaded.Permissions.Admin {
t.Fatal("expected admin after promote")
}
}
+2 -2
View File
@@ -210,14 +210,14 @@ func runCLI() (keepGoing bool, dbExists bool) {
switch {
case cmd == "" || cmd == "run":
requireExistingConfig(configPath)
dbExists = initializeDatabase(configPath)
dbExists = initializeDatabase(configPath, false)
return true, dbExists
case cmd == "version" || cmd == "setup":
parser.FatalIfErrorf(ctx.Run(&rootCLI))
return false, false
case cmd == "set rule" || cmd == "set" || strings.HasPrefix(cmd, "user set") || strings.HasPrefix(cmd, "user promote"):
requireExistingConfig(configPath)
dbExists = initializeDatabase(configPath)
dbExists = initializeDatabase(configPath, true)
parser.FatalIfErrorf(ctx.Run(&rootCLI))
return false, dbExists
default:
+6 -2
View File
@@ -29,8 +29,12 @@ import (
var runtimeApp *app.App
func initializeDatabase(configFile string) bool {
settings.Initialize(configFile)
func initializeDatabase(configFile string, cliMode bool) bool {
if cliMode {
settings.InitializeCLI(configFile)
} else {
settings.Initialize(configFile)
}
if err := validateDatabasePaths(); err != nil {
logger.Fatalf("%v", err)
+18 -25
View File
@@ -81,7 +81,8 @@ func validateUserInfo(newDB bool) {
logger.Info("Resetting admin user to default username and password.")
user.Permissions = settings.AdminPerms()
user.Password = settings.Config.Auth.AdminPassword
changedFields = append(changedFields, "permissions", "password")
user.LoginMethod = users.LoginMethodPassword
changedFields = append(changedFields, "permissions", "password", "loginMethod")
changePass = true
}
@@ -233,35 +234,27 @@ func updatePreviewSettings(user *users.User) bool {
// updateSidebarLinks normalizes sidebar links and ensures scoped sources have sidebar entries.
func updateSidebarLinks(user *users.User) bool {
updated := false
needsEnsure := usersidebar.NeedsSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes)
validBefore := usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks)
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
user.SidebarLinks = normalized
updated = true
links, changed := usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
if !changed {
return false
}
if !usersidebar.NeedsSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes) {
return updated
user.SidebarLinks = links
if needsEnsure {
if validBefore == 0 && len(user.SidebarLinks) > 0 {
logger.Infof("User %s has stale source sidebar links, merging missing links from scopes", user.Username)
} else if validBefore == 0 {
logger.Infof("User %s has no source sidebar links, building from scopes", user.Username)
} else {
logger.Infof("User %s is missing sidebar links for some scoped sources, merging from scopes", user.Username)
}
}
if usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks) == 0 && len(user.SidebarLinks) > 0 {
logger.Infof("User %s has stale source sidebar links, merging missing links from scopes", user.Username)
} else if usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks) == 0 {
logger.Infof("User %s has no source sidebar links, building from scopes", user.Username)
} else {
logger.Infof("User %s is missing sidebar links for some scoped sources, merging from scopes", user.Username)
}
merged, changed := usersidebar.EnsureSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes)
if changed {
user.SidebarLinks = merged
updated = true
}
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
user.SidebarLinks = normalized
updated = true
}
return updated
return true
}
func updateTokens(user *users.User) bool {
+84 -87
View File
@@ -19,13 +19,13 @@ require (
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-ffmpeg v0.5.1
github.com/gtsteffaniak/go-ffmpeg v0.5.2
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.8.23
github.com/mattn/go-sqlite3 v1.14.49
github.com/mattn/go-sqlite3 v1.14.50
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.55.0
@@ -38,28 +38,29 @@ require (
golang.org/x/time v0.15.0
golang.org/x/tools v0.49.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.56.0
modernc.org/sqlite v1.57.0
)
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gocheckcompilerdirectives v1.4.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
charm.land/lipgloss/v2 v2.0.3 // indirect
codeberg.org/chavacava/garif v0.2.0 // indirect
charm.land/lipgloss/v2 v2.0.6 // indirect
codeberg.org/chavacava/garif v0.2.1 // indirect
codeberg.org/polyfloyd/go-errorlint v1.9.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
dev.gaijin.team/go/exhaustruct/v5 v5.0.3 // indirect
dev.gaijin.team/go/golib v0.8.1 // indirect
github.com/4meepo/tagalign v1.4.3 // indirect
github.com/Abirdcfly/dupword v0.1.7 // indirect
github.com/Abirdcfly/dupword v0.1.8 // indirect
github.com/AdminBenni/iota-mixing v1.0.0 // indirect
github.com/AlwxSin/noinlineerr v1.0.5 // indirect
github.com/Antonboom/errname v1.1.1 // indirect
github.com/Antonboom/nilnil v1.1.1 // indirect
github.com/AlwxSin/noinlineerr v1.0.6 // indirect
github.com/Antonboom/errname v1.1.2 // indirect
github.com/Antonboom/nilnil v1.1.2 // indirect
github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.0 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
@@ -73,42 +74,41 @@ require (
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/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect
github.com/ashanbrown/makezero/v2 v2.2.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/bits-and-blooms/bitset v1.24.5 // indirect
github.com/bits-and-blooms/bitset v1.25.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.8.0 // indirect
github.com/bombsimon/wsl/v5 v5.9.0 // 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.1 // indirect
github.com/butuzov/mirror v1.3.0 // indirect
github.com/butuzov/mirror v1.3.3 // indirect
github.com/catenacyber/perfsprint v0.10.1 // 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.11 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260812204455-68fa937c71be // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/dave/dst v0.27.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/daixiang0/gci v0.14.0 // indirect
github.com/dave/dst v0.27.4 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/dlclark/regexp2/v2 v2.7.1 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -116,26 +116,26 @@ require (
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/firefart/nonamedreturns v1.0.8 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/ghostiam/protogetter v0.3.21 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-critic/go-critic v0.14.4 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
github.com/go-openapi/spec v0.22.9 // indirect
github.com/go-openapi/swag/conv v0.28.0 // indirect
github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
github.com/go-openapi/swag/loading v0.28.0 // indirect
github.com/go-openapi/swag/pools v0.28.0 // indirect
github.com/go-openapi/swag/stringutils v0.28.0 // indirect
github.com/go-openapi/swag/typeutils v0.28.0 // indirect
github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
github.com/go-openapi/swag/conv v0.29.1 // indirect
github.com/go-openapi/swag/jsonutils v0.29.1 // indirect
github.com/go-openapi/swag/loading v0.29.1 // indirect
github.com/go-openapi/swag/pools v0.29.1 // indirect
github.com/go-openapi/swag/stringutils v0.29.1 // indirect
github.com/go-openapi/swag/typeutils v0.29.1 // indirect
github.com/go-openapi/swag/yamlutils v0.29.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
@@ -146,26 +146,26 @@ require (
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.5.0 // indirect
github.com/go-webauthn/x v0.2.8 // indirect
github.com/go-webauthn/x v0.3.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hashstructure v0.6.0 // indirect
github.com/gohugoio/hugo v0.164.0 // indirect
github.com/gohugoio/hashstructure v1.0.0 // indirect
github.com/gohugoio/hugo v0.165.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20260810185319-42794aff7523 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.12.2 // indirect
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect
github.com/golangci/golangci-lint/v2 v2.13.1 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // indirect
github.com/golangci/rowserrcheck v0.0.0-20260801150524-d2031e3af752 // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/go-tpm v0.9.8 // indirect
@@ -177,15 +177,14 @@ require (
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jgautheron/goconst v1.10.0 // indirect
github.com/jgautheron/goconst v1.11.0 // indirect
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.10.0 // indirect
github.com/kisielk/errcheck v1.20.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
github.com/kovidgoyal/go-shm v1.0.0 // indirect
@@ -193,92 +192,90 @@ require (
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.5 // indirect
github.com/ldez/gomoddirectives v0.8.0 // indirect
github.com/ldez/gomoddirectives v0.9.0 // indirect
github.com/ldez/grignotin v0.10.1 // indirect
github.com/ldez/structtags v0.6.1 // indirect
github.com/ldez/tagliatelle v0.7.2 // indirect
github.com/ldez/usetesting v0.5.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
github.com/manuelarte/funcorder v0.6.0 // indirect
github.com/maratori/testableexamples v1.0.1 // indirect
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.15.0 // indirect
github.com/mattn/go-runewidth v0.0.28 // indirect
github.com/mgechev/revive v1.16.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
github.com/nunnatsa/ginkgolinter v0.24.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/prometheus/client_golang v1.24.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/raeperd/recvcheck v0.2.0 // indirect
github.com/raeperd/recvcheck v0.3.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect
github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.26.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/securego/gosec/v2 v2.28.0 // indirect
github.com/sirupsen/logrus v1.10.1 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sourcegraph/go-diff v0.8.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/tdewolff/parse/v2 v2.8.12 // indirect
github.com/tdewolff/parse/v2 v2.8.16 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c // indirect
github.com/timonwong/loggercheck v0.11.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/urfave/cli/v2 v2.27.7 // indirect
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.4.2 // indirect
github.com/uudashr/iface v1.5.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/xo/terminfo v1.0.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
@@ -286,25 +283,25 @@ require (
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.12.0 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/fatcontext v0.9.0 // indirect
go.augendre.info/fatcontext v0.10.0 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/exp/typeparams v0.0.0-20260820142414-ca536658362e // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.75.3 // indirect
honnef.co/go/tools v0.8.1 // indirect
modernc.org/libc v1.75.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
modernc.org/memory v1.12.1 // indirect
mvdan.cc/gofumpt v0.11.0 // indirect
mvdan.cc/unparam v0.0.0-20260820064413-a201bf8e0538 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
tool (
+197 -592
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -131,10 +131,10 @@ func checkPermissionsImpl(opts utils.FileOptions, user *users.User, s *Service)
return "", "", fmt.Errorf("user not provided")
}
if opts.Path == "" {
return "", "", fmt.Errorf("path not provided")
return "", "", fmt.Errorf("%w: path not provided", errors.ErrInvalidRequestParams)
}
if opts.Source == "" {
return "", "", fmt.Errorf("source not provided")
return "", "", fmt.Errorf("%w: source not provided", errors.ErrInvalidRequestParams)
}
// Get index
+2
View File
@@ -9,6 +9,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/ports"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
// Service performs authentication using injected user reads. It never persists users.
@@ -72,6 +73,7 @@ func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users
}
}
if user.LoginMethod != users.LoginMethodPassword {
logger.Debugf("login rejected: user %q attempted password login but account loginMethod is %q", username, user.LoginMethod)
return nil, errors.ErrWrongLoginMethod
}
return &user, nil
+1
View File
@@ -25,6 +25,7 @@ var (
ErrNotViewable = errors.New("directory or item is not viewable")
ErrPathEscapesScope = errors.New("resolved path escapes scope")
ErrWrongLoginMethod = errors.New("user attempted to login with wrong login method")
ErrInvalidLoginMethod = errors.New("invalid login method")
ErrDownloadNotAllowed = errors.New("downloads are not allowed for this share")
ErrUseMediaStream = errors.New("use /media/stream for audio and video")
ErrUploadNotAllowed = errors.New("upload permission not allowed for this share")
+60 -7
View File
@@ -5,6 +5,8 @@ import (
"context"
"errors"
"fmt"
"image"
"image/color"
"io/fs"
"os"
"path/filepath"
@@ -28,8 +30,9 @@ const (
// iconSize defines an icon size to generate
type iconSize struct {
size int
name string
size int
name string
maskable bool
}
// determineSourceType determines which type of favicon source we have
@@ -153,13 +156,16 @@ func handleRasterFavicon(faviconPath string) ([]byte, error) {
func getIconSizesToGenerate() []iconSize {
return []iconSize{
// Browser favicon (32x32 PNG for Safari/older browsers)
{32, "favicon-32x32.png"},
{32, "favicon-32x32.png", false},
// PWA icons (PNG required for Apple PWA compatibility)
{192, "pwa-icon-192.png"},
{256, "pwa-icon-256.png"}, // Also serves as Windows tile
{512, "pwa-icon-512.png"},
{192, "pwa-icon-192.png", false},
{256, "pwa-icon-256.png", false}, // Also serves as Windows tile
{512, "pwa-icon-512.png", false},
// Maskable PWA icons: art inset into the Android adaptive-icon safe zone
{192, "pwa-icon-maskable-192.png", true},
{512, "pwa-icon-maskable-512.png", true},
// Platform-specific icons
{180, "apple-touch-icon.png"}, // iOS home screen (required for Apple)
{180, "apple-touch-icon.png", false}, // iOS home screen (required for Apple)
}
}
@@ -193,8 +199,55 @@ func generateIconSizes(sourceData []byte) error {
return nil
}
// generateMaskableIcon insets the art into the safe zone on an opaque background, since launchers zoom maskable icons to fill the mask edge-to-edge
func generateMaskableIcon(sourceData []byte, icon iconSize) error {
img, err := imaging.Decode(bytes.NewReader(sourceData))
if err != nil {
return fmt.Errorf("failed to decode source: %w", err)
}
// Android adaptive-icon content ratio (72/108 ≈ 2/3) keeps square art inside every mask shape
inner := icon.size * 2 / 3
art := imaging.Fit(img, inner, inner, imaging.Lanczos)
canvas := imaging.PasteCenter(imaging.New(icon.size, icon.size, maskableBackground(img)), art)
outputPath := filepath.Join(settings.PWAIconsCacheDir(), icon.name)
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer outFile.Close()
if err := imaging.Encode(outFile, canvas, imaging.PNG); err != nil {
os.Remove(outputPath)
return fmt.Errorf("encode failed: %w", err)
}
return nil
}
// maskableBackground samples the art's corners then edge midpoints for an opaque pad color, else white
func maskableBackground(img image.Image) color.Color {
b := img.Bounds()
cx, cy := (b.Min.X+b.Max.X)/2, (b.Min.Y+b.Max.Y)/2
points := []image.Point{
{b.Min.X, b.Min.Y}, {b.Max.X - 1, b.Min.Y}, {b.Min.X, b.Max.Y - 1}, {b.Max.X - 1, b.Max.Y - 1},
{cx, b.Min.Y}, {cx, b.Max.Y - 1}, {b.Min.X, cy}, {b.Max.X - 1, cy},
}
for _, p := range points {
r, g, bl, a := img.At(p.X, p.Y).RGBA()
if a == 0xffff {
return color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(bl >> 8), A: 255}
}
}
return color.White
}
// generateSingleIcon generates a single icon file at the specified size
func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon iconSize) error {
if icon.maskable {
return generateMaskableIcon(sourceData, icon)
}
outputPath := filepath.Join(settings.PWAIconsCacheDir(), icon.name)
// Create output file
+64
View File
@@ -0,0 +1,64 @@
package icons
import (
"image"
"image/color"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestMaskableBackgroundOpaqueCorner(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 12, 12))
img.Set(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
got := maskableBackground(img)
want := color.NRGBA{R: 10, G: 20, B: 30, A: 255}
if got != want {
t.Fatalf("opaque corner: got %#v, want %#v", got, want)
}
}
func TestMaskableBackgroundOpaqueEdge(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 12, 12))
img.Set(6, 0, color.RGBA{R: 200, G: 100, B: 50, A: 255})
got := maskableBackground(img)
want := color.NRGBA{R: 200, G: 100, B: 50, A: 255}
if got != want {
t.Fatalf("opaque edge: got %#v, want %#v", got, want)
}
}
func TestMaskableBackgroundAllTransparent(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 12, 12))
if got := maskableBackground(img); got != color.White {
t.Fatalf("all transparent: got %#v, want white", got)
}
}
func TestDefaultBackgroundColor(t *testing.T) {
saved := settings.Config
defer func() { settings.Config = saved }()
dark := true
light := false
settings.Config.Frontend.Styling.DarkBackground = "#111111"
settings.Config.Frontend.Styling.LightBackground = "#ffffff"
settings.Config.UserDefaults.UI.DarkMode = &dark
if got := settings.DefaultBackgroundColor(); got != "#111111" {
t.Fatalf("dark branch: got %q, want %q", got, "#111111")
}
settings.Config.UserDefaults.UI.DarkMode = &light
if got := settings.DefaultBackgroundColor(); got != "#ffffff" {
t.Fatalf("light branch (dark disabled): got %q, want %q", got, "#ffffff")
}
settings.Config.UserDefaults.UI.DarkMode = nil
if got := settings.DefaultBackgroundColor(); got != "#ffffff" {
t.Fatalf("light branch (unset): got %q, want %q", got, "#ffffff")
}
}
+20 -6
View File
@@ -44,14 +44,14 @@ func pwaManifestName(name string) string {
}
// generatePWAManifest creates the PWA manifest structure
func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwaIcon256, pwaIcon512 string) PWAManifest {
func generatePWAManifest(name, description, baseURL, themeColor, backgroundColor, pwaIcon192, pwaIcon256, pwaIcon512, pwaIconMaskable192, pwaIconMaskable512 string) PWAManifest {
return PWAManifest{
Name: pwaManifestName(name),
ID: baseURL,
Scope: baseURL,
StartURL: baseURL,
Display: "standalone",
BackgroundColor: "#ffffff",
BackgroundColor: backgroundColor,
ThemeColor: themeColor,
Description: description,
Icons: []PWAIcon{
@@ -65,7 +65,7 @@ func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwa
Src: pwaIcon256,
Sizes: "256x256",
Type: "image/png",
Purpose: "any maskable",
Purpose: "any",
},
{
Src: pwaIcon512,
@@ -73,6 +73,18 @@ func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwa
Type: "image/png",
Purpose: "any",
},
{
Src: pwaIconMaskable192,
Sizes: "192x192",
Type: "image/png",
Purpose: "maskable",
},
{
Src: pwaIconMaskable512,
Sizes: "512x512",
Type: "image/png",
Purpose: "maskable",
},
},
}
}
@@ -85,11 +97,13 @@ func InitializePWAManifest() {
staticURL := config.Http.BaseURL + "public/static"
title := config.Frontend.Name
description := config.Frontend.Description
defaultThemeColor := "#455a64"
pwaIcon192 := staticURL + "/icons/pwa-icon-192.png"
pwaIcon256 := staticURL + "/icons/pwa-icon-256.png"
pwaIcon512 := staticURL + "/icons/pwa-icon-512.png"
pwaIconMaskable192 := staticURL + "/icons/pwa-icon-maskable-192.png"
pwaIconMaskable512 := staticURL + "/icons/pwa-icon-maskable-512.png"
CachedManifest = generatePWAManifest(title, description, config.Http.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
backgroundColor := settings.DefaultBackgroundColor()
CachedManifest = generatePWAManifest(title, description, config.Http.BaseURL, backgroundColor, backgroundColor, pwaIcon192, pwaIcon256, pwaIcon512, pwaIconMaskable192, pwaIconMaskable512)
}
+2
View File
@@ -56,6 +56,8 @@ func ExtractEmbeddedPreview(ctx context.Context, path string) ([]byte, error) {
switch {
case ext == ".cr3":
data, err = extimagemeta.PreviewCR3(f)
case ext == ".raf":
data, err = extractRAFEmbeddedPreview(f)
case isRawImageExtension(ext):
data, err = extractTIFFEmbeddedPreview(f)
case isHEICExtension(ext):
+68
View File
@@ -0,0 +1,68 @@
//go:build !386 && !arm
package imagemeta
import (
"encoding/binary"
"io"
"os"
)
const (
rafMagicLen = 16
rafMagic = "FUJIFILMCCD-RAW "
rafJPEGOffsetPos = 0x54
rafJPEGLengthPos = 0x58
rafHeaderMinBytes = 0x5C
)
// extractRAFEmbeddedPreview reads the camera-embedded JPEG from a Fuji RAF file.
// Offset and length are stored as big-endian uint32 at 0x54 and 0x58 in the RAF header directory.
func extractRAFEmbeddedPreview(f *os.File) ([]byte, error) {
info, err := f.Stat()
if err != nil {
return nil, err
}
if info.Size() < rafHeaderMinBytes {
return nil, nil
}
header := make([]byte, rafHeaderMinBytes)
n, err := f.ReadAt(header, 0)
if err != nil && err != io.EOF {
return nil, err
}
if n < rafHeaderMinBytes {
return nil, nil
}
if !isRAFMagic(header[:rafMagicLen]) {
return nil, nil
}
offset := binary.BigEndian.Uint32(header[rafJPEGOffsetPos:rafJPEGLengthPos])
length := binary.BigEndian.Uint32(header[rafJPEGLengthPos:rafHeaderMinBytes])
if offset == 0 || length == 0 {
return nil, nil
}
if int64(offset)+int64(length) > info.Size() {
return nil, nil
}
data, err := readFileRange(f, offset, length)
if err != nil || len(data) == 0 || !IsJPEG(data) {
return nil, nil
}
return data, nil
}
func isRAFMagic(b []byte) bool {
if len(b) < len(rafMagic) {
return false
}
for i := 0; i < len(rafMagic); i++ {
if b[i] != rafMagic[i] {
return false
}
}
return true
}
@@ -0,0 +1,94 @@
//go:build !386 && !arm
package imagemeta
import (
"context"
"encoding/binary"
"os"
"path/filepath"
"testing"
)
func TestExtractRAFEmbeddedPreviewSynthetic(t *testing.T) {
jpeg := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0xff, 0xd9}
const jpegOffset = uint32(0x94)
data := make([]byte, int(jpegOffset)+len(jpeg))
copy(data[:rafMagicLen], []byte(rafMagic))
binary.BigEndian.PutUint32(data[rafJPEGOffsetPos:rafJPEGLengthPos], jpegOffset)
binary.BigEndian.PutUint32(data[rafJPEGLengthPos:rafHeaderMinBytes], uint32(len(jpeg)))
copy(data[jpegOffset:], jpeg)
path := filepath.Join(t.TempDir(), "sample.raf")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
got, err := ExtractEmbeddedPreview(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if !IsJPEG(got) {
t.Fatalf("expected JPEG preview, got %d bytes", len(got))
}
if len(got) != len(jpeg) {
t.Fatalf("preview size = %d, want %d", len(got), len(jpeg))
}
}
func TestExtractRAFEmbeddedPreviewIgnoresInvalidMagic(t *testing.T) {
data := make([]byte, 256)
copy(data[:rafMagicLen], []byte("FUJIFILMCCD-RAW!"))
path := filepath.Join(t.TempDir(), "bad.raf")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
got, err := ExtractEmbeddedPreview(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Fatalf("ExtractEmbeddedPreview() = %v, want nil", got)
}
}
func TestExtractRAFEmbeddedPreviewDSCF1276(t *testing.T) {
path := "/Users/steffag/Downloads/DSCF1276.RAF"
if _, err := os.Stat(path); err != nil {
t.Skip(path)
}
got, err := ExtractEmbeddedPreview(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if !IsJPEG(got) {
t.Fatalf("expected JPEG preview, got %d bytes", len(got))
}
if len(got) < 100 {
t.Fatalf("preview size = %d, want >= 100", len(got))
}
}
func TestExtractRAFEmbeddedPreviewRejectsOutOfBoundsOffset(t *testing.T) {
jpeg := []byte{0xff, 0xd8, 0xff, 0xd9}
data := make([]byte, 256)
copy(data[:rafMagicLen], []byte(rafMagic))
binary.BigEndian.PutUint32(data[rafJPEGOffsetPos:rafJPEGLengthPos], 200)
binary.BigEndian.PutUint32(data[rafJPEGLengthPos:rafHeaderMinBytes], uint32(len(jpeg)))
path := filepath.Join(t.TempDir(), "oob.raf")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
got, err := ExtractEmbeddedPreview(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Fatalf("ExtractEmbeddedPreview() = %v, want nil", got)
}
}
+17 -3
View File
@@ -271,10 +271,17 @@ func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.Extended
case previewTypeImage:
ext := strings.ToLower(filepath.Ext(file.Name))
if iteminfo.IsRawImage(ext) && s.ffmpegService != nil {
if bytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize); err == nil && len(bytes) >= minPreviewSize {
return bytes, nil
if iteminfo.IsRawImage(ext) {
if s.ffmpegService != nil {
previewBytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err == nil && len(previewBytes) >= minPreviewSize {
return previewBytes, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
}
return nil, ErrUnsupportedFormat
}
return s.generateImagePreview(ctx, file, previewSize)
@@ -370,6 +377,13 @@ func (s *Service) generateImagePreview(ctx context.Context, file iteminfo.Extend
return nil, err
}
ext := strings.ToLower(filepath.Ext(file.Name))
format, err := s.FormatFromExtension(ext)
if err != nil {
return nil, err
}
options.Format = format
imageBytes, err := s.CreatePreview(ctx, f, file.Size, options)
if err != nil {
if ctx.Err() != nil {
@@ -0,0 +1,119 @@
package preview
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
)
func TestGenerateRawPreviewRawWithoutEmbeddedReturnsUnsupported(t *testing.T) {
data := make([]byte, 256)
copy(data[:16], []byte("FUJIFILMCCD-RAW "))
dir := t.TempDir()
path := filepath.Join(dir, "empty.raf")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
svc := NewPreviewGenerator(1, dir)
file := iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "empty.raf",
Size: int64(len(data)),
ModTime: time.Now(),
Type: "image/x-fuji-raf",
},
},
RealPath: path,
}
_, err := svc.generateRawPreview(context.Background(), file, "small", "", 0, "hash")
if !errors.Is(err, ErrUnsupportedFormat) {
t.Fatalf("generateRawPreview() err = %v, want ErrUnsupportedFormat", err)
}
if strings.Contains(err.Error(), "SOI") {
t.Fatalf("generateRawPreview() should fail fast, got JPEG decode error: %v", err)
}
}
func TestGenerateImagePreviewUnsupportedExtensionReturnsUnsupported(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "clip.mov")
// Must exceed maxSizeForOriginal (256KB) so we don't short-circuit to raw bytes.
payload := make([]byte, 256*1024+1)
copy(payload, []byte("not a video"))
if err := os.WriteFile(path, payload, 0o600); err != nil {
t.Fatal(err)
}
svc := NewPreviewGenerator(1, dir)
file := iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "clip.mov",
Size: int64(len(payload)),
ModTime: time.Now(),
Type: "video/quicktime",
},
},
RealPath: path,
}
_, err := svc.generateImagePreview(context.Background(), file, "small")
if !errors.Is(err, ErrUnsupportedFormat) {
t.Fatalf("generateImagePreview() err = %v, want ErrUnsupportedFormat", err)
}
}
func TestGenerateImagePreviewUsesPNGFormat(t *testing.T) {
dir := t.TempDir()
svc := NewPreviewGenerator(1, dir)
// 1x1 red PNG; pad past maxSizeForOriginal (256KB) so we exercise format selection.
const maxSizeForOriginal = 256 * 1024
pngBytes := []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d,
0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
0x44, 0xae, 0x42, 0x60, 0x82,
}
payload := make([]byte, maxSizeForOriginal+1)
copy(payload, pngBytes)
path := filepath.Join(dir, "pixel.png")
if err := os.WriteFile(path, payload, 0o600); err != nil {
t.Fatal(err)
}
file := iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "pixel.png",
Size: int64(len(payload)),
ModTime: time.Now(),
Type: "image/png",
},
},
RealPath: path,
}
got, err := svc.generateImagePreview(context.Background(), file, "small")
if err != nil {
t.Fatal(err)
}
if len(got) < 8 || string(got[:8]) != "\x89PNG\r\n\x1a\n" {
t.Fatalf("expected PNG signature, got %x", got[:min(8, len(got))])
}
}
+3 -1
View File
@@ -49,7 +49,9 @@ func initialize(dbPath string) (bool, error) {
defer sharesMux.Unlock()
defer indexMux.Unlock()
logger.Info("Initializing state management system...")
if !settings.Env.IsCLIMode {
logger.Info("Initializing state management system...")
}
var existingDb bool
var err error
sqlDb, existingDb, err = sqldb.NewSQLStore(dbPath)
+22
View File
@@ -10,6 +10,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
)
// User operations
@@ -221,6 +222,10 @@ func CreateUser(user *users.User, plaintextPassword string) error {
users.SyncBackendSourcePermissionsMap(user)
if links, changed := usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes); changed {
user.SidebarLinks = links
}
usersMux.Lock()
defer usersMux.Unlock()
@@ -328,6 +333,19 @@ func UpdateUser(user *users.User, plaintextPassword string, fields ...string) er
}
}
if sidebarFieldsPatched(fields) {
var links []users.SidebarLink
var changed bool
if fieldListRequiresScopeConversion(fields) {
links, changed = usersidebar.PrepareSidebarLinksForPersist(existingUser.SidebarLinks, existingUser.BackendScopes)
} else {
links, changed = usersidebar.NormalizeSidebarLinks(existingUser.SidebarLinks)
}
if changed {
existingUser.SidebarLinks = links
}
}
return commitUserUpdate(existingUser, storedSnapshot, sourceDefaults, sourceEnforced)
}
@@ -390,6 +408,10 @@ func fieldListRequiresScopeConversion(fields []string) bool {
return fieldListPatchesBackendScopes(fields) || fieldListPatchesAPISourcePermissions(fields)
}
func sidebarFieldsPatched(fields []string) bool {
return FieldListIncludes(fields, "sidebarLinks") || fieldListRequiresScopeConversion(fields)
}
// applyScopesFromAPI converts API scopes (with nested permissions) into BackendScopes.
// Legacy sourcePermissions map is merged into scopes when permissions are omitted per scope.
func applyScopesFromAPI(user *users.User) error {
@@ -0,0 +1,181 @@
package state
import (
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func initSidebarLinkTestDB(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize("../../../_docker/src/noauth/backend/config.yaml")
settings.Env.IsPlaywright = true
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
if _, err := Initialize(dbPath); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = Close() })
}
func countSourceSidebarLinks(links []users.SidebarLink) int {
n := 0
for _, link := range links {
if strings.HasPrefix(link.Category, "source") {
n++
}
}
return n
}
func TestCreateUserAddsSidebarLinksForAllScopes(t *testing.T) {
initSidebarLinkTestDB(t)
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "scope-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
{Name: "include", Scope: "/"},
},
},
}
if err := CreateUser(u, "password"); err != nil {
t.Fatal(err)
}
loaded, err := GetUserByUsername("scope-user")
if err != nil {
t.Fatal(err)
}
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 2 {
t.Fatalf("source sidebar links = %d, want 2: %#v", count, loaded.SidebarLinks)
}
}
func TestUpdateUserScopesAddsMissingSidebarLink(t *testing.T) {
initSidebarLinkTestDB(t)
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "patch-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
},
},
}
if err := CreateUser(u, "password"); err != nil {
t.Fatal(err)
}
patch := &users.User{
ID: u.ID,
FrontendUser: users.FrontendUser{
Username: "patch-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
{Name: "include", Scope: "/"},
},
},
}
if err := UpdateUser(patch, "", "scopes"); err != nil {
t.Fatal(err)
}
loaded, err := GetUserByUsername("patch-user")
if err != nil {
t.Fatal(err)
}
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 2 {
t.Fatalf("source sidebar links = %d, want 2: %#v", count, loaded.SidebarLinks)
}
}
func TestUpdateUserScopesKeepsStaleSidebarLinks(t *testing.T) {
initSidebarLinkTestDB(t)
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "stale-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
{Name: "include", Scope: "/"},
},
},
}
if err := CreateUser(u, "password"); err != nil {
t.Fatal(err)
}
patch := &users.User{
ID: u.ID,
FrontendUser: users.FrontendUser{
Username: "stale-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
},
},
}
if err := UpdateUser(patch, "", "scopes"); err != nil {
t.Fatal(err)
}
loaded, err := GetUserByUsername("stale-user")
if err != nil {
t.Fatal(err)
}
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 2 {
t.Fatalf("expected stale include link kept, got %d links: %#v", count, loaded.SidebarLinks)
}
}
func TestUpdateUserSidebarLinksPreservesFolderShortcuts(t *testing.T) {
initSidebarLinkTestDB(t)
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "folder-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
},
},
}
if err := CreateUser(u, "password"); err != nil {
t.Fatal(err)
}
links := []users.SidebarLink{
{Name: "exclude", Category: "source", SourceName: "exclude", Target: "/"},
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: "exclude", Target: "/photos"},
{Name: "divider", Category: "divider", Target: "#"},
}
patch := &users.User{
ID: u.ID,
FrontendUser: users.FrontendUser{
Username: "folder-user",
NonAdminEditable: users.NonAdminEditable{
SidebarLinks: links,
},
},
}
if err := UpdateUser(patch, "", "sidebarLinks"); err != nil {
t.Fatal(err)
}
loaded, err := GetUserByUsername("folder-user")
if err != nil {
t.Fatal(err)
}
if len(loaded.SidebarLinks) != 3 {
t.Fatalf("len(sidebarLinks) = %d, want 3: %#v", len(loaded.SidebarLinks), loaded.SidebarLinks)
}
if loaded.SidebarLinks[1].Name != "Photos" || loaded.SidebarLinks[1].Icon != "photo" || loaded.SidebarLinks[1].Target != "/photos" {
t.Fatalf("folder shortcut not preserved: %#v", loaded.SidebarLinks[1])
}
if !strings.Contains(loaded.SidebarLinks[0].SourceName, "playwright-files") {
t.Fatalf("expected canonical path persisted, got SourceName=%q", loaded.SidebarLinks[0].SourceName)
}
}
+32 -11
View File
@@ -8,9 +8,10 @@ import (
// NormalizeSidebarLinks canonicalizes persisted sidebar links for storage.
// Source links are resolved via ResolveSourceKey on SourceName, with Name as fallback.
// SourceName is set to the canonical backend path; Name to the configured display name.
// Unresolvable source links are dropped. Source links are deduped by canonical path
// (first occurrence wins). Non-source links pass through; duplicate Tools links are deduped.
// SourceName is set to the canonical backend path; custom Name, Icon, and Category are preserved.
// Unresolvable source links are dropped. Source links are deduped by canonical path plus target
// (first occurrence wins), so multiple folder shortcuts on one source are kept. Non-source links
// pass through; duplicate Tools links are deduped.
func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool) {
if !users.SourceConfigLoaded() {
return links, false
@@ -19,7 +20,7 @@ func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool
return links, false
}
seenSourcePaths := make(map[string]struct{})
seenSourceKeys := make(map[sourceLinkDedupeKey]struct{})
hasTools := false
out := make([]users.SidebarLink, 0, len(links))
changed := false
@@ -31,11 +32,6 @@ func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool
changed = true
continue
}
if _, dup := seenSourcePaths[source.Path]; dup {
changed = true
continue
}
seenSourcePaths[source.Path] = struct{}{}
normalized := link
normalized.Category = users.NormalizeSidebarLinkCategory(normalized.Category)
@@ -43,9 +39,15 @@ func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool
if strings.TrimSpace(normalized.Name) == "" {
normalized.Name = source.Name
}
if normalized.Target == "" {
normalized.Target = "/"
normalized.Target = normalizeSidebarTarget(normalized.Target)
key := sourceLinkDedupeKey{sourcePath: source.Path, target: normalized.Target}
if _, dup := seenSourceKeys[key]; dup {
changed = true
continue
}
seenSourceKeys[key] = struct{}{}
if normalized != link {
changed = true
}
@@ -77,3 +79,22 @@ func resolveSourceLink(link users.SidebarLink) (users.SourceInfo, bool) {
}
return users.SourceInfo{}, false
}
func normalizeSidebarTarget(target string) string {
t := strings.TrimSpace(target)
if t == "" || t == "#" {
return "/"
}
if !strings.HasPrefix(t, "/") {
t = "/" + t
}
if t != "/" && strings.HasSuffix(t, "/") {
t = strings.TrimSuffix(t, "/")
}
return t
}
type sourceLinkDedupeKey struct {
sourcePath string
target string
}
@@ -164,6 +164,88 @@ func TestNormalizeSidebarLinks_dedupesToolsLink(t *testing.T) {
}
}
func TestNormalizeSidebarLinks_preservesMultipleFolderShortcutsOnSameSource(t *testing.T) {
testSourceConfig(t)
in := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
{Name: "Docs", Category: string(users.SidebarLinkSourceAlt), Icon: "folder", SourceName: ".", Target: "/docs"},
{Name: "divider", Category: "divider", Target: "#"},
}
out, changed := NormalizeSidebarLinks(in)
if changed {
t.Fatal("expected canonical folder shortcuts unchanged")
}
if len(out) != 4 {
t.Fatalf("len(out) = %d, want 4", len(out))
}
if out[1].Name != "Photos" || out[1].Icon != "photo" || out[1].Target != "/photos" {
t.Fatalf("photos link = %#v", out[1])
}
if out[2].Category != string(users.SidebarLinkSourceAlt) {
t.Fatalf("docs category = %q", out[2].Category)
}
}
func TestNormalizeSidebarLinks_dedupesSameSourceAndTarget(t *testing.T) {
testSourceConfig(t)
in := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "docker root", Category: "source-minimal", Icon: "home", SourceName: "docker", Target: "/"},
}
out, changed := NormalizeSidebarLinks(in)
if !changed {
t.Fatal("expected changed=true")
}
if len(out) != 1 {
t.Fatalf("len(out) = %d, want 1", len(out))
}
if out[0].Name != "docker" {
t.Fatalf("first link wins: %#v", out[0])
}
}
func TestNormalizeSidebarLinks_noCollisionOnPipeInPathOrTarget(t *testing.T) {
had := users.SourceConfigLoaded()
t.Cleanup(func() {
if !had {
users.SetSourceConfig(nil)
}
})
users.SetSourceConfig(&users.SourceConfigProvider{
GetSourceByPath: func(path string) (users.SourceInfo, bool) {
switch path {
case "a":
return users.SourceInfo{Path: "a", Name: "src-a"}, true
case "a|/b":
return users.SourceInfo{Path: "a|/b", Name: "src-pipe"}, true
default:
return users.SourceInfo{}, false
}
},
GetSourceByName: func(name string) (users.SourceInfo, bool) {
return users.SourceInfo{}, false
},
})
in := []users.SidebarLink{
{Name: "one", Category: "source", SourceName: "a", Target: "/b|/c"},
{Name: "two", Category: "source", SourceName: "a|/b", Target: "/c"},
}
out, changed := NormalizeSidebarLinks(in)
if changed {
t.Fatal("expected changed=false for distinct path/target pairs")
}
if len(out) != 2 {
t.Fatalf("len(out) = %d, want 2 (no false dedupe)", len(out))
}
}
func TestNormalizeSidebarLinks_noConfigLoaded(t *testing.T) {
had := users.SourceConfigLoaded()
t.Cleanup(func() {
+28
View File
@@ -0,0 +1,28 @@
package usersidebar
import (
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
// PrepareSidebarLinksForPersist normalizes sidebar links and adds missing scoped source entries.
// Inaccessible sources are not pruned; callers rely on the UI to gray out stale links.
func PrepareSidebarLinksForPersist(links []users.SidebarLink, scopes []users.BackendScope) ([]users.SidebarLink, bool) {
updated := false
if normalized, changed := NormalizeSidebarLinks(links); changed {
links = normalized
updated = true
}
merged, changed := EnsureSidebarLinksFromScopes(links, scopes)
if changed {
links = merged
updated = true
if normalized, changed := NormalizeSidebarLinks(links); changed {
links = normalized
updated = true
}
}
return links, updated
}
@@ -0,0 +1,55 @@
package usersidebar
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func TestPrepareSidebarLinksForPersist_addsScopedSourcesAndPreservesFolders(t *testing.T) {
testSourceConfig(t)
links := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
}
scopes := []users.BackendScope{
{Path: "."},
{Path: "../frontend/tests/playwright-files"},
}
out, changed := PrepareSidebarLinksForPersist(links, scopes)
if !changed {
t.Fatal("expected changed=true")
}
if len(out) != 3 {
t.Fatalf("len(out) = %d, want 3", len(out))
}
if out[1].Name != "Photos" || out[1].Icon != "photo" {
t.Fatalf("folder shortcut lost: %#v", out[1])
}
if out[2].Name != "playwright + files" {
t.Fatalf("added scope link = %#v", out[2])
}
}
func TestPrepareSidebarLinksForPersist_keepsLinksWhenScopeRemoved(t *testing.T) {
testSourceConfig(t)
links := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
}
scopes := []users.BackendScope{
{Path: "."},
}
out, changed := PrepareSidebarLinksForPersist(links, scopes)
if changed {
t.Fatal("expected changed=false when only pruning is not performed")
}
if len(out) != 3 {
t.Fatalf("len(out) = %d, want 3 (stale scope link kept)", len(out))
}
}
+19
View File
@@ -89,3 +89,22 @@ func CSPNonce() (string, error) {
}
return base64.StdEncoding.EncodeToString(b), nil
}
// CSPNonceFromData returns an existing cspNonce from map template data, or generates
// and stores one when data is a map[string]interface{}. For other data types it
// generates a fresh nonce without modifying data.
func CSPNonceFromData(data interface{}) (string, error) {
m, ok := data.(map[string]interface{})
if !ok {
return CSPNonce()
}
if nonce, ok := m["cspNonce"].(string); ok && nonce != "" {
return nonce, nil
}
nonce, err := CSPNonce()
if err != nil {
return "", fmt.Errorf("csp nonce: %w", err)
}
m["cspNonce"] = nonce
return nonce, nil
}
+1
View File
@@ -69,6 +69,7 @@ type FileOptions struct {
// SanitizePath cleans and validates a path or path-like filter (index paths, globs, etc.)
// to block traversal. Rule 1: Do Not Use Untrusted Input in File Paths (without validation).
func SanitizePath(inputPath string) (string, error) {
inputPath = strings.ReplaceAll(inputPath, `\`, `/`)
clean := path.Clean(inputPath)
// Split the path into segments to check for path traversal attempts.
-4
View File
@@ -34,10 +34,6 @@ func ParseSanitizedIndexPath(userPath string, isDir bool) (IndexPath, error) {
}
func parseIndexPath(path string, isDir bool, strict bool) (IndexPath, error) {
if strict && strings.Contains(path, `\`) {
return IndexPath{}, fmt.Errorf("invalid path: backslashes not allowed")
}
inner := strings.Trim(path, "/")
if inner == "" {
return IndexPath{IsDir: isDir}, nil
+11
View File
@@ -53,6 +53,9 @@ func TestSanitizePath_indexPaths(t *testing.T) {
{"traversal segment", "..", "", true},
{"resolved traversal", "/../secret", "/secret", false},
{"empty", "", "", true},
{"backslash traversal", `..\secret`, "", true},
{"backslash double traversal", `..\..\secret`, "", true},
{"backslash path normalized", `\foo\bar`, "/foo/bar", false},
}
for _, tt := range tests {
@@ -109,6 +112,14 @@ func TestParseSanitizedIndexPath(t *testing.T) {
if got.String() != "/secret/" {
t.Errorf("got %q want /secret/", got.String())
}
got, err = ParseSanitizedIndexPath(`\foo\bar\`, true)
if err != nil {
t.Fatalf("unexpected error for backslash path: %v", err)
}
if got.String() != "/foo/bar/" {
t.Errorf("got %q want /foo/bar/", got.String())
}
}
func TestIndexPath_Parent(t *testing.T) {
+9 -2
View File
@@ -1,6 +1,7 @@
package utils
import (
"net/http"
"os"
"unicode/utf8"
)
@@ -27,7 +28,13 @@ func IsTextFile(realPath string) (bool, error) {
if len(content) > sampleSize {
sample = content[:sampleSize]
}
// Check 1: Count null bytes - binary files typically have many nulls
// Check 1: Reject PDFs. A PDF with a long ASCII text layer looks like text
// for far more than sampleSize bytes.
if http.DetectContentType(sample) == "application/pdf" {
return false, nil
}
// Check 2: Count null bytes - binary files typically have many nulls
nullCount := 0
for _, b := range sample {
if b == 0x00 {
@@ -42,7 +49,7 @@ func IsTextFile(realPath string) (bool, error) {
}
}
// Check 2: Validate UTF-8 encoding
// Check 3: Validate UTF-8 encoding
// Trim sample to last complete UTF-8 rune to avoid false negatives
trimmedSample := sample
for len(trimmedSample) > 0 {
+48
View File
@@ -0,0 +1,48 @@
package utils
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestIsTextFile(t *testing.T) {
t.Parallel()
asciiPageStream := strings.Repeat("BT /Ft0 1 Tf <00430044004500460047> Tj ET\n", 300)
tests := []struct {
name string
content []byte
want bool
}{
{name: "empty", content: nil, want: true},
{name: "plain text", content: []byte("hello\nworld\n"), want: true},
{name: "utf-8 text", content: []byte("café ☕\n"), want: true},
{name: "svg", content: []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"></svg>`), want: true},
{name: "binary with null bytes", content: append([]byte("GIF89a"), make([]byte, 64)...), want: false},
{
name: "pdf with an ascii text layer larger than the sample",
content: append([]byte("%PDF-1.3\n"+asciiPageStream), 0xFF, 0xD8, 0xFF, 0xE0),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "sample")
if err := os.WriteFile(path, tt.content, 0o600); err != nil {
t.Fatalf("writing sample: %v", err)
}
got, err := IsTextFile(path)
if err != nil {
t.Fatalf("IsTextFile: %v", err)
}
if got != tt.want {
t.Fatalf("IsTextFile(%s) = %v, want %v", tt.name, got, tt.want)
}
})
}
}
+21 -20
View File
@@ -14,9 +14,9 @@ import (
"github.com/golang-jwt/jwt/v4/request"
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
@@ -146,6 +146,7 @@ func getOrCreateAuthenticatedUser(username string, loginMethod users.LoginMethod
}
// Verify login method matches
if userValue.LoginMethod != loginMethod {
logger.Debugf("login rejected: user %q attempted %s login but account loginMethod is %q", username, loginMethod, userValue.LoginMethod)
return nil, errors.ErrWrongLoginMethod
}
// Sync IdP groups into access-control GroupMap (write-through). Skip when the
@@ -228,22 +229,12 @@ func sessionCookieDomain(r *http.Request) string {
// @Success 200 {object} map[string]string "{"logoutUrl": "http://..."}"
// @Router /api/auth/logout [post]
func logoutHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
if err := state.RevokeToken( d.Token); err != nil {
if err := state.RevokeToken(d.Token); err != nil {
logger.Errorf("Failed to revoke token on logout: %v", err)
}
// Clear the authentication cookie by setting it to expire in the past
host := sessionCookieDomain(r)
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: "",
Domain: host,
Path: "/",
SameSite: http.SameSiteStrictMode,
Expires: time.Unix(0, 0), // Expire immediately
MaxAge: -1, // Delete cookie
}
http.SetCookie(w, cookie)
http.SetCookie(w, sessionCookie(r, "", time.Unix(0, 0), -1, http.SameSiteStrictMode))
logoutUrl := fmt.Sprintf("%vlogin", settings.Config.Http.BaseURL) // Default fallback
if d.User != nil && d.User.LoginMethod == users.LoginMethodProxy {
@@ -427,17 +418,27 @@ func AuthenticateShareRequest(r *http.Request, l share.Share) (int, error) {
return 200, nil
}
// SetSessionCookie sets the authentication token as an HTTP cookie.
func SetSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresTime time.Time) {
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
const sessionCookieName = "filebrowser_quantum_jwt"
// sessionCookie builds the JWT cookie. HttpOnly prevents srcdoc XSS from reading
// document.cookie; Secure is set when the request is HTTPS (including via proxy).
func sessionCookie(r *http.Request, token string, expiresTime time.Time, maxAge int, sameSite http.SameSite) *http.Cookie {
return &http.Cookie{
Name: sessionCookieName,
Value: token,
Domain: sessionCookieDomain(r),
Path: "/",
SameSite: http.SameSiteStrictMode, // strict mode prevents cookie from being sent to other domains
SameSite: sameSite,
HttpOnly: true,
Secure: requestScheme(r) == "https",
Expires: expiresTime,
MaxAge: maxAge,
}
http.SetCookie(w, cookie)
}
// SetSessionCookie sets the authentication token as an HTTP cookie.
func SetSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresTime time.Time) {
http.SetCookie(w, sessionCookie(r, token, expiresTime, 0, http.SameSiteStrictMode))
}
// applyNamedApiTokenGlobalCaps intersects owner globals with JWT global caps for named custom API tokens.
+16
View File
@@ -0,0 +1,16 @@
package web
import (
libError "errors"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
)
// loginMethodHTTPStatus maps a login error to an HTTP status and client-safe message.
func loginMethodHTTPStatus(err error) (int, error) {
if libError.Is(err, errors.ErrWrongLoginMethod) {
return http.StatusUnauthorized, errors.ErrInvalidLoginMethod
}
return 0, err
}
@@ -0,0 +1,121 @@
package web
import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
fberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestGetOrCreateAuthenticatedUserRejectsLoginMethodMismatch(t *testing.T) {
setupTestEnv(t)
passwordUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodPassword,
},
}
if err := state.CreateUser(passwordUser, "secret"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
_, err := getOrCreateAuthenticatedUser("graham", users.LoginMethodOidc, false, nil)
if !errors.Is(err, fberrors.ErrWrongLoginMethod) {
t.Fatalf("getOrCreateAuthenticatedUser() err = %v, want ErrWrongLoginMethod", err)
}
loaded, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatalf("GetUserByUsername: %v", err)
}
if loaded.LoginMethod != users.LoginMethodPassword {
t.Fatalf("loginMethod changed to %q, want password", loaded.LoginMethod)
}
}
func TestGetOrCreateAuthenticatedUserRejectsPasswordUserForOIDCAccount(t *testing.T) {
setupTestEnv(t)
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, ""); err != nil {
t.Fatalf("CreateUser: %v", err)
}
_, err := getOrCreateAuthenticatedUser("graham", users.LoginMethodOidc, false, nil)
if err != nil {
t.Fatalf("getOrCreateAuthenticatedUser() for matching oidc user: %v", err)
}
}
func TestAuthenticatePasswordRejectsWrongLoginMethod(t *testing.T) {
setupTestEnv(t)
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, "secret"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/login?username=graham", nil)
req.Header.Set("X-Password", url.QueryEscape("secret"))
_, err := auth.AuthenticatePassword(req, true)
if !errors.Is(err, fberrors.ErrWrongLoginMethod) {
t.Fatalf("AuthenticatePassword() err = %v, want ErrWrongLoginMethod", err)
}
}
func TestLoginHelperReturnsInvalidLoginMethodForPasswordMismatch(t *testing.T) {
setupTestEnv(t)
app.MustWireServices(state.Default())
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, "secret"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
origPassword := settings.Config.Auth.Methods.PasswordAuth.Enabled
settings.Config.Auth.Methods.PasswordAuth.Enabled = true
t.Cleanup(func() {
settings.Config.Auth.Methods.PasswordAuth.Enabled = origPassword
})
handler := LoginHelper(true, func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
return http.StatusOK, nil
})
req := httptest.NewRequest(http.MethodPost, "/api/auth/login?username=graham", nil)
req.Header.Set("X-Password", url.QueryEscape("secret"))
rec := httptest.NewRecorder()
status, err := handler(rec, req, &requestContext{})
if status != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", status)
}
if !errors.Is(err, fberrors.ErrInvalidLoginMethod) {
t.Fatalf("err = %v, want ErrInvalidLoginMethod", err)
}
}
+3
View File
@@ -18,6 +18,7 @@ import (
libErrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-cache/cache"
@@ -139,6 +140,8 @@ func ErrToStatus(err error) int {
return http.StatusBadRequest
case errors.Is(err, libErrors.ErrIsDirectory):
return http.StatusMethodNotAllowed
case errors.Is(err, preview.ErrUnsupportedFormat):
return http.StatusUnsupportedMediaType
default:
return http.StatusInternalServerError
}
+4 -3
View File
@@ -118,12 +118,12 @@ func configureHTTPRouter(router, api, publicRoutes, publicApi *http.ServeMux) {
api.HandleFunc("PUT /settings/analytics", withTimeout(time5s, withAdminHelper(settingsAnalyticsUpdateHandler)))
api.HandleFunc("PATCH /settings/analytics", withTimeout(time5s, withAdminHelper(settingsAnalyticsUpdateHandler)))
api.HandleFunc("GET /settings/analytics/preview", withTimeout(time30s, withAdminHelper(settingsAnalyticsPreviewHandler)))
api.HandleFunc("GET /settings/user-defaults", withTimeout(time5s, withUserHelper(settingsUserDefaultsGetHandler)))
api.HandleFunc("PATCH /settings/user-defaults", withTimeout(time5s, withAdminHelper(settingsUserDefaultsPatchHandler)))
publicApi.HandleFunc("GET /settings/user-defaults", withTimeout(time5s, withUserHelper(settingsUserDefaultsGetHandler)))
api.HandleFunc("GET /settings/source", withTimeout(time5s, withUserHelper(settingsSourceGetHandler)))
api.HandleFunc("PATCH /settings/source", withTimeout(time5s, withAdminHelper(settingsSourcePatchHandler)))
api.HandleFunc("GET /settings/sources", withUser(getSourceInfoHandler))
api.HandleFunc("GET /settings/user-defaults", withTimeout(time5s, withUserHelper(settingsUserDefaultsGetHandler)))
api.HandleFunc("PATCH /settings/user-defaults", withTimeout(time5s, withAdminHelper(settingsUserDefaultsPatchHandler)))
publicApi.HandleFunc("GET /settings/user-defaults", withTimeout(time5s, withUserHelper(settingsUserDefaultsGetHandler)))
// ========================================
// Tools Routes - /api/tools/
@@ -143,6 +143,7 @@ func configureHTTPRouter(router, api, publicRoutes, publicApi *http.ServeMux) {
api.HandleFunc("GET /media/metadata", withTimeout(time60s, withUserHelper(metadataHandler)))
api.HandleFunc("GET /media/lyrics", withTimeout(time60s, withUserHelper(lyricsHandler)))
api.HandleFunc("GET /media/stream", withTimeout(time60s, withUserHelper(streamHandler)))
publicApi.HandleFunc("GET /media/subtitles", withTimeout(time60s, withHashFileHelper(publicSubtitlesHandler)))
publicApi.HandleFunc("GET /media/metadata", withTimeout(time60s, withHashFileHelper(publicMetadataHandler)))
publicApi.HandleFunc("GET /media/lyrics", withTimeout(time60s, withHashFileHelper(publicLyricsHandler)))
publicApi.HandleFunc("GET /media/stream", withTimeout(time60s, withHashFileHelper(publicStreamHandler)))
+24
View File
@@ -0,0 +1,24 @@
package web
import (
"errors"
"net/http"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
)
func TestErrToStatusUnsupportedPreviewFormat(t *testing.T) {
status := ErrToStatus(preview.ErrUnsupportedFormat)
if status != http.StatusUnsupportedMediaType {
t.Fatalf("ErrToStatus() = %d, want %d", status, http.StatusUnsupportedMediaType)
}
}
func TestErrToStatusWrapsUnsupportedPreviewFormat(t *testing.T) {
err := errors.Join(errors.New("preview failed"), preview.ErrUnsupportedFormat)
status := ErrToStatus(err)
if status != http.StatusUnsupportedMediaType {
t.Fatalf("ErrToStatus() = %d, want %d", status, http.StatusUnsupportedMediaType)
}
}
+1 -5
View File
@@ -66,11 +66,7 @@ func authenticateLDAP(username, password string) ([]string, map[string]string, e
logger.Debugf("ldap: no service account bind (userPassword empty)")
}
// Build list of attributes to fetch
groupAttr := c.GroupsClaim
if groupAttr == "" {
groupAttr = "memberOf"
}
groupAttr := settings.ResolveLdapGroupsClaim(c.GroupsClaim)
attributes := []string{"dn", groupAttr}
if c.UserIdentifier != "" {
attributes = append(attributes, c.UserIdentifier)
+63 -17
View File
@@ -13,7 +13,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
// subtitlesHandler handles subtitle requests for both external files and embedded streams
@@ -33,17 +32,18 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/media/subtitles [get]
func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
name := r.URL.Query().Get("name")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
name := r.URL.Query().Get("name")
embedded := r.URL.Query().Get("embedded") == "true"
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
if name == "" {
return http.StatusBadRequest, fmt.Errorf("name parameter is required")
}
if path == "" {
return http.StatusBadRequest, fmt.Errorf("path parameter is required")
}
filePerms, err := effectiveFilePerms(d, source)
if err != nil {
return http.StatusForbidden, err
@@ -52,18 +52,47 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
return http.StatusForbidden, fmt.Errorf("user is not allowed to view files in this source")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: path,
Source: source,
Expand: true,
Content: false,
Metadata: true,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.User.ShowHidden,
HideFileExt: d.User.HideFileExt,
SkipExtendedAttrs: false,
}, d.User)
fileUser := d.User
var fileOpts utils.FileOptions
if d.Share.Hash != "" {
source = d.Share.GetSourceName()
if source == "" {
return http.StatusNotFound, fmt.Errorf("source not found")
}
path = d.IndexPath
fileUser = d.ShareUser
fileOpts = utils.FileOptions{
Path: path,
Source: source,
Expand: true,
Content: false,
Metadata: true,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && d.Share.ExtractEmbeddedSubtitles,
ShowHidden: d.Share.ShowHidden,
HideFileExt: d.User.HideFileExt,
FollowSymlinks: false,
SkipExtendedAttrs: false,
}
} else {
if source == "" {
return http.StatusBadRequest, fmt.Errorf("source is required")
}
fileOpts = utils.FileOptions{
FollowSymlinks: true,
Path: path,
Source: source,
Expand: true,
Content: false,
Metadata: true,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.User.ShowHidden,
HideFileExt: d.User.HideFileExt,
SkipExtendedAttrs: false,
}
}
fileInfo, err := files.FileInfoFaster(fileOpts, fileUser)
if err != nil {
return ErrToStatus(err), err
}
@@ -289,3 +318,20 @@ func publicLyricsHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
}
return RenderJSON(w, r, map[string]any{"lyrics": lyrics})
}
// publicSubtitlesHandler is the share-link variant of subtitlesHandler.
// @Summary Get subtitle content (public share)
// @Tags Media
// @Produce text/plain
// @Param hash query string true "Share hash"
// @Param path query string false "Path within the share"
// @Param name query string true "Subtitle track name"
// @Param embedded query bool false "Whether this is an embedded stream"
// @Success 200 {string} string "Raw subtitle content"
// @Router /public/api/media/subtitles [get]
func publicSubtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
if d.Share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
}
return subtitlesHandler(w, r, d)
}
+10
View File
@@ -125,6 +125,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
(r.Method == "POST" && strings.Contains(r.URL.Path, "/resources/view-token")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/metadata")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/subtitles")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/download")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/view")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/stream")) {
@@ -386,6 +387,9 @@ func LoginHelper(disableOtp bool, fn handleFunc) handleFunc {
if err == errors.ErrNoTotpProvided {
return 403, err
}
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
return status, mapped
}
return 401, errors.ErrUnauthorized
}
d.User = user
@@ -503,6 +507,9 @@ func getJwtUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn
// Setup user based on JWT claims
user, err := SetupJwtUser(r, data, username, claims)
if err != nil {
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
return status, mapped
}
return http.StatusForbidden, err
}
data.User = user
@@ -534,6 +541,9 @@ func getProxyUser(w http.ResponseWriter, r *http.Request, data *requestContext,
// proxy user logic
user, err := SetupProxyUser(r, data, proxyUser)
if err != nil {
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
return status, mapped
}
return http.StatusForbidden, err
}
data.User = user
+32 -16
View File
@@ -239,9 +239,10 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
rawIDToken, ok := token.Extra("id_token").(string)
// accessToken := token.AccessToken // Access token is needed for UserInfo, already in 'token'
var userdata userInfo // Declare userdata here to be populated by either source
claimsFromIDToken := false // Flag to track if we successfully got claims from ID token
loginUsername := "" // Variable to hold the login username
var userdata userInfo // Declare userdata here to be populated by either source
var idTokenClaims map[string]any // Verified ID-token claims preserved across UserInfo fallback
claimsFromIDToken := false // Flag to track if we successfully got claims from ID token
loginUsername := "" // Variable to hold the login username
// Create custom unmarshaller for userInfo
userInfoUnmarshaller := &userInfoUnmarshaller{
@@ -267,11 +268,19 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
} else {
// Successfully verified and decoded ID token claims
logger.Debugf("ID Token verified and claims decoded: %+v", userdata)
if len(userdata.Claims) > 0 {
idTokenClaims = maps.Clone(userdata.Claims)
}
// Decide if we rely on ID token claims or still need UserInfo
// Even if parsing succeeded, if essential claims are missing, use UserInfo
if _, ok := userdata.Claims[oidcCfg.UserIdentifier]; ok {
claimsFromIDToken = true
needsGroups := oidcCfg.AdminGroup != "" || len(oidcCfg.UserGroups) > 0
if needsGroups && len(userdata.Groups) == 0 {
logger.Debugf("OIDC: ID token missing groups claim (adminGroup/userGroups configured); fetching UserInfo")
claimsFromIDToken = false
}
}
}
}
@@ -296,6 +305,9 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
logger.Errorf("failed to decode user info from endpoint: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to decode user info from endpoint: %v", err)
}
if len(idTokenClaims) > 0 {
mergeMissingOidcClaims(&userdata, idTokenClaims)
}
}
// --- Determine login username dynamically ---
@@ -314,6 +326,18 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
return loginWithOidcUser(w, r, loginUsername, userdata.Groups)
}
// mergeMissingOidcClaims copies claims from from into userdata when absent after UserInfo fallback.
func mergeMissingOidcClaims(userdata *userInfo, from map[string]any) {
if userdata.Claims == nil {
userdata.Claims = make(map[string]any)
}
for k, v := range from {
if _, exists := userdata.Claims[k]; !exists {
userdata.Claims[k] = v
}
}
}
// loginWithOidcUser extracts the username from the user claims (userInfo)
// based on the configured UserIdentifier and logs the user into the application.
// It creates a new user if one doesn't exist.
@@ -352,6 +376,9 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
user, err := getOrCreateAuthenticatedUser(username, users.LoginMethodOidc, isAdmin, groups)
if err != nil {
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
return status, mapped
}
return http.StatusUnauthorized, err
}
@@ -370,19 +397,8 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
// This allows backend to identify expired sessions and provide better user feedback
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
// Set the authentication token as an HTTP cookie
host := requestHost(r)
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: tokenString,
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
Path: "/",
SameSite: http.SameSiteLaxMode, // Lax mode allows cookie on navigation from OIDC provider
Expires: expiresTime,
// HttpOnly: true, // Cannot use HttpOnly since frontend needs to read cookie for renew operations
// Secure: true, // Enable this in production with HTTPS
}
http.SetCookie(w, cookie)
// Set the authentication token as an HTTP cookie (Lax for OIDC redirect return).
http.SetCookie(w, sessionCookie(r, tokenString, expiresTime, 0, http.SameSiteLaxMode))
// Set the authenticated user in the request context or response writer
// This allows subsequent handlers to access the current user.
+36
View File
@@ -206,6 +206,42 @@ func TestUserInfoUnmarshaller_InvalidJSON(t *testing.T) {
}
}
func TestMergeMissingOidcClaims_PreservesIDTokenIdentifier(t *testing.T) {
userdata := userInfo{
Claims: map[string]any{"groups": []any{"admins"}},
Groups: []string{"admins"},
}
idTokenClaims := map[string]any{
"preferred_username": "alice",
"sub": "user-123",
}
mergeMissingOidcClaims(&userdata, idTokenClaims)
if userdata.Claims["preferred_username"] != "alice" {
t.Fatalf("preferred_username = %v, want alice", userdata.Claims["preferred_username"])
}
if userdata.Claims["sub"] != "user-123" {
t.Fatalf("sub = %v, want user-123", userdata.Claims["sub"])
}
if _, ok := userdata.Claims["groups"]; !ok {
t.Fatal("UserInfo groups claim should be preserved")
}
}
func TestMergeMissingOidcClaims_UserInfoWinsOnConflict(t *testing.T) {
userdata := userInfo{
Claims: map[string]any{"email": "from-userinfo@example.com"},
}
idTokenClaims := map[string]any{"email": "from-idtoken@example.com"}
mergeMissingOidcClaims(&userdata, idTokenClaims)
if userdata.Claims["email"] != "from-userinfo@example.com" {
t.Fatalf("email = %v, want UserInfo value preserved", userdata.Claims["email"])
}
}
func TestOidcRedirectURLTrustProxyHeaders(t *testing.T) {
settings.Config.Http.BaseURL = "/"
settings.Config.Http.TrustProxyHeaders = true
+23 -6
View File
@@ -213,11 +213,13 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
return http.StatusForbidden, err
}
shareToken := shareTokenForOnlyOffice(d)
// Build view URL that OnlyOffice server will use to fetch the file
documentURL := buildOnlyOfficeViewURL(r, source, providedPath, d.FileInfo.Hash, viewToken, d.Token)
documentURL := buildOnlyOfficeViewURL(r, source, providedPath, d.FileInfo.Hash, viewToken, d.Token, shareToken)
// Build callback URL for OnlyOffice to notify us of changes
callbackURL := buildOnlyOfficeCallbackURL(r, source, providedPath, d.FileInfo.Hash, d.Token)
callbackURL := buildOnlyOfficeCallbackURL(r, source, providedPath, d.FileInfo.Hash, d.Token, shareToken)
// Build OnlyOffice client configuration
clientConfig := map[string]interface{}{
@@ -296,14 +298,26 @@ func joinOnlyOfficeAPIURL(baseURL, apiPath string) string {
return base + "/" + path
}
// shareTokenForOnlyOffice returns the share download token for OnlyOffice server URLs.
// Only password-protected shares have a token; callers must already have passed share auth.
func shareTokenForOnlyOffice(d *Context) string {
if d.Share.Hash == "" || d.Share.PasswordHash == "" || d.Share.Token == "" {
return ""
}
return d.Share.Token
}
// buildOnlyOfficeViewURL constructs the view URL that OnlyOffice server uses to fetch the document.
func buildOnlyOfficeViewURL(r *http.Request, source, path, hash, viewToken, authToken string) string {
func buildOnlyOfficeViewURL(r *http.Request, source, path, hash, viewToken, authToken, shareToken string) string {
baseURL := onlyOfficeFileBrowserBaseURL(r)
params := url.Values{}
params.Set("file", path)
params.Set("viewToken", viewToken)
if hash != "" {
params.Set("hash", hash)
if shareToken != "" {
params.Set("token", shareToken)
}
return joinOnlyOfficeAPIURL(baseURL, "public/api/resources/view") + "?" + params.Encode()
}
params.Set("source", source)
@@ -312,7 +326,7 @@ func buildOnlyOfficeViewURL(r *http.Request, source, path, hash, viewToken, auth
}
// buildOnlyOfficeCallbackURL constructs the callback URL that OnlyOffice server will use to notify us of changes
func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token string) string {
func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, authToken, shareToken string) string {
baseURL := onlyOfficeFileBrowserBaseURL(r)
params := url.Values{}
@@ -320,14 +334,17 @@ func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token strin
// Share callback URL - use public API and don't expose source, use path relative to share
params.Set("hash", hash)
params.Set("path", path)
params.Set("auth", token)
params.Set("auth", authToken)
if shareToken != "" {
params.Set("token", shareToken)
}
return joinOnlyOfficeAPIURL(baseURL, "public/api/office/callback") + "?" + params.Encode()
}
// Regular callback URL - include source for non-share requests
params.Set("source", source)
params.Set("path", path)
params.Set("auth", token)
params.Set("auth", authToken)
return joinOnlyOfficeAPIURL(baseURL, "api/office/callback") + "?" + params.Encode()
}
+80 -2
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
@@ -60,19 +61,96 @@ func TestBuildOnlyOfficeViewURL(t *testing.T) {
settings.Config.Http.BaseURL = "/testing/"
req := httptest.NewRequest(http.MethodGet, "/", nil)
got := buildOnlyOfficeViewURL(req, "Downloads", "/doc.docx", "", "view-token", "session.jwt.token")
got := buildOnlyOfficeViewURL(req, "Downloads", "/doc.docx", "", "view-token", "session.jwt.token", "")
want := "http://filebrowser:8080/testing/api/resources/view?auth=session.jwt.token&file=%2Fdoc.docx&source=Downloads&viewToken=view-token"
if got != want {
t.Fatalf("buildOnlyOfficeViewURL() = %q, want %q", got, want)
}
shareGot := buildOnlyOfficeViewURL(req, "Downloads", "/doc.docx", "share-hash", "view-token", "session.jwt.token")
shareGot := buildOnlyOfficeViewURL(req, "Downloads", "/doc.docx", "share-hash", "view-token", "session.jwt.token", "")
shareWant := "http://filebrowser:8080/testing/public/api/resources/view?file=%2Fdoc.docx&hash=share-hash&viewToken=view-token"
if shareGot != shareWant {
t.Fatalf("buildOnlyOfficeViewURL(share) = %q, want %q", shareGot, shareWant)
}
}
func TestBuildOnlyOfficeShareURLs(t *testing.T) {
origHTTP := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = origHTTP })
settings.Config.Http.BaseURL = "/files/"
settings.Config.Http.InternalUrl = ""
req := httptest.NewRequest(http.MethodGet, "http://example.com/files/public/api/office/config?hash=abc", nil)
t.Run("share view URL includes share token", func(t *testing.T) {
got := buildOnlyOfficeViewURL(req, "source", "/doc.docx", "shareHash", "view-token", "jwt-token", "share-download-token")
parsed, err := url.Parse(got)
if err != nil {
t.Fatalf("parse view URL: %v", err)
}
q := parsed.Query()
if q.Get("hash") != "shareHash" {
t.Errorf("hash = %q, want shareHash", q.Get("hash"))
}
if q.Get("viewToken") != "view-token" {
t.Errorf("viewToken = %q, want view-token", q.Get("viewToken"))
}
if q.Get("token") != "share-download-token" {
t.Errorf("token = %q, want share-download-token", q.Get("token"))
}
if q.Get("file") != "/doc.docx" {
t.Errorf("file = %q, want /doc.docx", q.Get("file"))
}
})
t.Run("share view URL omits token when empty", func(t *testing.T) {
got := buildOnlyOfficeViewURL(req, "source", "/doc.docx", "shareHash", "view-token", "jwt-token", "")
parsed, err := url.Parse(got)
if err != nil {
t.Fatalf("parse view URL: %v", err)
}
if parsed.Query().Get("token") != "" {
t.Errorf("expected no token param, got %q", parsed.Query().Get("token"))
}
})
t.Run("non-share view URL unchanged", func(t *testing.T) {
got := buildOnlyOfficeViewURL(req, "my-source", "/doc.docx", "", "view-token", "jwt-token", "share-download-token")
if strings.Contains(got, "token=") {
t.Errorf("non-share URL should not include share token: %q", got)
}
if !strings.Contains(got, "source=my-source") {
t.Errorf("expected source in URL, got %q", got)
}
})
t.Run("share callback URL includes share token", func(t *testing.T) {
got := buildOnlyOfficeCallbackURL(req, "source", "/doc.docx", "shareHash", "jwt-token", "share-download-token")
parsed, err := url.Parse(got)
if err != nil {
t.Fatalf("parse callback URL: %v", err)
}
q := parsed.Query()
if q.Get("token") != "share-download-token" {
t.Errorf("token = %q, want share-download-token", q.Get("token"))
}
if q.Get("auth") != "jwt-token" {
t.Errorf("auth = %q, want jwt-token", q.Get("auth"))
}
})
t.Run("share callback URL omits token when empty", func(t *testing.T) {
got := buildOnlyOfficeCallbackURL(req, "source", "/doc.docx", "shareHash", "jwt-token", "")
parsed, err := url.Parse(got)
if err != nil {
t.Fatalf("parse callback URL: %v", err)
}
if parsed.Query().Get("token") != "" {
t.Errorf("expected no token param, got %q", parsed.Query().Get("token"))
}
})
}
func TestJoinOnlyOfficeAPIURL(t *testing.T) {
tests := []struct {
base string
+6
View File
@@ -803,6 +803,7 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
if acquireErr := activeUploadSessions.acquire(realPath, sessionID); acquireErr != nil {
if isUploadSessionConflict(acquireErr) {
logger.Debugf("%v", acquireErr)
drainRequestBody(r)
return http.StatusConflict, acquireErr
}
logger.Debugf("%v", acquireErr)
@@ -819,6 +820,7 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
// If type mismatch (existing dir vs requesting file) and not overriding
if existingIsDir != requestingDir && r.URL.Query().Get("override") != "true" {
logger.Debugf("Type conflict detected in chunked: existing is dir=%v, requesting dir=%v at path=%v", existingIsDir, requestingDir, realPath)
drainRequestBody(r)
return http.StatusConflict, nil
}
}
@@ -828,6 +830,7 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
drainRequestBody(r)
return http.StatusConflict, nil
}
// If overriding, delete existing thumbnails
@@ -935,6 +938,9 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
activity.RecordUpload(r, toActor(d), source, path, false)
activeUploadSessions.release(realPath, sessionID)
}
if err = writeChunkUploadResponse(w, assembled, totalSize, assembled == totalSize); err != nil {
logger.Debugf("could not write chunk upload response: %v", err)
}
return http.StatusOK, nil
}
@@ -0,0 +1,34 @@
package web
import (
"net/http"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
)
func TestCheckPermissionsMissingParamsStatus(t *testing.T) {
t.Parallel()
tests := []struct {
name string
opts utils.FileOptions
}{
{name: "missing path", opts: utils.FileOptions{Source: "default"}},
{name: "missing source", opts: utils.FileOptions{Path: "/"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, err := files.CheckPermissions(tt.opts, &users.User{})
if err == nil {
t.Fatal("expected an error")
}
if status := ErrToStatus(err); status != http.StatusBadRequest {
t.Fatalf("status=%d err=%v, want %d", status, err, http.StatusBadRequest)
}
})
}
}
@@ -2,6 +2,7 @@ package web
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
@@ -560,3 +561,51 @@ func TestResourcePostHandler_ExpiredSessionAllowsNewUpload(t *testing.T) {
t.Fatalf("got %q want %q", got, body)
}
}
func TestResourcePostHandler_PartialChunkResponse(t *testing.T) {
_, user := setupUploadHTTPTest(t)
part1 := []byte("aaaa")
total := 8
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/resources?source=uploads&path=/partial.bin", bytes.NewReader(part1))
req.ContentLength = int64(len(part1))
req.Header.Set("X-File-Upload-Session", "session-partial")
req.Header.Set("X-File-Chunk-Offset", "0")
req.Header.Set("X-File-Total-Size", strconv.Itoa(total))
status, err := ResourcePostHandler(rec, req, &requestContext{User: user})
if status != http.StatusOK || err != nil {
t.Fatalf("chunk1 status=%d err=%v", status, err)
}
if rec.Header().Get("Connection") != "close" {
t.Fatalf("expected Connection: close, got %q", rec.Header().Get("Connection"))
}
var payload chunkUploadResponse
if decodeErr := json.NewDecoder(rec.Body).Decode(&payload); decodeErr != nil {
t.Fatalf("decode response: %v", decodeErr)
}
if payload.Complete || payload.Offset != int64(len(part1)) || payload.Total != int64(total) {
t.Fatalf("unexpected payload: %+v", payload)
}
part2 := []byte("bbbb")
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/api/resources?source=uploads&path=/partial.bin", bytes.NewReader(part2))
req2.ContentLength = int64(len(part2))
req2.Header.Set("X-File-Upload-Session", "session-partial")
req2.Header.Set("X-File-Chunk-Offset", strconv.Itoa(len(part1)))
req2.Header.Set("X-File-Total-Size", strconv.Itoa(total))
status, err = ResourcePostHandler(rec2, req2, &requestContext{User: user})
if status != http.StatusOK || err != nil {
t.Fatalf("chunk2 status=%d err=%v", status, err)
}
var finalPayload chunkUploadResponse
if decodeErr := json.NewDecoder(rec2.Body).Decode(&finalPayload); decodeErr != nil {
t.Fatalf("decode final response: %v", decodeErr)
}
if !finalPayload.Complete || finalPayload.Offset != int64(total) {
t.Fatalf("unexpected final payload: %+v", finalPayload)
}
}
@@ -0,0 +1,63 @@
package web
import (
"net/http"
"strings"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestSessionCookie_HttpOnlyAndSecureOnHTTPS(t *testing.T) {
prev := settings.Config.Http.TrustProxyHeaders
settings.Config.Http.TrustProxyHeaders = true
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = prev })
req, err := http.NewRequest(http.MethodGet, "https://example.com/", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Forwarded-Proto", "https")
req.Host = "example.com"
c := sessionCookie(req, "token", time.Now().Add(time.Hour), 0, http.SameSiteStrictMode)
if !c.HttpOnly {
t.Fatal("expected HttpOnly session cookie")
}
if !c.Secure {
t.Fatal("expected Secure session cookie over HTTPS")
}
if c.Name != sessionCookieName {
t.Fatalf("cookie name: got %q", c.Name)
}
}
func TestSessionCookie_NotSecureOnHTTP(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "http://localhost/", nil)
if err != nil {
t.Fatal(err)
}
req.Host = "localhost"
c := sessionCookie(req, "token", time.Now().Add(time.Hour), 0, http.SameSiteStrictMode)
if !c.HttpOnly {
t.Fatal("expected HttpOnly session cookie")
}
if c.Secure {
t.Fatal("did not expect Secure cookie on plain HTTP")
}
}
func TestSpaContentSecurityPolicy_BlocksUntrustedScripts(t *testing.T) {
csp := spaContentSecurityPolicy("testnonce")
if csp != "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com" {
t.Fatalf("unexpected CSP: %s", csp)
}
if strings.Contains(csp, "'unsafe-inline'") {
t.Fatal("script-src must not allow unsafe-inline (srcdoc XSS)")
}
if strings.Contains(csp, "default-src") {
t.Fatal("CSP should only restrict script-src to avoid breaking frames, blobs, and external assets")
}
}
+33 -2
View File
@@ -2,6 +2,7 @@ package web
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"io/fs"
@@ -42,7 +43,13 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
return fmt.Errorf("error reloading template: %w", err)
}
}
// Set headers
nonce, err := utils.CSPNonceFromData(data)
if err != nil {
return err
}
// Inherited by srcdoc preview frames: nonce'd SPA scripts run; untrusted preview scripts do not.
w.Header().Set("Content-Security-Policy", spaContentSecurityPolicy(nonce))
w.Header().Set("Cache-Control", "no-cache, private, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("X-Accel-Expires", "0")
@@ -51,6 +58,15 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
return templates.ExecuteTemplate(w, name, data)
}
// spaContentSecurityPolicy restricts only scripts. srcdoc preview frames inherit
// this header, blocking inline scripts without limiting frames, images, or API calls.
func spaContentSecurityPolicy(nonce string) string {
return fmt.Sprintf(
"script-src 'self' 'nonce-%s' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com",
nonce,
)
}
func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestContext, file, contentType string) (int, error) {
w.Header().Set("Content-Type", contentType)
userSelectedTheme := ""
@@ -80,7 +96,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
externalLinks = newExternalLinks
}
defaultThemeColor := "#455a64"
defaultThemeColor := settings.DefaultBackgroundColor()
staticURL := settings.Config.Http.BaseURL + "public/static"
description := settings.Config.Frontend.Description
title := settings.Config.Frontend.Name
@@ -195,6 +211,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"minSearchLength": settings.Config.Server.MinSearchLength,
"disableExternal": settings.Config.Frontend.DisableDefaultLinks,
"darkMode": settings.Config.UserDefaults.UI.DarkMode,
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
"baseURL": settings.Config.Http.BaseURL,
"version": versionString,
"commitSHA": commitSHAString,
@@ -223,8 +241,20 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"loginButtonText": settings.Config.Frontend.LoginButtonText,
"passkeyAvailable": settings.Config.Auth.Methods.PasskeyAuth.Enabled,
"passkeyLoginButtonText": settings.Config.Auth.Methods.PasskeyAuth.LoginButtonText,
"disablePWAInstall": settings.Config.Frontend.DisablePWAInstall,
}
cspNonce, err := utils.CSPNonce()
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("csp nonce: %w", err)
}
globalVars, ok := data["globalVars"].(map[string]interface{})
if !ok {
return http.StatusInternalServerError, errors.New("unable to load global variables")
}
globalVars["cspNonce"] = cspNonce
data["cspNonce"] = cspNonce
// Marshal each variable to JSON strings for direct template usage
globalVarsJSON, err := json.Marshal(data["globalVars"])
if err != nil {
@@ -324,6 +354,7 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
assetPath = "img/icons/favicon.png"
case "icons/favicon-32x32.png",
"icons/pwa-icon-192.png", "icons/pwa-icon-256.png", "icons/pwa-icon-512.png",
"icons/pwa-icon-maskable-192.png", "icons/pwa-icon-maskable-512.png",
"icons/apple-touch-icon.png", "icons/mstile-256x256.png":
// Files are generated as PWAIconsCacheDir()/basename (URL uses icons/ prefix only for routing)
rel := strings.TrimPrefix(path, "icons/")
+26
View File
@@ -3,7 +3,9 @@ package web
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"strconv"
@@ -96,3 +98,27 @@ func isContentUpload(r *http.Request) bool {
_, ok, err := parseUploadTotalSize(r)
return err == nil && ok
}
func drainRequestBody(r *http.Request) {
if r.Body == nil {
return
}
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()
}
type chunkUploadResponse struct {
Offset int64 `json:"offset"`
Total int64 `json:"total"`
Complete bool `json:"complete"`
}
func writeChunkUploadResponse(w http.ResponseWriter, offset, total int64, complete bool) error {
w.Header().Set("Connection", "close")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
return json.NewEncoder(w).Encode(chunkUploadResponse{
Offset: offset,
Total: total,
Complete: complete,
})
}
+28 -4
View File
@@ -4,8 +4,6 @@ import (
"encoding/json"
stderrors "errors"
"fmt"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"io"
"net/http"
"net/url"
@@ -13,15 +11,17 @@ import (
"sort"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
type UserRequest struct {
@@ -273,6 +273,25 @@ func validatePatchWhich(which []string) error {
return nil
}
// validateNonAdminSelfPatchWhich rejects self-service PATCH attempts that touch privileged fields.
// Non-admins may only update NonAdminEditable profile fields, plus password (with X-Password) and otpEnabled.
func validateNonAdminSelfPatchWhich(which []string) error {
allowed := nonAdminEditableFieldNameSet()
for _, w := range which {
f := utils.CapitalizeFirst(strings.TrimSpace(w))
if f == "" {
return fmt.Errorf("which must not contain empty field names")
}
if strings.EqualFold(f, "Password") || strings.EqualFold(f, "OtpEnabled") {
continue
}
if _, ok := allowed[f]; !ok {
return fmt.Errorf("cannot update privileged user fields without admin permissions")
}
}
return nil
}
// userPutOnlyNonAdminEditableFields reports whether req.Which lists exclusively NonAdminEditable fields,
// excluding Password.
func userPutOnlyNonAdminEditableFields(which []string) bool {
@@ -373,6 +392,11 @@ func userPatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
if !d.User.Permissions.Admin && targetUsername != d.User.Username {
return http.StatusForbidden, nil
}
if !d.User.Permissions.Admin && targetUsername == d.User.Username {
if err = validateNonAdminSelfPatchWhich(req.Which); err != nil {
return http.StatusForbidden, err
}
}
uValue, err2 := state.GetUserByUsername(targetUsername)
if err2 == errors.ErrNotExist {
+48
View File
@@ -53,3 +53,51 @@ func TestValidatePatchWhich(t *testing.T) {
}
})
}
func TestValidateNonAdminSelfPatchWhich(t *testing.T) {
t.Run("allows profile fields", func(t *testing.T) {
for _, which := range [][]string{
{"locale"},
{"darkMode", "viewMode"},
{"preview"},
{"sorting"},
} {
if err := validateNonAdminSelfPatchWhich(which); err != nil {
t.Fatalf("unexpected error for which=%v: %v", which, err)
}
}
})
t.Run("allows password and otpEnabled", func(t *testing.T) {
for _, which := range [][]string{
{"password"},
{"otpEnabled"},
{"locale", "password"},
} {
if err := validateNonAdminSelfPatchWhich(which); err != nil {
t.Fatalf("unexpected error for which=%v: %v", which, err)
}
}
})
t.Run("rejects privileged fields", func(t *testing.T) {
for _, which := range [][]string{
{"scopes"},
{"permissions"},
{"sourcePermissions"},
{"loginMethod"},
{"lockPassword"},
{"disableSettings"},
} {
if err := validateNonAdminSelfPatchWhich(which); err == nil {
t.Fatalf("expected error for which=%v", which)
}
}
})
t.Run("rejects mixed privileged and profile fields", func(t *testing.T) {
if err := validateNonAdminSelfPatchWhich([]string{"locale", "scopes"}); err == nil {
t.Fatal("expected error when which mixes profile and privileged fields")
}
})
}
+6 -6
View File
@@ -149,6 +149,10 @@ func ValidateLdapAuth() error {
if ldapCfg.UserFilter == "" {
ldapCfg.UserFilter = "(&(cn=%s)(objectClass=user))"
}
if ldapCfg.GroupsClaim == "" {
ldapCfg.GroupsClaim = defaultLdapGroupsClaim
}
applyAuthCommonDefaults(&ldapCfg.AuthCommon)
if err := verifyLdapConnection(); err != nil {
logger.Fatalf("LDAP connection check failed: %v", err)
}
@@ -158,9 +162,7 @@ func ValidateLdapAuth() error {
// ValidateOidcAuth processes the OIDC callback and retrieves user identity
func validateOidcAuth() error {
oidcCfg := &Config.Auth.Methods.OidcAuth // Use a pointer to modify the original config
if oidcCfg.GroupsClaim == "" {
oidcCfg.GroupsClaim = "groups"
}
applyAuthCommonDefaults(&oidcCfg.AuthCommon)
if oidcCfg.UserIdentifier == "" {
oidcCfg.UserIdentifier = "preferred_username"
}
@@ -289,9 +291,7 @@ func ValidateJwtAuth() error {
if jwtCfg.Algorithm == "" {
jwtCfg.Algorithm = "HS256"
}
if jwtCfg.GroupsClaim == "" {
jwtCfg.GroupsClaim = "groups"
}
applyAuthCommonDefaults(&jwtCfg.AuthCommon)
if jwtCfg.UserIdentifier == "" {
jwtCfg.UserIdentifier = "sub"
}
+27
View File
@@ -0,0 +1,27 @@
package settings
const (
defaultGroupsClaim = "groups"
defaultLdapGroupsClaim = "memberOf"
)
// ResolveGroupsClaim returns the configured groups claim or the shared default when empty.
func ResolveGroupsClaim(claim string) string {
if claim == "" {
return defaultGroupsClaim
}
return claim
}
// ResolveLdapGroupsClaim returns the configured LDAP groups attribute or memberOf when empty.
func ResolveLdapGroupsClaim(claim string) string {
if claim == "" {
return defaultLdapGroupsClaim
}
return claim
}
// applyAuthCommonDefaults sets shared defaults for external auth methods that support group claims.
func applyAuthCommonDefaults(c *AuthCommon) {
c.GroupsClaim = ResolveGroupsClaim(c.GroupsClaim)
}
+46
View File
@@ -0,0 +1,46 @@
package settings
import "testing"
func TestApplyAuthCommonDefaults(t *testing.T) {
empty := AuthCommon{}
applyAuthCommonDefaults(&empty)
if empty.GroupsClaim != "groups" {
t.Fatalf("default groupsClaim = %q, want groups", empty.GroupsClaim)
}
custom := AuthCommon{GroupsClaim: "memberOf"}
applyAuthCommonDefaults(&custom)
if custom.GroupsClaim != "memberOf" {
t.Fatalf("custom groupsClaim = %q, want memberOf", custom.GroupsClaim)
}
}
func TestResolveGroupsClaim(t *testing.T) {
if got := ResolveGroupsClaim(""); got != "groups" {
t.Fatalf("ResolveGroupsClaim(\"\") = %q, want groups", got)
}
if got := ResolveGroupsClaim("memberOf"); got != "memberOf" {
t.Fatalf("ResolveGroupsClaim(memberOf) = %q, want memberOf", got)
}
}
func TestResolveLdapGroupsClaim(t *testing.T) {
if got := ResolveLdapGroupsClaim(""); got != "memberOf" {
t.Fatalf("ResolveLdapGroupsClaim(\"\") = %q, want memberOf", got)
}
if got := ResolveLdapGroupsClaim("groups"); got != "groups" {
t.Fatalf("ResolveLdapGroupsClaim(groups) = %q, want groups", got)
}
}
func TestApplyLdapAuthCommonDefaultsUsesMemberOf(t *testing.T) {
ldapCfg := LdapConfig{AuthCommon: AuthCommon{}}
if ldapCfg.GroupsClaim == "" {
ldapCfg.GroupsClaim = defaultLdapGroupsClaim
}
applyAuthCommonDefaults(&ldapCfg.AuthCommon)
if ldapCfg.GroupsClaim != "memberOf" {
t.Fatalf("ldap groupsClaim = %q, want memberOf", ldapCfg.GroupsClaim)
}
}
+14
View File
@@ -31,6 +31,16 @@ const (
)
func Initialize(configFile string) {
initializeConfig(configFile, false)
}
// InitializeCLI loads config for CLI subcommands without server-only setup (ffmpeg, frontend, etc.).
func InitializeCLI(configFile string) {
initializeConfig(configFile, true)
}
func initializeConfig(configFile string, cliMode bool) {
Env.IsCLIMode = cliMode
err := loadConfigWithDefaults(configFile, false)
if err != nil {
logger.Error("unable to load config, waiting 5 seconds before exiting...")
@@ -57,6 +67,9 @@ func Initialize(configFile string) {
setupSources(false)
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
setupUrls()
if cliMode {
return
}
warnHttpProxyConfig()
setupFrontend(false)
setupMedia(false)
@@ -515,6 +528,7 @@ func setupAuth(generate bool) {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "password")
}
if Config.Auth.Methods.ProxyAuth.Enabled {
applyAuthCommonDefaults(&Config.Auth.Methods.ProxyAuth.AuthCommon)
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "proxy")
}
if Config.Auth.Methods.NoAuth {
+2
View File
@@ -38,6 +38,7 @@ type Http struct {
}
type Environment struct {
IsCLIMode bool `json:"-"` // true when running a CLI subcommand (user set, promote, etc.)
IsPlaywright bool `json:"-"`
IsDevMode bool `json:"-"`
IsFirstLoad bool `json:"-"` // used internally to track if this is the first load of the application
@@ -300,6 +301,7 @@ type Frontend struct {
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
LoginButtonText string `json:"loginButtonText"` // text to display on the login button
OIDCLoginButtonText string `json:"oidcLoginButtonText"` // text to display on the OIDC login button
DisablePWAInstall bool `json:"disablePWAInstall"` // disable the PWA install button in the sidebar
}
type StylingConfig struct {
+8
View File
@@ -118,3 +118,11 @@ func readCustomCSS(path string) (string, error) {
logger.Debugf("Loaded custom CSS from: %s (%d bytes)", path, len(content))
return string(content), nil
}
// DefaultBackgroundColor returns the background color of the instance's default theme.
func DefaultBackgroundColor() string {
if Config.UserDefaults.UI.DarkMode != nil && *Config.UserDefaults.UI.DarkMode {
return Config.Frontend.Styling.DarkBackground
}
return Config.Frontend.Styling.LightBackground
}
+51
View File
@@ -4003,6 +4003,53 @@ const docTemplate = `{
}
}
},
"/public/api/media/subtitles": {
"get": {
"produces": [
"text/plain"
],
"tags": [
"Media"
],
"summary": "Get subtitle content (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share",
"name": "path",
"in": "query"
},
{
"type": "string",
"description": "Subtitle track name",
"name": "name",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Whether this is an embedded stream",
"name": "embedded",
"in": "query"
}
],
"responses": {
"200": {
"description": "Raw subtitle content",
"schema": {
"type": "string"
}
}
}
}
},
"/public/api/resources": {
"get": {
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
@@ -5628,6 +5675,10 @@ const docTemplate = `{
"description": "disable the nav buttons in the sidebar",
"type": "boolean"
},
"disablePWAInstall": {
"description": "disable the PWA install button in the sidebar",
"type": "boolean"
},
"disableUsedPercentage": {
"description": "disable used percentage for the sources in the sidebar",
"type": "boolean"
+51
View File
@@ -3992,6 +3992,53 @@
}
}
},
"/public/api/media/subtitles": {
"get": {
"produces": [
"text/plain"
],
"tags": [
"Media"
],
"summary": "Get subtitle content (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share",
"name": "path",
"in": "query"
},
{
"type": "string",
"description": "Subtitle track name",
"name": "name",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Whether this is an embedded stream",
"name": "embedded",
"in": "query"
}
],
"responses": {
"200": {
"description": "Raw subtitle content",
"schema": {
"type": "string"
}
}
}
}
},
"/public/api/resources": {
"get": {
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
@@ -5617,6 +5664,10 @@
"description": "disable the nav buttons in the sidebar",
"type": "boolean"
},
"disablePWAInstall": {
"description": "disable the PWA install button in the sidebar",
"type": "boolean"
},
"disableUsedPercentage": {
"description": "disable used percentage for the sources in the sidebar",
"type": "boolean"
+34
View File
@@ -413,6 +413,9 @@ definitions:
disableNavButtons:
description: disable the nav buttons in the sidebar
type: boolean
disablePWAInstall:
description: disable the PWA install button in the sidebar
type: boolean
disableUsedPercentage:
description: disable used percentage for the sources in the sidebar
type: boolean
@@ -5074,6 +5077,37 @@ paths:
summary: Stream a single media file from a public share for inline viewing
tags:
- Resources
/public/api/media/subtitles:
get:
parameters:
- description: Share hash
in: query
name: hash
required: true
type: string
- description: Path within the share
in: query
name: path
type: string
- description: Subtitle track name
in: query
name: name
required: true
type: string
- description: Whether this is an embedded stream
in: query
name: embedded
type: boolean
produces:
- text/plain
responses:
"200":
description: Raw subtitle content
schema:
type: string
summary: Get subtitle content (public share)
tags:
- Media
/public/api/resources:
get:
consumes:
+166 -166
View File
@@ -946,9 +946,9 @@
}
},
"node_modules/@intlify/bundle-utils": {
"version": "11.2.4",
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-11.2.4.tgz",
"integrity": "sha512-eE18yR9eM9k5n8snCkHIYp2MuVTxa19aF8z9OMyxXWv0frz2HlBZDGIPFjA38pP3OJ1IlRBXC/dW5GILeLMSCQ==",
"version": "11.2.5",
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-11.2.5.tgz",
"integrity": "sha512-gl7CGygRALtuT4H8n/PNC0hubg4rnctPoT04//GZNsTemvJw0Q7i6jSaq0PZHxnH0cdGqyowNXmW4FYILbahzg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1155,14 +1155,14 @@
}
},
"node_modules/@intlify/unplugin-vue-i18n": {
"version": "11.2.4",
"resolved": "https://registry.npmjs.org/@intlify/unplugin-vue-i18n/-/unplugin-vue-i18n-11.2.4.tgz",
"integrity": "sha512-bY0ZOaVUvWTyvy4bRGCUKw4Brx5uH/ojjVKsZ1aWzY2drFKIJbeP8DpGMD2QZT8aLpZSUsHtiJlRGLQSZenrvw==",
"version": "11.2.5",
"resolved": "https://registry.npmjs.org/@intlify/unplugin-vue-i18n/-/unplugin-vue-i18n-11.2.5.tgz",
"integrity": "sha512-wLpS0cZTNAzaNL/STjjwSFhLunuoARKzEJh7jM/G00A40cVH+GEPhdo/WsvnesPvQVoN6JfjI/mGXwDNnTbvhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@intlify/bundle-utils": "11.2.4",
"@intlify/bundle-utils": "11.2.5",
"@intlify/shared": "~11.4.2",
"@intlify/vue-i18n-extensions": "^8.0.0",
"@rollup/pluginutils": "^5.1.0",
@@ -1505,9 +1505,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
"integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz",
"integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==",
"cpu": [
"arm"
],
@@ -1519,9 +1519,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
"integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz",
"integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==",
"cpu": [
"arm64"
],
@@ -1533,9 +1533,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
"integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz",
"integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==",
"cpu": [
"arm64"
],
@@ -1547,9 +1547,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
"integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz",
"integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==",
"cpu": [
"x64"
],
@@ -1561,9 +1561,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
"integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz",
"integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==",
"cpu": [
"arm64"
],
@@ -1575,9 +1575,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
"integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz",
"integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==",
"cpu": [
"x64"
],
@@ -1589,9 +1589,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
"integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz",
"integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==",
"cpu": [
"arm"
],
@@ -1603,9 +1603,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
"integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz",
"integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==",
"cpu": [
"arm"
],
@@ -1617,9 +1617,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
"integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz",
"integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==",
"cpu": [
"arm64"
],
@@ -1631,9 +1631,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
"integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz",
"integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==",
"cpu": [
"arm64"
],
@@ -1645,9 +1645,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
"integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz",
"integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==",
"cpu": [
"loong64"
],
@@ -1659,9 +1659,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
"integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz",
"integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==",
"cpu": [
"loong64"
],
@@ -1673,9 +1673,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
"integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz",
"integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==",
"cpu": [
"ppc64"
],
@@ -1687,9 +1687,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
"integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz",
"integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==",
"cpu": [
"ppc64"
],
@@ -1701,9 +1701,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
"integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz",
"integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==",
"cpu": [
"riscv64"
],
@@ -1715,9 +1715,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
"integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz",
"integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==",
"cpu": [
"riscv64"
],
@@ -1729,9 +1729,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
"integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz",
"integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==",
"cpu": [
"s390x"
],
@@ -1743,9 +1743,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
"integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz",
"integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==",
"cpu": [
"x64"
],
@@ -1757,9 +1757,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
"integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz",
"integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==",
"cpu": [
"x64"
],
@@ -1771,9 +1771,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
"integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz",
"integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==",
"cpu": [
"x64"
],
@@ -1785,9 +1785,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
"integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz",
"integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==",
"cpu": [
"arm64"
],
@@ -1799,9 +1799,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
"integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz",
"integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==",
"cpu": [
"arm64"
],
@@ -1813,9 +1813,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
"integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz",
"integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==",
"cpu": [
"ia32"
],
@@ -1827,9 +1827,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
"integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz",
"integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==",
"cpu": [
"x64"
],
@@ -1841,9 +1841,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
"integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz",
"integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==",
"cpu": [
"x64"
],
@@ -2196,16 +2196,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -2214,13 +2214,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -2251,9 +2251,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2264,13 +2264,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
@@ -2278,14 +2278,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -2294,9 +2294,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2304,13 +2304,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -2488,9 +2488,9 @@
"license": "MIT"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.14",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.14.tgz",
"integrity": "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==",
"version": "0.8.15",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
"integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -3011,9 +3011,9 @@
"license": "BSD-2-Clause"
},
"node_modules/dompurify": {
"version": "3.4.13",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
"version": "3.4.14",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz",
"integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -3104,9 +3104,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
@@ -3255,9 +3255,9 @@
}
},
"node_modules/eslint": {
"version": "10.8.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz",
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.0.tgz",
"integrity": "sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -5176,9 +5176,9 @@
}
},
"node_modules/rollup": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
"integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
"version": "4.62.5",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz",
"integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5193,31 +5193,31 @@
},
"optionalDependencies": {
"@napi-rs/lzma-linux-x64-gnu": "1.5.1",
"@rollup/rollup-android-arm-eabi": "4.62.4",
"@rollup/rollup-android-arm64": "4.62.4",
"@rollup/rollup-darwin-arm64": "4.62.4",
"@rollup/rollup-darwin-x64": "4.62.4",
"@rollup/rollup-freebsd-arm64": "4.62.4",
"@rollup/rollup-freebsd-x64": "4.62.4",
"@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
"@rollup/rollup-linux-arm-musleabihf": "4.62.4",
"@rollup/rollup-linux-arm64-gnu": "4.62.4",
"@rollup/rollup-linux-arm64-musl": "4.62.4",
"@rollup/rollup-linux-loong64-gnu": "4.62.4",
"@rollup/rollup-linux-loong64-musl": "4.62.4",
"@rollup/rollup-linux-ppc64-gnu": "4.62.4",
"@rollup/rollup-linux-ppc64-musl": "4.62.4",
"@rollup/rollup-linux-riscv64-gnu": "4.62.4",
"@rollup/rollup-linux-riscv64-musl": "4.62.4",
"@rollup/rollup-linux-s390x-gnu": "4.62.4",
"@rollup/rollup-linux-x64-gnu": "4.62.4",
"@rollup/rollup-linux-x64-musl": "4.62.4",
"@rollup/rollup-openbsd-x64": "4.62.4",
"@rollup/rollup-openharmony-arm64": "4.62.4",
"@rollup/rollup-win32-arm64-msvc": "4.62.4",
"@rollup/rollup-win32-ia32-msvc": "4.62.4",
"@rollup/rollup-win32-x64-gnu": "4.62.4",
"@rollup/rollup-win32-x64-msvc": "4.62.4",
"@rollup/rollup-android-arm-eabi": "4.62.5",
"@rollup/rollup-android-arm64": "4.62.5",
"@rollup/rollup-darwin-arm64": "4.62.5",
"@rollup/rollup-darwin-x64": "4.62.5",
"@rollup/rollup-freebsd-arm64": "4.62.5",
"@rollup/rollup-freebsd-x64": "4.62.5",
"@rollup/rollup-linux-arm-gnueabihf": "4.62.5",
"@rollup/rollup-linux-arm-musleabihf": "4.62.5",
"@rollup/rollup-linux-arm64-gnu": "4.62.5",
"@rollup/rollup-linux-arm64-musl": "4.62.5",
"@rollup/rollup-linux-loong64-gnu": "4.62.5",
"@rollup/rollup-linux-loong64-musl": "4.62.5",
"@rollup/rollup-linux-ppc64-gnu": "4.62.5",
"@rollup/rollup-linux-ppc64-musl": "4.62.5",
"@rollup/rollup-linux-riscv64-gnu": "4.62.5",
"@rollup/rollup-linux-riscv64-musl": "4.62.5",
"@rollup/rollup-linux-s390x-gnu": "4.62.5",
"@rollup/rollup-linux-x64-gnu": "4.62.5",
"@rollup/rollup-linux-x64-musl": "4.62.5",
"@rollup/rollup-openbsd-x64": "4.62.5",
"@rollup/rollup-openharmony-arm64": "4.62.5",
"@rollup/rollup-win32-arm64-msvc": "4.62.5",
"@rollup/rollup-win32-ia32-msvc": "4.62.5",
"@rollup/rollup-win32-x64-gnu": "4.62.5",
"@rollup/rollup-win32-x64-msvc": "4.62.5",
"fsevents": "~2.3.2"
}
},
@@ -5992,19 +5992,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -6032,12 +6032,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+1
View File
@@ -164,6 +164,7 @@ frontend:
loginIcon: "" # path to an image file for the login page icon
loginButtonText: "" # text to display on the login button
oidcLoginButtonText: "" # text to display on the OIDC login button
disablePWAInstall: false # disable the PWA install button in the sidebar
userDefaults: # optional signup/CLI defaults; per-user values are managed in the UI
sidebar:
disableQuickToggles: false # disable the quick toggles in the sidebar
+11 -3
View File
@@ -4,7 +4,7 @@
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
<title>"{{ .htmlVars.title }}"</title>
<link rel="icon" type="image/png" sizes="256x256" href="{{ .htmlVars.favicon }}">
@@ -21,7 +21,7 @@
<!-- Add to home screen for Safari on iOS/iPadOS -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="assets">
<link rel="apple-touch-icon" sizes="180x180" href="{{ .htmlVars.appIcon }}">
@@ -50,7 +50,7 @@
<link rel="canonical" href="https://filebrowserquantum.com">
<!-- Injected variables come from backend server -->
<script>
<script nonce="{{ .cspNonce }}">
window.globalVars = {{ .globalVars }};
window.__pwaDeferredPrompt = null;
window.addEventListener("beforeinstallprompt", function (event) {
@@ -72,6 +72,14 @@
--iconBackground: #dddddd;
--activeWhiteIcon: gray;
}
/* paint safe areas + iOS overscroll with app background (notched PWAs) */
html {
background-color: {{ .htmlVars.lightBackground }};
min-height: calc(100% + env(safe-area-inset-top, 0px));
}
html:has(.dark-mode) {
background-color: {{ .htmlVars.darkBackground }};
}
.dark-mode {
--background: {{ .htmlVars.darkBackground }};
--alt-background: #283136;
+26 -7
View File
@@ -4,16 +4,35 @@ import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { adjustedData, fetchURL } from "./utils";
import { getCachedDirMetadata, setCachedDirMetadata } from "@/utils/metadataCache.js";
// GET /api/media/subtitles
// GET /api/media/subtitles or /public/api/media/subtitles
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
try {
const apiPath = getApiPath('media/subtitles', {
source: source,
path: path,
const hash = state.shareInfo?.hash || null
const baseParams = {
path,
name: subtitleName,
embedded: embedded.toString()
})
const res = await fetchURL(apiPath)
...(embedded && { embedded: 'true' }),
}
let res
if (hash) {
const apiPath = getPublicApiPath('media/subtitles', {
hash,
...baseParams,
...(state.shareInfo.token && { token: state.shareInfo.token }),
})
const sharePassword = localStorage.getItem(`sharepass:${hash}`) || ''
res = await fetchURL(apiPath, {
headers: { 'X-SHARE-PASSWORD': sharePassword },
})
} else {
const apiPath = getApiPath('media/subtitles', {
source,
...baseParams,
})
res = await fetchURL(apiPath)
}
const content = await res.text()
return content
} catch (err) {
+12 -2
View File
@@ -1,4 +1,5 @@
import { notify } from '@/notify'
import { state } from '@/store'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { fetchURL } from './utils'
@@ -8,7 +9,16 @@ export async function getConfig(req) {
const params = {
path: req.path,
...(req.hash && { hash: req.hash }),
...(req.source && { source: req.source })
...(req.source && { source: req.source }),
...(req.hash && state.shareInfo?.token && { token: state.shareInfo.token }),
}
const headers = {}
if (req.hash) {
const sharePassword = localStorage.getItem(`sharepass:${req.hash}`)
if (sharePassword) {
headers['X-SHARE-PASSWORD'] = sharePassword
}
}
let apiPath
@@ -18,7 +28,7 @@ export async function getConfig(req) {
apiPath = getApiPath('office/config', params)
}
const res = await fetchURL(apiPath)
const res = await fetchURL(apiPath, { headers })
return await res.json()
} catch (err) {
notify.showError(err.message || 'Error fetching OnlyOffice configuration')
+17 -2
View File
@@ -19,6 +19,14 @@ export { fetchPreviewImage } from '@/utils/previewRequests'
const VIEW_TOKEN_TTL_SECONDS = 15 * 60;
function chunkUploadApiPath(apiPath, headers) {
if (headers["X-File-Chunk-Offset"] === undefined) {
return apiPath;
}
const sep = apiPath.includes("?") ? "&" : "?";
return `${apiPath}${sep}_chunkSeq=${headers["X-File-Chunk-Offset"]}`;
}
function cacheViewTokenFromListing(data) {
const scope = getters.isShare()
? state.shareInfo?.hash || data?.source
@@ -718,11 +726,15 @@ export function post(
}
const request = new XMLHttpRequest();
opState.xhr = request;
request.open("POST", apiPath, true);
const resolvedPath = chunkUploadApiPath(apiPath, headers);
request.open("POST", resolvedPath, true);
Object.entries(headers).forEach(([header, value]) => {
request.setRequestHeader(header, value);
});
if (headers["X-File-Chunk-Offset"] !== undefined) {
request.setRequestHeader("Connection", "close");
}
if (typeof onupload === "function") {
request.upload.onprogress = (event) => {
@@ -1258,11 +1270,14 @@ export function postPublic(
});
const request = new XMLHttpRequest();
request.open("POST", apiPath, true);
request.open("POST", chunkUploadApiPath(apiPath, headers), true);
Object.entries(headers).forEach(([header, value]) => {
request.setRequestHeader(header, value);
});
if (headers["X-File-Chunk-Offset"] !== undefined) {
request.setRequestHeader("Connection", "close");
}
if (typeof onupload === "function") {
request.upload.onprogress = (event) => {
+7 -4
View File
@@ -57,7 +57,7 @@ export default {
resizeStartX: 0,
resizeStartWidth: 0,
previousSidebarSize: null, // Remember the previous width when switching from desktop to mobile.
pwaInstallDismissed: sessionStorage.getItem("pwaInstallDismissed") === "true",
pwaInstallDismissed: localStorage.getItem("pwaInstallDismissed") === "true",
};
},
mounted() {
@@ -121,7 +121,7 @@ export default {
);
},
showPwaInstall() {
return installAvailable.value && !this.pwaInstallDismissed && !this.isSettings;
return installAvailable.value && !this.pwaInstallDismissed && !this.isSettings && !globalVars.disablePWAInstall;
},
},
methods: {
@@ -178,11 +178,14 @@ export default {
mutations.setSeenUpdate(globalVars.updateAvailable);
},
async installPwa() {
await promptInstall();
const accepted = await promptInstall();
if (accepted) {
this.dismissPwaInstall();
}
},
dismissPwaInstall() {
this.pwaInstallDismissed = true;
sessionStorage.setItem("pwaInstallDismissed", "true");
localStorage.setItem("pwaInstallDismissed", "true");
},
},
};
+7
View File
@@ -18,6 +18,13 @@
--layer-middle: 3;
--layer-below-top: 4;
--layer-top: 10;
/* device insets (notch, rounded corners, home indicator) */
--safe-area-top: env(safe-area-inset-top, 0px);
--safe-area-left: env(safe-area-inset-left, 0px);
--safe-area-right: env(safe-area-inset-right, 0px);
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
/* app top bar height, including the top safe-area inset */
--header-height: calc(4em + var(--safe-area-top));
--borderRadius: 1em;
--borderWidth: 1.5px;
--surfaceElevationShadow:
+5 -2
View File
@@ -1,6 +1,6 @@
body {
font-family: "Roboto", sans-serif;
padding-top: 4em;
padding-top: var(--header-height);
color: #333333;
overflow:initial;
scrollbar-width: none; /* Firefox */
@@ -70,9 +70,12 @@ i.spin {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
position: fixed;
padding-top: 4em;
padding-top: var(--header-height);
padding-left: var(--safe-area-left);
padding-right: var(--safe-area-right);
overflow: unset;
top: 0;
/* fill the visible viewport only; the html background paints the black-translucent safe areas */
height: 100%;
width: 100%;
display: flex;
+8
View File
@@ -12,6 +12,14 @@ header {
padding: 0.5em;
}
/* only the app top bar absorbs the safe-area insets (top bar, landscape notch) */
header.flexbar {
height: var(--header-height);
padding-top: calc(0.5em + var(--safe-area-top));
padding-left: calc(0.5em + var(--safe-area-left));
padding-right: calc(0.5em + var(--safe-area-right));
}
header title {
display: block;
flex: 1 1 auto;
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "مرحبًا بك في متصفح الملفات الكمي!",
"halloweenTitle": "عيد هالوين سعيد!",
"halloweenBody": "تم تطبيق سمة الهالوين. يمكنك تعطيله إذا لم يعجبك، أو تعطيله بشكل دائم في إعدادات الملف الشخصي عن طريق تحديد أي سمة أخرى.",
"firstLoadBody": "تم إنشاء قاعدة بيانات جديدة عند بدء التشغيل. إذا لم يكن هذا هو ما كنت تتوقعه، يرجى التحقق مرة أخرى من ملف التكوين الخاص بك لمسار 'server.database' والتأكد من وجوده. في docker، يمكن أن يكون مسار قاعدة البيانات \"./data/database.db\" أو \"./database.db\".",
"firstLoadBody": "تم إنشاء قاعدة بيانات جديدة عند بدء التشغيل. إذا لم يكن هذا هو ما كنت تتوقعه، يرجى التحقق مرة أخرى من ملف التكوين الخاص بك لمسار 'server.database' والتأكد من وجوده. في docker، يمكن أن يكون مسار قاعدة البيانات \"./data/filebrowser.sqlite\" أو \"./filebrowser.sqlite\".",
"selectPath": "تحديد المسار",
"renameSuccess": "تمت إعادة تسمية العنصر بنجاح!",
"newFileSuccess": "تم إنشاء الملف بنجاح!",
+1 -1
View File
@@ -450,7 +450,7 @@
"token": "Токен",
"onlyOfficeId": "OnlyOffice ID",
"firstLoadTitle": "Добре дошли във FileBrowser Quantum!",
"firstLoadBody": "Нова база данни беше създадена при стартиране. Ако това не е очакваното, моля, проверете конфигурационния файл за `server.database` път и се уверете, че съществува. В docker, пътят на базата данни може да бъде `./data/database.db` или `./database.db`.",
"firstLoadBody": "Нова база данни беше създадена при стартиране. Ако това не е очакваното, моля, проверете конфигурационния файл за `server.database` път и се уверете, че съществува. В docker, пътят на базата данни може да бъде `./data/filebrowser.sqlite` или `./filebrowser.sqlite`.",
"halloweenTitle": "Честит Хелоуин!",
"halloweenBody": "Темата за Хелоуин беше приложена. Можете да я изключите, ако не ви харесва, или да я изключите завинаги в настройките на профила, като изберете друга тема.",
"moveFailed": "Преместването не беше успешно",
+1 -1
View File
@@ -273,7 +273,7 @@
"firstLoadTitle": "Vítejte v aplikaci FileBrowser Quantum!",
"halloweenTitle": "Šťastný Halloween!",
"halloweenBody": "Bylo použito téma Halloween. Pokud se vám nelíbí, můžete jej vypnout nebo trvale zakázat v nastavení profilu výběrem jiného motivu.",
"firstLoadBody": "Při spuštění byla vytvořena nová databáze. Pokud to není to, co jste očekávali, překontrolujte prosím konfigurační soubor pro cestu `server.database` a ujistěte se, že existuje. V dockeru může být cesta k databázi `./data/database.db` nebo `./database.db`.",
"firstLoadBody": "Při spuštění byla vytvořena nová databáze. Pokud to není to, co jste očekávali, překontrolujte prosím konfigurační soubor pro cestu `server.database` a ujistěte se, že existuje. V dockeru může být cesta k databázi `./data/filebrowser.sqlite` nebo `./filebrowser.sqlite`.",
"selectPath": "Vybrat cestu",
"renameSuccess": "Položka byla úspěšně přejmenována!",
"newFileSuccess": "Soubor byl úspěšně vytvořen!",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "Willkommen bei FileBrowser Quantum!",
"halloweenTitle": "Fröhliches Halloween!",
"halloweenBody": "Das Halloween-Thema wurde angewendet. Sie können es deaktivieren, wenn es Ihnen nicht gefällt, oder es in den Profileinstellungen dauerhaft deaktivieren, indem Sie ein anderes Thema auswählen.",
"firstLoadBody": "Beim Starten wurde eine neue Datenbank erstellt. Wenn dies nicht das ist, was Sie erwartet haben, überprüfen Sie bitte Ihre Konfigurationsdatei auf den Pfad `server.database` und stellen Sie sicher, dass er existiert. Bei Docker kann der Datenbankpfad `./data/database.db` oder `./database.db` sein.",
"firstLoadBody": "Beim Starten wurde eine neue Datenbank erstellt. Wenn dies nicht das ist, was Sie erwartet haben, überprüfen Sie bitte Ihre Konfigurationsdatei auf den Pfad `server.database` und stellen Sie sicher, dass er existiert. Bei Docker kann der Datenbankpfad `./data/filebrowser.sqlite` oder `./filebrowser.sqlite` sein.",
"selectPath": "Pfad auswählen",
"renameSuccess": "Artikel erfolgreich umbenannt!",
"newFileSuccess": "Datei erfolgreich erstellt!",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "Καλώς ήρθατε στο FileBrowser Quantum!",
"halloweenTitle": "Καλές Απόκριες!",
"halloweenBody": "Το θέμα των αποκριών έχει εφαρμοστεί. Μπορείτε να το απενεργοποιήσετε αν δεν σας αρέσει ή να το απενεργοποιήσετε μόνιμα στις ρυθμίσεις προφίλ επιλέγοντας οποιοδήποτε άλλο θέμα.",
"firstLoadBody": "Κατά την εκκίνηση δημιουργήθηκε μια νέα βάση δεδομένων. Αν αυτό δεν είναι αυτό που περιμένατε, ελέγξτε ξανά το αρχείο ρυθμίσεων για τη διαδρομή `server.database` και βεβαιωθείτε ότι υπάρχει. Στο docker, η διαδρομή της βάσης δεδομένων μπορεί να είναι `./data/database.db` ή `./database.db`.",
"firstLoadBody": "Κατά την εκκίνηση δημιουργήθηκε μια νέα βάση δεδομένων. Αν αυτό δεν είναι αυτό που περιμένατε, ελέγξτε ξανά το αρχείο ρυθμίσεων για τη διαδρομή `server.database` και βεβαιωθείτε ότι υπάρχει. Στο docker, η διαδρομή της βάσης δεδομένων μπορεί να είναι `./data/filebrowser.sqlite` ή `./filebrowser.sqlite`.",
"selectPath": "Επιλέξτε διαδρομή",
"renameSuccess": "Το στοιχείο μετονομάστηκε επιτυχώς!",
"newFileSuccess": "Το αρχείο δημιουργήθηκε με επιτυχία!",
+1 -1
View File
@@ -450,7 +450,7 @@
"token": "Token",
"onlyOfficeId": "OnlyOffice ID",
"firstLoadTitle": "Welcome to FileBrowser Quantum!",
"firstLoadBody": "A new database was created on startup. If this is not what you expected, please double check your configuration file for `server.database` path and ensure it exists. On docker, database path could be `./data/database.db` or `./database.db`.",
"firstLoadBody": "A new database was created on startup. If this is not what you expected, please double check your configuration file for `server.database` path and ensure it exists. On docker, database path could be `./data/filebrowser.sqlite` or `./filebrowser.sqlite`.",
"halloweenTitle": "Happy Halloween!",
"halloweenBody": "The Halloween theme has been applied. You can disable it if you don't like it, or permanently disable it in profile settings by selecting any other theme.",
"moveFailed": "Move failed",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "¡Bienvenido a FileBrowser Quantum!",
"halloweenTitle": "¡Feliz Halloween!",
"halloweenBody": "¡Se aplicó el tema de Halloween! Puedes desactivarlo si no te gusta (temporal o permanentemente) en los ajustes seleccionando cualquier otro tema.",
"firstLoadBody": "Se creó una nueva base de datos al iniciar. Si esto no es lo que esperabas, aseguráte de que en tu configuración (config.yaml) tienes una ruta para la base de datos en `server.database`, y verifica si existe. Si usas Docker, la ubicación de la base de datos se encuentra en `./data/database.db`.",
"firstLoadBody": "Se creó una nueva base de datos al iniciar. Si esto no es lo que esperabas, aseguráte de que en tu configuración (config.yaml) tienes una ruta para la base de datos en `server.database`, y verifica si existe. Si usas Docker, la ubicación de la base de datos se encuentra en `./data/filebrowser.sqlite`.",
"selectPath": "Seleccionar ruta",
"renameSuccess": "El cambio de nombre se ha realizado correctamente",
"newFileSuccess": "¡Archivo creado con éxito!",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "Bienvenue à FileBrowser Quantum !",
"halloweenTitle": "Joyeux Halloween !",
"halloweenBody": "Le thème Halloween a été appliqué. Vous pouvez le désactiver si vous ne l'aimez pas, ou le désactiver définitivement dans les paramètres du profil en sélectionnant un autre thème.",
"firstLoadBody": "Une nouvelle base de données a été créée au démarrage. Si ce n'est pas ce que vous attendiez, vérifiez que le chemin `server.database` existe dans votre fichier de configuration. Sur docker, le chemin de la base de données peut être `./data/database.db` ou `./database.db`.",
"firstLoadBody": "Une nouvelle base de données a été créée au démarrage. Si ce n'est pas ce que vous attendiez, vérifiez que le chemin `server.database` existe dans votre fichier de configuration. Sur docker, le chemin de la base de données peut être `./data/filebrowser.sqlite` ou `./filebrowser.sqlite`.",
"selectPath": "Sélectionner un chemin",
"renameSuccess": "L'élément a été renommé avec succès !",
"newFileSuccess": "Fichier créé avec succès !",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "ברוכים הבאים ל-FileBrowser Quantum!",
"halloweenTitle": "חג ליל כל הקדושים שמח!",
"halloweenBody": "נושא ליל כל הקדושים הוחל. ניתן להשבית אותו אם אינו מוצא חן בעיניך, או להשבית אותו לצמיתות בהגדרות הפרופיל על ידי בחירת נושא אחר.",
"firstLoadBody": "במהלך ההפעלה נוצר מסד נתונים חדש. אם זה לא מה שציפית, אנא בדוק שוב את קובץ התצורה שלך עבור הנתיב `server.database` וודא שהוא קיים. ב-docker, נתיב מסד הנתונים יכול להיות `./data/database.db` או `./database.db`.",
"firstLoadBody": "במהלך ההפעלה נוצר מסד נתונים חדש. אם זה לא מה שציפית, אנא בדוק שוב את קובץ התצורה שלך עבור הנתיב `server.database` וודא שהוא קיים. ב-docker, נתיב מסד הנתונים יכול להיות `./data/filebrowser.sqlite` או `./filebrowser.sqlite`.",
"selectPath": "בחר נתיב",
"renameSuccess": "הפריט שונה בהצלחה!",
"newFileSuccess": "הקובץ נוצר בהצלחה!",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "Üdvözöljük a FileBrowser Quantum-on!",
"halloweenTitle": "Boldog Halloweent!",
"halloweenBody": "A Halloween témát alkalmazták. Ha nem tetszik, letilthatja, vagy véglegesen letilthatja a profilbeállításokban bármely más téma kiválasztásával.",
"firstLoadBody": "Az indításkor új adatbázis jött létre. Ha ez nem az, amire számított, kérjük, ellenőrizze kétszer is a konfigurációs fájlban a `server.database` elérési útvonalát, és győződjön meg róla, hogy létezik. Dockeren az adatbázis elérési útja lehet `./data/database.db` vagy `./database.db`.",
"firstLoadBody": "Az indításkor új adatbázis jött létre. Ha ez nem az, amire számított, kérjük, ellenőrizze kétszer is a konfigurációs fájlban a `server.database` elérési útvonalát, és győződjön meg róla, hogy létezik. Dockeren az adatbázis elérési útja lehet `./data/filebrowser.sqlite` vagy `./filebrowser.sqlite`.",
"selectPath": "Útvonal kiválasztása",
"renameSuccess": "Az elem átnevezése sikeres volt!",
"newFileSuccess": "A fájl sikeresen létrehozva!",
+1
View File
@@ -30,6 +30,7 @@ export const availableLocales: Record<string, string> = {
ru: 'ru',
sk: 'sk',
svSE: 'sv-se',
tr: 'tr',
ua: 'ua',
zhCN: 'zh-cn',
zhTW: 'zh-tw',
+1 -1
View File
@@ -449,7 +449,7 @@
"token": "Token",
"onlyOfficeId": "ID OnlyOffice",
"firstLoadTitle": "Benvenuto in FileBrowser Quantum!",
"firstLoadBody": "È stato creato un nuovo database all'avvio. Se non è quello che ti aspettavi, controlla il percorso `server.database` nel file di configurazione. Su Docker, il percorso potrebbe essere `./data/database.db` o `./database.db`.",
"firstLoadBody": "È stato creato un nuovo database all'avvio. Se non è quello che ti aspettavi, controlla il percorso `server.database` nel file di configurazione. Su Docker, il percorso potrebbe essere `./data/filebrowser.sqlite` o `./filebrowser.sqlite`.",
"halloweenTitle": "Buon Halloween!",
"halloweenBody": "Il tema di Halloween è stato applicato. Puoi disabilitarlo o cambiarlo permanentemente nelle impostazioni del profilo.",
"moveFailed": "Spostamento fallito",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "FileBrowser Quantumへようこそ!",
"halloweenTitle": "ハッピー・ハロウィーン",
"halloweenBody": "ハロウィーンのテーマが適用されました。気に入らなければ無効にするか、プロフィール設定で他のテーマを選択して永久に無効にすることができます。",
"firstLoadBody": "起動時に新しいデータベースが作成されました。これが期待したものでない場合は、設定ファイルの `server.database` パスを再確認し、それが存在することを確認してください。docker では、データベースのパスは `./data/database.db` または `./database.db` になります。",
"firstLoadBody": "起動時に新しいデータベースが作成されました。これが期待したものでない場合は、設定ファイルの `server.database` パスを再確認し、それが存在することを確認してください。docker では、データベースのパスは `./data/filebrowser.sqlite` または `./filebrowser.sqlite` になります。",
"selectPath": "パスを選択",
"renameSuccess": "アイテム名の変更に成功しました!",
"newFileSuccess": "ファイルは正常に作成されました!",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "Welkom bij FileBrowser Quantum!",
"halloweenTitle": "Gelukkig Halloween!",
"halloweenBody": "Het Halloween-thema is toegepast. Je kunt het uitschakelen als je het niet leuk vindt, of permanent uitschakelen in de profielinstellingen door een ander thema te selecteren.",
"firstLoadBody": "Er is een nieuwe database aangemaakt bij het opstarten. Als dit niet is wat je verwachtte, controleer dan je configuratiebestand voor het `server.database` pad en zorg ervoor dat het bestaat. Op docker kan het databasepad `./data/database.db` of `./database.db` zijn.",
"firstLoadBody": "Er is een nieuwe database aangemaakt bij het opstarten. Als dit niet is wat je verwachtte, controleer dan je configuratiebestand voor het `server.database` pad en zorg ervoor dat het bestaat. Op docker kan het databasepad `./data/filebrowser.sqlite` of `./filebrowser.sqlite` zijn.",
"selectPath": "Pad kiezen",
"renameSuccess": "Item hernoemd succesvol!",
"newFileSuccess": "Bestand succesvol aangemaakt!",
+1 -1
View File
@@ -450,7 +450,7 @@
"token": "Token",
"onlyOfficeId": "OnlyOffice-ID",
"firstLoadTitle": "Welkom bij FileBrowser Quantum!",
"firstLoadBody": "Er is een nieuwe database aangemaakt bij het opstarten. Als dit niet is wat u verwachtte, controleer dan het configuratiebestand voor het `server.database` pad en zorg ervoor dat het bestaat. Op docker kan het databasepad `./data/database.db` of `./database.db` zijn.",
"firstLoadBody": "Er is een nieuwe database aangemaakt bij het opstarten. Als dit niet is wat u verwachtte, controleer dan het configuratiebestand voor het `server.database` pad en zorg ervoor dat het bestaat. Op docker kan het databasepad `./data/filebrowser.sqlite` of `./filebrowser.sqlite` zijn.",
"halloweenTitle": "Happy Halloween!",
"halloweenBody": "Het Halloween-thema is toegepast. U kunt het uitschakelen als u dit niet prettig vindt, of permanent uitschakelen in de profielinstellingen door een ander thema te selecteren.",
"moveFailed": "Verplaatsen mislukt",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "Witamy w FileBrowser Quantum!",
"halloweenTitle": "Wesołego Halloween!",
"halloweenBody": "Motyw Halloween został zastosowany. Możesz go wyłączyć, jeśli Ci się nie podoba, lub wyłączyć go na stałe w ustawieniach profilu, wybierając inny motyw.",
"firstLoadBody": "Nowa baza danych została utworzona podczas uruchamiania. Jeśli nie jest to to, czego oczekiwałeś, sprawdź dokładnie plik konfiguracyjny pod kątem ścieżki `server.database` i upewnij się, że istnieje. W dockerze ścieżka bazy danych może być `./data/database.db` lub `./database.db`.",
"firstLoadBody": "Nowa baza danych została utworzona podczas uruchamiania. Jeśli nie jest to to, czego oczekiwałeś, sprawdź dokładnie plik konfiguracyjny pod kątem ścieżki `server.database` i upewnij się, że istnieje. W dockerze ścieżka bazy danych może być `./data/filebrowser.sqlite` lub `./filebrowser.sqlite`.",
"selectPath": "Wybierz ścieżkę",
"renameSuccess": "Nazwa elementu została zmieniona pomyślnie!",
"newFileSuccess": "Plik został utworzony pomyślnie!",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "Bem-vindo ao FileBrowser Quantum!",
"halloweenTitle": "Feliz Dia das Bruxas!",
"halloweenBody": "O tema Halloween foi aplicado. Você pode desativá-lo se não gostar dele ou desativá-lo permanentemente nas configurações de perfil, selecionando qualquer outro tema.",
"firstLoadBody": "Um novo banco de dados foi criado na inicialização. Se isso não for o que você esperava, verifique novamente em seu arquivo de configuração o caminho `server.database` e certifique-se de que ele exista. No docker, o caminho do banco de dados pode ser `./data/database.db` ou `./database.db`.",
"firstLoadBody": "Um novo banco de dados foi criado na inicialização. Se isso não for o que você esperava, verifique novamente em seu arquivo de configuração o caminho `server.database` e certifique-se de que ele exista. No docker, o caminho do banco de dados pode ser `./data/filebrowser.sqlite` ou `./filebrowser.sqlite`.",
"selectPath": "Selecionar caminho",
"renameSuccess": "Item renomeado com sucesso!",
"newFileSuccess": "Arquivo criado com sucesso!",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "Bem-vindo ao FileBrowser Quantum!",
"halloweenTitle": "Feliz Dia das Bruxas!",
"halloweenBody": "O tema do Dia das Bruxas foi aplicado. Pode desactivá-lo se não gostar dele, ou desactivá-lo permanentemente nas definições do perfil, selecionando qualquer outro tema.",
"firstLoadBody": "Foi criada uma nova base de dados no arranque. Se isso não for o que você esperava, verifique novamente seu arquivo de configuração para o caminho `server.database` e certifique-se de que ele existe. No docker, o caminho do banco de dados pode ser `./data/database.db` ou `./database.db`.",
"firstLoadBody": "Foi criada uma nova base de dados no arranque. Se isso não for o que você esperava, verifique novamente seu arquivo de configuração para o caminho `server.database` e certifique-se de que ele existe. No docker, o caminho do banco de dados pode ser `./data/filebrowser.sqlite` ou `./filebrowser.sqlite`.",
"selectPath": "Selecionar caminho",
"renameSuccess": "Item renomeado com sucesso!",
"newFileSuccess": "Ficheiro criado com sucesso!",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "Bine ați venit la FileBrowser Quantum!",
"halloweenTitle": "Halloween fericit!",
"halloweenBody": "Tema Halloween a fost aplicată. Puteți să o dezactivați dacă nu vă place sau să o dezactivați permanent în setările profilului selectând orice altă temă.",
"firstLoadBody": "O nouă bază de date a fost creată la pornire. Dacă acest lucru nu este ceea ce vă așteptați, vă rugăm să verificați fișierul de configurare pentru calea `server.database` și asigurați-vă că există. Pe docker, calea bazei de date poate fi `./data/database.db` sau `./database.db`.",
"firstLoadBody": "O nouă bază de date a fost creată la pornire. Dacă acest lucru nu este ceea ce vă așteptați, vă rugăm să verificați fișierul de configurare pentru calea `server.database` și asigurați-vă că există. Pe docker, calea bazei de date poate fi `./data/filebrowser.sqlite` sau `./filebrowser.sqlite`.",
"selectPath": "Selectați calea",
"renameSuccess": "Element redenumit cu succes!",
"newFileSuccess": "Fișier creat cu succes!",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "Добро пожаловать в FileBrowser Quantum!",
"halloweenTitle": "Счастливого Хэллоуина!",
"halloweenBody": "Тема Хэллоуина была применена. Вы можете отключить ее, если она вам не нравится, или навсегда отключить ее в настройках профиля, выбрав любую другую тему.",
"firstLoadBody": "При запуске была создана новая база данных. Если это не то, что вы ожидали, проверьте путь к базе данных `server.database в вашем конфигурационном файле и убедитесь, что он существует. В docker путь к базе данных может быть `./data/database.db` или `./database.db`.",
"firstLoadBody": "При запуске была создана новая база данных. Если это не то, что вы ожидали, проверьте путь к базе данных `server.database в вашем конфигурационном файле и убедитесь, что он существует. В docker путь к базе данных может быть `./data/filebrowser.sqlite` или `./filebrowser.sqlite`.",
"selectPath": "Выберите путь",
"renameSuccess": "Элемент успешно переименован!",
"newFileSuccess": "Файл создан успешно!",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "Vitajte v programe FileBrowser Quantum!",
"halloweenTitle": "Šťastný Halloween!",
"halloweenBody": "Bola použitá téma Halloween. Ak sa vám nepáči, môžete ju vypnúť, alebo ju natrvalo zakázať v nastaveniach profilu výberom akejkoľvek inej témy.",
"firstLoadBody": "Pri spustení bola vytvorená nová databáza. Ak to nie je to, čo ste očakávali, dvakrát skontrolujte konfiguračný súbor pre cestu `server.database` a uistite sa, že existuje. V dockeri môže byť cesta k databáze `./data/database.db` alebo `./database.db`.",
"firstLoadBody": "Pri spustení bola vytvorená nová databáza. Ak to nie je to, čo ste očakávali, dvakrát skontrolujte konfiguračný súbor pre cestu `server.database` a uistite sa, že existuje. V dockeri môže byť cesta k databáze `./data/filebrowser.sqlite` alebo `./filebrowser.sqlite`.",
"selectPath": "Vyberte cestu",
"renameSuccess": "Položka bola úspešne premenovaná!",
"newFileSuccess": "Súbor bol úspešne vytvorený!",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "Välkommen till FileBrowser Quantum!",
"halloweenTitle": "Glad Halloween!",
"halloweenBody": "Halloween-temat har lagts till. Du kan inaktivera det om du inte gillar det, eller inaktivera det permanent i profilinställningarna genom att välja ett annat tema.",
"firstLoadBody": "En ny databas skapades vid uppstart. Om detta inte är vad du förväntade dig, vänligen dubbelkolla din konfigurationsfil för `server.database` sökväg och se till att den finns. På docker kan databassökvägen vara `./data/database.db` eller `./database.db`.",
"firstLoadBody": "En ny databas skapades vid uppstart. Om detta inte är vad du förväntade dig, vänligen dubbelkolla din konfigurationsfil för `server.database` sökväg och se till att den finns. På docker kan databassökvägen vara `./data/filebrowser.sqlite` eller `./filebrowser.sqlite`.",
"selectPath": "Välj sökväg",
"renameSuccess": "Objektet har bytt namn framgångsrikt!",
"newFileSuccess": "Filen skapades framgångsrikt!",
+225 -225
View File
@@ -8,26 +8,26 @@
"selectMultiple": "Çoklu seçim",
"switchView": "Görünümü değiştir",
"copyDownloadLinkToClipboard": "İndirme bağlantısını panoya kopyala",
"generateNewOtp": "Sıfırlama ve yeni iki faktörlü kod oluşturma",
"generateNewOtp": "Sıfırla ve yeni iki faktörlü kod oluştur",
"pauseAll": "Tümünü duraklat",
"resumeAll": "Tümünü devam ettir",
"clearCompleted": "Temizleme tamamlandı",
"openParentFolder": "Üst klasörü açın",
"clearCompleted": "Tamamlananları Temizle",
"openParentFolder": "Üst klasörü aç",
"selectedCount": "Bir eylemi gerçekleştirmek için seçilen öğe sayısı",
"listView": "Liste Görünümü",
"normalView": "Normal Görünüm",
"galleryView": "Galeri Görünümü",
"selectAll": "Tümünü seçin",
"discardAndExit": "At ve Çık",
"selectAll": "Tümünü seç",
"discardAndExit": "Kaydetmeden Çık",
"saveAndExit": "Kaydet ve Çık",
"keepEditing": "Düzenlemeye Devam Et",
"closeAnyway": "Her Neyse Kapat",
"closeAnyway": "Yine de Kapat",
"watchFile": "Dosyayı izle",
"showMore": "Gelişmiş Seçenekleri Göster",
"showLess": "Gelişmiş Seçenekleri Gizle",
"goToSource": "Kaynak Konuma Git",
"goToItem": "Öğeye git",
"copyPath": "Kopyalama Yolu",
"copyPath": "Yolu Kopyala",
"pinItem": "Sabitle",
"unpinItem": "Sabitlemeyi Kaldır",
"sendToApp": "Uygulamaya gönder",
@@ -43,12 +43,12 @@
"requestTimedOut": "İstek zaman aşımına uğradı. Ağ ayarlarınızı veya ters proxy ayarlarınızı kontrol edin."
},
"files": {
"lastModified": "Son güncellenme",
"lastModified": "Son değiştirilme{suffix}",
"multipleSelectionEnabled": "Çoklu seçim etkin",
"sortByLastModified": "Güncelleme tarihine göre sırala",
"sortByName": "İsme göre sırala",
"sortBySize": "Boyuta göre sırala",
"noPreview": "Bu dosya için önizleme aktif değil",
"noPreview": "Bu dosya için önizleme kullanılamıyor.",
"noDownloadAccess": "İndirme mevcut değil.",
"newFile": "Yeni dosya{suffix}",
"newFolder": "Yeni klasör{suffix}",
@@ -70,17 +70,17 @@
"f2": "Normal görünüme geç",
"f3": "Galeri görünümüne geç",
"scroll": "Liste ve düzenleyici yazı tipi boyutlarını değiştir",
"d": "seçili öğeleri indirin",
"d": "seçili öğeleri indir",
"b": "kenar çubuğunu değiştir",
"comma": "ayarlara git",
"c": "seçili öğeleri kopyala",
"v": "kopyalanan/kesilen öğeleri yapıştırma",
"v": "kopyalanan/kesilen öğeleri yapıştır",
"x": "seçili öğeleri kes",
"e": "Düzenleyici ve Markdown görüntüleyici arasında geçiş yap (.md dosyalarında)",
"click": "birden fazla dosya veya klasör seçin"
"click": "birden fazla dosya veya klasör seç"
},
"del": "seçilileri sil",
"esc": "seçimi temizle veya kapatın",
"del": "seçili öğeleri sil",
"esc": "seçimi temizle ve/veya istemi kapat",
"f4": "listeyi ve meta verileri yenile",
"f2": "dosyayı yeniden adlandır",
"f1": "yardım istemini göster",
@@ -89,7 +89,7 @@
"wiki": "FileBrowser Quantum Resmi Dokümanları",
"media": {
"title": "Medya Oynatıcı",
"playPause": "Oynat/Durdur",
"playPause": "Oynat/Duraklat",
"seekPercentage": "%10'dan %90'a atla",
"seek": "10 sn geri veya ileri atla",
"volume": "Sesi aç veya kıs",
@@ -103,8 +103,8 @@
},
"altArrowUp": "Üst klasöre git",
"altArrowDown": "Seçilen dosya/klasörü aç",
"click": "dosya veya klasör seçin",
"marqueeSelect": "Boş alana sürükleyerek birden fazla dosya veya klasör seçin"
"click": "dosya veya klasör seç",
"marqueeSelect": "boş alana sürükleyerek birden fazla dosya veya klasör seç"
},
"languages": {
"ar": "العربية",
@@ -151,28 +151,28 @@
"deleteTitle": "Dosyaları sil",
"displayName": "Görünen Ad:",
"downloadMessage": "İndirmek istediğiniz formatı seçin.",
"error": "Bir şeyler yanlış gitti",
"error": "Bir şeyler ters gitti",
"fileInfo": "Dosya bilgisi",
"newFile": "Yeni dosya",
"newFileMessage": "Yeni dosyanın adını yazın.",
"replaceMessage": "Yüklemeye çalıştığınız dosyalardan biri, adı nedeniyle çakışıyor. Mevcut olanı değiştirmek istiyor musunuz?\n",
"replaceMessage": "Yüklemeye çalıştığınız dosyalardan biri, adı nedeniyle çakışıyor. Yüklemeye devam etmek mi yoksa mevcut dosyayı değiştirmek mi istersiniz?\n\n",
"optionalPassword": "İsteğe bağlı şifre",
"deleteUserMessage": "Bu kullanıcıyı silmek istediğinizden emin misiniz?",
"deleted": "Başarıyla silindi!",
"destinationSource": "Hedef Kaynak:",
"dragAndDrop": "Dosyaları buraya sürükleyip bırakın",
"completed": "Tamamlandı",
"conflictsDetected": "Tespit edilen çatışmalar",
"conflictsDetected": "Çakışmalar tespit edildi",
"downloadsLimit": "İndirme sayısı sınırı",
"maxBandwidth": "Maksimum indirme bant genişliği (kbps)",
"shareThemeColor": "Tema rengini paylaş (CSS değeri)",
"shareDescription": "Açıklama paylaş",
"shareBanner": "Banner URL'sini paylaş",
"shareFavicon": "Favicon URL'sini paylaş",
"shareThemeColor": "Paylaşım tema rengi (CSS değeri)",
"shareDescription": "Paylaşım açıklaması",
"shareBanner": "Paylaşım afiş URL'si",
"shareFavicon": "Paylaşım favicon URL'si",
"renameMessage": "Yeni bir ad girin:",
"renameMessageInvalid": "Bir dosya \"/\" veya \"\\\" içeremez",
"deleteMessageMultiple": "{count} dosya(lar)ını silmek istediğinizden emin misiniz?",
"deleteMessageShare": "Bu paylaşımı silmek istediğinizden emin misiniz? {path} ?",
"renameMessageInvalid": "Bir dosya adı \"/\" veya \"\\\" içeremez",
"deleteMessageMultiple": "{count} dosyayı silmek istediğinizden emin misiniz?",
"deleteMessageShare": "Bu paylaşımı silmek istediğinizden emin misiniz: {path}?",
"renameMessageConflict": "\"{filename}\" adında bir öğe zaten var!",
"uploadSettingsChunked": "Maksimum eşzamanlı yükleme: {maxConcurrentUpload}, yığın boyutu: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Maksimum eşzamanlı yükleme: {maxConcurrentUpload}, yığınlanmış yükleme devre dışı",
@@ -183,27 +183,27 @@
"saveBeforeExit": "Kaydedilmemiş Değişiklikler",
"saveBeforeExitMessage": "Düzenleyicide kaydedilmemiş değişiklikleriniz var. Ne yapmak istiyorsunuz?",
"copyPasteConfirm": "Kopyala/Yapıştır Onayı",
"copyItemQuestion": "Bu öğeyi yeni konuma kopyalayayım mı?",
"moveItemQuestion": "Bu öğeyi yeni konuma taşıyın mı?",
"destinationPath": "Varış yeri:",
"copyItemQuestion": "Bu öğe yeni konuma kopyalansın mı?",
"moveItemQuestion": "Bu öğe yeni konuma taşınsın mı?",
"destinationPath": "Hedef:",
"closeWithActiveUploadsMessage": "Uyarı: Aktif yüklemeleriniz var ve yükleme istemini kapatmak tamamlanmamış yüklemelere neden olabilir. Ne yapmak istiyorsunuz?",
"closeWithActiveUploads": "Aktif yüklemeler",
"basicInfo": "Temel Bilgiler",
"additionalInfo": "Ek Bilgi",
"hidden": "Gizli",
"hasPreview": "Has Önizleme",
"checksums": "Checksumlar",
"hasPreview": "Önizleme Var",
"checksums": "Sağlama Toplamları",
"hashAlgorithm": "Hash Algoritması",
"hashValue": "Hash Değeri",
"selectHashAlgorithm": "Bir karma algoritma seçin",
"generate": "Oluşturmak",
"selectHashAlgorithm": "Bir hash algoritması seçin",
"generate": "Oluştur",
"errorGeneratingHash": "Hash oluştururken hata oluştu",
"token": "Jeton",
"onlyOfficeId": "SadeceOfis Kimliği",
"token": "Belirteç",
"onlyOfficeId": "OnlyOffice Kimliği",
"firstLoadTitle": "FileBrowser Quantum'a hoş geldiniz!",
"halloweenTitle": "Mutlu Cadılar Bayramı!",
"halloweenBody": "Cadılar Bayramı teması uygulanmıştır. Beğenmediyseniz devre dışı bırakabilir veya profil ayarlarından başka bir tema seçerek kalıcı olarak devre dışı bırakabilirsiniz.",
"firstLoadBody": "Başlangıçta yeni bir veritabanı oluşturuldu. Beklediğiniz bu değilse, lütfen `server.database` yolu için yapılandırma dosyanızı iki kez kontrol edin ve var olduğundan emin olun. Docker üzerinde, veritabanı yolu `./data/database.db` veya `./database.db` olabilir.",
"firstLoadBody": "Başlangıçta yeni bir veritabanı oluşturuldu. Beklediğiniz bu değilse, lütfen `server.database` yolu için yapılandırma dosyanızı iki kez kontrol edin ve var olduğundan emin olun. Docker üzerinde, veritabanı yolu `./data/filebrowser.sqlite` veya `./filebrowser.sqlite` olabilir.",
"selectPath": "Yol Seçin",
"renameSuccess": "Öğe başarıyla yeniden adlandırıldı!",
"newFileSuccess": "Dosya başarıyla oluşturuldu!",
@@ -219,28 +219,28 @@
"archiveSuccess": "Arşiv başarıyla oluşturuldu!",
"unarchive": "Arşivi ayıkla",
"unarchiveMessage": "Arşivin nereye çıkarılacağını seçin.",
"unarchiveDestination": "Özetle:",
"unarchiveDestination": "Şuraya çıkar:",
"unarchiveSuccess": "Arşiv başarıyla çıkarıldı!",
"downloadFailed": "Yığın indirme başarısız oldu - bu tekrarlanırsa yığın boyutunu küçültmeyi deneyin: {message}",
"confirmPasswordToSaveUser": "Bu kullanıcı değişikliklerini kaydetmek için mevcut şifrenizi girin."
},
"search": {
"smallerThan": "Daha küçük:",
"largerThan": "Daha büyük:",
"smallerThan": "Şundan küçük:",
"largerThan": "Şundan büyük:",
"onlyFolders": "Yalnızca Klasörler",
"onlyFiles": "Sadece Dosyalar",
"onlyFiles": "Yalnızca Dosyalar",
"searchContext": "Arama Bağlamı: {context}",
"notEnoughCharacters": "Arama yapmak için yeterli karakter yok (min {minSearchLength})",
"typeToSearch": "Aramaya başlamak için {minSearchLength} veya daha fazla karakter yazmaya başlayın.",
"showPreviewImages": "Önizleme resimlerini göster",
"showPreviewImagesDescription": "Kullanılabilir olduğunda arama sonuçları için küçük resim önizlemelerini görüntüleyin ve tüm sonuçlar için daha büyük simgeler gösterin.",
"olderThanDate": "Daha yaşlı:",
"newerThanDate": "Daha yeni:",
"olderThanDate": "Şundan eski:",
"newerThanDate": "Şundan yeni:",
"noResults": "Sonuç bulunamadı.",
"helpText1": "Arama, yazdığınız her karakterde gerçekleşir ({minSearchLength} minimum karakter).",
"helpText2": "Yalnızca dosya veya klasör adlarında alt dize eşleşmesi (tam yollarda değil). case:exact büyük/küçük harfe duyarlı eşleştirme için\n\n terimler arasında VEYA için boru karakteri kullanın; bir ifade için çift tırnak kullanın.\n\nDaha gelişmiş seçenekler için joker karakter aramayı etkinleştirin.",
"useWildcardSearch": "Joker karakter araması (büyük/küçük harfe duyarlı)",
"useWildcardSearchDescription": "Desteklenen joker karakter seçenekleri:\n\n- Herhangi bir diziyi eşleştirin: *\n\n- Bir karakteri eşleştirin: ?\n\n- Karakter sınıfı: [a-z], [0-9]\n\n- Negasyon: [^0-9]\n\n- Literal *, ? veya [ parantez içine alınmalıdır, örneğin [*], [?], [[]\n\n- Tüm sonuçlar büyük/küçük harfe duyarlıdır",
"helpText1": "Arama, yazdığınız her karakterde gerçekleşir (en az {minSearchLength} karakter).",
"helpText2": "Yalnızca dosya veya klasör adlarında büyük/küçük harfe duyarsız alt dize eşleşmesi (tam yollarda değil). Boşlukla ayrılmış kelimeler; nokta, alt çizgi ve tire gibi yaygın ayraçlar arasında eşleşir (örn. \"Blade Runner\" araması Blade.Runner.mkv dosyasını bulur). Büyük/küçük harfe duyarlı eşleştirme için case:exact kullanın.\n\nTerimler arasında VEYA için dik çizgi karakterini kullanın; boşluk içeren tam bir ifade için çift tırnak kullanın.\n\nGelişmiş glob kalıpları (*, ?, [a-z]) için joker karakter aramasını etkinleştirin.",
"useWildcardSearch": "Joker karakter araması",
"useWildcardSearchDescription": "Desteklenen joker karakter seçenekleri:\n\n• Herhangi bir diziyi eşleştir: *\n\n• Bir karakteri eşleştir: ?\n\n• Karakter sınıfı: [a-z], [0-9]\n\n• Olumsuzlama: [^0-9]\n\n• Literal *, ? veya [ karakterleri köşeli parantez içine alınmalıdır, örn. [*], [?], [[]",
"openInAdvancedSearch": "Gelişmiş Aramada Aç",
"caseExact": "Büyük/küçük harf duyarlı eşleştirme",
"caseExactDescription": "Büyük ve küçük harfleri göz ardı etmek yerine, dosya ve klasör adlarını tam harf büyük/küçük yazımına göre eşleştirin.",
@@ -248,12 +248,12 @@
"allFileTypes": "Tüm Dosya Türleri"
},
"settings": {
"avoidChanges": "(değişiklikleri önlemek için boş bırakın)",
"avoidChanges": "bu parolayı uygulamak için 'parolayı değiştir' seçeneğini de işaretleyin",
"lockPassword": "Kullanıcının parolayı değiştirmesini engelle",
"permissions": {
"admin": "Yönetici",
"realtime": "Gerçek zamanlı bağlantıları ve güncellemeleri etkinleştirin",
"api": "Uzun ömürlü API Belirteçleri oluşturma ve yönetme"
"api": "Uzun ömürlü API Belirteçleri oluştur ve yönet"
},
"settingsUpdated": "Ayarlar güncellendi!",
"shareDeleted": "Paylaşım silindi!",
@@ -262,8 +262,8 @@
"modifyOtherUser": "Kullanıcıyı değiştir:",
"themeColor": "Tema Rengi",
"listingOptions": "Listeleme seçenekleri",
"emptyGlobal": "Şu anda global yapılandırma seçenekleri yoktur, sistem ayarlarını yapmak için yapılandırma yaml'ını düzenleyebilirsiniz.",
"scopes": "İzin verilen kaynakları ve bunlar içindeki kullanıcı kapsamını tanımlama",
"emptyGlobal": "Şu anda genel yapılandırma seçeneği yok; sistem ayarlarını düzenlemek için yapılandırma yaml dosyasını kullanabilirsiniz.",
"scopes": "İzin verilen kaynakları ve bunlar içindeki kullanıcı kapsamını tanımlayın",
"userNotAdmin": "Kullanıcı yönetici değil",
"enterPassword": "Şifre girin",
"enterPasswordAgain": "Şifreyi tekrar girin",
@@ -285,7 +285,7 @@
"analyticsEnabledDescription": "Kullanıcının onayıyla gerçekleştirilen dağıtım analitiği, uygulamanın geliştirilmesine katkıda bulunmak amacıyla FileBrowser Quantum geliştirici sunucularına anonim bilgiler gönderir.",
"analyticsLoadFailed": "Analitik ayarları yüklenemedi",
"analyticsUpdateFailed": "Analitik ayarları güncellenemedi",
"analyticsPreviewFailed": "Analitik tanılama bilgilerini yükleyemedi",
"analyticsPreviewFailed": "Analitik tanılama bilgileri yüklenemedi",
"scopePath": "Kapsam yolu",
"sourcePermissionsHelp": "Bir kaynak satırını genişleterek, o kaynak için görüntüleme, indirme, değiştirme, oluşturma ve silme izinlerini ayarlayın.",
"permissionsHelp": "Kullanıcının genel izinleri, tüm kaynaklar ve paylaşımlar dahil olmak üzere her yerde geçerlidir.",
@@ -294,21 +294,21 @@
"accountDefaults": "Hesap varsayılan ayarları",
"disableUserSettings": "Kullanıcı ayarları sayfasını devre dışı bırak",
"userDefaultFieldLockedFromConfig": "Bu varsayılan ayar, yapılandırma dosyasında belirlenmiştir ve buradan değiştirilemez.",
"sourceAccessDefaultsHelp": "Kaynaklara ve yeni kullanıcılara varsayılan dosya izinleri uygulanır. “Enforce” seçeneği, yönetici olmayan tüm kullanıcılar için bir değeri kilitler."
"sourceAccessDefaultsHelp": "Kaynaklara ve yeni kullanıcılara uygulanan varsayılan dosya izinleri. \"Uygula\" seçeneği, yönetici olmayan tüm kullanıcılar için bir değeri kilitler."
},
"sidebar": {
"updateIsAvailable": "Bir güncelleme mevcut!",
"fileActions": "Dosya işlemleri",
"customizeLinks": "Kenar Çubuğu Bağlantılarını Özelleştirme",
"customizeLinks": "Kenar Çubuğu Bağlantılarını Özelleştir",
"customizeLinksDescription": "Kenar çubuğunuzda gösterilen bağlantıları özelleştirin. Yeniden sıralamak için sürükleyip bırakın.",
"currentLinks": "Güncel Bağlantılar",
"addNewLink": "Yeni Bağlantı Ekle",
"linkType": "Bağlantı Türü",
"selectLinkType": "Bir bağlantı türü seçin",
"customLink": "Özel Bağlantı",
"selectSource": "Kaynak Seçiniz",
"selectSource": "Kaynak Seç",
"chooseSource": "Bir kaynak seçin",
"selectTool": "Araç Seçin",
"selectTool": "Araç Seç",
"chooseTool": "Bir araç seçin",
"linkName": "Bağlantı Adı",
"linkNamePlaceholder": "Bağlantı adını girin",
@@ -321,40 +321,40 @@
"noLinksYet": "Henüz yapılandırılmış bağlantı yok. İlk bağlantınızı aşağıya ekleyin.",
"customizeShareLinksDescription": "Bu paylaşım için kenar çubuğunda gösterilen bağlantıları özelleştirin. Yeniden sıralamak için sürükleyip bırakın.",
"editLink": "Bağlantıyı Düzenle",
"selectShare": "Paylaşımı Seçin",
"selectShare": "Paylaşım Seç",
"chooseShare": "Bir paylaşım seçin",
"browseIcons": "Simgelere Gözat",
"browseIcons": "Simgelere Göz At",
"pickIcon": "Bir Simge Seçin",
"pickIconDescription": "Materyal Simgeleri kitaplığından bir simge arayın ve seçin",
"linkIconPlaceholder": "İsteğe bağlı olarak bir malzeme simgesi adı belirtin",
"pickIconDescription": "Material Icons kitaplığından bir simge arayın ve seçin",
"linkIconPlaceholder": "İsteğe bağlı olarak bir Material simge adı belirtin",
"switchMode": "Kenar çubuğu modunu değiştir",
"showIndexedUsageDescription": "Kaynaktaki dizinlenmiş dosyalardan hesaplanan kullanım çubuğunu görüntüleyin.",
"showDiskUsageDescription": "İşletim sistemi tarafından bildirilen disk/bölüm boyutundan kullanım çubuğunu görüntüleyin. Bu boyut büyük olasılıkla daha büyük olacaktır ve dizine eklenmemiş dosyaları da içerir.",
"usageTextDisplay": "Hangi kullanım metni görüntülenmelidir?",
"usageTextIndexed": "endeksli olarak kullanım",
"usageTextIndexed": "dizinlenmiş haliyle kullanım",
"usageTextDisk": "disk/bölüm tarafından bildirilen kullanım",
"dividerNamePlaceholder": "Bölücü metin (bir çizgi göstermek için boş bırakın)",
"showIndexedUsage": "İndekslenmiş haliyle kullanımını göster",
"dividerNamePlaceholder": "Ayırıcı metin (bir çizgi göstermek için boş bırakın)",
"showIndexedUsage": "Kullanımı dizinlenmiş haliyle göster",
"showDiskUsage": "Disk/bölüm tarafından bildirilen kullanım miktarını göster"
},
"share": {
"notice": "Not: Bir paylaşım oluşturulduğunda, herkese daha açık hale gelir ve erişim için farklı kurallara sahip olur. Paylaşımınız güvenli bir şekilde yapılandırılmamışsa, herkes tarafından erişilebilir.",
"disableAnonymousDescription": "Etkinleştirildiğinde, paylaşıma yalnızca kimliği doğrulanmış kullanıcılar erişebilir. Kullanıcının paylaşımın kaynağına erişimi olmalıdır.",
"disableThumbnailsDescription": "Etkinleştirildiğinde, önizleme küçük resimleri paylaşımda gösterilmeyecektir.",
"enableAllowedUsernamesDescription": "Etkinleştirildiğinde, paylaşıma yalnızca belirtilen kullanıcılar erişebilir. Kullanıcının paylaşımın kaynağına erişimi olmalıdır.",
"disableAnonymousDescription": "Paylaşıma yalnızca kimliği doğrulanmış kullanıcılar erişebilir. Kullanıcının paylaşımın kaynağına erişimi olmalıdır.",
"disableThumbnailsDescription": "Önizleme küçük resimleri paylaşımda gösterilmez.",
"enableAllowedUsernamesDescription": "Paylaşıma yalnızca belirtilen kullanıcılar erişebilir. Kullanıcının paylaşımın kaynağına erişimi olmalıdır.",
"downloadsLimitDescription": "Paylaşımdaki herhangi bir dosya/klasörün en fazla kaç kez indirilebileceği. Sınırsız için boş bırakın.",
"maxBandwidthDescription": "Saniye başına kilobayt cinsinden maksimum indirme bant genişliği. Sınırsız için boş bırakın.",
"shareThemeDescription": "Paylaşım bağlantısı için kullanılacak tema.",
"disableAnonymous": "Anonim erişimi devre dışı bırakın",
"maxBandwidthDescription": "kbps cinsinden maksimum indirme bant genişliği. Sınırsız için boş bırakın.",
"shareThemeDescription": "Paylaşım bağlantısı için kullanılacak tema",
"disableAnonymous": "Anonim erişimi devre dışı bırak",
"disableThumbnails": "Küçük Resimleri Devre Dışı Bırak",
"enableAllowedUsernames": "Yalnızca belirli kullanıcılarla paylaşın",
"enableAllowedUsernames": "Yalnızca belirli kullanıcılarla paylaş",
"allowedUsernamesPlaceholder": "Kullanıcı adlarını virgülle ayırarak girin",
"keepAfterExpiration": "Süresi dolduktan sonra paylaşımı silme",
"keepAfterExpirationDescription": "Paylaşım, süresi dolduktan sonra silinmeyecektir. Bu, bir paylaşımın süresini uzatmak veya süresi dolduktan sonra daha fazla düzenlemek istiyorsanız kullanışlıdır.",
"shareDurationDescription": "Paylaşımın sona ermesinden önceki süre. Kalıcı bir paylaşım için boş bırakın.",
"passwordDescription": "Kimliği doğrulanmış kullanıcılar da dahil olmak üzere bu paylaşıma erişmek için gereken isteğe bağlı parola.",
"shareThemeColorDescription": "Paylaşımın temasına uygulanan CSS renk değeri (örneğin, kırmızı, #0ea5e9, rgb(14,165,233)).",
"shareTitleDescription": "Paylaşım sayfasında gösterilen özel sayfa başlığı.",
"shareTitleDescription": "Paylaşım sayfasında gösterilen özel sayfa başlığı. Bu paylaşım bir PWA olarak yüklenirse, uygulama adı 30 karakterle sınırlıdır.",
"shareDescriptionHelp": "Paylaşım sayfasında gösterilen kısa açıklama (meta etiketlerde kullanılabilir).",
"shareBannerDescription": "Banner resmi URL'si veya istemci tarafından erişilebilen yol -- mutlak veya dizin yolu kabul edilir: örneğin 'https://domain.com/banner.png' veya '/path/to/banner.png'",
"shareFaviconDescription": "Favicon URL'si veya istemci tarafından erişilebilen yol -- mutlak veya dizin yolu kabul edilir: örneğin 'https://domain.com/favicon.png' veya '/path/to/favicon.png'",
@@ -367,10 +367,10 @@
"disableSidebarDescription": "Paylaşılan sayfadaki kenar çubuğunu devre dışı bırakın.",
"enforceDarkLightMode": "Tema modunu zorla",
"enforceDarkLightModeDescription": "Kullanıcı tercihlerini geçersiz kılarak bu paylaşım için belirli bir tema modunu (koyu veya açık) zorlayın.",
"default": "Bir modu zorlamayın",
"dark": "Karanlık",
"light": "Işık",
"enableOnlyOffice": "OnlyOffice görüntüleyiciyi etkinleştirin",
"default": "Kullanıcı tercihini kullan",
"dark": "Koyu",
"light": "Açık",
"enableOnlyOffice": "OnlyOffice görüntüleyiciyi etkinleştir",
"enableOnlyOfficeDescription": "Bu paylaşımda OnlyOffice kullanarak ofis dosyalarının görüntülenmesine izin verin.",
"titleDefault": "Paylaşılan dosyalar - {title}",
"defaultViewMode": "Varsayılan görünüm modu",
@@ -383,7 +383,7 @@
"perUserDownloadLimit": "İndirme sınırını her kullanıcıya uygulayın",
"perUserDownloadLimitDescription": "Etkinleştirildiğinde, indirme sınırı her kullanıcıya uygulanacaktır. Anonim kullanıcılar bu paylaşımdan herhangi bir dosya indiremeyecektir.",
"descriptionUploadDefault": "Dosyaları yüklemeniz için size bir paylaşım gönderildi.",
"extractEmbeddedSubtitles": "Gömülü altyazıları çıkarma",
"extractEmbeddedSubtitles": "Gömülü altyazıları çıkar",
"extractEmbeddedSubtitlesDescription": "Yüklenen medya dosyalarından gömülü altyazıları ayıklayın -- bu işlem büyük dosyalar için yavaş olabilir.",
"disableDownload": "Dosya indirmeyi devre dışı bırak",
"disableDownloadDescription": "Hiç kimse dosya indiremeyecek veya bir dosyanın içeriğini görüntüleyemeyecektir.",
@@ -396,22 +396,22 @@
"allowCreate": "Dosya ve klasör oluşturmaya ve yüklemeye izin ver",
"allowCreateDescription": "Herkes bu paylaşımda yeni dosyalar ve klasörler oluşturabilir ve yükleyebilir.",
"customizeSidebarLinksButton": "Bağlantıları Özelleştir",
"shareInfo": "QR Kodunu ve Bilgilerini Paylaş",
"shareInfoDescription": "Bu bağlantı, başlık, açıklama, başlık ve QR kodu dahil olmak üzere paylaşımın bilgilerini bir açılır pencerede görüntüler.",
"shareInfo": "Paylaşım QR Kodu ve Bilgisi",
"shareInfoDescription": "Bu bağlantı, başlık, açıklama, afiş ve QR kodu dahil olmak üzere paylaşımın bilgilerini bir açılır pencerede görüntüler.",
"downloadDescription": "Bu bağlantı, tüm paylaşım içeriğinin sıkıştırılmış bir dosya olarak indirilmesini sağlar.",
"disableLoginOption": "Giriş seçeneğini gizle",
"disableLoginOptionDescription": "Anonim kullanıcılar için kenar çubuğundaki oturum açma düğmesini gizleyin. Etkinleştirildiğinde, anonim kullanıcılar oturum açma seçeneğini görmez.",
"browseFiles": "Dosyalara göz atın",
"selectBannerImage": "Afiş resmini seçin",
"selectFaviconImage": "Favicon resmini seçin",
"browseFiles": "Dosyalara göz at",
"selectBannerImage": "Afiş görselini seç",
"selectFaviconImage": "Favicon görselini seç",
"deleteFailed": "Paylaşım silinemedi",
"readOnlySourceNotice": "Bu kaynak salt okunurdur. Yükleme paylaşımları ve dosyaları değiştiren izinler devre dışı bırakılmıştır."
},
"api": {
"createdAt": "Oluşturuldu:",
"expiresAt": "Son kullanma tarihi:",
"expiresAt": "Sona erme:",
"permissions": "İzinler:",
"tokenDuration": "Token Süresi:",
"tokenDuration": "Belirteç Süresi:",
"durationNumberPlaceholder": "Sayı",
"permissionNote": "Anahtar için en az bir izin seçin. Kullanıcınız da bu izne sahip olmalıdır.",
"swaggerLinkText": "swagger sayfası",
@@ -421,29 +421,29 @@
"apiKeyDeleted": "API belirteci silindi!",
"description": "API belirteçleri, onları oluşturan kullanıcıyı temel alır.",
"clickToCopyKey": "Belirteci kopyalamak için tıklayın (gizli)",
"customizeToken": "Simgeyi Özelleştir",
"customizeToken": "Belirteci Özelleştir",
"minimalInfo": "Belirteç özelleştirmelerini hariç tutar, izinler ilişkili kullanıcıyla eşleşir ve başka hiçbir özniteliğe sahip olmaz.",
"createTitle": "API Belirteci Oluşturma",
"createTitle": "API Belirteci Oluştur",
"keyNamePlaceholder": "API belirteci için benzersiz bir ad girin",
"customizeTokenInfo": "Belirteç izinlerini ve özniteliklerini özelleştirin. Bu, bazı WebDAV istemcilerinde çalışmayabilecek daha uzun bir belirteç oluşturacaktır."
"customizeTokenInfo": "Genel belirteç izinlerini özelleştirin (yönetici, API, paylaşım, gerçek zamanlı). Dosya izinleri her zaman kaynak bazında ilişkili kullanıcı hesabından gelir."
},
"index": {
"assessment": "Değerlendirme",
"lastScanned": "Son Taranan",
"lastScanned": "Son Tarama",
"quickScan": "Hızlı Tarama Saniyeleri",
"fullScan": "Tam Tarama Saniyeleri",
"settingsHover": "Kullanıcı Ayarları",
"toggleClick": "Tek Tıklamayı Değiştir",
"toggleDark": "Karanlık Modu Aç / Kapat",
"toggleSticky": "Yapışkan Modunu Değiştir",
"toggleClick": "Tek Tıklamayı Aç/Kapat",
"toggleDark": "Koyu Modu Aç/Kapat",
"toggleSticky": "Sabit Modu Aç/Kapat",
"noSources": "Bu kullanıcı için kullanılabilir kaynak yok. Ya bu kullanıcı için bir kaynak ekleyin ya da defaultEnabled: true ile bir kaynak yapılandırın",
"unknown": "Bilinmiyor",
"simple": "Basit",
"normal": "Normal",
"complex": "Kompleks",
"highlyComplex": "Son Derece Karmaşık",
"complex": "Karmaşık",
"highlyComplex": "Çok Karmaşık",
"ready": "Hazır",
"indexing": "İndeksleme",
"indexing": "Dizinleniyor",
"unavailable": "Kullanılamıyor"
},
"otp": {
@@ -454,36 +454,36 @@
"invalidCodeType": "Doğrulama kodu 6 İngilizce hane olmalıdır",
"verificationFailed": "Doğrulama Başarısız",
"verificationSucceed": "Doğrulama Başarılı",
"generationFailed": "Nesil Başarısız",
"generationFailed": "Oluşturma Başarısız",
"passwordRequiredForGeneration": "Kimlik doğrulayıcı kodu oluşturmak için parolanız gereklidir."
},
"general": {
"name": "İsim",
"path": "Yol",
"user": "Kullanıcı",
"group": "Grup",
"signup": "Kaydolun",
"password": "Şifre",
"username": "Kullanıcı Adı",
"files": "Dosyalar",
"folders": "Klasörler",
"exit": "Çıkış",
"permanent": "Daimi",
"hash": "Hash",
"of": ".",
"name": "İsim{suffix}",
"path": "Yol{suffix}",
"user": "Kullanıcı{suffix}",
"group": "Grup{suffix}",
"signup": "Kaydol{suffix}",
"password": "Parola{suffix}",
"username": "Kullanıcı Adı{suffix}",
"files": "Dosyalar{suffix}",
"folders": "Klasörler{suffix}",
"exit": "Çıkış{suffix}",
"permanent": "Kalıcı{suffix}",
"hash": "Hash{suffix}",
"of": "/{suffix}",
"percentage": "{percentage}%",
"type": "Tip{suffix}",
"type": "Tür{suffix}",
"size": "Boyut{suffix}",
"home": "Ev{suffix}",
"home": "Ana Sayfa{suffix}",
"help": "Yardım{suffix}",
"login": "Giriş{suffix}",
"logout": "Oturum Kapatma{suffix}",
"logout": "Çıkış Yap{suffix}",
"preview": "Önizleme{suffix}",
"settings": "Ayarlar{suffix}",
"links": "Bağlantılar{suffix}",
"tool": "Alet{suffix}",
"tool": "Araç{suffix}",
"status": "Durum{suffix}",
"analyze": "Analiz{suffix}",
"analyze": "Analiz Et{suffix}",
"results": "Sonuçlar{suffix}",
"users": "Kullanıcılar{suffix}",
"photos": "Fotoğraflar",
@@ -491,118 +491,118 @@
"models": "3D Modeller",
"documents": "Belgeler{suffix}",
"archives": "Arşivler{suffix}",
"images": "Görüntüler{suffix}",
"images": "Görseller{suffix}",
"search": "Arama{suffix}",
"number": "Sayı{suffix}",
"types": "Türleri{suffix}",
"number": "Numara{suffix}",
"types": "Türler{suffix}",
"admin": "Yönetici{suffix}",
"language": "Dil{suffix}",
"dark": "Karanlık{suffix}",
"light": "Işık{suffix}",
"expires": "Sona erdi{suffix}",
"dark": "Koyu{suffix}",
"light": "Açık{suffix}",
"expires": "Sona eriyor{suffix}",
"downloads": "İndirmeler{suffix}",
"submit": "Giriş{suffix}",
"submit": "Gönder{suffix}",
"navigation": "Navigasyon{suffix}",
"loading": "Yükleniyor{suffix}",
"add": "Ekle{suffix}",
"cancel": "İptal{suffix}",
"verify": "Doğrulama{suffix}",
"verify": "Doğrula{suffix}",
"close": "Kapat{suffix}",
"copy": "Anlaşıldı{suffix}",
"copy": "Kopyala{suffix}",
"create": "Oluştur{suffix}",
"delete": "Silme{suffix}",
"delete": "Sil{suffix}",
"download": "İndir{suffix}",
"file": "Dosya{suffix}",
"folder": "Klasör{suffix}",
"info": "Bilgi{suffix}",
"move": "Hareket{suffix}",
"move": "Taşı{suffix}",
"new": "Yeni{suffix}",
"next": "Sonraki{suffix}",
"ok": "TAMAM.{suffix}",
"ok": "Tamam{suffix}",
"previous": "Önceki{suffix}",
"rename": "Yeniden Adlandır{suffix}",
"replace": "Değiştirin{suffix}",
"replace": "Değiştir{suffix}",
"save": "Kaydet{suffix}",
"select": "Seçiniz{suffix}",
"select": "Seç{suffix}",
"share": "Paylaş{suffix}",
"update": "Güncelleme{suffix}",
"update": "Güncelle{suffix}",
"upload": "Yükle{suffix}",
"openFile": "Dosya aç{suffix}",
"edit": "Düzenle{suffix}",
"pause": "Duraklat{suffix}",
"resume": "Özgeçmiş{suffix}",
"resume": "Devam Et{suffix}",
"retry": "Yeniden Dene{suffix}",
"disable": "Devre dışı bırak{suffix}",
"confirm": "Onaylayın{suffix}",
"stay": "Kalmak{suffix}",
"scopes": "Dürbünler{suffix}",
"uploading": "Yükleme{suffix}",
"confirm": "Onayla{suffix}",
"stay": "Kal{suffix}",
"scopes": "Kapsamlar{suffix}",
"uploading": "Yükleniyor{suffix}",
"paused": "Duraklatıldı{suffix}",
"conflict": "Çatışma{suffix}",
"change": "Değişim{suffix}",
"lines": "Çizgiler{suffix}",
"conflict": "Çakışma{suffix}",
"change": "Değiştir{suffix}",
"lines": "Satırlar{suffix}",
"lastUpdate": "Son Güncelleme{suffix}",
"latency": "Gecikme{suffix}",
"all": "Tümü{suffix}",
"acknowledge": "Onaylamak{suffix}",
"expiration": "Son kullanma tarihi{suffix}",
"acknowledge": "Onayla{suffix}",
"expiration": "Sona erme{suffix}",
"zoom": "Yakınlaştır{suffix}",
"background": "Arka plan{suffix}",
"reset": "Sıfırla{suffix}",
"space": "Uzay{suffix}",
"space": "Alan{suffix}",
"format": "Biçim{suffix}",
"office": "Ofis{suffix}",
"divider": "Bölücü{suffix}",
"divider": "Ayırıcı{suffix}",
"audio": "Ses{suffix}",
"readOnly": "Salt okunur{suffix}",
"private": "Özel{suffix}",
"yes": "Evet{suffix}",
"no": "Hayır{suffix}",
"to": "...'ye{suffix}",
"to": "-e{suffix}",
"and": "ve{suffix}",
"or": "veya{suffix}",
"camera": "Kamera{suffix}",
"video": "Video{suffix}",
"anonymous": "isimsiz",
"anonymous": "anonim",
"details": "Ayrıntılar{suffix}",
"ipAddress": "IP adresi{suffix}",
"paths": "Yollar{suffix}",
"browse": "Göz at{suffix}",
"error": "Hata{suffix}",
"value": "Değer{suffix}",
"selected": "{prefix}seçilen{suffix}",
"selected": "{prefix}seçili{suffix}",
"pinned": "Sabitlenmiş{suffix}",
"created": "Oluşturuldu{suffix}",
"actions": "Eylemler{suffix}",
"enabled": "Etkinleştirildi{suffix}",
"disabled": "Engelli{suffix}",
"disabled": "Devre dışı{suffix}",
"permissions": "İzinler{suffix}",
"management": "Yönetim{suffix}",
"profile": "Profil{suffix}",
"global": "Küresel{suffix}",
"global": "Genel{suffix}",
"theme": "Tema{suffix}",
"pinnedFolders": "Sabitlenmiş klasörler",
"pinnedFiles": "Sabitlenmiş dosyalar",
"selectionSingle": "seçilen öğe",
"selectionMultiple": "seçilen öğeler",
"selectionSingle": "öğe seçildi",
"selectionMultiple": "öğe seçildi",
"newUser": "Yeni kullanıcı",
"newFolder": "Yeni klasör",
"profileSettings": "Profil ayarları",
"shareSettings": "Ayarları paylaş",
"shareSettings": "Paylaşım ayarları",
"globalSettings": "Genel ayarlar",
"userManagement": "Kullanıcı yönetimi",
"shareManagement": "Hisse yönetimi",
"shareHash": "Hash'i paylaş",
"shareManagement": "Paylaşım yönetimi",
"shareHash": "Paylaşım hash'i",
"downloadFiles": "Dosyaları indir",
"editFiles": "Dosyaları düzenle",
"createFiles": "Dosya oluştur",
"deleteFiles": "Dosyaları sil",
"shareFiles": "Dosya paylaşımı",
"shareTheme": "Temayı paylaş",
"shareTitle": "Başlığı paylaş",
"shareTheme": "Paylaşım teması",
"shareTitle": "Paylaşım başlığı",
"allUsers": "Tüm kullanıcılar",
"allSources": "Tüm kaynaklar",
"allShares": "Tüm hisseler",
"allShares": "Tüm paylaşımlar",
"playPause": "Oynat/Duraklat",
"clearSelection": "Seçimi temizle",
"interval": "Aralık{suffix}",
@@ -625,7 +625,7 @@
"totalDenied": "Reddedildi",
"totalAllowed": "İzin verildi",
"all": "Tümünü Reddet",
"defaultBehaviorDescription": "Bu, bir kullanıcı belirli erişim kurallarının bulunmadığı bir yola erişmeye çalıştığında ne olacağını gösterir. 'Allow' kullanıcıların varsayılan olarak erişebileceği anlamına gelir, 'Deny' ise açıkça izin verilmediği sürece kullanıcıların varsayılan olarak engellendiği anlamına gelir.",
"defaultBehaviorDescription": "Bu, bir kullanıcı belirli erişim kurallarının bulunmadığı bir yola erişmeye çalıştığında ne olacağını gösterir. 'İzin ver' kullanıcıların varsayılan olarak erişebileceği, 'Reddet' ise açıkça izin verilmediği sürece kullanıcıların varsayılan olarak engellendiği anlamına gelir.",
"defaultBehavior": "Varsayılan eylem{suffix}",
"deletedCascade": "Erişim kuralları yoldan ve tüm alt yollardan silindi!",
"cascadeDelete": "Yinelemeli Silme",
@@ -635,9 +635,9 @@
"title": "Yüklemeler & İndirmeler",
"maxConcurrentUpload": "Maksimum eşzamanlı yükleme",
"maxConcurrentUploadHelp": "Eşzamanlı yüklemelerin maksimum sayısı. Daha yüksek değerler genellikle daha iyi performans gösterir, ancak bu birçok faktöre bağlıdır.",
"uploadChunkSizeMb": "MB cinsinden yığın boyutu yükleme",
"uploadChunkSizeMb": "MB cinsinden yükleme yığın boyutu",
"uploadChunkSizeMbHelp": "Tipik olarak 5 ila 50 MB, daha küçük kararsız veya kısıtlı bağlantılar için daha iyidir. 0 olarak ayarlandığında yükleme yığınlaması devre dışı bırakılır.",
"clearAll": "Clear completed hata, duraklama ve çakışmayı kaldırmalıdır",
"clearAll": "Tamamlananları temizle; hata, duraklatma ve çakışmaları da kaldırmalı",
"clearAllDescription": "Tamamlananları temizle düğmesi hata, duraklatılmış ve çakışma durumları da dahil olmak üzere bitmiş durumdaki tüm yüklemeleri temizler.",
"uploadSettings": "Yükleme Ayarları",
"downloadChunkSizeMb": "MB cinsinden indirme yığın boyutu",
@@ -648,7 +648,7 @@
"green": "yeşil",
"blue": "mavi",
"yellow": "sarı",
"violet": "menekşe",
"violet": "mor",
"orange": "turuncu"
},
"profileSettings": {
@@ -659,34 +659,34 @@
"deleteAfterArchive": "Arşiv oluşturma/çıkarma işleminden sonra kaynak dosyaları sil",
"deleteAfterArchiveDescription": "Etkinleştirildiğinde, arşivlerin başarıyla oluşturulması/çıkarılmasından sonra kaynak dosyaları siler",
"hideFilesInTree": "Ağaç gezintisinde dosyaları gizle",
"hideFilesInTreeDescription": "Etkinleştirildiğinde, kenar çubuğu Ağaç Gezintisinde yalnızca klasörler gösterilir",
"setDateFormat": "Tam tarih biçimini ayarlama",
"hideFilesInTreeDescription": "Etkinleştirildiğinde, kenar çubuğu Ağaç Gezintisinde yalnızca klasörler gösterilir.",
"setDateFormat": "Tam tarih biçimini ayarla",
"showHiddenFiles": "Gizli dosyaları göster",
"showHiddenFilesDescription": "Gizli dosyaları listede gösterin (örn. .thumbnails, ProgramData), ancak dizine eklenecek şekilde yapılandırılmışlarsa yine de manuel olarak erişilebilirler.",
"showQuickDownload": "Hızlı erişim için indirme simgesini göster",
"showQuickDownloadDescription": "Anında indirmek için tıklanabilecek her dosya öğesinde hızlı erişim için indirme simgesini gösterin.",
"disableUpdateNotifications": "Bildirim güncellemesini göstermeyi devre dışı bırak",
"disableUpdateNotificationsDescription": "Kenar çubuğunda yönetici kullanıcılar için gösterilen güncelleme bildirim başlığını devre dışı bırakın.",
"disableHideSidebar": "Dosyaları önizlerken veya düzenlerken kenar çubuğunu açık tutma",
"disableHideSidebar": "Dosyaları önizlerken veya düzenlerken kenar çubuğunu açık tut",
"disableHideSidebarDescription": "Etkinleştirildiğinde, dosyaları önizlerken veya düzenlerken kenar çubuğu otomatik olarak gizlenmez.",
"autoplayMedia": "Medya dosyalarını görüntülerken otomatik oynatma",
"autoplayMedia": "Medya dosyalarını görüntülerken otomatik oynat",
"autoplayMediaDescription": "Medya dosyalarını görüntülerken otomatik oynat. Bu yalnızca dosya bir medya dosyasıysa çalışacaktır.",
"sidebarOptions": "Kenar çubuğu seçenekleri",
"disableQuickToggles": "Hızlı geçişleri devre dışı bırakma",
"disableQuickToggles": "Hızlı geçişleri devre dışı bırak",
"disableQuickTogglesDescription": "Kenar çubuğunda görünen hızlı geçişleri devre dışı bırakın.",
"disableSearchOptions": "Arama seçeneklerini devre dışı bırak",
"disableSearchOptionsDescription": "Kenar çubuğunda görünen arama seçeneklerini devre dışı bırakın.",
"hideSidebarFileActions": "Kenar çubuğundaki dosya eylemleri düğmesini gizle",
"deleteWithoutConfirming": "Dosyaları onay istemi olmadan silme",
"deleteWithoutConfirming": "Dosyaları onay istemi olmadan sil",
"deleteWithoutConfirmingDescription": "Dosyaları onay istemi olmadan silin. Bu yalnızca dosya bir medya dosyasıysa çalışacaktır.",
"editorQuickSave": "Düzenleyicide hızlı kaydetme düğmesi",
"editorQuickSaveDescription": "Metin düzenleyici her zaman kaydet simgesini gösterecek ve diğer eylemleri gizleyecektir",
"previewOfficeDescription": "Liste görünümünde ofis dosya türleri için küçük resimleri gösterme",
"disableThumbnailPreviews": "Belirli dosya uzantıları için küçük resimleri devre dışı bırakma",
"previewOfficeDescription": "Liste görünümünde ofis dosya türleri için küçük resimleri göster",
"disableThumbnailPreviews": "Belirli dosya uzantıları için küçük resimleri devre dışı bırak",
"disableThumbnailPreviewsDescription": "Küçük resim önizlemelerinin devre dışı bırakılacağı dosya uzantılarının boşluk bırakılarak ayrılmış listesi (örn. '.txt .html').",
"disableViewingFiles": "Belirli dosya uzantıları için dosya görüntüleyiciyi/editörü devre dışı bırakma",
"disableViewingFiles": "Belirli dosya uzantıları için dosya görüntüleyiciyi/düzenleyiciyi devre dışı bırak",
"disableViewingFilesDescription": "Dosya görüntüleyici/düzenleyicinin devre dışı bırakılacağı dosya uzantılarının boşluk bırakılarak ayrılmış listesi (örn. '.txt .pdf').",
"disableOfficeEditor": "Belirli dosya uzantıları için ofis görüntüleyiciyi devre dışı bırakma",
"disableOfficeEditor": "Belirli dosya uzantıları için ofis görüntüleyiciyi devre dışı bırak",
"defaultThemeDescription": "default - Varsayılan tema",
"customTheme": "Temanızı seçin",
"showSelectMultiple": "Masaüstündeki bağlam menüsünde çoklu seçmeyi göster",
@@ -723,9 +723,9 @@
"showToolsInSidebar": "Araçları kenar çubuğunda göster",
"showToolsInSidebarDescription": "Araçlar bağlantısını her zaman kenar çubuğunda gösterin.",
"enforcedByAdmin": "Yöneticiniz, kullanıcıların bu özelliği değiştirmesini engellemiştir",
"singleClick": "Dosyaları tek tıklamayla açın",
"singleClick": "Dosyaları tek tıklamayla aç",
"stickySidebar": "Sabit kenar çubuğu",
"darkMode": "Karanlık mod"
"darkMode": "Koyu mod"
},
"editor": {
"uninitialized": "Düzenleyici başlatılamadı. Lütfen yeniden yükleyin.",
@@ -750,8 +750,8 @@
"emptyQueue": "Kuyruk boş",
"queueCleared": "Kuyruk temizlendi",
"queue": "Kuyruk",
"lyrics": "Şarkı sözü",
"noLyrics": "Şarkı sözü mevcut değil",
"lyrics": "Şarkı sözleri",
"noLyrics": "Şarkı sözü bulunamadı",
"lockLyrics": "Şarkı sözlerini kilitle",
"unlockLyrics": "Şarkı sözlerinin kilidini aç",
"toggleLyrics": "Şarkı sözlerini göster/gizle",
@@ -782,8 +782,8 @@
}
},
"fileSizeAnalyzer": {
"title": "Dosya Boyutu Analizörü",
"largerThan": "Daha Büyük (MB)",
"title": "Dosya Boyutu Analizcisi",
"largerThan": "Şundan Büyük (MB)",
"includeFolders": "Klasörleri Dahil Et",
"totalFiles": "Toplam Dosya{suffix}",
"totalSize": "Toplam Boyut{suffix}",
@@ -791,7 +791,7 @@
"completeResults": "Tam sonuçlar gösterilmektedir!",
"incompleteResults": "Sonuçlar eksik olabilir",
"relativePercentage": "Göreceli Yüzde{suffix}",
"percentageOfResults": "{percentage}Sonuçların %'si",
"percentageOfResults": "sonuçların %{percentage}'i",
"includeFoldersDescription": "Analize klasörleri dahil et"
},
"fileTypes": {
@@ -806,7 +806,7 @@
"tools": {
"description": "Dosyalarınız için yardımcı programlar ve analiz araçları",
"sizeAnalyzer": {
"name": "Dosya Boyutu Analizörü",
"name": "Dosya Boyutu Analizcisi",
"description": "Etkileşimli bir ağaç haritası ile büyük dosya ve klasörleri görselleştirin"
},
"title": "Araçlar",
@@ -815,15 +815,15 @@
"description": "Depolama alanını geri kazanmak için yinelenen dosyaları bulun ve yönetin"
},
"materialIconPicker": {
"name": "Malzeme Simge Seçici",
"description": "Canlı önizleme ile Materyal Simgelerine göz atın ve arayın",
"searchPlaceholder": "Arama simgeleri...",
"popularIcons": "{count} popüler simgeler gösteriliyor",
"showingResults": "{count} sonuçları gösteriliyor",
"name": "Material Simge Seçici",
"description": "Material Icons simgelerine canlı önizleme ile göz atın ve arayın",
"searchPlaceholder": "Simge ara...",
"popularIcons": "{count} popüler simge gösteriliyor",
"showingResults": "{count} sonuç gösteriliyor",
"noResults": "Simge bulunamadı",
"tryDifferentSearch": "Farklı bir arama terimi deneyin",
"custom": "Özel",
"useCustomIcon": "Özel simge kullanın: {name}",
"useCustomIcon": "Özel simge kullan: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
"browseFullLibrary": "Simge kitaplığının tamamına göz atın"
},
@@ -832,16 +832,16 @@
"fileWatcher": {
"name": "Dosya İzleyici",
"description": "Gecikme izleme ile dosyaları gerçek zamanlı olarak izleyin ve takip edin",
"emptyState": "Bir dosya seçin ve dosya değişikliklerini izlemeye başlamak için oynat'a tıklayın",
"emptyState": "Bir dosya seçin ve dosya değişikliklerini izlemeye başlamak için Oynat'a tıklayın",
"chooseFile": "Bir dosya seçin"
},
"advancedSearch": {
"name": "Gelişmiş Arama",
"description": "Kaynaklar, filtreler ve seçenekler üzerinde tam kontrol ile dizinde arama yapın",
"searchTerms": "Arama terimleri",
"matchAllTerms": "Tüm terimleri eşleştirin",
"matchAllTerms": "Tüm terimleri eşleştir",
"matchAllTermsDescription": "Etkinleştirildiğinde, her dosya için tüm arama terimleri bulunmalıdır",
"scopeAria": "Aramayı bir klasörle sınırlandırın",
"scopeAria": "Aramayı bir klasörle sınırla",
"noTerms": "En az bir arama terimi girin.",
"termTooShort": "Her bir arama terimi en az {minSearchLength} karakter olmalıdır",
"resultsTitle": "Arama sonuçları",
@@ -849,7 +849,7 @@
"noResults": "Eşleşen dosya veya klasör yok."
},
"activityViewer": {
"name": "Etkinlik Görüntüleyicisi",
"name": "Etkinlik Görüntüleyici",
"description": "İndirmeler, dosya değişiklikleri, paylaşımlar ve yönetici işlemleri kapsamındaki kullanıcı etkinliklerini görüntüleyin ve dışa aktarın",
"activityScope": "Etkinlik kategorisi",
"timeRange": "Zaman aralığı",
@@ -861,10 +861,10 @@
"eventType": "Etkinlik türü",
"eventSummary": "Etkinlik",
"interval": "Zaman aralığı",
"splitBy": "Şu kriterlere göre ayır",
"splitBy": "Şuna göre ayır",
"ungrouped": "Gruplama yok (toplamlar)",
"splitByTotal": "Toplam (bölünmemiş)",
"viewType": "Görüntüle",
"viewType": "Görünüm",
"chartView": "Çubuk grafik",
"lineView": "Çizgi grafiği",
"pieView": "Pasta grafik",
@@ -873,39 +873,39 @@
"exportCsv": "CSV'yi Dışa Aktar",
"totalEvents": "Toplam etkinlik sayısı{suffix}",
"cachedResult": "Önbelleğe alınmış sonuç",
"showingPage": "{total} adresindeki {shown} sayfası görüntüleniyor (sayfa {page}/{pages})",
"pageOf": "{page}. sayfası {pages}",
"showingPage": "{total} sonuçtan {shown} tanesi gösteriliyor (sayfa {page}/{pages})",
"pageOf": "Sayfa {page} / {pages}",
"pathFilter": "Yol filtresi",
"pathFilterMode": "Şu kriterlere göre filtrele",
"pathFilterMode": "Şuna göre filtrele",
"pathFilterPattern": "Desen",
"pathGlob": "Yol kalıbı (glob)",
"shareFilter": "Filtreyi paylaş",
"passkeyRegister": "Şifre eklendi",
"passkeyDelete": "Şifre kaldırıldı",
"shareFilter": "Paylaşım filtresi",
"passkeyRegister": "Geçiş anahtarı eklendi",
"passkeyDelete": "Geçiş anahtarı kaldırıldı",
"webSession": "Web oturumu",
"actorToken": "Aktör belirteci",
"affectedToken": "Etkilenen token",
"activityOverTime": "Zaman içindeki faaliyet",
"activityOverTimeBy": "Zaman içindeki faaliyetler, şuna göre: {subject}",
"activityBy": "Etkinlik düzenleyen {subject}",
"affectedToken": "Etkilenen belirteç",
"activityOverTime": "Zaman içindeki etkinlik",
"activityOverTimeBy": "{subject} bazında zaman içindeki etkinlik",
"activityBy": "{subject} bazında etkinlik",
"eventTotals": "Etkinlik toplamları",
"activityTotalsBy": "Etkinlik toplamları, şuna göre: {subject}",
"activityTotalsBy": "{subject} bazında etkinlik toplamları",
"subjectEventType": "etkinlik türü",
"viewActivity": "Etkinliği görüntüle",
"tableColumns": "Tablo sütunları",
"scopeAll": "Tüm etkinlikler",
"scopeFiles": "Dosya etkinliği",
"scopeShares": "Etkinliği paylaş",
"scopeShares": "Paylaşım etkinliği",
"eventBulkDelete": "Toplu silme",
"eventShareCreate": "Paylaş Oluştur",
"eventShareUpdate": "Güncellemeyi paylaş",
"eventShareDelete": "Paylaş Sil",
"eventShareCreate": "Paylaşım oluşturma",
"eventShareUpdate": "Paylaşım güncelleme",
"eventShareDelete": "Paylaşım silme",
"eventUserCreate": "Kullanıcı oluşturma",
"eventUserUpdate": "Kullanıcı güncellemesi",
"eventUserDelete": "Kullanıcı silme",
"eventAccessUpdate": "Erişim güncellemesi",
"eventTokenCreate": "Jeton oluşturma",
"eventTokenDelete": "Jeton silme",
"eventTokenCreate": "Belirteç oluşturma",
"eventTokenDelete": "Belirteç silme",
"webSessionNamed": "Web oturumu ({name})",
"scopeAccess": "Erişim etkinliği",
"eventAccessCreate": "Erişim oluştur",
@@ -924,10 +924,10 @@
"duplicateFinder": {
"minSize": "Minimum Boyut (MB){suffix}",
"minSizeHint": "Yalnızca bu boyuttan (megabayt cinsinden) büyük dosyaları analiz edin",
"findDuplicates": "Kopyaları Bul",
"findDuplicates": "Yinelenenleri Bul",
"groupsFound": "Yinelenen Gruplar Bulundu{suffix}",
"totalWastedSpace": "Toplam Boşa Harcanan Alan{suffix}",
"wasted": "Ziyan{suffix}",
"wasted": "Boşa Harcanan{suffix}",
"emptyState": "Yinelenen dosyaları belirlemek için arama kriterlerini girin ve Yinelenenleri Bul'a tıklayın"
},
"notifications": {
@@ -938,11 +938,11 @@
"appEnabled": "Uygulama bildirimleri",
"appEnabledDescription": "Bu sekme arka planda iken dosya işlemleri tamamlandığında tarayıcı bildirimlerini göster. Tarayıcı izni gerektirir. Yalnızca bu tarayıcıda saklanır.",
"appUnsupported": "Bu tarayıcıda uygulama bildirimleri desteklenmemektedir.",
"permissionGranted": "izin verilen",
"permissionGranted": "izin verildi",
"permissionDeniedHelp": "Bildirimler engellenmiştir. Bu site için tarayıcı ayarlarınızdan bildirimleri etkinleştirin.",
"uploadTitle": "Yükleme tamamlandı",
"downloadTitle": "Tam sürümü indir",
"moveTitle": "İşlem tamamlandı",
"downloadTitle": "İndirme tamamlandı",
"moveTitle": "Taşıma tamamlandı",
"copyTitle": "Kopyalama tamamlandı",
"uploadFailedTitle": "Yükleme başarısız",
"downloadFailedTitle": "İndirme başarısız",
@@ -955,13 +955,13 @@
"connectionLost": "Sunucu ile bağlantı kesildi. Yeniden bağlanmaya çalışıyorum..."
},
"threejs": {
"failedToLoad": "3B Model Yüklenemedi",
"failedToLoad": "3D Model Yüklenemedi",
"keyboard": "Klavye:",
"autoRotate": "Otomatik döndürme",
"rotateY": "Y Döndür",
"rotateX": "X'i döndür",
"rotateX": "X Döndür",
"controls": "3D Kontroller",
"changeBackground": "Arka plan rengini değiştirme"
"changeBackground": "Arka plan rengini değiştir"
},
"onlyoffice": {
"debugTitle": "OnlyOffice Hata Ayıklama İzi",
@@ -979,11 +979,11 @@
"onlyOffice": "OnlyOffice",
"fileBrowser": "FileBrowser",
"processSteps": "Süreç Adımları",
"entries": "girişler",
"entries": "kayıt",
"internalUrl": "Dahili URL",
"userRequest": "Kullanıcı Talebi",
"logsNotAvailable": "Arka uç günlükleri mevcut değil -- arka uç günlüklerini görüntülemek için kullanıcının yönetici olması gerekir",
"waitingForLogs": "Arka uç günlüklerini bekliyorum...",
"waitingForLogs": "Arka uç günlükleri bekleniyor...",
"errorDetected": "Hata Algılandı",
"integrationFailed": "OnlyOffice entegrasyonu başarısız oldu. Yukarıdaki başarısız adımları kontrol edin.",
"documentInitialized": "Belge Başlatıldı",
@@ -991,26 +991,26 @@
"troubleshootingGuide": "Sorun Giderme Kılavuzunu Görüntüle",
"startingInit": "OnlyOffice başlatılıyor...",
"configCheck": "Yapılandırma Kontrolü - URL Oluşturuldu",
"apiRequest": "API İsteği - Konfigürasyon Alındı",
"serverConnection": "OnlyOffice Sunucu Bağlantısı - Konfigürasyon Gönderildi",
"testingConnectivity": "OnlyOffice sunucu bağlantısını test etme...",
"apiRequest": "API İsteği - Yapılandırma Alındı",
"serverConnection": "OnlyOffice Sunucu Bağlantısı - Yapılandırma Gönderildi",
"testingConnectivity": "OnlyOffice sunucu bağlantısı test ediliyor...",
"serverReachable": "OnlyOffice sunucusuna erişilebilir görünüyor",
"documentDownload": "Belge İndirme - Tamamlandı",
"editorSuccess": "Editör Başlatma - Başarılı! Tüm adımlar tamamlandı.",
"editorSuccess": "Düzenleyici Başlatma - Başarılı! Tüm adımlar tamamlandı.",
"setupError": "Kurulum Hatası"
},
"pwa": {
"install": "Uygulamayı yükle"
},
"time": {
"time": "Zaman{suffix}",
"minute": "dakika{suffix}",
"minutes": "Tutanak{suffix}",
"hour": "saat{suffix}",
"hours": "Çalışma Saatleri{suffix}",
"day": "gün{suffix}",
"days": "Günler{suffix}",
"months": "Aylar{suffix}",
"time": "{prefix}Zaman{suffix}",
"minute": "{prefix}dakika{suffix}",
"minutes": "{prefix}Dakika{suffix}",
"hour": "{prefix}saat{suffix}",
"hours": "{prefix}Saat{suffix}",
"day": "{prefix}gün{suffix}",
"days": "{prefix}Gün{suffix}",
"months": "{prefix}Ay{suffix}",
"timeUnit": "Zaman birimi"
}
}
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "Ласкаво просимо до FileBrowser Quantum!",
"halloweenTitle": "Щасливого Гелловіну!",
"halloweenBody": "Застосовано тему Хеллоуїна. Ви можете вимкнути її, якщо вона вам не подобається, або назавжди вимкнути в налаштуваннях профілю, вибравши будь-яку іншу тему.",
"firstLoadBody": "Під час запуску було створено нову базу даних. Якщо це не те, що ви очікували, перевірте ваш конфігураційний файл на наявність шляху до `сервер.бази даних` і переконайтеся, що він існує. У докері шлях до бази даних може бути `./data/database.db` або `./database.db`.",
"firstLoadBody": "Під час запуску було створено нову базу даних. Якщо це не те, що ви очікували, перевірте ваш конфігураційний файл на наявність шляху до `сервер.бази даних` і переконайтеся, що він існує. У докері шлях до бази даних може бути `./data/filebrowser.sqlite` або `./filebrowser.sqlite`.",
"selectPath": "Виберіть шлях",
"renameSuccess": "Елемент перейменовано успішно!",
"newFileSuccess": "Файл успішно створено!",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "欢迎访问 FileBrowser Quantum!",
"halloweenTitle": "万圣节快乐",
"halloweenBody": "万圣节主题已应用。如果不喜欢,可以将其禁用,或者在个人档案设置中选择其他主题将其永久禁用。",
"firstLoadBody": "启动时创建了一个新数据库。如果这与您的预期不符,请仔细检查配置文件中的 `server.database` 路径并确保其存在。在 docker 上,数据库路径可以是 `./data/database.db` 或 `./database.db`。",
"firstLoadBody": "启动时创建了一个新数据库。如果这与您的预期不符,请仔细检查配置文件中的 `server.database` 路径并确保其存在。在 docker 上,数据库路径可以是 `./data/filebrowser.sqlite` 或 `./filebrowser.sqlite`。",
"selectPath": "选择路径",
"renameSuccess": "项目重命名成功!",
"newFileSuccess": "文件创建成功!",
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more