mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-15 05:31:59 -05:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3974b28e5 | ||
|
|
ea85c50946 | ||
|
|
be47c7a1e7 | ||
|
|
609e1d6482 |
No files matched your search
@@ -5,6 +5,9 @@ on:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test_playwright:
|
||||
name: Test Playwright
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
name: Promote Beta to Stable
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to promote (format: 0.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
promote-beta-to-stable:
|
||||
name: Promote Beta to Stable
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }} # Uses Personal Access Token for branch operations
|
||||
|
||||
- name: Validate version format
|
||||
id: validate_version
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "❌ ERROR: Version must be in '0.0.0' format."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Version format validated: $VERSION"
|
||||
|
||||
- name: Check if beta branch exists
|
||||
id: check_beta_branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
|
||||
if ! git ls-remote --exit-code origin "$BETA_BRANCH"; then
|
||||
echo "❌ ERROR: Beta branch '$BETA_BRANCH' does not exist!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Beta branch '$BETA_BRANCH' exists."
|
||||
|
||||
- name: Check if stable branch already exists
|
||||
id: check_stable_branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
STABLE_BRANCH="stable/v$VERSION"
|
||||
|
||||
if git ls-remote --exit-code origin "$STABLE_BRANCH"; then
|
||||
echo "❌ ERROR: Stable branch '$STABLE_BRANCH' already exists!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Stable branch '$STABLE_BRANCH' does not exist."
|
||||
|
||||
- name: Create and push new stable branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
STABLE_BRANCH="stable/v$VERSION"
|
||||
|
||||
# Fetch latest changes
|
||||
git fetch origin "$BETA_BRANCH"
|
||||
|
||||
# Create new stable branch from beta branch
|
||||
git checkout -b "$STABLE_BRANCH" origin/"$BETA_BRANCH"
|
||||
|
||||
# Push new stable branch to remote
|
||||
git push origin "$STABLE_BRANCH"
|
||||
|
||||
echo "✅ Successfully created stable branch '$STABLE_BRANCH' from '$BETA_BRANCH'."
|
||||
|
||||
- name: Merge beta into stable
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
STABLE_BRANCH="stable/v$VERSION"
|
||||
|
||||
# Checkout stable branch
|
||||
git checkout "$STABLE_BRANCH"
|
||||
|
||||
# Merge beta into stable
|
||||
git merge --no-ff "origin/$BETA_BRANCH" -m "Merge $BETA_BRANCH into $STABLE_BRANCH"
|
||||
|
||||
# Push merge changes
|
||||
git push origin "$STABLE_BRANCH"
|
||||
|
||||
echo "✅ Successfully merged '$BETA_BRANCH' into '$STABLE_BRANCH'."
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
@@ -1,66 +0,0 @@
|
||||
name: stable release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "stable/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]+')-stable
|
||||
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: true
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
@@ -2,6 +2,44 @@
|
||||
|
||||
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.4
|
||||
|
||||
**Bugfixes**:
|
||||
- OnlyOffice editor failed to load after the CSP security fix; allow configured `integrations.office.url` and `integrations.office.internalUrl` origins in `script-src` ([#2874](https://github.com/gtsteffaniak/filebrowser/issues/2874)).
|
||||
|
||||
## 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**:
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
|
||||
- **Go 1.25+** (see `backend/go.mod`)
|
||||
- **Node.js 22.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
|
||||
- **Docker** (optional, for containerized development)
|
||||
- **Git** especially improtant on windows -- needed for bash support.
|
||||
- **Git** especially important on Windows -- needed for bash support.
|
||||
|
||||
### Optional Tools
|
||||
|
||||
|
||||
+10
-4
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,122 @@
|
||||
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) {
|
||||
prev := settings.Config.Integrations.OnlyOffice
|
||||
settings.Config.Integrations.OnlyOffice = settings.OnlyOffice{}
|
||||
t.Cleanup(func() { settings.Config.Integrations.OnlyOffice = prev })
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpaContentSecurityPolicy_IncludesOnlyOfficeOrigins(t *testing.T) {
|
||||
prev := settings.Config.Integrations.OnlyOffice
|
||||
t.Cleanup(func() { settings.Config.Integrations.OnlyOffice = prev })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
oo settings.OnlyOffice
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "url only",
|
||||
oo: settings.OnlyOffice{Url: "http://localhost:9052"},
|
||||
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://localhost:9052",
|
||||
},
|
||||
{
|
||||
name: "internal url only",
|
||||
oo: settings.OnlyOffice{InternalUrl: "http://onlyoffice-internal:80"},
|
||||
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://onlyoffice-internal:80",
|
||||
},
|
||||
{
|
||||
name: "both urls",
|
||||
oo: settings.OnlyOffice{
|
||||
Url: "https://office.example.com",
|
||||
InternalUrl: "http://onlyoffice-internal",
|
||||
},
|
||||
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com https://office.example.com http://onlyoffice-internal",
|
||||
},
|
||||
{
|
||||
name: "duplicate origins",
|
||||
oo: settings.OnlyOffice{
|
||||
Url: "http://localhost:9052",
|
||||
InternalUrl: "http://localhost:9052",
|
||||
},
|
||||
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://localhost:9052",
|
||||
},
|
||||
{
|
||||
name: "invalid urls ignored",
|
||||
oo: settings.OnlyOffice{
|
||||
Url: "not-a-url",
|
||||
InternalUrl: "://bad",
|
||||
},
|
||||
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
settings.Config.Integrations.OnlyOffice = tt.oo
|
||||
if got := spaContentSecurityPolicy("testnonce"); got != tt.want {
|
||||
t.Fatalf("unexpected CSP:\ngot: %s\nwant: %s", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,51 @@ 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 {
|
||||
policy := fmt.Sprintf(
|
||||
"script-src 'self' 'nonce-%s' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com",
|
||||
nonce,
|
||||
)
|
||||
for _, origin := range onlyOfficeScriptSrcOrigins() {
|
||||
policy += " " + origin
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func onlyOfficeScriptSrcOrigins() []string {
|
||||
oo := settings.Config.Integrations.OnlyOffice
|
||||
candidates := []string{
|
||||
cspOriginFromURL(oo.Url),
|
||||
cspOriginFromURL(oo.InternalUrl),
|
||||
}
|
||||
var origins []string
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
for _, origin := range candidates {
|
||||
if origin == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[origin]; ok {
|
||||
continue
|
||||
}
|
||||
seen[origin] = struct{}{}
|
||||
origins = append(origins, origin)
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
func cspOriginFromURL(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
|
||||
func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestContext, file, contentType string) (int, error) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
userSelectedTheme := ""
|
||||
@@ -80,7 +132,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 +247,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 +277,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 +390,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/")
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+166
-166
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": "تم إنشاء الملف بنجاح!",
|
||||
|
||||
@@ -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": "Преместването не беше успешно",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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": "Το αρχείο δημιουργήθηκε με επιτυχία!",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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 !",
|
||||
|
||||
@@ -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": "הקובץ נוצר בהצלחה!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ファイルは正常に作成されました!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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": "Файл создан успешно!",
|
||||
|
||||
@@ -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ý!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user