Compare commits

...
14 Commits
95 changed files with 1359 additions and 1028 deletions

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
{
"name": "filebrowser",
"image": "mcr.microsoft.com/devcontainers/go:1.25-bookworm",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"containerEnv": {
"CGO_ENABLED": "1",
"FILEBROWSER_DEVMODE": "true"
},
"forwardPorts": [80],
"portsAttributes": {
"80": {
"label": "filebrowser app",
"onAutoForward": "notify"
}
},
"postCreateCommand": "make setup",
"customizations": {
"vscode": {
"extensions": [
"golang.go",
"Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker"
]
}
}
}
+2
View File
@@ -1,4 +1,6 @@
*.db
*.db.old
*.sqlite
*.bak
*.log
*.mjs
+19
View File
@@ -2,11 +2,30 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v1.3.5
**Notes**:
- any password change or admin user property change for a password user requires reauthentication (#2112)
**BugFixes**:
- PWA icon fixes (#2292)
- deny-rule'd folders visible in directory listings (regression from v1.2.4-stable) (#2295)
- OTP and password requirement fixes (#2112) (#2263)
- CLI not picking up config properly
- Storage usage numbers on first load adjusted.
- Some items in userDefaults are not setting defaults for new users properly #2278
- Fix copy to clipboard and simplify code (#2281) (#2274)
- Fix search shortcut `/` and pdf previews (#2307)
- Special permissions bit not set when declared in createDirectoryPermission (#2283)
- Execute permission altered after editing sh script with FB Quantum (#2309)
- Source is red but working (#2289)
## v1.3.4
**New Features**:
- Epub placementg url anchors to bookmark a specific location on the doc.
- Add Requirement for Current Password When Changing Account Password (#2112)
- Copy file path to clipborad through right click (#2204)
**Notes**:
- user scope editing has path picker and filesystem validation
+3 -2
View File
@@ -18,5 +18,6 @@ COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser set -u testuser1,testuser1
RUN ./filebrowser set rule -f /tests/playwright-files -p / -r user -v admin -allow -c config.yaml
RUN ./filebrowser set rule -f /tests/playwright-files -p /excluded -r user -v admin -c config.yaml
RUN ./filebrowser set rule -s access -p / -r user -v admin -allow -c config.yaml
RUN ./filebrowser set rule -s access -p /excluded -r user -v admin -c config.yaml
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+107
View File
@@ -1,6 +1,7 @@
package files
import (
"errors"
"fmt"
"os"
"path/filepath"
@@ -11,6 +12,7 @@ import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
liberrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
@@ -88,6 +90,111 @@ func Test_GetRealPath(t *testing.T) {
}
}
// Regression: PUT / save must not os.Chmod to PermFile for existing files, or +x (and other mode bits) are lost (#2309).
func TestWriteFilePreservesExecutableBit(t *testing.T) {
cacheDir := t.TempDir()
originalCache := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = cacheDir
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
fileutils.SetFsPermissions(0o644, 0o755)
if indexing.GetIndexDB() == nil {
db, _, err := dbsql.NewIndexDB("test_writefile_exec", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
root := t.TempDir()
scriptName := "backupscript.sh"
scriptAbs := filepath.Join(root, scriptName)
if err := os.WriteFile(scriptAbs, []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil {
t.Fatal(err)
}
sourceName := "test_writefile_exec"
indexing.Initialize(&settings.Source{
Name: sourceName,
Path: root,
}, false, false)
idx := indexing.GetIndex(sourceName)
if idx == nil {
t.Fatal("Failed to get test index")
}
stopPoll := startScannerStatusPoll(t, idx, 1)
defer stopPoll()
waitForScannerReady(t, idx)
indexPath := "/" + scriptName
if err := WriteFile(sourceName, indexPath, strings.NewReader("#!/bin/sh\necho bye\n")); err != nil {
t.Fatalf("WriteFile: %v", err)
}
st, err := os.Stat(scriptAbs)
if err != nil {
t.Fatal(err)
}
if st.Mode()&0o111 == 0 {
t.Fatalf("executable bits cleared after save: mode=%v", st.Mode())
}
indexing.StopAllScanners()
time.Sleep(50 * time.Millisecond)
indexing.ClearTestIndices()
}
func TestWriteFileRejectsExistingDirectory(t *testing.T) {
cacheDir := t.TempDir()
originalCache := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = cacheDir
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
fileutils.SetFsPermissions(0o644, 0o755)
if indexing.GetIndexDB() == nil {
db, _, err := dbsql.NewIndexDB("test_writefile_isdir", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
root := t.TempDir()
dirName := "folder"
if err := os.Mkdir(filepath.Join(root, dirName), 0o755); err != nil {
t.Fatal(err)
}
sourceName := "test_writefile_isdir"
indexing.Initialize(&settings.Source{
Name: sourceName,
Path: root,
}, false, false)
idx := indexing.GetIndex(sourceName)
if idx == nil {
t.Fatal("Failed to get test index")
}
stopPoll := startScannerStatusPoll(t, idx, 1)
defer stopPoll()
waitForScannerReady(t, idx)
err := WriteFile(sourceName, "/"+dirName, strings.NewReader("file content"))
if err == nil {
t.Fatal("expected error when path is an existing directory")
}
if !errors.Is(err, liberrors.ErrIsDirectory) {
t.Fatalf("expected ErrIsDirectory, got: %v", err)
}
indexing.StopAllScanners()
time.Sleep(50 * time.Millisecond)
indexing.ClearTestIndices()
}
func TestSortItems(t *testing.T) {
tests := []struct {
name string
+21 -23
View File
@@ -226,18 +226,17 @@ func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *us
return response, err // Path excluded by index rules OR doesn't exist
}
// Build response
response.FileInfo = *info
response.RealPath = filepath.Join(idx.Path, indexPath)
response.Source = opts.Source
// Layer 3: FILTER CHILDREN (user access)
// Remove child items THIS user can't access
// otherwise response keeps unfiltered Folders/Files while CheckChildItemAccess only mutates info.
if info.Type == "directory" {
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
return response, err
}
}
// Build response
response.FileInfo = *info
response.RealPath = filepath.Join(idx.Path, indexPath)
response.Source = opts.Source
if share != nil && user.Permissions.Share && opts.ShowSharedAttr {
for i := range response.Files {
file := &response.Files[i]
@@ -762,22 +761,21 @@ func WriteFile(source, path string, in io.Reader) error {
if err != nil {
return err
}
applyDefaultFilePerm := false
var stat os.FileInfo
// Check if the destination exists and is a directory
if stat, err = os.Stat(realPath); err == nil {
if stat.IsDir() {
// If it's a directory and we're trying to create a file, remove the directory first
err = os.RemoveAll(realPath)
if err != nil {
return fmt.Errorf("could not remove existing directory to create file: %v", err)
}
stat, err = os.Stat(realPath)
if err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("stat file: %w", err)
}
// If file exists, its permissions will be preserved (O_TRUNC doesn't change permissions)
applyDefaultFilePerm = true
} else if stat.IsDir() {
return fmt.Errorf("%w: %q", errors.ErrIsDirectory, path)
}
// Open the file for writing (create if it doesn't exist, truncate if it does)
// For new files: permissions are set to fileutils.PermFile (subject to umask, which is usually acceptable)
// For existing files: permissions are preserved automatically (O_TRUNC doesn't change them)
// For new files: permissions are set to fileutils.PermFile (subject to umask, then Chmod bypasses umask)
// For existing regular files: do not Chmod so saves preserve mode and special bits
file, err := os.OpenFile(realPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
@@ -790,11 +788,11 @@ func WriteFile(source, path string, in io.Reader) error {
return err
}
// Explicitly set file permissions to bypass umask
err = os.Chmod(realPath, fileutils.PermFile)
if err != nil {
// Handle chmod error gracefully
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
if applyDefaultFilePerm {
err = os.Chmod(realPath, fileutils.PermFile)
if err != nil {
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
}
}
// Refresh the file itself
+21 -3
View File
@@ -12,9 +12,27 @@ import (
var PermFile os.FileMode
var PermDir os.FileMode
func SetFsPermissions(PermFileOctal os.FileMode, PermDirOctal os.FileMode) {
PermFile = PermFileOctal
PermDir = PermDirOctal
// SetFsPermissions sets create modes from Unix chmod(2)-style values (for example
// values produced by strconv.ParseUint(s, 8, 32)). setuid, setgid, and sticky
// bits must be converted for Go's os.FileMode; a plain cast from Unix octal is
// incorrect for those bits.
func SetFsPermissions(unixFileMode, unixDirMode uint32) {
PermFile = unixModeToFileMode(unixFileMode)
PermDir = unixModeToFileMode(unixDirMode)
}
func unixModeToFileMode(u uint32) os.FileMode {
m := os.FileMode(u & 0777)
if u&0o4000 != 0 {
m |= os.ModeSetuid
}
if u&0o2000 != 0 {
m |= os.ModeSetgid
}
if u&0o1000 != 0 {
m |= os.ModeSticky
}
return m
}
// MoveFile moves a file from src to dst.
+17 -1
View File
@@ -1,6 +1,22 @@
package fileutils
import "testing"
import (
"os"
"testing"
)
func TestUnixModeToFileMode_setgid(t *testing.T) {
m := unixModeToFileMode(0o2770)
if got := m.Perm(); got != 0o770 {
t.Fatalf("Perm() = %#o, want 0770", got)
}
if m&os.ModeSetgid == 0 {
t.Fatal("expected ModeSetgid")
}
if m&os.ModeSetuid != 0 || m&os.ModeSticky != 0 {
t.Fatalf("unexpected special bits: %#o", m)
}
}
func TestCommonPrefix(t *testing.T) {
testCases := map[string]struct {
+3 -3
View File
@@ -15,7 +15,8 @@ import (
// JSONAuth is a json implementation of an Auther.
type JSONAuth struct {
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
DisableOtp bool `json:"disableOtp" yaml:"disableOtp"`
}
// Auth authenticates the user via a json in content body.
@@ -58,8 +59,7 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
if getErr != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
// check for OTP for password
if user.TOTPSecret != "" {
if user.TOTPSecret != "" && !auther.DisableOtp {
if totpCode == "" {
return nil, errors.ErrNoTotpProvided
}
+70 -60
View File
@@ -23,7 +23,7 @@ var (
)
// return bool to indicate if the program should continue running
func runCLI() bool {
func runCLI() (bool, bool) {
generateYaml()
@@ -53,85 +53,100 @@ func runCLI() bool {
// Show help if requested
if help {
generalUsage()
return false
return false, false
}
// Create a new FlagSet for the 'set' subcommand
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
var user, dbConfig string
var asAdmin bool
var fsPath, indexPath, ruleCategory, value string
var sourceName, indexPath, ruleCategory, value string
var allow bool
setCmd.StringVar(&user, "u", "", "Comma-separated username and password: \"set -u <username>,<password>\"")
setCmd.BoolVar(&asAdmin, "a", false, "Create a user as admin user, used in combination with -u")
setCmd.StringVar(&dbConfig, "c", "config.yaml", "Path to the config file, default: config.yaml")
setCmd.StringVar(&fsPath, "f", "", "Real filesystem path (e.g. /mnt/storage) for rule command")
setCmd.StringVar(&sourceName, "s", "", "source name to apply the rule to")
setCmd.StringVar(&indexPath, "p", "", "Index path (e.g. /secret) for rule command")
setCmd.StringVar(&ruleCategory, "r", "", "Rule category: 'user', 'group', or 'all' (for deny only)")
setCmd.StringVar(&value, "v", "", "Value: username or groupname (not required if ruleCategory is 'all')")
setCmd.BoolVar(&allow, "allow", false, "Allow access (default: false, which means deny)")
// Parse subcommand flags only if a subcommand is specified
storeCfg := configPath
cliSubcmd := ""
var setKind string
if len(os.Args) > 1 {
switch os.Args[1] {
case "setup":
createConfig(configPath)
return false
case "set":
if len(os.Args) < 3 {
fmt.Printf("error: missing subcommand for 'set'. Use 'set user' or 'set rule'\n")
os.Exit(1)
}
subcommand := os.Args[2]
// Handle old syntax: "set -u user,pass -c config.yaml"
if subcommand == "-u" {
// Parse flags starting from os.Args[2:] to include the -u flag
err := setCmd.Parse(os.Args[2:])
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
logger.Debugf("setUser called with args: %v, config: %v, admin: %v", os.Args, dbConfig, asAdmin)
err = setUser(dbConfig, asAdmin)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false
}
// New pattern: subcommand-based (e.g., "set user" or "set rule")
// Parse flags starting from os.Args[3:] to skip the subcommand
err := setCmd.Parse(os.Args[3:])
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
switch subcommand {
case "rule":
err := setRule(dbConfig, fsPath, indexPath, ruleCategory, value, allow)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false
default:
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", subcommand)
os.Exit(1)
}
case "version":
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
Version : %v
Commit : %v
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
`, version.Version, version.CommitSHA, version.Version)
return false
`, version.Version, version.CommitSHA, version.Version)
return false, false
case "setup":
cliSubcmd = "setup"
case "set":
cliSubcmd = "set"
if len(os.Args) < 3 {
fmt.Printf("error: missing subcommand for 'set'. Use 'set user' or 'set rule'\n")
os.Exit(1)
}
setKind = os.Args[2]
var err error
// Handle old syntax: "set -u user,pass -c config.yaml"
if setKind == "-u" {
err = setCmd.Parse(os.Args[2:])
} else {
// New pattern: subcommand-based (e.g., "set user" or "set rule")
err = setCmd.Parse(os.Args[3:])
}
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
storeCfg = dbConfig
}
}
return true
dbExists := getStore(storeCfg)
switch cliSubcmd {
case "setup":
createConfig(configPath)
return false, dbExists
case "set":
if setKind == "-u" {
logger.Debugf("setUser called with args: %v, config: %v, admin: %v", os.Args, dbConfig, asAdmin)
err := setUser(dbConfig, asAdmin)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false, dbExists
}
switch setKind {
case "rule":
sourceInfo, ok := settings.Config.Server.NameToSource[sourceName] // backend source is path
if !ok {
fmt.Printf("error: invalid source name: %s\n", sourceName)
os.Exit(1)
}
err := setRule(dbConfig, sourceInfo.Path, indexPath, ruleCategory, value, allow)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false, dbExists
default:
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", setKind)
os.Exit(1)
}
return false, dbExists
default:
return true, dbExists
}
}
func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool) error {
@@ -148,10 +163,6 @@ func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool
if ruleCategory != "all" && value == "" {
return fmt.Errorf("value is required when ruleCategory is 'user' or 'group': use -v <username|groupname>")
}
// Initialize store and settings
_ = getStore(dbConfig) // ignore bool check
// Apply the rule based on allow flag and ruleCategory
var err error
if allow {
@@ -202,7 +213,6 @@ func setUser(dbConfig string, asAdmin bool) error {
}
username := userInfo[0]
password := userInfo[1]
_ = getStore(dbConfig) // ignore bool check
user, err := store.Users.Get(username)
if err != nil {
newUser := users.User{
+5 -6
View File
@@ -54,10 +54,14 @@ commands:
}
func StartFilebrowser() {
keepGoing := runCLI()
keepGoing, dbExists := runCLI()
if !keepGoing {
return
}
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
}
if !settings.Config.Server.DisableUpdateCheck {
info, _ := utils.CheckForUpdates()
if info.LatestVersion != "" {
@@ -76,11 +80,6 @@ func StartFilebrowser() {
done := make(chan struct{}) // Signals server has stopped
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
dbExists := getStore(configPath)
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
}
// Dev mode enables development features like template hot-reloading
_, err := os.Stat("http/dist")
+1 -1
View File
@@ -19,7 +19,7 @@ var (
ErrInvalidRequestParams = errors.New("invalid request params")
ErrSourceIsParent = errors.New("source is parent")
ErrNoTotpProvided = errors.New("OTP code is required for user")
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not yet configured")
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not configured")
ErrUnauthorized = errors.New("user unauthorized")
ErrNotIndexed = errors.New("directory or item excluded from indexing")
ErrNotViewable = errors.New("directory or item is not viewable")
@@ -0,0 +1,192 @@
package settings
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// TestApplyUserDefaults_copiesUserDefaultsOntoUser guards against drift between
// [UserDefaults] and [ApplyUserDefaults] (regression for #2278-style bugs).
func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
saved := Config
defer func() { Config = saved }()
dark := false
dlFalse := false
Config = Settings{
Server: Server{},
UserDefaults: UserDefaults{
EditorQuickSave: true,
HideSidebarFileActions: true,
DisableQuickToggles: true,
DisableSearchOptions: true,
StickySidebar: false,
HideFilesInTree: true,
DarkMode: &dark,
Locale: "de",
ViewMode: "list",
SingleClick: true,
ShowHidden: true,
DateFormat: true,
GallerySize: 7,
ThemeColor: "var(--red)",
QuickDownload: true,
DisablePreviewExt: ".exe",
DisableViewingExt: ".bat",
DisableSettings: true,
LockPassword: true,
DisableUpdateNotifications: true,
DeleteWithoutConfirming: true,
DeleteAfterArchive: false,
DisableOfficePreviewExt: "legacy",
DisableOnlyOfficeExt: ".x",
CustomTheme: "Night",
ShowSelectMultiple: true,
DebugOffice: true,
PreferEditorForMarkdown: true,
LoginMethod: "password",
FileLoading: users.FileLoading{
MaxConcurrent: 3,
UploadChunkSize: 5,
DownloadChunkSize: 2,
},
Permissions: UserDefaultsPermissions{
Api: true,
Admin: true,
Modify: true,
Share: true,
Realtime: true,
Delete: true,
Create: true,
Download: &dlFalse,
},
Preview: UserDefaultsPreview{
DisableHideSidebar: true,
Image: boolPtr(false),
Video: boolPtr(false),
Audio: boolPtr(false),
MotionVideoPreview: boolPtr(false),
Office: boolPtr(false),
PopUp: boolPtr(false),
AutoplayMedia: boolPtr(false),
DefaultMediaPlayer: true,
Folder: boolPtr(false),
Models: boolPtr(false),
},
},
}
u := &users.User{Username: "alice"}
ApplyUserDefaults(u)
want := users.User{
DisableSettings: true,
LockPassword: true,
LoginMethod: users.LoginMethodPassword,
Permissions: users.Permissions{
Api: true,
Admin: true,
Modify: true,
Share: true,
Realtime: true,
Delete: true,
Create: true,
Download: false,
},
NonAdminEditable: users.NonAdminEditable{
EditorQuickSave: true,
HideSidebarFileActions: true,
DisableQuickToggles: true,
DisableSearchOptions: true,
StickySidebar: false,
HideFilesInTree: true,
DarkMode: false,
Locale: "de",
ViewMode: "list",
SingleClick: true,
ShowHidden: true,
DateFormat: true,
GallerySize: 7,
ThemeColor: "var(--red)",
QuickDownload: true,
DisablePreviewExt: ".exe",
DisableViewingExt: ".bat",
DisableUpdateNotifications: true,
DisableOfficePreviewExt: "legacy",
DisableOnlyOfficeExt: ".x",
CustomTheme: "Night",
ShowSelectMultiple: true,
DebugOffice: true,
DeleteWithoutConfirming: true,
DeleteAfterArchive: false,
PreferEditorForMarkdown: true,
FileLoading: users.FileLoading{
MaxConcurrent: 3,
UploadChunkSize: 5,
DownloadChunkSize: 2,
},
Preview: users.Preview{
DisableHideSidebar: true,
Image: false,
Video: false,
Audio: false,
MotionVideoPreview: false,
Office: false,
PopUp: false,
AutoplayMedia: false,
DefaultMediaPlayer: true,
Folder: false,
Models: false,
},
},
}
if diff := cmp.Diff(want.Permissions, u.Permissions); diff != "" {
t.Fatalf("Permissions mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(want.NonAdminEditable, u.NonAdminEditable); diff != "" {
t.Fatalf("NonAdminEditable mismatch (-want +got):\n%s", diff)
}
if u.DisableSettings != want.DisableSettings {
t.Fatalf("DisableSettings: got %v want %v", u.DisableSettings, want.DisableSettings)
}
if u.LockPassword != want.LockPassword {
t.Fatalf("LockPassword: got %v want %v", u.LockPassword, want.LockPassword)
}
if u.LoginMethod != want.LoginMethod {
t.Fatalf("LoginMethod: got %q want %q", u.LoginMethod, want.LoginMethod)
}
}
func TestApplyUserDefaults_setsLoginMethodWhenEmpty(t *testing.T) {
saved := Config
defer func() { Config = saved }()
Config = Settings{
Server: Server{},
UserDefaults: UserDefaults{LoginMethod: "jwt"},
}
u := &users.User{Username: "x"}
ApplyUserDefaults(u)
if u.LoginMethod != users.LoginMethodJwt {
t.Fatalf("LoginMethod: got %q want jwt", u.LoginMethod)
}
}
func TestApplyUserDefaults_preservesLoginMethodWhenAlreadySet(t *testing.T) {
saved := Config
defer func() { Config = saved }()
Config = Settings{
Server: Server{},
UserDefaults: UserDefaults{LoginMethod: "password"},
}
u := &users.User{Username: "x", LoginMethod: users.LoginMethodProxy}
ApplyUserDefaults(u)
if u.LoginMethod != users.LoginMethodProxy {
t.Fatalf("LoginMethod: got %q want proxy", u.LoginMethod)
}
}
+8 -1
View File
@@ -93,7 +93,7 @@ func setupFs() {
Config.Server.Filesystem.CreateDirectoryPermission = "755"
dirPermOctal, _ = strconv.ParseUint("755", 8, 32)
}
fileutils.SetFsPermissions(os.FileMode(filePermOctal), os.FileMode(dirPermOctal))
fileutils.SetFsPermissions(uint32(filePermOctal), uint32(dirPermOctal))
// Perform mandatory cache directory speed test
testCacheDirSpeed()
@@ -817,6 +817,13 @@ func loadCustomFavicon() {
Env.FaviconPath = validatedPath
Env.FaviconIsCustom = true
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
// PWA and platform PNGs are generated at server startup from this path (SVG uses a raster
// sidecar next to the .svg when present; see icons.GeneratePWAIcons).
}
// PWAIconsCacheDir is where startup icon generation writes PNGs (under the server cache dir).
func PWAIconsCacheDir() string {
return filepath.Join(Config.Server.CacheDir, "icons")
}
func loadLoginIcon() {
+55 -41
View File
@@ -65,53 +65,67 @@ func AdminPerms() users.Permissions {
}
// Apply applies the default options to a user.
// Keep this in sync with [UserDefaults]: every user-facing field there should be copied
// here (except DefaultScopes, which is wired through source config rather than this helper).
func ApplyUserDefaults(u *users.User) {
u.StickySidebar = Config.UserDefaults.StickySidebar
u.DisableSettings = Config.UserDefaults.DisableSettings
u.HideFilesInTree = Config.UserDefaults.HideFilesInTree
u.DeleteAfterArchive = Config.UserDefaults.DeleteAfterArchive
d := Config.UserDefaults
// Handle DarkMode with default - dereference pointer from config
u.DarkMode = boolValueOrDefault(Config.UserDefaults.DarkMode, true)
u.DisableSettings = d.DisableSettings
u.LockPassword = d.LockPassword
u.Locale = Config.UserDefaults.Locale
u.ViewMode = Config.UserDefaults.ViewMode
u.SingleClick = Config.UserDefaults.SingleClick
u.EditorQuickSave = d.EditorQuickSave
u.HideSidebarFileActions = d.HideSidebarFileActions
u.DisableQuickToggles = d.DisableQuickToggles
u.DisableSearchOptions = d.DisableSearchOptions
u.StickySidebar = d.StickySidebar
u.HideFilesInTree = d.HideFilesInTree
u.DarkMode = boolValueOrDefault(d.DarkMode, true)
u.Locale = d.Locale
u.ViewMode = d.ViewMode
u.SingleClick = d.SingleClick
u.ShowHidden = d.ShowHidden
u.DateFormat = d.DateFormat
u.GallerySize = d.GallerySize
u.ThemeColor = d.ThemeColor
u.QuickDownload = d.QuickDownload
u.DisablePreviewExt = d.DisablePreviewExt
u.DisableViewingExt = d.DisableViewingExt
u.DisableUpdateNotifications = d.DisableUpdateNotifications
u.DisableOfficePreviewExt = d.DisableOfficePreviewExt
u.DisableOnlyOfficeExt = d.DisableOnlyOfficeExt
u.CustomTheme = d.CustomTheme
u.ShowSelectMultiple = d.ShowSelectMultiple
u.DebugOffice = d.DebugOffice
u.DeleteWithoutConfirming = d.DeleteWithoutConfirming
u.DeleteAfterArchive = d.DeleteAfterArchive
u.PreferEditorForMarkdown = d.PreferEditorForMarkdown
u.FileLoading = d.FileLoading
// Handle Permissions - convert from pointer-based defaults to regular bools
u.Permissions.Api = Config.UserDefaults.Permissions.Api
u.Permissions.Admin = Config.UserDefaults.Permissions.Admin
u.Permissions.Modify = Config.UserDefaults.Permissions.Modify
u.Permissions.Share = Config.UserDefaults.Permissions.Share
u.Permissions.Realtime = Config.UserDefaults.Permissions.Realtime
u.Permissions.Delete = Config.UserDefaults.Permissions.Delete
u.Permissions.Create = Config.UserDefaults.Permissions.Create
u.Permissions.Download = boolValueOrDefault(Config.UserDefaults.Permissions.Download, true)
u.Permissions.Api = d.Permissions.Api
u.Permissions.Admin = d.Permissions.Admin
u.Permissions.Modify = d.Permissions.Modify
u.Permissions.Share = d.Permissions.Share
u.Permissions.Realtime = d.Permissions.Realtime
u.Permissions.Delete = d.Permissions.Delete
u.Permissions.Create = d.Permissions.Create
u.Permissions.Download = boolValueOrDefault(d.Permissions.Download, true)
// Handle Preview - convert from pointer-based defaults to regular bools
u.Preview.DisableHideSidebar = Config.UserDefaults.Preview.DisableHideSidebar
u.Preview.Image = boolValueOrDefault(Config.UserDefaults.Preview.Image, true)
u.Preview.Video = boolValueOrDefault(Config.UserDefaults.Preview.Video, true)
u.Preview.Audio = boolValueOrDefault(Config.UserDefaults.Preview.Audio, true)
u.Preview.MotionVideoPreview = boolValueOrDefault(Config.UserDefaults.Preview.MotionVideoPreview, true)
u.Preview.Office = boolValueOrDefault(Config.UserDefaults.Preview.Office, true)
u.Preview.PopUp = boolValueOrDefault(Config.UserDefaults.Preview.PopUp, true)
u.Preview.AutoplayMedia = boolValueOrDefault(Config.UserDefaults.Preview.AutoplayMedia, true)
u.Preview.DefaultMediaPlayer = Config.UserDefaults.Preview.DefaultMediaPlayer
u.Preview.Folder = boolValueOrDefault(Config.UserDefaults.Preview.Folder, true)
u.Preview.Models = boolValueOrDefault(Config.UserDefaults.Preview.Models, true)
u.Preview.DisableHideSidebar = d.Preview.DisableHideSidebar
u.Preview.Image = boolValueOrDefault(d.Preview.Image, true)
u.Preview.Video = boolValueOrDefault(d.Preview.Video, true)
u.Preview.Audio = boolValueOrDefault(d.Preview.Audio, true)
u.Preview.MotionVideoPreview = boolValueOrDefault(d.Preview.MotionVideoPreview, true)
u.Preview.Office = boolValueOrDefault(d.Preview.Office, true)
u.Preview.PopUp = boolValueOrDefault(d.Preview.PopUp, true)
u.Preview.AutoplayMedia = boolValueOrDefault(d.Preview.AutoplayMedia, true)
u.Preview.DefaultMediaPlayer = d.Preview.DefaultMediaPlayer
u.Preview.Folder = boolValueOrDefault(d.Preview.Folder, true)
u.Preview.Models = boolValueOrDefault(d.Preview.Models, true)
if u.LoginMethod == "" && d.LoginMethod != "" {
u.LoginMethod = users.LoginMethod(d.LoginMethod)
}
u.ShowHidden = Config.UserDefaults.ShowHidden
u.DateFormat = Config.UserDefaults.DateFormat
u.DisableViewingExt = Config.UserDefaults.DisableViewingExt
u.ThemeColor = Config.UserDefaults.ThemeColor
u.GallerySize = Config.UserDefaults.GallerySize
u.QuickDownload = Config.UserDefaults.QuickDownload
u.LockPassword = Config.UserDefaults.LockPassword
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
u.FileLoading = Config.UserDefaults.FileLoading
u.DisableOfficePreviewExt = Config.UserDefaults.DisableOfficePreviewExt
u.PreferEditorForMarkdown = Config.UserDefaults.PreferEditorForMarkdown
if len(u.Scopes) == 0 && u.Username != "anonymous" {
for _, source := range Config.Server.Sources {
if source.Config.DefaultEnabled {
-4
View File
@@ -36,10 +36,6 @@ type Environment struct {
FaviconPath string `json:"-"` // resolved favicon path (filesystem or embedded)
FaviconIsCustom bool `json:"-"` // true if favicon is from custom filesystem path
FaviconEmbeddedPath string `json:"-"` // embedded asset path for default favicon
PWAIconsDir string `json:"-"` // directory where generated PWA icons are stored
PWAIcon192 string `json:"-"` // path to 192x192 PWA icon
PWAIcon256 string `json:"-"` // path to 256x256 PWA icon
PWAIcon512 string `json:"-"` // path to 512x512 PWA icon
}
type Server struct {
+12 -7
View File
@@ -74,11 +74,6 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
return err
}
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if passwordUser && enforcedOtp && !existingUser.OtpEnabled {
return errors.ErrNoTotpConfigured
}
if user.LoginMethod == "" {
user.LoginMethod = existingUser.LoginMethod
}
@@ -86,11 +81,21 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
if err != nil {
return err
}
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if user.LoginMethod == "" {
user.LoginMethod = existingUser.LoginMethod
}
if user.TOTPNonce == "" {
user.TOTPNonce = existingUser.TOTPNonce
}
if user.TOTPSecret == "" {
user.TOTPSecret = existingUser.TOTPSecret
}
if user.LoginMethod == users.LoginMethodPassword && enforcedOtp && !user.OtpEnabled {
if slices.Contains(fields, "OtpEnabled") {
return errors.ErrNoTotpConfigured
}
}
if !slices.Contains(fields, "Password") {
user.Password = existingUser.Password
} else {
+1 -1
View File
@@ -83,7 +83,6 @@ type User struct {
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
Version int `json:"version"`
ShowFirstLogin bool `json:"showFirstLogin"`
// legacy for migration purposes... og filebrowser has perm attribute
Perm Permissions `json:"perm,omitzero"` // deprecated: use Permissions instead
}
@@ -128,6 +127,7 @@ type NonAdminEditable struct {
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
ShowFirstLogin bool `json:"showFirstLogin"`
}
type FileLoading struct {
+15 -15
View File
@@ -15,7 +15,7 @@ require (
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-logger v1.0.2
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.8.21
github.com/mattn/go-sqlite3 v1.14.42
github.com/pquerna/otp v1.5.0
@@ -26,13 +26,13 @@ require (
golang.org/x/crypto v0.50.0
golang.org/x/image v0.39.0
golang.org/x/mod v0.35.0
golang.org/x/net v0.52.0
golang.org/x/net v0.53.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.43.0
golang.org/x/time v0.15.0
golang.org/x/tools v0.43.0
golang.org/x/tools v0.44.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.48.2
modernc.org/sqlite v1.49.1
)
require (
@@ -57,7 +57,7 @@ require (
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.65.0 // indirect
github.com/air-verse/air v1.65.1 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
@@ -114,16 +114,16 @@ require (
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/jsonpointer v0.23.0 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag/conv v0.25.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
github.com/go-openapi/swag/loading v0.25.5 // indirect
github.com/go-openapi/swag/stringutils v0.25.5 // indirect
github.com/go-openapi/swag/typeutils v0.25.5 // indirect
github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
github.com/go-openapi/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
github.com/go-openapi/swag/loading v0.26.0 // indirect
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
@@ -139,7 +139,7 @@ require (
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/golang/geo v0.0.0-20260401045841-3289a7742193 // indirect
github.com/golang/geo v0.0.0-20260415063119-550b242b3150 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
@@ -281,7 +281,7 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+40 -62
View File
@@ -75,18 +75,14 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/MirrexOne/unqueryvet v1.5.3 h1:LpT3rsH+IY3cQddWF9bg4C7jsbASdGnrOSofY8IPEiw=
github.com/MirrexOne/unqueryvet v1.5.3/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ=
github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4=
github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/air-verse/air v1.65.0 h1:MCJTmXL+UBS1Ji05jsIaUBXokeW10W+Y6ODkx6GcfZk=
github.com/air-verse/air v1.65.0/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/air-verse/air v1.65.1 h1:G4hy4YemKrIcGAvYMor+86PhxdtBEq0n9ebJ4RoYy8w=
github.com/air-verse/air v1.65.1/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
@@ -104,8 +100,6 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ=
github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
github.com/alexkohler/prealloc v1.0.2 h1:MPo8cIkGkZytq7WNH9UHv3DIX1mPz1RatPXnZb0zHWQ=
github.com/alexkohler/prealloc v1.0.2/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
github.com/alexkohler/prealloc v1.1.0 h1:cKGRBqlXw5iyQGLYhrXrDlcHxugXpTq4tQ5c91wkf8M=
github.com/alexkohler/prealloc v1.1.0/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc=
@@ -264,8 +258,6 @@ github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=
github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A=
github.com/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs=
github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
@@ -314,33 +306,33 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/jsonpointer v0.23.0 h1:c25HFTJ6uWGmoe5BQI6p72p4o7KnlWYsy1MeFlAumsw=
github.com/go-openapi/jsonpointer v0.23.0/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -409,8 +401,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20260401045841-3289a7742193 h1:TWcQGetTogk7h5+TRKI6dnvXRQCtEdSPXLu4RdouuM8=
github.com/golang/geo v0.0.0-20260401045841-3289a7742193/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/geo v0.0.0-20260415063119-550b242b3150 h1:F5CQANtNulclG+xJt370ohUujAEHq2XNBBAjkoSukQI=
github.com/golang/geo v0.0.0-20260415063119-550b242b3150/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -450,8 +442,6 @@ github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarog
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
github.com/golangci/golangci-lint/v2 v2.10.1 h1:flhw5Px6ojbLyEFzXvJn5B2HEdkkRlkhE1SnmCbQBiE=
github.com/golangci/golangci-lint/v2 v2.10.1/go.mod h1:dBsrOk6zj0vDhlTv+IiJGqkDokR24IVTS7W3EVfPTQY=
github.com/golangci/golangci-lint/v2 v2.11.4 h1:GK+UlZBN5y7rh2PBnHA93XLSX6RaF7uhzJQ3JwU1wuA=
github.com/golangci/golangci-lint/v2 v2.11.4/go.mod h1:ODQDCASMA3VqfZYIbbQLpTRTzV7O/vjmIRF6u8NyFwI=
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
@@ -516,8 +506,8 @@ github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+
github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs=
github.com/gtsteffaniak/go-cache v1.1.0 h1:ni/2cJpCxpGmaMZd92iTDIGOy+yUcMcxQuW7bSKhkCE=
github.com/gtsteffaniak/go-cache v1.1.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
github.com/gtsteffaniak/go-logger v1.0.2 h1:S4AgR7yDu2geD8y2D9NyJVOBqpZRnl37r2mBi7GDNR0=
github.com/gtsteffaniak/go-logger v1.0.2/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
github.com/gtsteffaniak/go-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcdxwMIVKI=
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo=
@@ -579,8 +569,6 @@ github.com/jupiterrider/ffi v0.6.0 h1:UX378KcZvH5c8qgLi9KL/bL82SZTHdRspZ+jj7bvBn
github.com/jupiterrider/ffi v0.6.0/go.mod h1:PqZ5Go6X9by8CIXgfprxfMPYmn8oT5m2O7AA56s64bY=
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw=
github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
@@ -660,8 +648,6 @@ github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgechev/revive v1.14.0 h1:CC2Ulb3kV7JFYt+izwORoS3VT/+Plb8BvslI/l1yZsc=
github.com/mgechev/revive v1.14.0/go.mod h1:MvnujelCZBZCaoDv5B3foPo6WWgULSSFxvfxp7GsPfo=
github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q=
github.com/mgechev/revive v1.15.0/go.mod h1:LlAKO3QQe9OJ0pVZzI2GPa8CbXGZ/9lNpCGvK4T/a8A=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
@@ -783,8 +769,6 @@ github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g=
github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I=
github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU=
github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ=
github.com/ryanrolds/sqlclosecheck v0.6.0 h1:pEyL9okISdg1F1SEpJNlrEotkTGerv5BMk7U4AG0eVg=
github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU=
github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0=
@@ -795,8 +779,6 @@ github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tM
github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
github.com/securego/gosec/v2 v2.23.0 h1:h4TtF64qFzvnkqvsHC/knT7YC5fqyOCItlVR8+ptEBo=
github.com/securego/gosec/v2 v2.23.0/go.mod h1:qRHEgXLFuYUDkI2T7W7NJAmOkxVhkR0x9xyHOIcMNZ0=
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 h1:AoLtJX4WUtZkhhUUMFy3GgecAALp/Mb4S1iyQOA2s0U=
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08/go.mod h1:+XLCJiRE95ga77XInNELh2M6zQP+PdqiT9Zpm0D9Wpk=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
@@ -811,8 +793,6 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
github.com/sonatard/noctx v0.4.0/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs=
github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0=
@@ -880,8 +860,6 @@ github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSW
github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYRA=
github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU=
github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4=
github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU=
@@ -1041,8 +1019,8 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1194,8 +1172,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -1312,10 +1290,10 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -1324,8 +1302,8 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -1334,8 +1312,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+20 -4
View File
@@ -147,6 +147,22 @@ type AuthTokenFrontend struct {
Permissions users.Permissions `json:"Permissions,omitempty"`
}
// authTokenIssuedUnix returns iat from JWT claims when set, otherwise the persisted int64 (e.g. loaded from DB).
func authTokenIssuedUnix(t users.AuthToken) int64 {
if t.RegisteredClaims.IssuedAt != nil {
return t.RegisteredClaims.IssuedAt.Unix()
}
return t.IssuedAt
}
// authTokenExpiresUnix returns exp from JWT claims when set, otherwise the persisted int64.
func authTokenExpiresUnix(t users.AuthToken) int64 {
if t.RegisteredClaims.ExpiresAt != nil {
return t.RegisteredClaims.ExpiresAt.Unix()
}
return t.ExpiresAt
}
// listApiTokensHandler lists all API tokens or retrieves details for a specific token.
// @Summary List API tokens
// @Description List all API tokens or retrieve details for a specific token.
@@ -169,8 +185,8 @@ func listApiTokensHandler(w http.ResponseWriter, r *http.Request, d *requestCont
AuthTokensFrontend = append(AuthTokensFrontend, AuthTokenFrontend{
Token: token.Token,
Name: name,
IssuedAt: token.RegisteredClaims.IssuedAt.Unix(),
ExpiresAt: token.RegisteredClaims.ExpiresAt.Unix(),
IssuedAt: authTokenIssuedUnix(token),
ExpiresAt: authTokenExpiresUnix(token),
Permissions: token.Permissions,
})
}
@@ -205,8 +221,8 @@ func getApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContex
AuthTokenFrontendResponse := AuthTokenFrontend{
Token: tokenInfo.Token,
Name: name,
IssuedAt: tokenInfo.IssuedAt,
ExpiresAt: tokenInfo.ExpiresAt,
IssuedAt: authTokenIssuedUnix(tokenInfo),
ExpiresAt: authTokenExpiresUnix(tokenInfo),
Permissions: tokenInfo.Permissions,
}
return renderJSON(w, r, AuthTokenFrontendResponse)
+3 -3
View File
@@ -92,11 +92,11 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
// ========================================
// Auth Routes - /api/auth/
// ========================================
api.HandleFunc("POST /auth/login", userWithoutOTP(loginHandler))
api.HandleFunc("POST /auth/login", userWithoutOTP(false, loginHandler))
api.HandleFunc("POST /auth/logout", withOrWithoutUser(logoutHandler))
api.HandleFunc("POST /auth/signup", withoutUser(signupHandler))
api.HandleFunc("POST /auth/otp/generate", userWithoutOTP(generateOTPHandler))
api.HandleFunc("POST /auth/otp/verify", userWithoutOTP(verifyOTPHandler))
api.HandleFunc("POST /auth/otp/generate", userWithoutOTP(true, generateOTPHandler))
api.HandleFunc("POST /auth/otp/verify", userWithoutOTP(false, verifyOTPHandler))
api.HandleFunc("POST /auth/renew", withUser(renewHandler))
api.HandleFunc("POST /auth/token", withUser(createApiTokenHandler))
api.HandleFunc("DELETE /auth/token", withUser(deleteApiTokenHandler))
+10 -6
View File
@@ -314,7 +314,7 @@ func withoutUserHelper(fn handleFunc) handleFunc {
}
// allow user without OTP to pass
func userWithoutOTPhelper(fn handleFunc) handleFunc {
func userWithoutOTPhelper(disableOtp bool, fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Auth.Methods.ProxyAuth.Enabled {
@@ -344,15 +344,19 @@ func userWithoutOTPhelper(fn handleFunc) handleFunc {
logger.Debug("ldap auth failed, calling password auth", err)
}
if config.Auth.Methods.PasswordAuth.Enabled {
// Get the authentication method from the settings
auther, err := store.Auth.Get("password")
if err != nil {
return 401, errors.ErrUnauthorized
}
// Authenticate the user based on the request
// OTP routes: password only (do not mutate stored *JSONAuth).
if ja, ok := auther.(*auth.JSONAuth); ok {
x := *ja
x.DisableOtp = disableOtp
auther = &x
}
user, err := auther.Auth(r, store.Users)
if err != nil {
logger.Debug("password auth failed, calling handler", err)
logger.Debug("password auth failed, calling handler:", err)
if err == errors.ErrNoTotpProvided {
return 403, err
}
@@ -655,8 +659,8 @@ func withoutUser(fn handleFunc) http.HandlerFunc {
return wrapHandler(withoutUserHelper(fn))
}
func userWithoutOTP(fn handleFunc) http.HandlerFunc {
return wrapHandler(userWithoutOTPhelper(fn))
func userWithoutOTP(disableOtp bool, fn handleFunc) http.HandlerFunc {
return wrapHandler(userWithoutOTPhelper(disableOtp, fn))
}
func withSelfOrAdmin(fn handleFunc) http.HandlerFunc {
+4
View File
@@ -521,6 +521,10 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
existingIsDir := stat.IsDir()
requestingDir := isDir
if r.URL.Query().Get("override") != "true" {
return http.StatusConflict, nil
}
// If type mismatch (file vs folder or folder vs 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)
+13 -34
View File
@@ -83,7 +83,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
staticURL := config.Server.BaseURL + "public/static"
description := config.Frontend.Description
title := config.Frontend.Name
banner := staticURL + "/pwa-icon-512.png" // Use largest generated icon for best quality
banner := staticURL + "/icons/pwa-icon-512.png" // largest generated PWA icon
disableSidebar := false
// Use custom favicon if configured and validated, otherwise fall back to default
@@ -139,9 +139,9 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
// Determine OpenGraph image: use banner if set, otherwise use largest available icon (512x512)
ogImage := banner
if banner == staticURL+"/pwa-icon-512.png" {
if banner == staticURL+"/icons/pwa-icon-512.png" {
// Note: 512x512 is square; OpenGraph prefers 1200x630 (1.91:1 ratio) but square works fine
ogImage = staticURL + "/pwa-icon-512.png"
ogImage = staticURL + "/icons/pwa-icon-512.png"
}
// Construct the full URL for the current request
@@ -158,22 +158,9 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
fullURL = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.Path)
}
// Determine PWA icon URLs based on custom favicon settings
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
// If custom favicon is set and it's SVG, use the favicon directly
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
pwaIcon192 = favicon
pwaIcon256 = favicon
pwaIcon512 = favicon
} else if settings.Env.FaviconIsCustom {
// For custom PNG/ICO favicons, use generated PWA icons
pwaIcon192 = staticURL + "/pwa-icon-192.png"
pwaIcon256 = staticURL + "/pwa-icon-256.png"
pwaIcon512 = staticURL + "/pwa-icon-512.png"
}
pwaIcon192 := staticURL + "/icons/pwa-icon-192.png"
pwaIcon256 := staticURL + "/icons/pwa-icon-256.png"
pwaIcon512 := staticURL + "/icons/pwa-icon-512.png"
data["htmlVars"] = map[string]interface{}{
"title": title,
@@ -186,8 +173,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"favicon": favicon,
"loginIcon": loginIcon,
"color": defaultThemeColor,
"winIcon": staticURL + "/mstile-256x256.png",
"appIcon": staticURL + "/apple-touch-icon.png",
"winIcon": staticURL + "/icons/mstile-256x256.png",
"appIcon": staticURL + "/icons/apple-touch-icon.png",
"description": description,
"loadingSpinnersCSS": template.CSS(loadingSpinnersCSS),
"banner": banner,
@@ -313,7 +300,7 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
assetPath = settings.Env.FaviconEmbeddedPath
case "favicon.png":
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-512.png")
iconPath := filepath.Join(settings.PWAIconsCacheDir(), "pwa-icon-512.png")
if _, err := os.Stat(iconPath); err == nil {
http.ServeFile(w, r, iconPath)
return
@@ -323,18 +310,10 @@ 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/apple-touch-icon.png":
// Serve generated icons from cache directory
iconPath := filepath.Join(settings.Env.PWAIconsDir, path)
if _, err := os.Stat(iconPath); err == nil {
http.ServeFile(w, r, iconPath)
return
}
// Fall back to embedded favicon.png if generation failed
assetPath = "img/icons/favicon.png"
case "icons/mstile-256x256.png":
// Windows tile - redirect to pwa-icon-256.png (they're identical)
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-256.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/")
iconPath := filepath.Join(settings.PWAIconsCacheDir(), rel)
if _, err := os.Stat(iconPath); err == nil {
http.ServeFile(w, r, iconPath)
return
+41 -21
View File
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/url"
"reflect"
"slices"
"sort"
"strconv"
@@ -257,25 +258,43 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusCreated, nil
}
// passwordUpdateRequested reports whether the client intends to change the target user's password
// (non-empty new password and "password" in the which list). This matches storage parseFields behavior.
func passwordUpdateRequested(req *UserRequest) bool {
if req.User.Password == "" {
return false
// nonAdminEditableFieldNameSet returns User.NonAdminEditable struct field names (e.g. "Locale", "Preview").
func nonAdminEditableFieldNameSet() map[string]struct{} {
m := make(map[string]struct{})
t := reflect.TypeOf(users.NonAdminEditable{})
for i := 0; i < t.NumField(); i++ {
m[t.Field(i).Name] = struct{}{}
}
for _, w := range req.Which {
if strings.EqualFold(w, "password") {
return true
}
}
return false
return m
}
// verifyActorPasswordForUserPasswordChange ensures the authenticated actor re-proves their password
// when changing another user's or their own password via PUT. Requires URL-encoded X-Password (same as login).
func verifyActorPasswordForUserPasswordChange(r *http.Request, d *requestContext, target *users.User) (int, error) {
if d.user.LoginMethod != users.LoginMethodPassword || target.LoginMethod != users.LoginMethodPassword {
return http.StatusForbidden, fmt.Errorf("password can only be changed when both accounts use password login")
// userPutOnlyNonAdminEditableFields reports whether req.Which lists exclusively NonAdminEditable fields,
// excluding Password. Empty which or which[0] == "all" means a broad update and returns false.
func userPutOnlyNonAdminEditableFields(which []string) bool {
if len(which) == 0 || strings.EqualFold(strings.TrimSpace(which[0]), "all") {
return false
}
allowed := nonAdminEditableFieldNameSet()
for _, w := range which {
f := utils.CapitalizeFirst(strings.TrimSpace(w))
if f == "" {
return false
}
if strings.EqualFold(f, "Password") {
return false
}
if _, ok := allowed[f]; !ok {
return false
}
}
return true
}
// verifyActorPasswordForUserPut requires URL-encoded X-Password when the authenticated actor uses
// password login. Callers should invoke this only when the update requires re-authentication.
func verifyActorPasswordForUserPut(r *http.Request, d *requestContext) (int, error) {
if d.user.LoginMethod != users.LoginMethodPassword {
return 0, nil
}
encoded := r.Header.Get("X-Password")
if encoded == "" {
@@ -300,16 +319,16 @@ func verifyActorPasswordForUserPasswordChange(r *http.Request, d *requestContext
// userPutHandler updates an existing user's details.
// @Summary Update a user's details
// @Description Updates the details of a user identified by ID. When updating the target user's password the actor must send their current password in the X-Password header
// @Description Updates the details of a user identified by ID. When the authenticated actor uses password login, they must send their current password in the X-Password header unless the update only touches NonAdminEditable profile fields (not password). Full updates (which empty or "all") or any admin-only field require confirmation.
// @Tags Users
// @Accept json
// @Param id query string false "user ID to update"
// @Param id query string false "usename to update"
// @Param X-Password header string false "Actor's current password (URL-encoded); required when changing a password user's password"
// @Param X-Password header string false "Actor's current password (URL-encoded); required for password-login actors when updating password, using which=all, or any field outside NonAdminEditable"
// @Param data body users.User true "User data to update"
// @Success 204 "No Content - User updated successfully"
// @Failure 400 {object} map[string]string "Bad Request"
// @Failure 401 {object} map[string]string "Unauthorized - invalid or missing actor password for password change"
// @Failure 401 {object} map[string]string "Unauthorized - invalid or missing actor password when required"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/users [put]
@@ -361,9 +380,10 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
return http.StatusBadRequest, fmt.Errorf("failed to get user: %w", err)
}
if passwordUpdateRequested(&req) {
if d.user.LoginMethod == users.LoginMethodPassword && !userPutOnlyNonAdminEditableFields(req.Which) {
fmt.Println("verifyActorPasswordForUserPut", req.Which)
var status int
status, err = verifyActorPasswordForUserPasswordChange(r, d, oldUser)
status, err = verifyActorPasswordForUserPut(r, d)
if err != nil {
return status, err
}
+2
View File
@@ -24,6 +24,8 @@ func errToStatus(err error) int {
return http.StatusForbidden
case errors.Is(err, libErrors.ErrInvalidRequestParams):
return http.StatusBadRequest
case errors.Is(err, libErrors.ErrIsDirectory):
return http.StatusMethodNotAllowed
default:
return http.StatusInternalServerError
}
+9 -21
View File
@@ -29,7 +29,6 @@ const (
type iconSize struct {
size int
name string
env *string
}
// determineSourceType determines which type of favicon source we have
@@ -153,13 +152,13 @@ func handleRasterFavicon(faviconPath string) ([]byte, error) {
func getIconSizesToGenerate() []iconSize {
return []iconSize{
// Browser favicon (32x32 PNG for Safari/older browsers)
{32, "favicon-32x32.png", nil},
{32, "favicon-32x32.png"},
// PWA icons (PNG required for Apple PWA compatibility)
{192, "pwa-icon-192.png", &settings.Env.PWAIcon192},
{256, "pwa-icon-256.png", &settings.Env.PWAIcon256}, // Also serves as Windows tile
{512, "pwa-icon-512.png", &settings.Env.PWAIcon512},
{192, "pwa-icon-192.png"},
{256, "pwa-icon-256.png"}, // Also serves as Windows tile
{512, "pwa-icon-512.png"},
// Platform-specific icons
{180, "apple-touch-icon.png", nil}, // iOS home screen (required for Apple)
{180, "apple-touch-icon.png"}, // iOS home screen (required for Apple)
}
}
@@ -181,10 +180,6 @@ func generateIconSizes(sourceData []byte) error {
continue
}
// Update environment variable if specified
if icon.env != nil {
*icon.env = filepath.Join("icons", icon.name)
}
generatedCount++
}
@@ -199,7 +194,7 @@ func generateIconSizes(sourceData []byte) error {
// generateSingleIcon generates a single icon file at the specified size
func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon iconSize) error {
outputPath := filepath.Join(settings.Env.PWAIconsDir, icon.name)
outputPath := filepath.Join(settings.PWAIconsCacheDir(), icon.name)
// Create output file
outFile, err := os.Create(outputPath)
@@ -233,18 +228,11 @@ func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon
// All icons are generated as PNG for maximum compatibility (Apple devices, PWA, older browsers)
func GeneratePWAIcons() error {
logger.Info("Generating PWA icons...")
// Set PWA icons directory in cache
settings.Env.PWAIconsDir = filepath.Join(settings.Config.Server.CacheDir, "icons")
// Initialize with default paths (all icons will be generated)
settings.Env.PWAIcon192 = "icons/pwa-icon-192.png"
settings.Env.PWAIcon256 = "icons/pwa-icon-256.png"
settings.Env.PWAIcon512 = "icons/pwa-icon-512.png"
cacheDir := settings.PWAIconsCacheDir()
// Create icons directory with configured permissions
// Note: Parent cache directory should already exist from testCacheDirSpeed()
if err := os.MkdirAll(settings.Env.PWAIconsDir, fileutils.PermDir); err != nil {
logger.Warningf("Failed to create PWA icons directory %s: %v", settings.Env.PWAIconsDir, err)
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
logger.Warningf("Failed to create PWA icons directory %s: %v", cacheDir, err)
}
// Load and prepare source data as PNG
sourceData, err := loadFaviconSource()
+9 -19
View File
@@ -1,9 +1,6 @@
package icons
import (
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
@@ -36,6 +33,9 @@ func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwa
if len(name) > 12 {
shortName = name[:12]
}
if name == "FileBrowser Quantum" {
shortName = "FBQ"
}
return PWAManifest{
Name: name,
@@ -68,7 +68,9 @@ func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwa
}
}
// InitializePWAManifest generates and caches the PWA manifest at startup
// InitializePWAManifest caches the PWA manifest at startup. Icon URLs always point at
// PNGs under public/static/icons/, which GeneratePWAIcons produces from the configured
// favicon (custom SVG uses a raster sidecar as the raster source).
func InitializePWAManifest() {
config := &settings.Config
staticURL := config.Server.BaseURL + "public/static"
@@ -76,21 +78,9 @@ func InitializePWAManifest() {
description := config.Frontend.Description
defaultThemeColor := "#455a64"
// Determine PWA icon URLs based on custom favicon settings
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
favicon := staticURL + "/favicon"
pwaIcon192 = favicon
pwaIcon256 = favicon
pwaIcon512 = favicon
} else if settings.Env.FaviconIsCustom {
pwaIcon192 = staticURL + "/icons/pwa-icon-192.png"
pwaIcon256 = staticURL + "/icons/pwa-icon-256.png"
pwaIcon512 = staticURL + "/icons/pwa-icon-512.png"
}
pwaIcon192 := staticURL + "/icons/pwa-icon-192.png"
pwaIcon256 := staticURL + "/icons/pwa-icon-256.png"
pwaIcon512 := staticURL + "/icons/pwa-icon-512.png"
CachedManifest = generatePWAManifest(title, description, config.Server.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
}
+10 -29
View File
@@ -85,17 +85,17 @@ type Stats struct {
LastIndexedUnix int64 `json:"lastIndexedUnixTime"`
Complexity uint `json:"complexity"`
LastScanned time.Time `json:"lastScanned"`
DiskUsed uint64 `json:"used"`
UsedAlt uint64 `json:"usedAlt"`
UsedAsIndexed uint64 `json:"used"`
UsedDisk uint64 `json:"usedAlt"`
DiskTotal uint64 `json:"total"`
}
// reduced index is json exposed to the client
type ReducedIndex struct {
Stats
IdxName string `json:"name"`
DiskTotal uint64 `json:"total"`
Status IndexStatus `json:"status"`
Scanners []*ScannerInfo `json:"scanners,omitempty"`
IdxName string `json:"name"`
Status IndexStatus `json:"status"`
Scanners []*ScannerInfo `json:"scanners,omitempty"`
}
type Index struct {
@@ -1158,22 +1158,6 @@ func (idx *Index) updateFolderSizeAndParents(path string, newSize uint64, previo
}
}
// SetFolderSize sets the size for a directory in the in-memory map
func (idx *Index) SetFolderSize(path string, newSize uint64) {
idx.folderSizesMu.Lock()
defer idx.folderSizesMu.Unlock()
idx.folderSizes[path] = newSize
idx.folderSizesUnsynced[path] = struct{}{} // Mark as changed for next DB sync
}
// GetFolderSize retrieves the size for a directory from the in-memory map
func (idx *Index) GetFolderSize(path string) (uint64, bool) {
idx.folderSizesMu.RLock()
defer idx.folderSizesMu.RUnlock()
size, exists := idx.folderSizes[path]
return size, exists
}
// GetFolderSizeForDisplay retrieves folder size with appropriate formatting based on config
func (idx *Index) GetFolderSizeForDisplay(path string) int64 {
size, exists := idx.GetFolderSize(path)
@@ -1532,18 +1516,15 @@ func (idx *Index) IsNeverWatchPath(adjustedPath string) bool {
return exists
}
type DiskUsage struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
}
func (idx *Index) SetUsage(totalBytes uint64) {
func (idx *Index) SetUsage(totalDiskSize, partitionUsed, UsedAsIndexed uint64) {
if settings.Config.Frontend.DisableUsedPercentage {
return
}
idx.mu.Lock()
defer idx.mu.Unlock()
idx.DiskTotal = totalBytes
idx.DiskTotal = totalDiskSize
idx.UsedAsIndexed = UsedAsIndexed
idx.UsedDisk = partitionUsed
}
func (idx *Index) SetStatus(status IndexStatus) error {
-10
View File
@@ -231,16 +231,6 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
logger.Errorf("Failed to load persisted index data for [%v]: %v", idx.Name, err)
}
// Load existing folder sizes from database to avoid marking everything as dirty on startup
existingSizes, err := idx.db.LoadFolderSizes(idx.Name)
if err != nil {
logger.Errorf("[INIT] Failed to load existing folder sizes for [%s]: %v", idx.Name, err)
} else {
idx.folderSizesMu.Lock()
idx.folderSizes = existingSizes
idx.folderSizesMu.Unlock()
}
idx.mu.Lock()
idx.scanners = make(map[string]*Scanner)
idx.mu.Unlock()
+32 -26
View File
@@ -296,8 +296,8 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
return ReducedIndex{}, fmt.Errorf("index %s not found", sourceName)
}
// Only update disk total if cache is missing or explicitly forced
// The "used" value comes from totalSize and is always current
// Only update disk total if cache is missing or explicitly forced.
// Indexed "used" bytes for the UI come from the SQLite root row (see below), not in-memory scan state.
sourcePath := idx.Path
cacheKey := "usageCache-" + sourceName
if forceCacheRefresh {
@@ -307,26 +307,24 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
_, ok = utils.DiskUsageCache.Get(cacheKey)
if !ok {
// Only fetch disk total and used if not cached (this is expensive, so we cache it)
totalBytes, err := fileutils.GetPartitionSize(sourcePath)
totalPartitionSize, err := fileutils.GetPartitionSize(sourcePath)
if err != nil {
idx.mu.Lock()
idx.Status = UNAVAILABLE
idx.mu.Unlock()
return ReducedIndex{}, fmt.Errorf("error getting disk usage for %s: %v", sourcePath, err)
logger.Errorf("Failed to get partition size for index %s: %v", sourceName, err)
}
itemInfo, dbErr := idx.db.GetDirectoryChildren(idx.Name, "/")
if dbErr != nil {
logger.Debugf("GetIndexInfo: could not read root folder size from index DB for %s: %v", sourceName, dbErr)
}
indexedSizeFromDB := uint64(0)
for _, item := range itemInfo {
indexedSizeFromDB += uint64(item.Size)
}
idx.SetUsage(totalBytes)
// Also fetch OS-reported partition used space (total - free)
usedAlt, err := fileutils.GetPartitionUsed(sourcePath)
partitionUsed, err := fileutils.GetPartitionUsed(sourcePath)
if err != nil {
logger.Errorf("Failed to get partition used space for index %s: %v", sourceName, err)
usedAlt = 0
}
idx.mu.Lock()
idx.UsedAlt = usedAlt
idx.mu.Unlock()
idx.SetUsage(totalPartitionSize, partitionUsed, indexedSizeFromDB)
utils.DiskUsageCache.Set(cacheKey, true)
}
@@ -348,16 +346,8 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
})
}
reducedIdx := idx.ReducedIndex
var diskUsed uint64
rootTotal, ok := idx.GetFolderSize("/")
if !ok {
logger.Errorf("Failed to get root-level indexed total for index %s", sourceName)
diskUsed = 0
} else {
diskUsed = rootTotal
}
reducedIdx.DiskUsed = diskUsed
reducedIdx.UsedAlt = idx.UsedAlt
reducedIdx.UsedAsIndexed = idx.UsedAsIndexed
reducedIdx.UsedDisk = idx.UsedDisk
reducedIdx.DiskTotal = idx.DiskTotal
reducedIdx.Scanners = scannerInfos
reducedIdx.NumDirs = idx.getNumDirsUnlocked()
@@ -372,3 +362,19 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
idx.mu.RUnlock()
return reducedIdx, nil
}
// SetFolderSize sets the size for a directory in the in-memory map
func (idx *Index) SetFolderSize(path string, newSize uint64) {
idx.folderSizesMu.Lock()
defer idx.folderSizesMu.Unlock()
idx.folderSizes[path] = newSize
idx.folderSizesUnsynced[path] = struct{}{} // Mark as changed for next DB sync
}
// GetFolderSize retrieves the size for a directory from the in-memory map
func (idx *Index) GetFolderSize(path string) (uint64, bool) {
idx.folderSizesMu.RLock()
defer idx.folderSizesMu.RUnlock()
size, exists := idx.folderSizes[path]
return size, exists
}
+2 -2
View File
@@ -1,6 +1,6 @@
.card,
#listingView.gallery .item,
#listingView.normal .item,
.listing-item,
.floating-window,
.button,
button,
.input,
+3 -3
View File
@@ -3076,7 +3076,7 @@ const docTemplate = `{
}
},
"put": {
"description": "Updates the details of a user identified by ID. When updating the target user's password the actor must send their current password in the X-Password header",
"description": "Updates the details of a user identified by ID. When the authenticated actor uses password login, they must send their current password in the X-Password header unless the update only touches NonAdminEditable profile fields (not password). Full updates (which empty or \"all\") or any admin-only field require confirmation.",
"consumes": [
"application/json"
],
@@ -3099,7 +3099,7 @@ const docTemplate = `{
},
{
"type": "string",
"description": "Actor's current password (URL-encoded); required when changing a password user's password",
"description": "Actor's current password (URL-encoded); required for password-login actors when updating password, using which=all, or any field outside NonAdminEditable",
"name": "X-Password",
"in": "header"
},
@@ -3127,7 +3127,7 @@ const docTemplate = `{
}
},
"401": {
"description": "Unauthorized - invalid or missing actor password for password change",
"description": "Unauthorized - invalid or missing actor password when required",
"schema": {
"type": "object",
"additionalProperties": {
+3 -3
View File
@@ -3065,7 +3065,7 @@
}
},
"put": {
"description": "Updates the details of a user identified by ID. When updating the target user's password the actor must send their current password in the X-Password header",
"description": "Updates the details of a user identified by ID. When the authenticated actor uses password login, they must send their current password in the X-Password header unless the update only touches NonAdminEditable profile fields (not password). Full updates (which empty or \"all\") or any admin-only field require confirmation.",
"consumes": [
"application/json"
],
@@ -3088,7 +3088,7 @@
},
{
"type": "string",
"description": "Actor's current password (URL-encoded); required when changing a password user's password",
"description": "Actor's current password (URL-encoded); required for password-login actors when updating password, using which=all, or any field outside NonAdminEditable",
"name": "X-Password",
"in": "header"
},
@@ -3116,7 +3116,7 @@
}
},
"401": {
"description": "Unauthorized - invalid or missing actor password for password change",
"description": "Unauthorized - invalid or missing actor password when required",
"schema": {
"type": "object",
"additionalProperties": {
+8 -7
View File
@@ -4237,9 +4237,11 @@ paths:
put:
consumes:
- application/json
description: Updates the details of a user identified by ID. When updating the
target user's password the actor must send their current password in the X-Password
header
description: Updates the details of a user identified by ID. When the authenticated
actor uses password login, they must send their current password in the X-Password
header unless the update only touches NonAdminEditable profile fields (not
password). Full updates (which empty or "all") or any admin-only field require
confirmation.
parameters:
- description: user ID to update
in: query
@@ -4249,8 +4251,8 @@ paths:
in: query
name: id
type: string
- description: Actor's current password (URL-encoded); required when changing
a password user's password
- description: Actor's current password (URL-encoded); required for password-login
actors when updating password, using which=all, or any field outside NonAdminEditable
in: header
name: X-Password
type: string
@@ -4270,8 +4272,7 @@ paths:
type: string
type: object
"401":
description: Unauthorized - invalid or missing actor password for password
change
description: Unauthorized - invalid or missing actor password when required
schema:
additionalProperties:
type: string
+15 -54
View File
@@ -11,7 +11,6 @@
"@onlyoffice/document-editor-vue": "^1.4.0",
"ace-builds": "^1.24.2",
"axios": "^1.13.5",
"clipboard": "^2.0.4",
"css-vars-ponyfill": "^2.4.3",
"dompurify": "^3.2.4",
"epubjs": "^0.3.93",
@@ -2444,17 +2443,6 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/clipboard": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz",
"integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==",
"license": "MIT",
"dependencies": {
"good-listener": "^1.2.2",
"select": "^1.1.2",
"tiny-emitter": "^2.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2659,9 +2647,9 @@
"license": "MIT"
},
"node_modules/deepl-node": {
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.25.0.tgz",
"integrity": "sha512-vOdPNZCegDS9F2GR8iwsEL+2w+Io9LBO0iECuZqWQMePZMNeNuODs6cRaiPUmVobPr6Dkt3HKiAAlZqFfl7vzw==",
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.26.0.tgz",
"integrity": "sha512-KaMZ248O/N2HrevQnsJoCoHxDUNBbsQ7+OZJa2YAOhym4qtVCNTzRst+4rLX9B9T84SVN4xVQSuJyFIdlI/x1w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2702,12 +2690,6 @@
"node": ">=0.4.0"
}
},
"node_modules/delegate": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
"integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==",
"license": "MIT"
},
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
@@ -2741,9 +2723,9 @@
}
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -3493,9 +3475,9 @@
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -3709,15 +3691,6 @@
"node": ">= 4"
}
},
"node_modules/good-listener": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz",
"integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==",
"license": "MIT",
"dependencies": {
"delegate": "^3.1.2"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -4885,9 +4858,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"funding": [
{
"type": "opencollective",
@@ -5188,12 +5161,6 @@
"node": ">=v12.22.7"
}
},
"node_modules/select": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz",
"integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -5507,12 +5474,6 @@
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
"license": "MIT"
},
"node_modules/tiny-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -5693,9 +5654,9 @@
}
},
"node_modules/typescript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
-1
View File
@@ -25,7 +25,6 @@
"@onlyoffice/document-editor-vue": "^1.4.0",
"ace-builds": "^1.24.2",
"axios": "^1.13.5",
"clipboard": "^2.0.4",
"css-vars-ponyfill": "^2.4.3",
"dompurify": "^3.2.4",
"epubjs": "^0.3.93",
+20 -4
View File
@@ -83,13 +83,19 @@ export async function generateOTP(username, password) {
let apiPath = getApiPath('auth/otp/generate', params)
const res = await fetch(apiPath, {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-Password': encodeURIComponent(password),
}
})
return await res.json()
const body = await res.json().catch(() => ({}))
if (!res.ok) {
const msg = body.message || 'Failed to generate OTP'
throw new Error(msg)
}
return body
} catch (error) {
notify.showError(error || 'Failed to generate OTP')
notify.showError(error.message || error || 'Failed to generate OTP')
throw error
}
}
@@ -101,16 +107,26 @@ export async function verifyOTP(username, password, otp) {
let apiPath = getApiPath('auth/otp/verify', params)
const res = await fetch(apiPath, {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-Password': encodeURIComponent(password),
'X-Secret': otp,
}
})
if (res.status != 200) {
throw new Error('Failed to verify OTP')
let msg = 'Failed to verify OTP'
try {
const errBody = await res.json()
if (errBody.message) {
msg = errBody.message
}
} catch {
// keep default message
}
throw new Error(msg)
}
} catch (error) {
notify.showError(error || 'Failed to verify OTP')
notify.showError(error.message || error || 'Failed to verify OTP')
throw error
}
}
+59 -10
View File
@@ -1,6 +1,9 @@
import { fetchURL, fetchJSON } from '@/api/utils'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { notify } from '@/notify'
import { state } from '@/store/state.js'
import { mutations } from '@/store/mutations.js'
import i18n from '@/i18n'
// GET /api/users (list all)
export async function getAllUsers() {
@@ -48,14 +51,18 @@ export async function create(user) {
}
// PUT /api/users (update user)
// Optional `options.headers` (e.g. X-Password when changing a password user's password).
// Password-login: tries without X-Password first; on 401 requiring X-Password, opens the prompt and retries.
// options.skipActorPasswordConfirm / pre-set X-Password skip that flow.
// options.actorPasswordPromptI18nKey — optional vue-i18n key (default: confirmPasswordToSaveUser).
export async function update(user, which = ['all'], options = {}) {
const excludeKeys = ['id', 'name']
which = which.filter(item => !excludeKeys.includes(item))
if (user.username === 'anonymous') {
return
}
const mergedHeaders = { ...(options.headers || {}) }
let userData = user
if (which.length !== 1 || which[0] !== 'all') {
userData = {}
@@ -67,15 +74,57 @@ export async function update(user, which = ['all'], options = {}) {
}
const apiPath = getApiPath('users', { id: user.id })
const extraHeaders = options.headers || {}
await fetchURL(apiPath, {
method: 'PUT',
body: JSON.stringify({
which: which,
data: userData
}),
headers: extraHeaders,
const body = JSON.stringify({
which: which,
data: userData
})
const needsActorPasswordRetry = (err) =>
state.user?.loginMethod === 'password' &&
options.skipActorPasswordConfirm !== true &&
mergedHeaders['X-Password'] === undefined &&
err &&
err.status === 401 &&
typeof err.message === 'string' &&
err.message.includes('X-Password')
try {
await fetchURL(apiPath, {
method: 'PUT',
body,
headers: mergedHeaders,
})
} catch (e) {
if (!needsActorPasswordRetry(e)) {
throw e
}
const promptKey =
options.actorPasswordPromptI18nKey || 'prompts.confirmPasswordToSaveUser'
return new Promise((resolve, reject) => {
mutations.showPrompt({
name: 'password',
props: {
infoText: i18n.global.t(promptKey),
submitLabel: i18n.global.t('general.confirm'),
submitCallback: async (actorPassword) => {
try {
await update(user, which, {
...options,
headers: {
...mergedHeaders,
'X-Password': encodeURIComponent(actorPassword),
},
skipActorPasswordConfirm: true,
})
resolve(undefined)
} catch (err) {
reject(err)
}
},
},
})
})
}
}
// DELETE /api/users (remove user)
+2 -27
View File
@@ -186,6 +186,7 @@ import buttons from "@/utils/buttons";
import { notify } from "@/notify";
import { resourcesApi } from "@/api";
import { url } from "@/utils";
import { copyToClipboard } from "@/utils/clipboard";
function isArchivePath(pathOrName) {
if (!pathOrName || typeof pathOrName !== "string") return false;
@@ -662,33 +663,7 @@ export default {
const item = this.firstSelected;
const path = item?.path || "";
if (!path) {
notify.showErrorToast(this.$t("prompts.copyToClipboardFailed"));
mutations.closeHovers();
return;
}
try {
await navigator.clipboard.writeText(path);
notify.showSuccessToast(this.$t("buttons.copySuccess"));
} catch (err) {
const textArea = document.createElement("textarea");
textArea.value = path;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
notify.showSuccessToast(this.$t("buttons.copySuccess"));
} catch (e) {
notify.showErrorToast(this.$t("prompts.copyToClipboardFailed"));
}
document.body.removeChild(textArea);
}
await copyToClipboard(path);
mutations.closeHovers();
},
showNewDirPrompt() {
+21 -1
View File
@@ -207,7 +207,7 @@ export default {
// Add keyboard event listener for "/" to activate search
this.handleKeydown = (event) => {
if (event.key === '/' && !state.isSearchActive && getters.currentPrompt() === null) {
if (event.key === '/' || event.key === ' ' && !state.isSearchActive && getters.currentPrompt() === null) {
event.preventDefault();
this.open();
}
@@ -546,6 +546,26 @@ export default {
return "";
}
},
open() {
if (state.isSearchActive) return;
if (getters.currentPromptName()) return;
mutations.closeHovers();
mutations.closeSidebar();
mutations.resetSelected();
mutations.setSearch(true);
this.$nextTick(() => {
const input = this.$refs.input;
if (input) {
input.focus();
}
const resultList = document.getElementById("result-list");
if (resultList) {
resultList.classList.add("active");
}
});
},
addSelected(event, s) {
event.preventDefault();
const pathParts = url.removeTrailingSlash(s.path).split("/");
@@ -223,7 +223,8 @@ export default {
fromSource: selectedItem.source,
to: url.joinPath(targetPath, selectedItem.name),
toSource: source,
itemType: selectedItem.type
itemType: selectedItem.type,
name: selectedItem.name
});
}
@@ -554,13 +554,11 @@ export default {
let items = [];
for (let i of state.selected) {
items.push({
// @ts-ignore
from: state.req.items[i].path,
// @ts-ignore
fromSource: state.req.items[i].source,
// @ts-ignore
to: url.joinPath(this.path, state.req.items[i].name),
toSource: this.source,
name: state.req.items[i].name,
});
}
@@ -3,8 +3,8 @@
<!-- API Token Section -->
<div class="api-section">
<button
class="action copy-clipboard api-key-button"
:data-clipboard-text="name"
class="action api-key-button"
@click.stop="copyToClipboard(name)"
:aria-label="$t('buttons.copyToClipboard')"
:title="$t('buttons.copyToClipboard')"
>
@@ -12,8 +12,8 @@
<i class="material-symbols">content_paste</i>
</button>
<button
class="action copy-clipboard api-key-value-button"
:data-clipboard-text="info.token"
class="action api-key-value-button"
@click.stop="copyToClipboard(info.token)"
:aria-label="$t('api.clickToCopyKey')"
:title="$t('api.clickToCopyKey')"
>
@@ -73,6 +73,7 @@ import { mutations } from "@/store";
import { notify } from "@/notify";
import { authApi } from "@/api";
import { eventBus } from "@/store/eventBus";
import { copyToClipboard } from "@/utils/clipboard";
export default {
name: "ActionApi",
@@ -93,6 +94,9 @@ export default {
},
},
methods: {
async copyToClipboard(text) {
await copyToClipboard(text);
},
formatTime(timestamp) {
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
year: "numeric",
@@ -81,9 +81,12 @@ export default {
closeTopPrompt() {
mutations.closeTopPrompt();
},
confirm() {
this.onConfirm();
mutations.closeTopPrompt();
async confirm() {
try {
await this.onConfirm();
} finally {
mutations.closeTopPrompt();
}
},
},
};
+2 -20
View File
@@ -86,6 +86,7 @@
<script>
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { formatTimestamp } from "@/utils/moment";
import { copyToClipboard } from "@/utils/clipboard";
import { resourcesApi } from "@/api";
import { state } from "@/store";
import { notify } from "@/notify";
@@ -180,26 +181,7 @@ export default {
},
async copyToClipboard() {
if (!this.hashResult) return;
try {
await navigator.clipboard.writeText(this.hashResult);
notify.showSuccessToast(this.$t("prompts.hashCopied"));
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = this.hashResult;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
notify.showSuccessToast(this.$t("prompts.hashCopied"));
} catch (e) {
notify.showError(this.$t("prompts.errorCopyingHash"));
}
document.body.removeChild(textArea);
}
await copyToClipboard(this.hashResult);
},
},
};
+14 -2
View File
@@ -78,6 +78,16 @@ export default {
source: state.req?.source || null,
};
},
currentPromptName() {
return getters.currentPromptName();
},
},
watch: {
currentPromptName(newName, oldName) {
if (this.creating && oldName === "replace-rename" && newName !== "new-dir") {
this.creating = false;
}
},
},
methods: {
closeTopPrompt() {
@@ -151,13 +161,15 @@ export default {
if (getters.isShare()) {
await resourcesApi.postPublic(state.shareInfo?.hash, newPath, "", false, undefined, {}, true);
mutations.setReload(true);
mutations.closeTopPrompt();
mutations.closeTopPrompt(); // close conflict prompt
mutations.closeTopPrompt(); // close new dir prompt
success = true;
return;
}
await resourcesApi.post(source, newPath, "", false, undefined, {}, true);
mutations.setReload(true);
mutations.closeTopPrompt();
mutations.closeTopPrompt(); // close conflict prompt
mutations.closeTopPrompt(); // close new dir prompt
success = true;
// Show success notification with "go to item" button
+14 -2
View File
@@ -78,6 +78,16 @@ export default {
source: state.req?.source || null,
};
},
currentPromptName() {
return getters.currentPromptName();
},
},
watch: {
currentPromptName(newName, oldName) {
if (this.creating && oldName === "replace-rename" && newName !== "new-file") {
this.creating = false;
}
},
},
methods: {
async submit(event) {
@@ -148,13 +158,15 @@ export default {
if (getters.isShare()) {
await resourcesApi.postPublic(state.shareInfo?.hash, newPath, "", false);
mutations.setReload(true);
mutations.closeTopPrompt();
mutations.closeTopPrompt(); // close conflict prompt
mutations.closeTopPrompt(); // close new file prompt
success = true;
return;
}
await resourcesApi.post(source, newPath, "", false);
mutations.setReload(true);
mutations.closeTopPrompt();
mutations.closeTopPrompt(); // close conflict prompt
mutations.closeTopPrompt(); // close new file prompt
success = true;
// Show success notification with "go to item" button
@@ -287,6 +287,8 @@ export default {
// convert to lowercase
// Explicit switch statement for compile-time safety with ESLint i18n validation
switch (promptName.toLowerCase()) {
case "user-edit":
return this.$t("settings.modifyOtherUser");
case "delete":
return this.$t("prompts.deleteTitle");
case "access":
+6 -37
View File
@@ -57,14 +57,14 @@
</button>
</td>
<td class="small">
<button class="action copy-clipboard" :data-clipboard-text="link.shareURL"
<button class="action" @click.stop="copyToClipboard(link.shareURL)"
:aria-label="$t('buttons.copyToClipboard')" :title="$t('buttons.copyToClipboard')">
<i class="material-symbols">content_paste</i>
</button>
</td>
<td class="small">
<button :disabled="link.shareType == 'upload'" class="action copy-clipboard"
:data-clipboard-text="link.downloadURL" :aria-label="$t('buttons.copyDownloadLinkToClipboard')"
<button :disabled="link.shareType == 'upload'" class="action" @click.stop="copyToClipboard(link.downloadURL)"
:aria-label="$t('buttons.copyDownloadLinkToClipboard')"
:title="$t('buttons.copyDownloadLinkToClipboard')">
<i class="material-symbols">content_paste_go</i>
</button>
@@ -321,7 +321,7 @@
import { notify } from "@/notify";
import { state, getters, mutations } from "@/store";
import { shareApi } from "@/api";
import Clipboard from "clipboard";
import { copyToClipboard } from "@/utils/clipboard";
import { fromNow } from "@/utils/moment";
import { buildItemUrl } from "@/utils/url";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
@@ -364,8 +364,6 @@ export default {
unit: "hours",
/** @type {Share[]} */
links: [],
/** @type {Clipboard | null} */
clip: null,
password: "",
listing: true,
allowModify: false,
@@ -558,10 +556,6 @@ export default {
}
},
mounted() {
// Initialize clipboard after DOM is ready
this.$nextTick(() => {
this.initClipboard();
});
// Listen for sidebar links updates from the SidebarLinks prompt
eventBus.on('shareSidebarLinksUpdated', this.handleSidebarLinksUpdate);
eventBus.on('pathSelected', this.onBannerFaviconPathSelected);
@@ -573,23 +567,10 @@ export default {
eventBus.off('shareSidebarLinksUpdated', this.handleSidebarLinksUpdate);
eventBus.off('pathSelected', this.onBannerFaviconPathSelected);
eventBus.off('pathPickerCancelled', this.onBannerFaviconPathPickerCancelled);
// Clean up clipboard
if (this.clip) {
this.clip.destroy();
}
},
methods: {
initClipboard() {
// Destroy existing clipboard first
if (this.clip) {
this.clip.destroy();
}
// Create new clipboard instance
this.clip = new Clipboard(".copy-clipboard");
this.clip.on("success", () => {
notify.showSuccessToast(this.$t("success.linkCopied"));
});
async copyToClipboard(text) {
await copyToClipboard(text);
},
/**
* @param {MouseEvent} event
@@ -676,10 +657,6 @@ export default {
if (!this.isEditMode && !this.editingLink) {
this.links.push(res);
this.sort();
// reinitialize the clipboard after adding a new link
this.$nextTick(() => {
this.initClipboard();
});
} else if (this.editingLink) {
// Update the link in the local list
const index = this.links.findIndex(l => l.hash === this.editingLink.hash);
@@ -689,10 +666,6 @@ export default {
this.editingLink = null;
// emit event to reload shares in settings view
eventBus.emit('sharesChanged');
// Reinitialize clipboard after edit the share
this.$nextTick(() => {
this.initClipboard();
});
} else {
// emit event to reload shares in settings view
eventBus.emit('sharesChanged');
@@ -767,10 +740,6 @@ export default {
try {
await shareApi.remove(link.hash);
this.links = this.links.filter((item) => item.hash !== link.hash);
// Reinitialize clipboard after deletion too
this.$nextTick(() => {
this.initClipboard();
});
// emit event to reload shares in settings view
eventBus.emit('sharesChanged');
if (this.links.length === 0) {
+27 -6
View File
@@ -7,12 +7,13 @@
<qrcode-vue class="qrcode" :value="url" :size="200" level="M"></qrcode-vue>
</div>
<p>{{ $t("otp.verifyInstructions") }}</p>
<input :class="{'form-invalid': error != '' && code != ''}" v-focus class="input" type="text" v-model="code" @keyup.enter="verifyCode"
<input :class="{'form-invalid': error != '' && code != ''}" v-focus class="input" type="text" v-model="code"
@keydown.enter.prevent="verifyCode"
:placeholder="$t('otp.codeInputPlaceholder')" />
</div>
<div class="card-actions">
<button v-if="!succeeded && code != ''" class="button button--flat button--blue" @click="verifyCode"
<button v-if="!succeeded && code != ''" type="button" class="button button--flat button--blue" @click="verifyCode"
:title="$t('general.verify')">
{{ $t("general.verify") }}
</button>
@@ -37,6 +38,7 @@ export default {
code: "",
url: "",
succeeded: false,
verifyInFlight: false,
};
},
props: {
@@ -59,6 +61,10 @@ export default {
},
async mounted() {
if (this.generate) {
if (String(this.password ?? "").trim() === "") {
this.error = this.$t("otp.passwordRequiredForGeneration");
return;
}
this.generateNewCode();
}
},
@@ -69,6 +75,10 @@ export default {
},
methods: {
async generateNewCode() {
if (String(this.password ?? "").trim() === "") {
this.error = this.$t("otp.passwordRequiredForGeneration");
return;
}
try {
const resp = await authApi.generateOTP(this.username, this.password);
this.url = resp.url;
@@ -78,28 +88,39 @@ export default {
}
},
async verifyCode(event) {
event.preventDefault();
event?.preventDefault?.();
if (this.verifyInFlight || this.succeeded) {
return;
}
if (!this.code) {
this.error = this.$t("otp.invalidCodeType");
notify.showError(this.$t("otp.invalidCodeType"));
return;
}
if (String(this.password ?? "").trim() === "") {
this.error = this.$t("otp.passwordRequiredForGeneration");
notify.showError(this.$t("otp.passwordRequiredForGeneration"));
return;
}
this.verifyInFlight = true;
try {
await authApi.verifyOTP(this.username, this.password, this.code);
if (this.redirect != "") {
await authApi.login(this.username, this.password, this.redirect, this.code);
await initAuth();
this.$router.push(this.redirect);
// Wait for navigation so Layout mounts and registers the toast callback before we toast
await this.$router.push(this.redirect);
}
mutations.updateCurrentUser({ otpEnabled: true });
this.succeeded = true
this.succeeded = true;
this.error = "";
notify.showSuccessToast(this.$t("otp.verificationSucceed"));
mutations.closeTopPrompt();
} catch (error) {
this.error = this.$t("otp.verificationFailed");
console.log("error", error);
return;
} finally {
this.verifyInFlight = false;
}
},
},
+24 -26
View File
@@ -434,11 +434,20 @@ export default {
},
newOTP() {
mutations.showPrompt({
name: "totp",
name: "password",
props: {
generate: true,
username: this.user.username,
password: this.passwordRef || this.user.password || "",
infoText: this.$t("prompts.confirmPasswordToSaveUser"),
submitLabel: this.$t("general.confirm"),
submitCallback: (accountPassword) => {
mutations.showPrompt({
name: "totp",
props: {
generate: true,
username: this.user.username,
password: accountPassword,
},
});
},
},
});
},
@@ -447,28 +456,17 @@ export default {
if (!this.canUpdatePassword) {
return;
}
mutations.showPrompt({
name: "password",
props: {
infoText: this.$t("prompts.confirmPasswordToChangeUserPassword"),
submitLabel: this.$t("general.confirm"),
submitCallback: async (actorPassword) => {
try {
await usersApi.update(this.user, ["password"], {
headers: {
"X-Password": encodeURIComponent(actorPassword),
},
});
if (state.user.permissions.admin && this.user.id !== state.user.id) {
eventBus.emit("usersChanged");
}
notify.showSuccessToast(this.$t("settings.userUpdated"));
} catch (e) {
notify.showError(e);
}
},
},
});
try {
await usersApi.update(this.user, ["password"], {
actorPasswordPromptI18nKey: "prompts.confirmPasswordToSaveUser",
});
if (state.user.permissions.admin && this.user.id !== state.user.id) {
eventBus.emit("usersChanged");
}
notify.showSuccessToast(this.$t("settings.userUpdated"));
} catch (e) {
notify.showError(e);
}
},
emitUserUpdate() {
// Update the user object with current scopes
+2 -3
View File
@@ -39,7 +39,7 @@
</div>
</a>
<!-- Source-type links (source, source-minimal, source-alt, source-hybrid, source-hybrid-2); usage bar hidden for source-minimal -->
<a v-else-if="link.category === 'source' || link.category === 'source-minimal' || link.category === 'source-alt' || link.category === 'source-hybrid' || link.category === 'source-hybrid-2'" :href="getLinkHref(link)"
<a v-else-if="isSourceCategory(link.category)" :href="getLinkHref(link)"
class="action button source-button sidebar-link-button" :class="{
active: isLinkActive(link),
disabled: !isLinkAccessible(link)
@@ -318,8 +318,7 @@ export default {
},
methods: {
isSourceCategory(category) {
return category === 'source' || category === 'source-minimal' || category === 'source-alt' ||
category === 'source-hybrid' || category === 'source-hybrid-2';
return category.startsWith("source")
},
getIconClass,
hasUsageInfo(link) {
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "تم النسخ إلى الحافظة!",
"copyFailed": "فشل النسخ إلى الحافظة",
"copyFile": "نسخ الملف",
"copyToClipboard": "نسخ الى الحافظة",
"moveFile": "نقل الملف",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "تحديد خوارزمية تجزئة",
"generate": "توليد",
"errorGeneratingHash": "خطأ في توليد التجزئة",
"hashCopied": "التجزئة المنسوخة إلى الحافظة",
"errorCopyingHash": "خطأ في نسخ تجزئة التجزئة",
"token": "الرمز المميز",
"onlyOfficeId": "معرّف المكتب فقط",
"firstLoadTitle": "مرحبًا بك في متصفح الملفات الكمي!",
@@ -227,7 +226,6 @@
"renameSuccess": "تمت إعادة تسمية العنصر بنجاح!",
"newFileSuccess": "تم إنشاء الملف بنجاح!",
"newDirSuccess": "تم إنشاء الدليل بنجاح!",
"copyToClipboardFailed": "تعذر النسخ إلى الحافظة",
"moveFailed": "فشلت عملية النقل",
"operationFailed": "فشلت العملية",
"partialSuccess": "فشلت بعض العمليات: {succeeded} نجحت ، {failed} فشلت ",
@@ -243,7 +241,7 @@
"unarchiveDestination": "مقتطف إلى:",
"unarchiveSuccess": "تم استخراج الأرشيف بنجاح!",
"downloadFailed": "فشل التحميل المقطوع - إذا تكرر ذلك، حاول تقليل حجم القطعة: {message}",
"confirmPasswordToChangeUserPassword": "أدخل كلمة المرور الحالية للسماح بتغيير كلمة المرور هذه."
"confirmPasswordToSaveUser": "أدخل كلمة المرور الحالية لحفظ تغييرات المستخدم هذه."
},
"search": {
"smallerThan": "أصغر من:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "تم تمكين OTP بنجاح",
"verificationFailed": "فشل التحقق",
"verificationSucceed": "نجاح التحقق",
"generationFailed": "فشل التوليد"
"generationFailed": "فشل التوليد",
"passwordRequiredForGeneration": "كلمة المرور الخاصة بك مطلوبة لإنشاء رمز المصادقة."
},
"general": {
"name": "الاسم",
@@ -845,8 +844,6 @@
"showingResults": "عرض {count} النتائج ",
"noResults": "لم يتم العثور على أي أيقونات",
"tryDifferentSearch": "جرّب مصطلح بحث مختلف",
"copiedToClipboard": "نسخ \"{name}\" إلى الحافظة!",
"copyFailed": "فشل النسخ إلى الحافظة",
"custom": "مخصص",
"useCustomIcon": "استخدام أيقونة مخصصة: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "Zkopírováno do schránky!",
"copyFailed": "Kopírování do schránky se nezdařilo",
"generateNewOtp": "Resetovat a vygenerovat nový kód dvoufázového ověření",
"copyFile": "Kopírovat soubor",
"copyToClipboard": "Kopírovat do schránky",
@@ -293,8 +294,6 @@
"selectHashAlgorithm": "Výběr algoritmu hash",
"generate": "Generovat",
"errorGeneratingHash": "Chyba při generování hash",
"hashCopied": "Hash zkopírovaný do schránky",
"errorCopyingHash": "Chyba při kopírování hash",
"token": "Token",
"onlyOfficeId": "OnlyOffice ID",
"firstLoadTitle": "Vítejte v aplikaci FileBrowser Quantum!",
@@ -305,7 +304,6 @@
"renameSuccess": "Položka byla úspěšně přejmenována!",
"newFileSuccess": "Soubor byl úspěšně vytvořen!",
"newDirSuccess": "Adresář byl úspěšně vytvořen!",
"copyToClipboardFailed": "Nelze zkopírovat do schránky",
"moveFailed": "Přesun se nezdařil",
"operationFailed": "Operace se nezdařila",
"partialSuccess": "Některé operace se nezdařily: {succeeded} uspěl, {failed} neuspěl.",
@@ -321,7 +319,7 @@
"unarchiveDestination": "Výpis na:",
"unarchiveSuccess": "Archiv úspěšně extrahován!",
"downloadFailed": "Stahování po částech se nezdařilo - pokud se tato situace opakuje, zkuste zmenšit velikost části: {message}",
"confirmPasswordToChangeUserPassword": "Zadejte své aktuální heslo a autorizujte tuto změnu hesla."
"confirmPasswordToSaveUser": "Pro uložení těchto uživatelských změn zadejte své aktuální heslo."
},
"search": {
"smallerThan": "Menší než:",
@@ -609,7 +607,8 @@
"enabledSuccessfully": "OTP úspěšně aktivováno",
"verificationFailed": "Ověření se nezdařilo",
"verificationSucceed": "Ověření proběhlo úspěšně",
"generationFailed": "Generování selhalo"
"generationFailed": "Generování selhalo",
"passwordRequiredForGeneration": "Pro vygenerování ověřovacího kódu je nutné vaše heslo."
},
"fileLoading": {
"title": "Nahrávání a stahování",
@@ -845,8 +844,6 @@
"showingResults": "Zobrazují se výsledky {count} ",
"noResults": "Nebyly nalezeny žádné ikony",
"tryDifferentSearch": "Zkuste použít jiný vyhledávací výraz",
"copiedToClipboard": "Zkopírováno '{name}' do schránky!",
"copyFailed": "Nepodařilo se zkopírovat do schránky",
"custom": "Vlastní",
"useCustomIcon": "Použití vlastní ikony: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "In die Zwischenablage kopiert!",
"copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen",
"copyFile": "Datei kopieren",
"copyToClipboard": "In Zwischenablage kopieren",
"moveFile": "Datei verschieben",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "Wählen Sie einen Hash-Algorithmus",
"generate": "Erzeugen Sie",
"errorGeneratingHash": "Fehler bei der Hash-Erzeugung",
"hashCopied": "Hash in Zwischenablage kopiert",
"errorCopyingHash": "Fehler beim Kopieren des Hashes",
"token": "Token",
"onlyOfficeId": "OnlyOffice ID",
"firstLoadTitle": "Willkommen bei FileBrowser Quantum!",
@@ -227,7 +226,6 @@
"renameSuccess": "Artikel erfolgreich umbenannt!",
"newFileSuccess": "Datei erfolgreich erstellt!",
"newDirSuccess": "Verzeichnis erfolgreich erstellt!",
"copyToClipboardFailed": "Kopieren in die Zwischenablage nicht möglich",
"moveFailed": "Verschieben fehlgeschlagen",
"operationFailed": "Operation fehlgeschlagen",
"partialSuccess": "Einige Operationen sind fehlgeschlagen: {succeeded} erfolgreich, {failed} fehlgeschlagen",
@@ -243,7 +241,7 @@
"unarchiveDestination": "Auszug zu:",
"unarchiveSuccess": "Archiv erfolgreich entpackt!",
"downloadFailed": "Chunked Download fehlgeschlagen - falls sich dies wiederholt, versuchen Sie, die Chunk-Größe zu verringern: {message}",
"confirmPasswordToChangeUserPassword": "Geben Sie Ihr aktuelles Passwort ein, um diese Passwortänderung zu autorisieren."
"confirmPasswordToSaveUser": "Geben Sie Ihr aktuelles Kennwort ein, um die Benutzeränderungen zu speichern."
},
"search": {
"smallerThan": "Kleiner als:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "OTP erfolgreich aktiviert",
"verificationFailed": "Verifizierung fehlgeschlagen",
"verificationSucceed": "Überprüfung Erfolgreich",
"generationFailed": "Generation gescheitert"
"generationFailed": "Generation gescheitert",
"passwordRequiredForGeneration": "Ihr Passwort ist erforderlich, um einen Authentifizierungscode zu generieren."
},
"general": {
"name": "Name",
@@ -845,8 +844,6 @@
"showingResults": "{count} Ergebnisse anzeigen",
"noResults": "Keine Icons gefunden",
"tryDifferentSearch": "Versuchen Sie einen anderen Suchbegriff",
"copiedToClipboard": "Kopiert '{name}' in die Zwischenablage!",
"copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen",
"custom": "Benutzerdefiniert",
"useCustomIcon": "Benutzerdefiniertes Symbol verwenden: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "Αντιγράφηκε στο πρόχειρο!",
"copyFailed": "Απέτυχε η αντιγραφή στο πρόχειρο",
"copyFile": "Αντιγραφή αρχείου",
"copyToClipboard": "Αντιγραφή στο πρόχειρο",
"copyDownloadLinkToClipboard": "Αντιγραφή συνδέσμου λήψης στο πρόχειρο",
@@ -218,8 +219,6 @@
"selectHashAlgorithm": "Επιλέξτε έναν αλγόριθμο κατακερματισμού",
"generate": "Δημιουργία",
"errorGeneratingHash": "Σφάλμα δημιουργίας hash",
"hashCopied": "Αντιγραφή κατακερματισμού στο πρόχειρο",
"errorCopyingHash": "Σφάλμα αντιγραφής hash",
"token": "Token",
"onlyOfficeId": "ΜόνοΤαυτότητα γραφείου",
"firstLoadTitle": "Καλώς ήρθατε στο FileBrowser Quantum!",
@@ -230,7 +229,6 @@
"renameSuccess": "Το στοιχείο μετονομάστηκε επιτυχώς!",
"newFileSuccess": "Το αρχείο δημιουργήθηκε με επιτυχία!",
"newDirSuccess": "Ο κατάλογος δημιουργήθηκε με επιτυχία!",
"copyToClipboardFailed": "Αδυναμία αντιγραφής στο πρόχειρο",
"moveFailed": "Η μετακίνηση απέτυχε",
"operationFailed": "Η λειτουργία απέτυχε",
"partialSuccess": "Ορισμένες λειτουργίες απέτυχαν: {succeeded} πέτυχε, {failed} απέτυχε",
@@ -246,7 +244,7 @@
"unarchiveDestination": "Απόσπασμα προς:",
"unarchiveSuccess": "Το αρχείο εξήχθη με επιτυχία!",
"downloadFailed": "Αποτυχημένη λήψη σε τεμάχια - αν αυτό επαναληφθεί, δοκιμάστε να μειώσετε το μέγεθος των τεμαχίων: {message}",
"confirmPasswordToChangeUserPassword": "Εισάγετε τον τρέχοντα κωδικό πρόσβασής σας για να εγκρίνετε αυτή την αλλαγή κωδικού πρόσβασης."
"confirmPasswordToSaveUser": "Εισάγετε τον τρέχοντα κωδικό πρόσβασης για να αποθηκεύσετε αυτές τις αλλαγές χρήστη."
},
"search": {
"smallerThan": "Μικρότερο από:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "Το OTP ενεργοποιήθηκε με επιτυχία",
"verificationFailed": "Η επαλήθευση απέτυχε",
"verificationSucceed": "Επαλήθευση Succeed",
"generationFailed": "Γενιά που απέτυχε"
"generationFailed": "Γενιά που απέτυχε",
"passwordRequiredForGeneration": "Ο κωδικός πρόσβασής σας απαιτείται για τη δημιουργία ενός κωδικού ελέγχου ταυτότητας."
},
"general": {
"name": "Όνομα",
@@ -845,8 +844,6 @@
"showingResults": "Εμφάνιση {count} αποτελεσμάτων",
"noResults": "Δεν βρέθηκαν εικονίδια",
"tryDifferentSearch": "Δοκιμάστε έναν διαφορετικό όρο αναζήτησης",
"copiedToClipboard": "Αντιγραφή του '{name}' στο πρόχειρο!",
"copyFailed": "Απέτυχε η αντιγραφή στο πρόχειρο",
"custom": "Προσαρμοσμένο",
"useCustomIcon": "Χρήση προσαρμοσμένου εικονιδίου: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -132,6 +132,7 @@
},
"buttons": {
"copySuccess": "Copied to clipboard!",
"copyFailed": "Failed to copy to clipboard",
"generateNewOtp": "Reset and generate new two-factor code",
"copyFile": "Copy file",
"copyPath": "Copy Path",
@@ -440,9 +441,6 @@
"selectHashAlgorithm": "Select a hash algorithm",
"generate": "Generate",
"errorGeneratingHash": "Error generating hash",
"hashCopied": "Hash copied to clipboard",
"copyToClipboardFailed": "Unable to copy to clipboard",
"errorCopyingHash": "Error copying hash",
"token": "Token",
"onlyOfficeId": "OnlyOffice ID",
"firstLoadTitle": "Welcome to FileBrowser Quantum!",
@@ -452,7 +450,7 @@
"moveFailed": "Move failed",
"operationFailed": "Operation failed",
"partialSuccess": "Some operations failed: {succeeded} succeeded, {failed} failed",
"confirmPasswordToChangeUserPassword": "Enter your current password to authorize this password change."
"confirmPasswordToSaveUser": "Enter your current password to save these user changes."
},
"search": {
"searchContext": "Search Context: {context}",
@@ -740,7 +738,8 @@
"enabledSuccessfully": "OTP enabled successfully",
"verificationFailed": "Verification Failed",
"verificationSucceed": "Verification Succeed",
"generationFailed": "Generation Failed"
"generationFailed": "Generation Failed",
"passwordRequiredForGeneration": "Your password is required to generate an authenticator code."
},
"access": {
"all": "Deny All",
@@ -855,8 +854,6 @@
"showingResults": "Showing {count} results",
"noResults": "No icons found",
"tryDifferentSearch": "Try a different search term",
"copiedToClipboard": "Copied '{name}' to clipboard!",
"copyFailed": "Failed to copy to clipboard",
"custom": "Custom",
"useCustomIcon": "Use custom icon: {name}"
},
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "¡Copiado al portapapeles!",
"copyFailed": "Error al copiar al portapapeles",
"copyFile": "Copiar archivo",
"copyToClipboard": "Copiar al portapapeles",
"moveFile": "Mover archivo",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "Selecciona un algoritmo",
"generate": "Generar",
"errorGeneratingHash": "Hubo un error al obtener el hash",
"hashCopied": "¡Se copio el hash al portapapeles!",
"errorCopyingHash": "Hubo un error al copiar el hash",
"token": "Token",
"onlyOfficeId": "OnlyOffice ID",
"firstLoadTitle": "¡Bienvenido a FileBrowser Quantum!",
@@ -227,7 +226,6 @@
"renameSuccess": "El cambio de nombre se ha realizado correctamente",
"newFileSuccess": "¡Archivo creado con éxito!",
"newDirSuccess": "¡Carpeta creada con éxito!",
"copyToClipboardFailed": "Hubo un error al copiar en el portapapeles",
"moveFailed": "Error al mover",
"operationFailed": "Operación fallida",
"partialSuccess": "Algunas acciones fallaron: {succeeded} se completaron, y {failed} fallaron",
@@ -243,7 +241,7 @@
"unarchiveDestination": "Extraer a:",
"unarchiveSuccess": "¡Archivo extraído con éxito!",
"downloadFailed": "Fallo en la descarga por trozos. Si se repite, intente reducir el tamaño de los trozos: {message}",
"confirmPasswordToChangeUserPassword": "Introduzca su contraseña actual para autorizar este cambio de contraseña."
"confirmPasswordToSaveUser": "Introduzca su contraseña actual para guardar estos cambios de usuario."
},
"search": {
"smallerThan": "Menor que:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "¡2FA activado!",
"verificationFailed": "No se pudo autenticar",
"verificationSucceed": "¡Verificación exitosa!",
"generationFailed": "Hubo un error al generar el código"
"generationFailed": "Hubo un error al generar el código",
"passwordRequiredForGeneration": "Su contraseña es necesaria para generar un código autenticador."
},
"general": {
"name": "Nombre",
@@ -845,8 +844,6 @@
"showingResults": "Se encontraron {count} iconos",
"noResults": "No se pudieorn encontrar iconos",
"tryDifferentSearch": "Prueba con otro nombre",
"copiedToClipboard": "¡Se copió '{name}' al portapapeles!",
"copyFailed": "Hubo un error al copiar al portapapeles",
"custom": "Personalizar",
"useCustomIcon": "Utilizar icono personalizado: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "Copié dans le presse-papiers !",
"copyFailed": "Échec de la copie dans le presse-papiers",
"copyFile": "Copier le fichier",
"copyToClipboard": "Copier dans le presse-papier",
"moveFile": "Déplacer le fichier",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "Sélectionner un algorithme de hachage",
"generate": "Générer",
"errorGeneratingHash": "Erreur dans la génération du hachage",
"hashCopied": "Hash copié dans le presse-papiers",
"errorCopyingHash": "Erreur de copie du hachage",
"token": "Jeton",
"onlyOfficeId": "OnlyOffice ID",
"firstLoadTitle": "Bienvenue à FileBrowser Quantum !",
@@ -227,7 +226,6 @@
"renameSuccess": "L'élément a été renommé avec succès !",
"newFileSuccess": "Fichier créé avec succès !",
"newDirSuccess": "Répertoire créé avec succès !",
"copyToClipboardFailed": "Impossible de copier dans le presse-papiers",
"moveFailed": "Échec du déplacement",
"operationFailed": "Échec de l'opération",
"partialSuccess": "Certaines opérations ont échoué : {succeeded} a réussi, {failed} a échoué",
@@ -243,7 +241,7 @@
"unarchiveDestination": "Extrait à :",
"unarchiveSuccess": "L'archive a été extraite avec succès !",
"downloadFailed": "Échec du téléchargement groupé - si cela se répète, essayez de réduire la taille des morceaux : {message}",
"confirmPasswordToChangeUserPassword": "Saisissez votre mot de passe actuel pour autoriser ce changement de mot de passe."
"confirmPasswordToSaveUser": "Saisissez votre mot de passe actuel pour enregistrer ces changements d'utilisateur."
},
"search": {
"smallerThan": "Plus petit que :",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "OTP activé avec succès",
"verificationFailed": "Échec de la vérification",
"verificationSucceed": "Vérification réussie",
"generationFailed": "Génération échouée"
"generationFailed": "Génération échouée",
"passwordRequiredForGeneration": "Votre mot de passe est nécessaire pour générer un code d'authentification."
},
"general": {
"name": "Nom",
@@ -845,8 +844,6 @@
"showingResults": "Afficher les résultats de {count} ",
"noResults": "Aucune icône trouvée",
"tryDifferentSearch": "Essayez un autre terme de recherche",
"copiedToClipboard": "Copie de '{name}' dans le presse-papiers !",
"copyFailed": "Échec de la copie dans le presse-papiers",
"custom": "Sur mesure",
"useCustomIcon": "Utiliser une icône personnalisée : {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "הועתק ללוח!",
"copyFailed": "העתקה ללוח נכשלה",
"copyFile": "העתק קובץ",
"copyToClipboard": "העתק ללוח",
"moveFile": "העבר קובץ",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "בחר אלגוריתם hash",
"generate": "ליצור",
"errorGeneratingHash": "שגיאה ביצירת hash",
"hashCopied": "ה-hash הועתק ללוח",
"errorCopyingHash": "שגיאה בהעתקת hash",
"token": "אסימון",
"onlyOfficeId": "מזהה OnlyOffice",
"firstLoadTitle": "ברוכים הבאים ל-FileBrowser Quantum!",
@@ -227,7 +226,6 @@
"renameSuccess": "הפריט שונה בהצלחה!",
"newFileSuccess": "הקובץ נוצר בהצלחה!",
"newDirSuccess": "הספרייה נוצרה בהצלחה!",
"copyToClipboardFailed": "לא ניתן להעתיק ללוח",
"moveFailed": "העברה נכשלה",
"operationFailed": "הפעולה נכשלה",
"partialSuccess": "כמה פעולות נכשלו: {succeeded} הצליחו, {failed} נכשלו",
@@ -243,7 +241,7 @@
"unarchiveDestination": "חלץ אל:",
"unarchiveSuccess": "הארכיון חולץ בהצלחה!",
"downloadFailed": "ההורדה המקוטעת נכשלה - אם הבעיה חוזרת על עצמה, נסה להקטין את גודל החלקים: {message}",
"confirmPasswordToChangeUserPassword": "הזן את הסיסמה הנוכחית שלך כדי לאשר את שינוי הסיסמה."
"confirmPasswordToSaveUser": "הזן את הסיסמה הנוכחית שלך כדי לשמור את שינויי המשתמש."
},
"search": {
"smallerThan": "קטן מ:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "OTP הופעל בהצלחה",
"verificationFailed": "האימות נכשל",
"verificationSucceed": "האימות הצליח",
"generationFailed": "דור שנכשל"
"generationFailed": "דור שנכשל",
"passwordRequiredForGeneration": "יש להזין את הסיסמה שלך כדי ליצור קוד אימות."
},
"general": {
"name": "שם",
@@ -845,8 +844,6 @@
"showingResults": "הצגת תוצאות {count} ",
"noResults": "לא נמצאו סמלים",
"tryDifferentSearch": "נסה מונח חיפוש אחר",
"copiedToClipboard": "הועתק '{name}' ללוח!",
"copyFailed": "לא ניתן להעתיק ללוח",
"custom": "מותאם אישית",
"useCustomIcon": "השתמש בסמל מותאם אישית: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "Vágólapra másolva!",
"copyFailed": "Nem sikerült a vágólapra másolni",
"copyFile": "Fájl másolása",
"copyToClipboard": "Másolás vágólapra",
"moveFile": "Fájl mozgatása",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "Válasszon ki egy hash algoritmust",
"generate": "Generálja",
"errorGeneratingHash": "Hiba generáló hash",
"hashCopied": "Hash másolva a vágólapra",
"errorCopyingHash": "Hiba a hash másolásában",
"token": "Token",
"onlyOfficeId": "OnlyOffice ID",
"firstLoadTitle": "Üdvözöljük a FileBrowser Quantum-on!",
@@ -227,7 +226,6 @@
"renameSuccess": "Az elem átnevezése sikeres volt!",
"newFileSuccess": "A fájl sikeresen létrehozva!",
"newDirSuccess": "A könyvtárat sikeresen létrehoztuk!",
"copyToClipboardFailed": "Nem lehet a vágólapra másolni",
"moveFailed": "Áthelyezés sikertelen",
"operationFailed": "A művelet sikertelen",
"partialSuccess": "Néhány művelet sikertelen: {succeeded} sikeres volt, {failed} sikertelen.",
@@ -243,7 +241,7 @@
"unarchiveDestination": "Kivonat a következő címre:",
"unarchiveSuccess": "Az archívum sikeresen kinyomtatva!",
"downloadFailed": "A darabos letöltés sikertelen - ha ez ismétlődik, próbálja meg csökkenteni a darabos letöltés méretét: {message}",
"confirmPasswordToChangeUserPassword": "A jelszóváltoztatás engedélyezéséhez adja meg a jelenlegi jelszavát."
"confirmPasswordToSaveUser": "A felhasználói módosítások mentéséhez adja meg az aktuális jelszavát."
},
"search": {
"smallerThan": "Kisebb, mint:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "OTP sikeresen engedélyezve",
"verificationFailed": "Ellenőrzés sikertelen",
"verificationSucceed": "Ellenőrzés Succeed",
"generationFailed": "Nemzedék sikertelen"
"generationFailed": "Nemzedék sikertelen",
"passwordRequiredForGeneration": "A hitelesítési kód generálásához a jelszóra van szükség."
},
"general": {
"name": "Név",
@@ -845,8 +844,6 @@
"showingResults": "{count} eredmények megjelenítése",
"noResults": "Nem talált ikonok",
"tryDifferentSearch": "Próbáljon ki egy másik kereső kifejezést",
"copiedToClipboard": "Másolt '{name}' a vágólapra!",
"copyFailed": "Nem sikerült a vágólapra másolni",
"custom": "Custom",
"useCustomIcon": "Egyéni ikon használata: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -132,6 +132,7 @@
},
"buttons": {
"copySuccess": "Copiato negli appunti!",
"copyFailed": "Copia negli appunti fallita",
"generateNewOtp": "Resetta e genera nuovo codice a due fattori",
"copyFile": "Copia file",
"copyToClipboard": "Copia negli appunti",
@@ -439,9 +440,6 @@
"selectHashAlgorithm": "Seleziona un algoritmo hash",
"generate": "Genera",
"errorGeneratingHash": "Errore durante la generazione dell'hash",
"hashCopied": "Hash copiato negli appunti",
"copyToClipboardFailed": "Impossibile copiare negli appunti",
"errorCopyingHash": "Errore durante la copia dell'hash",
"token": "Token",
"onlyOfficeId": "ID OnlyOffice",
"firstLoadTitle": "Benvenuto in FileBrowser Quantum!",
@@ -452,7 +450,7 @@
"operationFailed": "Operazione fallita",
"partialSuccess": "Alcune operazioni sono fallite: {succeeded} riuscite, {failed} fallite",
"downloadFailed": "Il download in chunk non è riuscito: se il problema si ripete, provare a ridurre la dimensione del chunk: {message}",
"confirmPasswordToChangeUserPassword": "Inserire la password attuale per autorizzare la modifica della password."
"confirmPasswordToSaveUser": "Immettere la password attuale per salvare le modifiche apportate all'utente."
},
"search": {
"searchContext": "Contesto di ricerca: {context}",
@@ -740,7 +738,8 @@
"enabledSuccessfully": "OTP abilitato con successo",
"verificationFailed": "Verifica fallita",
"verificationSucceed": "Verifica riuscita",
"generationFailed": "Generazione fallita"
"generationFailed": "Generazione fallita",
"passwordRequiredForGeneration": "La password è necessaria per generare un codice autenticatore."
},
"access": {
"all": "Nega Tutto",
@@ -855,8 +854,6 @@
"showingResults": "Mostrando {count} risultati",
"noResults": "Nessuna icona trovata",
"tryDifferentSearch": "Prova un termine diverso",
"copiedToClipboard": "Copiato '{name}' negli appunti!",
"copyFailed": "Copia fallita",
"custom": "Personalizzata",
"useCustomIcon": "Usa icona personalizzata: {name}"
},
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "クリップボードにコピーしました!",
"copyFailed": "クリップボードへのコピーに失敗しました",
"copyFile": "ファイルをコピー",
"copyToClipboard": "クリップボードにコピー",
"moveFile": "ファイルを移動",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "ハッシュアルゴリズムの選択",
"generate": "生成する",
"errorGeneratingHash": "ハッシュ生成エラー",
"hashCopied": "クリップボードにコピーされたハッシュ",
"errorCopyingHash": "ハッシュのコピーエラー",
"token": "トークン",
"onlyOfficeId": "オンリーオフィスID",
"firstLoadTitle": "FileBrowser Quantumへようこそ!",
@@ -227,7 +226,6 @@
"renameSuccess": "アイテム名の変更に成功しました!",
"newFileSuccess": "ファイルは正常に作成されました!",
"newDirSuccess": "ディレクトリは正常に作成されました!",
"copyToClipboardFailed": "クリップボードにコピーできない",
"moveFailed": "移動に失敗しました",
"operationFailed": "操作失敗",
"partialSuccess": "{succeeded} は成功、{failed} は失敗。",
@@ -243,7 +241,7 @@
"unarchiveDestination": "に抜粋する:",
"unarchiveSuccess": "アーカイブは正常に抽出されました!",
"downloadFailed": "チャンクダウンロードに失敗しました - これが繰り返される場合は、チャンクサイズを小さくしてみてください:{message}",
"confirmPasswordToChangeUserPassword": "現在のパスワードを入力して、パスワードの変更を承認します。"
"confirmPasswordToSaveUser": "現在のパスワードを入力して、ユーザーの変更を保存します。"
},
"search": {
"smallerThan": "より小さい:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "OTPの有効化に成功しました",
"verificationFailed": "検証に失敗しました",
"verificationSucceed": "検証に成功しました",
"generationFailed": "生成に失敗しました"
"generationFailed": "生成に失敗しました",
"passwordRequiredForGeneration": "認証コードを生成するにはパスワードが必要です。"
},
"general": {
"name": "名前",
@@ -845,8 +844,6 @@
"showingResults": "{count} の結果を表示",
"noResults": "アイコンが見つかりません",
"tryDifferentSearch": "別の検索語を試す",
"copiedToClipboard": "{name}' をクリップボードにコピー!",
"copyFailed": "クリップボードへのコピーに失敗しました",
"custom": "カスタム",
"useCustomIcon": "カスタムアイコンを使用する:{name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "클립보드에 복사되었습니다!",
"copyFailed": "클립보드 복사 실패",
"copyFile": "파일 복사",
"copyToClipboard": "클립보드 복사",
"moveFile": "파일 이동",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "해시 알고리즘 선택",
"generate": "생성",
"errorGeneratingHash": "해시 생성 오류",
"hashCopied": "클립보드에 해시 복사",
"errorCopyingHash": "해시 복사 오류",
"token": "토큰",
"onlyOfficeId": "OnlyOffice ID",
"firstLoadTitle": "파일브라우저 퀀텀에 오신 것을 환영합니다!",
@@ -227,7 +226,6 @@
"renameSuccess": "아이템 이름 변경에 성공했습니다!",
"newFileSuccess": "파일이 성공적으로 생성되었습니다!",
"newDirSuccess": "디렉토리가 성공적으로 생성되었습니다!",
"copyToClipboardFailed": "클립보드에 복사할 수 없습니다.",
"moveFailed": "이동 실패",
"operationFailed": "작업 실패",
"partialSuccess": "일부 작업 실패: {succeeded} 성공, {failed} 실패",
@@ -243,7 +241,7 @@
"unarchiveDestination": "추출 대상:",
"unarchiveSuccess": "아카이브가 성공적으로 추출되었습니다!",
"downloadFailed": "청크 다운로드에 실패했습니다 - 이 문제가 반복되면 청크 크기를 줄여 보세요: {message}",
"confirmPasswordToChangeUserPassword": "비밀번호 변경을 승인하려면 현재 비밀번호를 입력하세요."
"confirmPasswordToSaveUser": "이러한 사용자 변경 사항을 저장하려면 현재 비밀번호를 입력합니다."
},
"search": {
"smallerThan": "보다 작습니다:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "OTP 활성화 성공",
"verificationFailed": "인증 실패",
"verificationSucceed": "인증 성공",
"generationFailed": "세대 실패"
"generationFailed": "세대 실패",
"passwordRequiredForGeneration": "인증 코드를 생성하려면 비밀번호가 필요합니다."
},
"general": {
"name": "이름",
@@ -845,8 +844,6 @@
"showingResults": "{count} 결과 표시",
"noResults": "아이콘을 찾을 수 없습니다.",
"tryDifferentSearch": "다른 검색어를 사용해 보세요.",
"copiedToClipboard": "'{name}' 클립보드에 복사했습니다!",
"copyFailed": "클립보드에 복사하지 못했습니다.",
"custom": "사용자 지정",
"useCustomIcon": "사용자 지정 아이콘을 사용합니다: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "Gekopieerd naar klembord!",
"copyFailed": "Kopiëren naar klembord mislukt",
"copyFile": "Bestand kopiëren",
"copyToClipboard": "Kopiëren naar klembord",
"moveFile": "Verplaats bestand",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "Selecteer een hash-algoritme",
"generate": "genereren",
"errorGeneratingHash": "Fout bij het genereren van hash",
"hashCopied": "Hash gekopieerd naar klembord",
"errorCopyingHash": "Fout bij het kopiëren van hash",
"token": "Penning",
"onlyOfficeId": "Alleen kantoor-ID",
"firstLoadTitle": "Welkom bij FileBrowser Quantum!",
@@ -227,7 +226,6 @@
"renameSuccess": "Item hernoemd succesvol!",
"newFileSuccess": "Bestand succesvol aangemaakt!",
"newDirSuccess": "Directory succesvol aangemaakt!",
"copyToClipboardFailed": "Kan niet naar klembord kopiëren",
"moveFailed": "Verplaatsen mislukt",
"operationFailed": "Operatie mislukt",
"partialSuccess": "Sommige bewerkingen zijn mislukt: {succeeded} gelukt, {failed} mislukt.",
@@ -243,7 +241,7 @@
"unarchiveDestination": "Uittreksel naar:",
"unarchiveSuccess": "Archief met succes uitgepakt!",
"downloadFailed": "Chunkedownload mislukt - als dit zich herhaalt, probeer dan de grootte van de chunk te verkleinen: {message}",
"confirmPasswordToChangeUserPassword": "Voer uw huidige wachtwoord in om deze wachtwoordwijziging te autoriseren."
"confirmPasswordToSaveUser": "Voer uw huidige wachtwoord in om deze gebruikerswijzigingen op te slaan."
},
"search": {
"smallerThan": "Kleiner dan:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "OTP succesvol ingeschakeld",
"verificationFailed": "Verificatie mislukt",
"verificationSucceed": "Verificatie slagen",
"generationFailed": "Generatie mislukt"
"generationFailed": "Generatie mislukt",
"passwordRequiredForGeneration": "Uw wachtwoord is nodig om een verificatiecode te genereren."
},
"fileLoading": {
"title": "Uploaden en downloads",
@@ -845,8 +844,6 @@
"showingResults": "Resultaten tonen {count} ",
"noResults": "Geen pictogrammen gevonden",
"tryDifferentSearch": "Probeer een andere zoekterm",
"copiedToClipboard": "Gekopieerd '{name}' naar klembord!",
"copyFailed": "Kopiëren naar klembord mislukt",
"custom": "Aangepast",
"useCustomIcon": "Gebruik een aangepast pictogram: {name}",
"iconLibraryUrl": "lettertypen.google.com/iconen",
+16 -19
View File
@@ -57,6 +57,7 @@
"scopes": "Bereiken{suffix}",
"commands": "Opdrachten{suffix}",
"photos": "Foto's",
"audio": "Audio{suffix}",
"videos": "Video{suffix}",
"documents": "Documenten{suffix}",
"archives": "Archieven{suffix}",
@@ -127,13 +128,14 @@
"watching": "Bekijken{suffix}",
"lines": "Regels{suffix}",
"lastUpdate": "Laatste update{suffix}",
"latency": "Vertraging{suffix}",
"audio": "Audio{suffix}"
"latency": "Vertraging{suffix}"
},
"buttons": {
"copySuccess": "Gekopieerd naar klembord!",
"copyFailed": "Kopiëren naar klembord mislukt",
"generateNewOtp": "Herstellen en nieuwe twee-factorcode genereren",
"copyFile": "Bestand kopiëren",
"copyPath": "Pad kopiëren",
"copyToClipboard": "Kopiëren naar klembord",
"copyDownloadLinkToClipboard": "Downloadlink kopiëren naar klembord",
"moveFile": "Bestand verplaatsen",
@@ -164,8 +166,7 @@
"keepEditing": "Blijven bewerken",
"closeAnyway": "Sluiten",
"goToItem": "Ga naar item",
"goToSource": "Ga naar de bronlocatie",
"copyPath": "Kopieer pad"
"goToSource": "Ga naar de bronlocatie"
},
"download": {
"downloadFile": "Bestand downloaden",
@@ -440,9 +441,6 @@
"selectHashAlgorithm": "Selecteer een hash-algoritme",
"generate": "Genereren",
"errorGeneratingHash": "Fout bij het genereren van hash",
"hashCopied": "Hash gekopieerd naar klembord",
"copyToClipboardFailed": "Kan niet naar klembord kopiëren",
"errorCopyingHash": "Fout bij het kopiëren van hash",
"token": "Token",
"onlyOfficeId": "OnlyOffice-ID",
"firstLoadTitle": "Welkom bij FileBrowser Quantum!",
@@ -451,8 +449,8 @@
"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",
"operationFailed": "Bewerking is mislukt",
"confirmPasswordToChangeUserPassword": "Voer uw huidige wachtwoord in om deze wachtwoordwijziging te autoriseren.",
"partialSuccess": "Enkele bewerkingen zijn mislukt: {succeeded} gelukt, {failed} mislukt."
"partialSuccess": "Enkele bewerkingen zijn mislukt: {succeeded} gelukt, {failed} mislukt.",
"confirmPasswordToSaveUser": "Voer uw huidige wachtwoord in om deze gebruikerswijzigingen op te slaan."
},
"search": {
"searchContext": "Zoekcontext: {context}",
@@ -461,15 +459,15 @@
"showOptions": "Opties weergeven",
"notEnoughCharacters": "Niet genoeg tekens om te zoeken (min {minSearchLength})",
"typeToSearch": "Begin met het typen van {minSearchLength} of meer tekens om te beginnen met zoeken.",
"noResults": "Geen resultaten gevonden.",
"smallerThan": "Kleiner dan:",
"largerThan": "Groter dan:",
"olderThanDate": "Ouder dan:",
"newerThanDate": "Nieuwer dan:",
"showPreviewImages": "Voorbeeldafbeeldingen tonen",
"showPreviewImagesDescription": "Toon, indien beschikbaar, miniatuurvoorbeelden voor zoekresultaten en toon grotere pictogrammen voor alle resultaten.",
"helpText2": "Zoekresultaten zijn gebaseerd op geïndexeerde bestanden, dus nieuw toegevoegde bestanden verschijnen mogelijk pas als ze geïndexeerd zijn.",
"noResults": "Geen resultaten gevonden.",
"helpText1": "Er wordt gezocht op elk teken dat je typt (minimaal{minSearchLength} tekens)."
"helpText1": "Er wordt gezocht op elk teken dat je typt (minimaal{minSearchLength} tekens).",
"helpText2": "Zoekresultaten zijn gebaseerd op geïndexeerde bestanden, dus nieuw toegevoegde bestanden verschijnen mogelijk pas als ze geïndexeerd zijn."
},
"profileSettings": {
"preferEditorForMarkdown": "Geef de voorkeur aan editor voor Markdown-bestanden",
@@ -493,6 +491,7 @@
"showHiddenFilesDescription": "Toon verborgen bestanden in de lijst (bijv. .thumbnails, ProgramData), maar ze zijn nog steeds handmatig toegankelijk als ze zijn geconfigureerd om te worden geïndexeerd.",
"showQuickDownload": "Downloadpictogram weergeven voor snelle toegang",
"showQuickDownloadDescription": "Toon een downloadpictogram voor snelle toegang tot elk bestandsitem waarop kan worden geklikt om het meteen te downloaden.",
"previewDescription": "Toon miniatuurvoorbeelden voor {filetype}, toont generiek pictogram indien uitgeschakeld.",
"previewMotion": "Bewegingsvoorbeelden",
"previewMotionVideosDescription": "Bewegingsvoorbeeld is het geanimeerde popup-voorbeeld dat door de frames van de video of meerdere afbeeldingen voor een map loopt",
"editOffice": "Office-bestanden bekijken en bewerken met OnlyOffice",
@@ -513,6 +512,8 @@
"disableSearchOptions": "Zoekopties uitschakelen",
"disableSearchOptionsDescription": "Schakel de zoekopties in de zijbalk uit.",
"hideSidebarFileActions": "Verberg de knop Bestandsacties in de zijbalk",
"showCopyPath": "Pad kopiëren tonen in contextmenu",
"showCopyPathDescription": "Een actie Pad kopiëren weergeven in het contextmenu van bestanden en mappen.",
"deleteWithoutConfirming": "Bestanden verwijderen zonder bevestigingsvenster",
"deleteWithoutConfirmingDescription": "Bestanden verwijderen zonder bevestigingsvenster. Dit werkt alleen als het bestand een mediabestand is.",
"editorViewerOptions": "Opties voor editor en viewer",
@@ -531,10 +532,7 @@
"themeAndLanguage": "Thema & Taal",
"thumbnailOptions": "Miniatuuropties",
"showThumbnails": "Toon miniaturen voor ondersteunde items",
"showThumbnailsDescription": "Als deze optie is ingeschakeld, worden miniaturen weergegeven in de lijst met items die deze ondersteunen. Schakel deze optie uit om alle miniaturen te verbergen.",
"showCopyPath": "Pad kopiëren tonen in contextmenu",
"showCopyPathDescription": "Een actie Pad kopiëren weergeven in het contextmenu van bestanden en mappen.",
"previewDescription": "Toon miniatuurvoorbeelden voor {filetype}, toont generiek pictogram indien uitgeschakeld."
"showThumbnailsDescription": "Als deze optie is ingeschakeld, worden miniaturen weergegeven in de lijst met items die deze ondersteunen. Schakel deze optie uit om alle miniaturen te verbergen."
},
"settings": {
"systemAdmin": "Systeem & Beheer",
@@ -740,7 +738,8 @@
"enabledSuccessfully": "OTP succesvol ingeschakeld",
"verificationFailed": "Verificatie is mislukt",
"verificationSucceed": "Verificatie is geslaagd",
"generationFailed": "Aanmaken is mislukt"
"generationFailed": "Aanmaken is mislukt",
"passwordRequiredForGeneration": "Uw wachtwoord is nodig om een verificatiecode te genereren."
},
"access": {
"all": "Alles weigeren",
@@ -855,8 +854,6 @@
"showingResults": "{count} resultaten weergeven",
"noResults": "Geen pictogrammen gevonden",
"tryDifferentSearch": "Probeer een andere zoekterm",
"copiedToClipboard": "'{name}' is gekopieerd naar klembord!",
"copyFailed": "Kopiëren naar klembord mislukt",
"custom": "Aangepast",
"useCustomIcon": "Gebruik een aangepast pictogram: {name}"
},
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "Skopiowano do schowka!",
"copyFailed": "Nie udało się skopiować do schowka",
"copyFile": "Kopiuj plik",
"copyToClipboard": "kopiuj do schowka",
"moveFile": "Przenieś plik",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "Wybór algorytmu skrótu",
"generate": "Generowanie",
"errorGeneratingHash": "Błąd generowania skrótu",
"hashCopied": "Hash skopiowany do schowka",
"errorCopyingHash": "Błąd kopiowania skrótu",
"token": "Token",
"onlyOfficeId": "OnlyOffice ID",
"firstLoadTitle": "Witamy w FileBrowser Quantum!",
@@ -227,7 +226,6 @@
"renameSuccess": "Nazwa elementu została zmieniona pomyślnie!",
"newFileSuccess": "Plik został utworzony pomyślnie!",
"newDirSuccess": "Katalog utworzony pomyślnie!",
"copyToClipboardFailed": "Nie można skopiować do schowka",
"moveFailed": "Przenoszenie nie powiodło się",
"operationFailed": "Operacja nie powiodła się",
"partialSuccess": "Niektóre operacje nie powiodły się: {succeeded} powiodło się, {failed} nie powiodło się",
@@ -243,7 +241,7 @@
"unarchiveDestination": "Wyciąg do:",
"unarchiveSuccess": "Archiwum rozpakowane pomyślnie!",
"downloadFailed": "Pobieranie fragmentów nie powiodło się - jeśli sytuacja się powtórzy, spróbuj zmniejszyć rozmiar fragmentu: {message}",
"confirmPasswordToChangeUserPassword": "Wprowadź bieżące hasło, aby autoryzować zmianę hasła."
"confirmPasswordToSaveUser": "Wprowadź aktualne hasło, aby zapisać zmiany użytkownika."
},
"search": {
"smallerThan": "Mniejszy niż:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "OTP został pomyślnie włączony",
"verificationFailed": "Weryfikacja nie powiodła się",
"verificationSucceed": "Sukces weryfikacji",
"generationFailed": "Generacja nieudana"
"generationFailed": "Generacja nieudana",
"passwordRequiredForGeneration": "Hasło jest wymagane do wygenerowania kodu uwierzytelniającego."
},
"general": {
"name": "Nazwa",
@@ -845,8 +844,6 @@
"showingResults": "Wyświetlanie wyników {count} ",
"noResults": "Nie znaleziono ikon",
"tryDifferentSearch": "Wypróbuj inne wyszukiwane hasło",
"copiedToClipboard": "Skopiowano '{name}' do schowka!",
"copyFailed": "Nie udało się skopiować do schowka",
"custom": "Niestandardowe",
"useCustomIcon": "Użyj niestandardowej ikony: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "Copiado para a área de transferência!",
"copyFailed": "Falha ao copiar para a área de transferência",
"copyFile": "Copiar arquivo",
"copyToClipboard": "Copiar",
"moveFile": "Mover arquivo",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "Selecione um algoritmo de hash",
"generate": "Gerar",
"errorGeneratingHash": "Erro ao gerar hash",
"hashCopied": "Hash copiado para a área de transferência",
"errorCopyingHash": "Erro ao copiar o hash",
"token": "Token",
"onlyOfficeId": "ID do OnlyOffice",
"firstLoadTitle": "Bem-vindo ao FileBrowser Quantum!",
@@ -227,7 +226,6 @@
"renameSuccess": "Item renomeado com sucesso!",
"newFileSuccess": "Arquivo criado com sucesso!",
"newDirSuccess": "Diretório criado com sucesso!",
"copyToClipboardFailed": "Não é possível copiar para a área de transferência",
"moveFailed": "Falha ao mover",
"operationFailed": "Falha na operação",
"partialSuccess": "Algumas operações falharam: {succeeded} foi bem-sucedido, {failed} falhou",
@@ -243,7 +241,7 @@
"unarchiveDestination": "Extrair para:",
"unarchiveSuccess": "Arquivo extraído com sucesso!",
"downloadFailed": "Falha no download em pedaços - se isso se repetir, tente reduzir o tamanho do pedaço: {message}",
"confirmPasswordToChangeUserPassword": "Digite sua senha atual para autorizar a alteração da senha."
"confirmPasswordToSaveUser": "Digite sua senha atual para salvar essas alterações de usuário."
},
"search": {
"smallerThan": "Menor que:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "OTP ativado com sucesso",
"verificationFailed": "Falha na verificação",
"verificationSucceed": "Verificação de sucesso",
"generationFailed": "Falha na geração"
"generationFailed": "Falha na geração",
"passwordRequiredForGeneration": "Sua senha é necessária para gerar um código de autenticação."
},
"general": {
"name": "Nome",
@@ -845,8 +844,6 @@
"showingResults": "Mostrando {count} resultados",
"noResults": "Nenhum ícone encontrado",
"tryDifferentSearch": "Tente um termo de pesquisa diferente",
"copiedToClipboard": "Copiou '{name}' para a área de transferência!",
"copyFailed": "Falha ao copiar para a área de transferência",
"custom": "Personalizado",
"useCustomIcon": "Usar ícone personalizado: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "Copiado para a área de transferência!",
"copyFailed": "Falha ao copiar para a área de transferência",
"copyFile": "Copiar ficheiro",
"copyToClipboard": "Copiar",
"moveFile": "Mover ficheiro",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "Selecionar um algoritmo de hash",
"generate": "Gerar",
"errorGeneratingHash": "Erro ao gerar hash",
"hashCopied": "Hash copiado para a área de transferência",
"errorCopyingHash": "Erro ao copiar o hash",
"token": "Ficha",
"onlyOfficeId": "ID do OnlyOffice",
"firstLoadTitle": "Bem-vindo ao FileBrowser Quantum!",
@@ -227,7 +226,6 @@
"renameSuccess": "Item renomeado com sucesso!",
"newFileSuccess": "Ficheiro criado com sucesso!",
"newDirSuccess": "Diretório criado com sucesso!",
"copyToClipboardFailed": "Não é possível copiar para a área de transferência",
"moveFailed": "Falha ao mover",
"operationFailed": "A operação falhou",
"partialSuccess": "Algumas operações falharam: {succeeded} foi bem sucedido, {failed} falhou",
@@ -243,7 +241,7 @@
"unarchiveDestination": "Extrair para:",
"unarchiveSuccess": "Arquivo extraído com sucesso!",
"downloadFailed": "Falha na transferência em pedaços - se isto se repetir, tente reduzir o tamanho dos pedaços: {message}",
"confirmPasswordToChangeUserPassword": "Introduza a sua palavra-passe atual para autorizar esta alteração de palavra-passe."
"confirmPasswordToSaveUser": "Introduza a sua palavra-passe atual para guardar estas alterações de utilizador."
},
"search": {
"smallerThan": "Mais pequeno do que:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "OTP ativado com sucesso",
"verificationFailed": "Falha na verificação",
"verificationSucceed": "Verificação de sucesso",
"generationFailed": "Geração falhada"
"generationFailed": "Geração falhada",
"passwordRequiredForGeneration": "A sua palavra-passe é necessária para gerar um código de autenticação."
},
"fileLoading": {
"title": "Carregamentos e transferências",
@@ -845,8 +844,6 @@
"showingResults": "Mostrando {count} resultados",
"noResults": "Não foram encontrados ícones",
"tryDifferentSearch": "Experimente um termo de pesquisa diferente",
"copiedToClipboard": "Copiou '{name}' para a área de transferência!",
"copyFailed": "Falha ao copiar para a área de transferência",
"custom": "Personalizado",
"useCustomIcon": "Utilizar um ícone personalizado: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "Copiat în clipboard!",
"copyFailed": "Copierea în clipboard a eșuat",
"copyFile": "Copiază fișier",
"copyToClipboard": "Copiază în clipboard",
"moveFile": "Mută fișier",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "Selectați un algoritm hash",
"generate": "Generați",
"errorGeneratingHash": "Eroare în generarea hash-ului",
"hashCopied": "Hash copiat în clipboard",
"errorCopyingHash": "Eroare copiere hash",
"token": "Token",
"onlyOfficeId": "NumaiOffice ID",
"firstLoadTitle": "Bine ați venit la FileBrowser Quantum!",
@@ -227,7 +226,6 @@
"renameSuccess": "Element redenumit cu succes!",
"newFileSuccess": "Fișier creat cu succes!",
"newDirSuccess": "Director creat cu succes!",
"copyToClipboardFailed": "Imposibilitatea de a copia în clipboard",
"moveFailed": "Mutarea a eșuat",
"operationFailed": "Operațiunea a eșuat",
"partialSuccess": "Unele operațiuni au eșuat: {succeeded} a reușit, {failed} a eșuat",
@@ -243,7 +241,7 @@
"unarchiveDestination": "Extras la:",
"unarchiveSuccess": "Arhiva extrasă cu succes!",
"downloadFailed": "Descărcarea fragmentată a eșuat - dacă acest lucru se repetă, încercați să reduceți dimensiunea fragmentului: {message}",
"confirmPasswordToChangeUserPassword": "Introduceți parola curentă pentru a autoriza această modificare a parolei."
"confirmPasswordToSaveUser": "Introduceți parola curentă pentru a salva aceste modificări de utilizator."
},
"search": {
"smallerThan": "Mai mic decât:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "OTP activat cu succes",
"verificationFailed": "Verificarea a eșuat",
"verificationSucceed": "Verificarea reușită",
"generationFailed": "Generație eșuată"
"generationFailed": "Generație eșuată",
"passwordRequiredForGeneration": "Parola dvs. este necesară pentru a genera un cod de autentificare."
},
"general": {
"name": "Nume și prenume",
@@ -845,8 +844,6 @@
"showingResults": "Afișarea rezultatelor {count} ",
"noResults": "Nu s-au găsit pictograme",
"tryDifferentSearch": "Încercați un alt termen de căutare",
"copiedToClipboard": "Copiat '{name}' în clipboard!",
"copyFailed": "A eșuat copierea în clipboard",
"custom": "Personalizat",
"useCustomIcon": "Utilizați pictograma personalizată: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "Скопировано в буфер обмена!",
"copyFailed": "Не удалось скопировать в буфер обмена",
"copyFile": "Скопировать файл",
"copyToClipboard": "Скопировать в буфер",
"moveFile": "Переместить файл",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "Выберите алгоритм хэширования",
"generate": "Создать",
"errorGeneratingHash": "Ошибка при генерации хэша",
"hashCopied": "Хэш копируется в буфер обмена",
"errorCopyingHash": "Ошибка при копировании хэша",
"token": "Токен",
"onlyOfficeId": "Идентификатор OnlyOffice",
"firstLoadTitle": "Добро пожаловать в FileBrowser Quantum!",
@@ -227,7 +226,6 @@
"renameSuccess": "Элемент успешно переименован!",
"newFileSuccess": "Файл создан успешно!",
"newDirSuccess": "Каталог создан успешно!",
"copyToClipboardFailed": "Невозможно скопировать в буфер обмена",
"moveFailed": "Перемещение не удалось",
"operationFailed": "Операция не удалась",
"partialSuccess": "Некоторые операции завершились неудачей: {succeeded} - успешно, {failed} - неудачно.",
@@ -243,7 +241,7 @@
"unarchiveDestination": "Отрывок в:",
"unarchiveSuccess": "Архив извлечен успешно!",
"downloadFailed": "Не удалось загрузить чанк - если ситуация повторяется, попробуйте уменьшить размер чанка: {message}",
"confirmPasswordToChangeUserPassword": "Введите свой текущий пароль, чтобы разрешить смену пароля."
"confirmPasswordToSaveUser": "Введите свой текущий пароль, чтобы сохранить изменения пользователя."
},
"search": {
"smallerThan": "Меньше, чем:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "OTP успешно активирован",
"verificationFailed": "Верификация не удалась",
"verificationSucceed": "Проверка удалась",
"generationFailed": "Не удалось создать"
"generationFailed": "Не удалось создать",
"passwordRequiredForGeneration": "Ваш пароль необходим для генерации кода аутентификатора."
},
"general": {
"name": "Имя",
@@ -845,8 +844,6 @@
"showingResults": "Показать результаты {count} ",
"noResults": "Значки не найдены",
"tryDifferentSearch": "Попробуйте использовать другой поисковый запрос",
"copiedToClipboard": "Скопировано '{name}' в буфер обмена!",
"copyFailed": "Не удалось скопировать в буфер обмена",
"custom": "Пользовательское",
"useCustomIcon": "Используйте пользовательский значок: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "Skopírované do schránky!",
"copyFailed": "Kopírovanie do schránky zlyhalo",
"copyFile": "Kopírovať súbor",
"copyToClipboard": "Kopírovať do schránky",
"moveFile": "Presunúť súbory",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "Výber algoritmu hash",
"generate": "Generovať",
"errorGeneratingHash": "Chyba pri generovaní hash",
"hashCopied": "Hash skopírovaný do schránky",
"errorCopyingHash": "Chyba pri kopírovaní hash",
"token": "Token",
"onlyOfficeId": "IbaOffice ID",
"firstLoadTitle": "Vitajte v programe FileBrowser Quantum!",
@@ -227,7 +226,6 @@
"renameSuccess": "Položka bola úspešne premenovaná!",
"newFileSuccess": "Súbor bol úspešne vytvorený!",
"newDirSuccess": "Adresár bol úspešne vytvorený!",
"copyToClipboardFailed": "Nemožnosť kopírovania do schránky",
"moveFailed": "Presun zlyhal",
"operationFailed": "Operácia zlyhala",
"partialSuccess": "Niektoré operácie zlyhali: {succeeded} sa podarilo, {failed} sa nepodarilo",
@@ -243,7 +241,7 @@
"unarchiveDestination": "Výpis na:",
"unarchiveSuccess": "Archív úspešne extrahovaný!",
"downloadFailed": "Sťahovanie po častiach zlyhalo - ak sa to opakuje, skúste zmenšiť veľkosť časti: {message}",
"confirmPasswordToChangeUserPassword": "Zadajte svoje aktuálne heslo, aby ste autorizovali túto zmenu hesla."
"confirmPasswordToSaveUser": "Zadajte svoje aktuálne heslo, aby ste tieto zmeny používateľa uložili."
},
"search": {
"smallerThan": "Menšie ako:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "OTP úspešne povolený",
"verificationFailed": "Overenie sa nepodarilo",
"verificationSucceed": "Overovanie Úspech",
"generationFailed": "Generácia zlyhala"
"generationFailed": "Generácia zlyhala",
"passwordRequiredForGeneration": "Na vygenerovanie autentifikačného kódu je potrebné vaše heslo."
},
"general": {
"name": "Názov",
@@ -845,8 +844,6 @@
"showingResults": "Zobrazené výsledky {count} ",
"noResults": "Nenašli sa žiadne ikony",
"tryDifferentSearch": "Skúste použiť iný vyhľadávací výraz",
"copiedToClipboard": "Skopírované '{name}' do schránky!",
"copyFailed": "Nepodarilo sa skopírovať do schránky",
"custom": "Vlastné",
"useCustomIcon": "Použite vlastnú ikonu: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "Kopierat till urklipp!",
"copyFailed": "Misslyckades med att kopiera till urklipp",
"copyFile": "Kopiera fil",
"copyToClipboard": "Kopiera till urklipp",
"moveFile": "Flytta fil",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "Välj en hashalgoritm",
"generate": "Generera",
"errorGeneratingHash": "Fel vid generering av hash",
"hashCopied": "Hash kopieras till urklipp",
"errorCopyingHash": "Fel vid kopiering av hash",
"token": "Token",
"onlyOfficeId": "OnlyOffice ID",
"firstLoadTitle": "Välkommen till FileBrowser Quantum!",
@@ -227,7 +226,6 @@
"renameSuccess": "Objektet har bytt namn framgångsrikt!",
"newFileSuccess": "Filen skapades framgångsrikt!",
"newDirSuccess": "Katalog skapad framgångsrikt!",
"copyToClipboardFailed": "Det går inte att kopiera till urklipp",
"moveFailed": "Flyttning misslyckades",
"operationFailed": "Operationen misslyckades",
"partialSuccess": "Vissa operationer misslyckades: {succeeded} lyckades, {failed} misslyckades",
@@ -243,7 +241,7 @@
"unarchiveDestination": "Extrahera till:",
"unarchiveSuccess": "Arkivet har extraherats framgångsrikt!",
"downloadFailed": "Nedladdningen av chunked misslyckades - om detta upprepas, försök att minska chunkstorleken: {message}",
"confirmPasswordToChangeUserPassword": "Ange ditt nuvarande lösenord för att godkänna denna lösenordsändring."
"confirmPasswordToSaveUser": "Ange ditt nuvarande lösenord för att spara dessa användarändringar."
},
"search": {
"smallerThan": "Mindre än:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "OTP aktiverat framgångsrikt",
"verificationFailed": "Verifiering misslyckades",
"verificationSucceed": "Verifiering lyckas",
"generationFailed": "Generation misslyckad"
"generationFailed": "Generation misslyckad",
"passwordRequiredForGeneration": "Ditt lösenord krävs för att generera en autentiseringskod."
},
"fileLoading": {
"title": "Uppladdningar och nedladdningar",
@@ -845,8 +844,6 @@
"showingResults": "Visar resultat från {count} ",
"noResults": "Inga ikoner hittades",
"tryDifferentSearch": "Försök med en annan sökterm",
"copiedToClipboard": "Kopierade '{name}' till urklipp!",
"copyFailed": "Misslyckades med att kopiera till klippbordet",
"custom": "Anpassad",
"useCustomIcon": "Använd anpassad ikon: {name}",
"iconLibraryUrl": "teckensnitt.google.com/ikoner",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "Panoya kopyalandı!",
"copyFailed": "Panoya kopyalama başarısız",
"copyFile": "Dosyayı kopyala",
"copyToClipboard": "Panoya kopyala",
"moveFile": "Dosyayı taşı",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "Bir karma algoritma seçin",
"generate": "Oluşturmak",
"errorGeneratingHash": "Hash oluştururken hata oluştu",
"hashCopied": "Panoya kopyalanan karma",
"errorCopyingHash": "Karma kopyalama hatası",
"token": "Jeton",
"onlyOfficeId": "SadeceOfis Kimliği",
"firstLoadTitle": "FileBrowser Quantum'a hoş geldiniz!",
@@ -227,7 +226,6 @@
"renameSuccess": "Öğe başarıyla yeniden adlandırıldı!",
"newFileSuccess": "Dosya başarıyla oluşturuldu!",
"newDirSuccess": "Dizin başarıyla oluşturuldu!",
"copyToClipboardFailed": "Panoya kopyalanamıyor",
"moveFailed": "Taşıma başarısız",
"operationFailed": "İşlem başarısız oldu",
"partialSuccess": "Bazı işlemler başarısız oldu: {succeeded} başarılı, {failed} başarısız",
@@ -243,7 +241,7 @@
"unarchiveDestination": "Özetle:",
"unarchiveSuccess": "Arşiv başarıyla çıkarıldı!",
"downloadFailed": "Yığın indirme başarısız oldu - bu tekrarlanırsa yığın boyutunu küçültmeyi deneyin: {message}",
"confirmPasswordToChangeUserPassword": "Bu şifre değişikliğini onaylamak için mevcut şifrenizi girin."
"confirmPasswordToSaveUser": "Bu kullanıcı değişikliklerini kaydetmek için mevcut şifrenizi girin."
},
"search": {
"smallerThan": "Daha küçük:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "OTP başarıyla etkinleştirildi",
"verificationFailed": "Doğrulama Başarısız",
"verificationSucceed": "Doğrulama Başarılı",
"generationFailed": "Nesil Başarısız"
"generationFailed": "Nesil Başarısız",
"passwordRequiredForGeneration": "Kimlik doğrulayıcı kodu oluşturmak için parolanız gereklidir."
},
"general": {
"name": "İsim",
@@ -845,8 +844,6 @@
"showingResults": "{count} sonuçları gösteriliyor",
"noResults": "Simge bulunamadı",
"tryDifferentSearch": "Farklı bir arama terimi deneyin",
"copiedToClipboard": "'{name}' panoya kopyalandı!",
"copyFailed": "Panoya kopyalanamadı",
"custom": "Özel",
"useCustomIcon": "Özel simge kullanın: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "Скопійовано в буфер обміну!",
"copyFailed": "Не вдалося скопіювати в буфер обміну",
"copyFile": "Копіювати файл",
"copyToClipboard": "Копіювати в буфер обміну",
"moveFile": "Перемістити файл",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "Виберіть алгоритм хешування",
"generate": "Згенерувати",
"errorGeneratingHash": "Хеш, що генерує помилки",
"hashCopied": "Хеш скопійовано до буфера обміну",
"errorCopyingHash": "Помилка копіювання хешу",
"token": "Токен.",
"onlyOfficeId": "OnlyOffice ID",
"firstLoadTitle": "Ласкаво просимо до FileBrowser Quantum!",
@@ -227,7 +226,6 @@
"renameSuccess": "Елемент перейменовано успішно!",
"newFileSuccess": "Файл успішно створено!",
"newDirSuccess": "Каталог створено успішно!",
"copyToClipboardFailed": "Неможливо скопіювати в буфер обміну",
"moveFailed": "Переміщення не вдалося",
"operationFailed": "Операція не вдалася",
"partialSuccess": "Деякі операції не вдалися: {succeeded} вдалося, {failed} не вдалося",
@@ -243,7 +241,7 @@
"unarchiveDestination": "Витягнути до:",
"unarchiveSuccess": "Архів успішно розпаковано!",
"downloadFailed": "Не вдалося завантажити фрагмент - якщо це повториться, спробуйте зменшити розмір фрагмента: {message}",
"confirmPasswordToChangeUserPassword": "Введіть ваш поточний пароль, щоб дозволити зміну пароля."
"confirmPasswordToSaveUser": "Введіть ваш поточний пароль, щоб зберегти зміни користувача."
},
"search": {
"smallerThan": "Менше, ніж:",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "OTP успішно ввімкнено",
"verificationFailed": "Перевірка не вдалася",
"verificationSucceed": "Перевірка пройшла успішно",
"generationFailed": "Покоління зазнало невдачі"
"generationFailed": "Покоління зазнало невдачі",
"passwordRequiredForGeneration": "Ваш пароль необхідний для генерації коду автентифікатора."
},
"fileLoading": {
"title": "Завантаження та вивантаження",
@@ -845,8 +844,6 @@
"showingResults": "Показує результати {count} ",
"noResults": "Не знайдено жодної піктограми",
"tryDifferentSearch": "Спробуйте інший пошуковий запит",
"copiedToClipboard": "Скопіюйте '{name}' в буфер обміну!",
"copyFailed": "Не вдалося скопіювати в буфер обміну",
"custom": "Нестандартний",
"useCustomIcon": "Використовуйте власну іконку: {name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "已复制到剪贴板!",
"copyFailed": "复制到剪贴板失败",
"copyFile": "复制文件",
"copyToClipboard": "复制到剪贴板",
"moveFile": "移动文件",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "选择散列算法",
"generate": "生成",
"errorGeneratingHash": "错误生成哈希值",
"hashCopied": "哈希值复制到剪贴板",
"errorCopyingHash": "复制哈希值出错",
"token": "代币",
"onlyOfficeId": "唯一办公室 ID",
"firstLoadTitle": "欢迎访问 FileBrowser Quantum!",
@@ -227,7 +226,6 @@
"renameSuccess": "项目重命名成功!",
"newFileSuccess": "文件创建成功!",
"newDirSuccess": "目录创建成功!",
"copyToClipboardFailed": "无法复制到剪贴板",
"moveFailed": "移动失败",
"operationFailed": "操作失败",
"partialSuccess": "某些操作失败:{succeeded} 成功,{failed} 失败",
@@ -243,7 +241,7 @@
"unarchiveDestination": "摘录至",
"unarchiveSuccess": "档案提取成功!",
"downloadFailed": "分块下载失败 - 如果重复出现这种情况,请尝试减小分块大小:{message}",
"confirmPasswordToChangeUserPassword": "输入您的当前密码以授权密码更改。"
"confirmPasswordToSaveUser": "输入您当前的密码以保存这些用户更改。"
},
"search": {
"smallerThan": "小于",
@@ -544,7 +542,8 @@
"enabledSuccessfully": "成功启用 OTP",
"verificationFailed": "验证失败",
"verificationSucceed": "验证成功",
"generationFailed": "一代失败"
"generationFailed": "一代失败",
"passwordRequiredForGeneration": "生成验证码需要您输入密码。"
},
"fileLoading": {
"title": "上传和下载",
@@ -845,8 +844,6 @@
"showingResults": "显示{count} 结果",
"noResults": "未找到图标",
"tryDifferentSearch": "尝试不同的搜索词",
"copiedToClipboard": "将 '{name}' 复制到剪贴板!",
"copyFailed": "复制到剪贴板失败",
"custom": "定制",
"useCustomIcon": "使用自定义图标:{name}",
"iconLibraryUrl": "fonts.google.com/icons",
+4 -7
View File
@@ -1,6 +1,7 @@
{
"buttons": {
"copySuccess": "已複製到剪貼簿!",
"copyFailed": "複製到剪貼簿失敗",
"copyFile": "複製檔案",
"copyToClipboard": "複製到剪貼簿",
"moveFile": "移動檔案",
@@ -215,8 +216,6 @@
"selectHashAlgorithm": "選擇雜湊演算法",
"generate": "產生",
"errorGeneratingHash": "產生雜湊時出錯",
"hashCopied": "已將雜湊值複製到剪貼簿",
"errorCopyingHash": "複製雜湊時出錯",
"token": "權杖",
"onlyOfficeId": "OnlyOffice ID",
"firstLoadTitle": "歡迎來到 FileBrowser Quantum!",
@@ -227,7 +226,6 @@
"renameSuccess": "項目已成功重新命名!",
"newFileSuccess": "檔案已成功建立!",
"newDirSuccess": "目錄建立成功!",
"copyToClipboardFailed": "無法複製到剪貼簿",
"moveFailed": "移動失敗",
"operationFailed": "操作失敗",
"partialSuccess": "部分作業失敗:成功 {succeeded} 項,失敗 {failed} 項",
@@ -243,7 +241,7 @@
"unarchiveDestination": "解壓縮至:",
"unarchiveSuccess": "封存檔解壓縮成功!",
"downloadFailed": "分塊下載失敗 - 如果此情況重複發生,請嘗試減少分塊大小:{message}",
"confirmPasswordToChangeUserPassword": "輸入您目前的密碼以授權此密碼變更。"
"confirmPasswordToSaveUser": "輸入您目前的密碼以儲存這些使用者變更。"
},
"search": {
"smallerThan": "小於:",
@@ -609,7 +607,8 @@
"enabledSuccessfully": "已成功啟用雙因素驗證",
"verificationFailed": "驗證失敗",
"verificationSucceed": "驗證成功",
"generationFailed": "產生驗證碼失敗"
"generationFailed": "產生驗證碼失敗",
"passwordRequiredForGeneration": "生成驗證碼需要您的密碼。"
},
"fileLoading": {
"title": "上傳與下載",
@@ -845,8 +844,6 @@
"showingResults": "顯示 {count} 筆結果",
"noResults": "找不到圖示",
"tryDifferentSearch": "嘗試不同的搜尋字詞",
"copiedToClipboard": "已將「{name}」複製到剪貼簿!",
"copyFailed": "複製到剪貼簿失敗",
"custom": "自訂",
"useCustomIcon": "使用自訂圖示:{name}",
"iconLibraryUrl": "fonts.google.com/icons",
+2
View File
@@ -15,6 +15,8 @@ import i18n from "@/i18n";
const titles = {
Login: i18n.global.t("general.login"),
Share: i18n.global.t("general.share"),
/** Same component as Share; name differs for /public optional-auth routes. */
PublicShare: i18n.global.t("general.share"),
Files: i18n.global.t("general.files"),
Tools: i18n.global.t("tools.title"),
ChildTool: i18n.global.t("tools.title"),
+36 -15
View File
@@ -160,25 +160,46 @@ export const mutations = {
emitStateChanged();
},
setSources: (user) => {
const currentSource = user.scopes.length > 0 ? user.scopes[0].name : "";
// Rebuilding sources from /api/users must not wipe stats already merged from
// /api/settings/sources, SSE, or a prior validateLogin — otherwise the sidebar
// shows "unknown"/red until a full page reload (common after OTP: initAuth then
// router beforeResolve both refresh the user).
const sameUser = Boolean(state.user && user.username === state.user.username);
const prevInfo = sameUser && state.sources?.info ? state.sources.info : {};
let currentSource = user.scopes.length > 0 ? user.scopes[0].name : "";
if (
sameUser &&
state.sources?.current &&
user.scopes.some((s) => s.name === state.sources.current)
) {
currentSource = state.sources.current;
}
let sources = {info: {}, current: currentSource, count: user.scopes.length};
for (const source of user.scopes) {
const prev = prevInfo[source.name];
const merge = Boolean(prev);
sources.info[source.name] = {
pathPrefix: sources.count == 1 ? "" : encodeURIComponent(source.name),
used: 0,
total: 0,
usedAlt: 0,
usedPercentage: 0,
status: "unknown",
name: source.name,
files: 0,
folders: 0,
lastIndex: 0,
quickScanDurationSeconds: 0,
fullScanDurationSeconds: 0,
complexity: 0,
used: merge ? prev.used : 0,
total: merge ? prev.total : 0,
usedAlt: merge ? prev.usedAlt : 0,
usedPercentage: merge ? prev.usedPercentage : 0,
status: merge ? prev.status : "unknown",
name: merge ? prev.name : source.name,
files: merge ? prev.files : 0,
folders: merge ? prev.folders : 0,
lastIndex: merge ? prev.lastIndex : 0,
quickScanDurationSeconds: merge ? prev.quickScanDurationSeconds : 0,
fullScanDurationSeconds: merge ? prev.fullScanDurationSeconds : 0,
complexity: merge ? prev.complexity : 0,
scanners: merge && prev.scanners ? [...prev.scanners] : [],
};
}
// Sidebar usage bar uses hasSourceInfo + per-source used/total; must survive object replace
sources.hasSourceInfo = user.scopes.some((s) => {
const e = sources.info[s.name];
return e && (e.total > 0 || e.used > 0 || e.usedAlt > 0);
});
// Check if user has custom sidebar links with sources
let targetSource = sources.current;
if (state.user?.sidebarLinks && state.user.sidebarLinks.length > 0) {
@@ -200,7 +221,7 @@ export const mutations = {
}
state.user.gallerySize = value
emitStateChanged();
usersApi.update(state.user, ['gallerySize']);
void usersApi.update(state.user, ['gallerySize']).catch((e) => notify.showError(e));
},
setActiveSettingsView: (value) => {
if (value == state.activeSettingsView) {
@@ -535,7 +556,7 @@ export const mutations = {
value.id = state.user.id;
value.username = state.user?.username;
if (updatedProperties.length > 0) {
usersApi.update(value, updatedProperties);
void usersApi.update(value, updatedProperties).catch((e) => notify.showError(e));
}
}
// Emit state change event
-1
View File
@@ -101,7 +101,6 @@ export async function logout() {
export async function initAuth() {
if (!getters.isShare()) {
console.log("validating login");
await validateLogin();
}
if (globalVars.recaptcha) {
+44
View File
@@ -0,0 +1,44 @@
// Copy to clipboard.
// Uses clipboard API when available, and will fallback to execCommand (which is deprecated, but will make it work on insecure connections)
import i18n from '@/i18n';
import { notify } from '@/notify';
/**
* @param {string} text - Text to copy.
* @returns {Promise<boolean>} True if copy succeeded, false otherwise.
*/
export async function copyToClipboard(text) {
const successMessage = i18n.global.t('buttons.copySuccess');
const errorMessage = i18n.global.t('buttons.copyFailed');
if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
notify.showSuccessToast(successMessage);
return true;
}
try {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const success = document.execCommand('copy');
document.body.removeChild(textArea);
if (success) {
notify.showSuccessToast(successMessage);
return true;
} else {
throw new Error('execCommand returned false');
}
} catch (err) {
console.error('Copy failed:', err);
notify.showError(errorMessage);
return false;
}
}
+2 -1
View File
@@ -84,10 +84,11 @@ export default {
if (!state.sessionId) {
mutations.setSession(generateRandomCode(8));
}
// Set up toast callback
// Set up toast callback (sync any toasts that were queued before Layout mounted, e.g. OTP on login)
notify.setToastUpdateCallback((toasts) => {
this.toasts = toasts;
});
this.toasts = notify.getToasts();
this.reEval()
this.initialize();
},
+1 -1
View File
@@ -356,7 +356,7 @@ export default {
},
});
}
if (e.message.includes("OTP is enforced, but user is not yet configured")) {
if (e.message.includes("OTP is enforced, but user is not configured")) {
mutations.showPrompt({
name: "totp",
pinned: true,
+42 -54
View File
@@ -732,19 +732,6 @@ export default {
return;
}
}
// Handle the space bar key
if (key === " " && !modifierKeys) {
event.preventDefault();
if (state.isSearchActive) {
mutations.setSearch(false);
mutations.closeHovers();
} else {
mutations.setSearch(true);
}
}
if (getters.currentPromptName()) {
return;
}
let currentPath = url.removeTrailingSlash(state.route.path);
let newPath = currentPath.substring(0, currentPath.lastIndexOf("/"));
@@ -1000,7 +987,8 @@ export default {
from: item.from,
fromSource: item.fromSource,
to: destPath + item.name,
toSource: state.req.source
toSource: state.req.source,
name: item.name,
}));
const operation = this.clipboard.key === "x" ? "move" : "copy";
@@ -1011,51 +999,51 @@ export default {
props: {
operation: operation,
items: items,
onConfirm: async () => {
mutations.setLoading("listing", true);
onConfirm: () => {
return new Promise((resolve, reject) => {
let action = async (overwrite, rename) => {
try {
if (getters.isShare()) {
await resourcesApi.moveCopyPublic(state.shareInfo.hash, items, operation, overwrite, rename);
} else {
await resourcesApi.moveCopy(items, operation, overwrite, rename);
const action = async (overwrite, rename) => {
try {
if (getters.isShare()) {
await resourcesApi.moveCopyPublic(state.shareInfo.hash, items, operation, overwrite, rename);
} else {
await resourcesApi.moveCopy(items, operation, overwrite, rename);
}
if (operation === "move") {
this.clipboard = { items: [] };
this.internalClipboardTimestamp = 0;
}
mutations.setReload(true);
resolve();
} catch (error) {
console.error("Error moving/copying items:", error);
reject(error);
}
if (operation === "move") {
this.clipboard = { items: [] };
this.internalClipboardTimestamp = 0;
}
mutations.setLoading("listing", false);
} catch (error) {
console.error("Error moving/copying items:", error);
} finally {
mutations.setLoading("listing", false);
mutations.setReload(true);
};
if (this.clipboard.path === state.route.path) {
action(false, true);
return;
}
};
if (this.clipboard.path === state.route.path) {
action(false, true);
return;
}
const conflict = upload.checkConflict(items, state.req.items);
const conflict = upload.checkConflict(items, state.req.items);
if (conflict) {
mutations.showPrompt({
name: "replace-rename",
pinned: true,
confirm: (event, option) => {
const overwrite = option === "overwrite";
const rename = option === "rename";
event.preventDefault();
mutations.closeTopPrompt();
action(overwrite, rename);
},
});
return;
}
action(false, false);
if (conflict) {
mutations.showPrompt({
name: "replace-rename",
pinned: true,
confirm: (event, option) => {
const overwrite = option === "overwrite";
const rename = option === "rename";
event.preventDefault();
mutations.closeTopPrompt();
action(overwrite, rename);
},
});
return;
}
action(false, false);
});
},
},
});
+3 -29
View File
@@ -12,7 +12,7 @@ import { marked } from "marked";
import DOMPurify from 'dompurify';
import { state, mutations, getters } from "@/store";
import hljs from 'highlight.js';
import { notify } from "@/notify";
import { copyToClipboard } from "@/utils/clipboard";
export default {
name: "markdownViewer",
@@ -99,37 +99,11 @@ export default {
}, 1500);
};
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
// Using clipboard API
navigator.clipboard.writeText(text).then(() => {
showFeedback(true);
notify.showSuccessToast(this.$t('buttons.copySuccess'));
}).catch((err) => {
console.error('Clipboard API error:', err);
showFeedback(false);
notify.showErrorToast(this.$t('tools.materialIconPicker.copyFailed'));
});
} else {
// Fallback using execCommand.
// This seems the only way to allow copy from http (insecure) connections (even if is marked as deprecated)
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
const success = document.execCommand('copy');
document.body.removeChild(textarea);
if (success) {
showFeedback(true);
notify.showSuccessToast(this.$t('buttons.copySuccess'));
} else {
showFeedback(false);
notify.showErrorToast(this.$t('tools.materialIconPicker.copyFailed'));
}
}
const success = await copyToClipboard(text);
showFeedback(success);
} catch (err) {
console.error('Copy failed:', err);
showFeedback(false);
notify.showErrorToast(this.$t('tools.materialIconPicker.copyFailed'));
}
});
wrapper.appendChild(copyButton);
+4 -1
View File
@@ -35,7 +35,7 @@
/>
</div>
<div v-else-if="previewType == 'pdf'" class="pdf-wrapper">
<div v-else-if="isPdf" class="pdf-wrapper">
<iframe class="pdf" :src="raw"></iframe>
<a v-if="isMobileSafari" :href="raw" target="_blank" class="button button--flat floating-btn">
<div>
@@ -166,6 +166,9 @@ export default {
}
return getters.previewType();
},
isPdf() {
return state.req.type === 'application/pdf';
},
raw() {
const isHeicOrHeif = state.req.type === "image/heic" || state.req.type === "image/heif";
+4 -22
View File
@@ -47,7 +47,7 @@
</button>
</td>
<td class="small">
<button class="action copy-clipboard" :data-clipboard-text="link.token"
<button class="action" @click.stop="copyToClipboard(link.token)"
:aria-label="$t('buttons.copyToClipboard')" :title="$t('buttons.copyToClipboard')">
<i class="material-symbols">content_paste</i>
</button>
@@ -65,10 +65,9 @@
</template>
<script>
import { notify } from "@/notify";
import { authApi } from "@/api";
import { state, mutations, getters } from "@/store";
import Clipboard from "clipboard";
import { copyToClipboard } from "@/utils/clipboard";
import Errors from "@/views/Errors.vue";
import { eventBus } from "@/store/eventBus";
@@ -81,7 +80,6 @@ export default {
return {
error: null,
links: [],
clip: null,
user: {
permissions: { ...state.user.permissions}
},
@@ -93,14 +91,10 @@ export default {
mounted() {
// Listen for API key changes
eventBus.on('apiKeysChanged', this.reloadApiKeys);
this.initClipboard();
},
beforeUnmount() {
// Clean up event listener
eventBus.off('apiKeysChanged', this.reloadApiKeys);
if (this.clip) {
this.clip.destroy();
}
},
computed: {
settings() {
@@ -114,16 +108,8 @@ export default {
},
},
methods: {
initClipboard() {
// Destroy existing clipboard first
if (this.clip) {
this.clip.destroy();
}
// Create new clipboard instance
this.clip = new Clipboard(".copy-clipboard");
this.clip.on("success", () => {
notify.showSuccessToast(this.$t("success.linkCopied")); // Use consistent message
});
async copyToClipboard(text) {
await copyToClipboard(text);
},
async reloadApiKeys() {
mutations.setLoading("api", true);
@@ -131,10 +117,6 @@ export default {
// Fetch the API keys from the specified endpoint
this.links = await authApi.getApiKeys();
this.error = null; // Clear errors
// Reinitialize clipboard after data changes
this.$nextTick(() => {
this.initClipboard();
});
} catch (e) {
// ignore 404 errors
if (e.status !== 404) {
+5 -4
View File
@@ -376,13 +376,14 @@ export default {
"debugOffice",
"preferEditorForMarkdown",
]);
if (themeChanged) {
window.location.reload();
}
notify.showSuccessToast(
this.$t('settings.settingsUpdated')
);
if (themeChanged) {
setTimeout(() => {
window.location.reload();
}, 1000);
}
} catch (e) {
console.error(e);
}
+6 -27
View File
@@ -51,13 +51,13 @@
</button>
</td>
<td class="small">
<button class="action copy-clipboard" :data-clipboard-text="buildLink(item)"
<button class="action" @click.stop="copyToClipboard(buildLink(item))"
:aria-label="$t('buttons.copyToClipboard')" :title="$t('buttons.copyToClipboard')">
<i class="material-symbols">content_paste</i>
</button>
</td>
<td class="small">
<button :disabled="item.shareType == 'upload'" class="action copy-clipboard" :data-clipboard-text="item.downloadURL" v-if="item.downloadURL"
<button :disabled="item.shareType == 'upload'" class="action" @click.stop="copyToClipboard(item.downloadURL)" v-if="item.downloadURL"
:aria-label="$t('buttons.copyDownloadLinkToClipboard')" :title="$t('buttons.copyDownloadLinkToClipboard')">
<i class="material-symbols">content_paste_go</i>
</button>
@@ -76,10 +76,10 @@
import { notify } from "@/notify";
import { shareApi } from "@/api";
import { state, mutations, getters } from "@/store";
import Clipboard from "clipboard";
import Errors from "@/views/Errors.vue";
import { fromNow } from '@/utils/moment';
import { eventBus } from "@/store/eventBus";
import { copyToClipboard } from "@/utils/clipboard";
export default {
name: "shares",
@@ -92,23 +92,16 @@ export default {
error: null,
/** @type {any[]} */
links: [],
/** @type {any} */
clip: null,
};
},
async created() {
await this.reloadShares();
},
mounted() {
this.initClipboard();
// Listen for share changes
eventBus.on('sharesChanged', this.reloadShares);
},
beforeUnmount() {
// Clean up clipboard
if (this.clip) {
this.clip.destroy();
}
// Clean up event listener
eventBus.off('sharesChanged', this.reloadShares);
},
@@ -127,6 +120,9 @@ export default {
},
},
methods: {
async copyToClipboard(text) {
await copyToClipboard(text);
},
async reloadShares() {
mutations.setLoading("shares", true);
try {
@@ -137,9 +133,6 @@ export default {
}
this.links = links;
this.error = null; // Clear any previous errors
this.$nextTick(() => {
this.initClipboard();
});
} catch (e) {
this.error = e;
console.error(e);
@@ -147,20 +140,6 @@ export default {
mutations.setLoading("shares", false);
}
},
initClipboard() {
// First destroy any existing clipboard
if (this.clip) {
this.clip.destroy();
}
// Create new clipboard
this.clip = new Clipboard(".copy-clipboard");
this.clip.on("success", () => {
notify.showSuccessToast(this.$t("success.linkCopied"));
});
this.clip.on("error", () => {
notify.showErrorToast(this.$t("prompts.copyToClipboardFailed"));
});
},
editLink(item) {
mutations.showPrompt({
name: "share",
@@ -66,7 +66,7 @@
<script>
import { materialSymbols, getIconClass } from "@/utils/material-symbols";
import { notify } from "@/notify";
import { copyToClipboard } from "@/utils/clipboard";
export default {
name: "MaterialIconPicker",
@@ -125,37 +125,7 @@ export default {
methods: {
getIconClass,
async copyIconName(iconName) {
try {
await navigator.clipboard.writeText(iconName);
notify.showSuccessToast(
this.$t('tools.materialIconPicker.copiedToClipboard', { name: iconName })
);
} catch (err) {
console.error("Failed to copy icon name:", err);
// Fallback for older browsers
this.fallbackCopy(iconName);
}
},
fallbackCopy(text) {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand("copy");
notify.showSuccessToast(
this.$t('tools.materialIconPicker.copiedToClipboard', { name: text })
);
} catch (err) {
console.error("Fallback copy failed:", err);
notify.showErrorToast(this.$t('tools.materialIconPicker.copyFailed'));
}
document.body.removeChild(textArea);
await copyToClipboard(iconName);
},
},
};
@@ -30,18 +30,24 @@ test("navigate from search item", async({ page, checkForErrors, context }) => {
await page.locator('#search-bar-input').click()
await page.locator('select[aria-label="search sources dropdown"]').selectOption('access');
await page.locator('#search-input').fill('showme.txt');
await expect(page.locator('#result-list')).toHaveCount(0);
await expect(page.locator('.searchPrompt p')).toHaveText('No results found.');
checkForErrors()
});
test("share access controls exist", async ({ page, checkForErrors, context }) => {
const rootShareHash = await page.evaluate(() => localStorage.getItem('rootShareHash'));
// localStorage is not available on about:blank; Firefox throws "The operation is insecure."
await page.goto("/files/");
// Leaving /files/ while fetches are still in flight aborts them; Firefox logs NetworkError to the console.
await page.waitForLoadState("networkidle");
const rootShareHash = await page.evaluate(() => localStorage.getItem("rootShareHash"));
if (!rootShareHash) {
throw new Error("Share hash not found in localStorage");
}
await page.goto("/files/share/" + rootShareHash );
await expect(page).toHaveTitle("Graham's Filebrowser - Share - playwright-files");
// expect the excluded folder to not be visible
await page.goto("/public/share/" + rootShareHash);
// Document title stays at router default until Files.vue loads share metadata (see router beforeResolve vs Files.vue).
await expect(page.locator('a[aria-label="excludedButVisible"]')).toBeVisible();
await expect(page.locator('div[aria-label="excluded"]')).toBeHidden();
checkForErrors();
await expect(page).toHaveTitle("Graham's Filebrowser - Share - playwright-files");
checkForErrors();
});
@@ -1,15 +1,29 @@
import { test, expect } from "../test-setup";
import { Page } from "@playwright/test";
test('create, check settings, and delete testuser2', async ({
/**
* User PUT for admin-only fields returns 401 until X-Password is supplied; the UI opens password-prompt on top.
* Password must match tests/playwright/global-setup.ts (same as two factor auth check).
*/
async function confirmActorPasswordPrompt(page: Page) {
const passwordModal = page.locator('div[aria-label="password-prompt"]');
await expect(passwordModal).toBeVisible({ timeout: 3000 });
await passwordModal.locator("input").fill("admin");
await passwordModal.locator('button[aria-label="Confirm"]').click();
await expect(passwordModal).not.toBeVisible();
}
test("create, check settings, and delete user (retry-safe name)", async ({
page,
checkForErrors,
}) => {
}, testInfo) => {
// Unique username per run/retry so retries don't conflict with leftover users (same idea as proxy/preview.spec.ts).
const username = `testuser2-${testInfo.retry + 1}`;
await page.goto('/settings')
await expect(page).toHaveTitle("Graham's Filebrowser - Settings")
await page.locator('#users-sidebar').click();
await page.locator('button[aria-label="Add New User"]').click()
await page.locator('#username').fill('testuser2')
await page.locator('#username').fill(username)
await page.locator('input[aria-label="Password1"]').fill('testpassword')
await page.locator('input[aria-label="Password2"]').fill('testpass')
// check that the invalid-field class is added properly
@@ -18,19 +32,25 @@ test('create, check settings, and delete testuser2', async ({
)
await page.locator('input[aria-label="Password2"]').fill('testpassword')
// Just create the user first
// Just create the user first (listen before click to avoid missing a fast 201).
const createResponse = page.waitForResponse(
(resp) =>
resp.url().includes('/api/users') &&
resp.request().method() === 'POST' &&
resp.status() === 201
);
await page.locator('button[aria-label="Save"]').click();
await page.waitForResponse((resp) => resp.url().includes('/api/users') && resp.status() === 201)
await createResponse;
// We should be back on the settings page
await expect(page.locator('tr.item', { hasText: 'testuser2' })).toBeVisible();
await expect(page.locator('tr.item', { hasText: username })).toBeVisible();
// Now, click the edit button for testuser2
await page.locator('tr.item', { hasText: 'testuser2' }).locator('td[aria-label="Edit User"] .clickable').click();
// Now, click the edit button for the new user
await page.locator('tr.item', { hasText: username }).locator('td[aria-label="Edit User"] .clickable').click();
// Now on the edit page, toggle the settings
await expect(page.locator('.card.floating')).toBeVisible();
const modal = page.locator('.card.floating');
await expect(page.locator('div[aria-label="user-edit-prompt"]')).toBeVisible();
const modal = page.locator('div[aria-label="user-edit-prompt"]');
const settingsToToggle = [
"Administrator",
@@ -47,13 +67,14 @@ test('create, check settings, and delete testuser2', async ({
await toggleSwitch.click();
}
// Save the updated settings
// Save the updated settings (admin actor password required for sensitive user fields)
await modal.locator('button[aria-label="Save"]').click();
await confirmActorPasswordPrompt(page);
await expect(modal).not.toBeVisible();
// Re-open the modal to check the settings
await page.locator('tr.item', { hasText: 'testuser2' }).locator('td[aria-label="Edit User"] .clickable').click();
await expect(page.locator('.card.floating')).toBeVisible();
await page.locator('tr.item', { hasText: username }).locator('td[aria-label="Edit User"] .clickable').click();
await expect(page.locator('div[aria-label="user-edit-prompt"]')).toBeVisible();
for (const settingName of settingsToToggle) {
const checkbox = modal.locator(`.toggle-container:has-text("${settingName}") input[type="checkbox"]`);
@@ -62,14 +83,17 @@ test('create, check settings, and delete testuser2', async ({
// Delete the user
await modal.locator('button[aria-label="Delete User"]').click();
await page.locator('button[aria-label="Confirm Delete"]').click();
const genericModal = page.locator('div[aria-label="generic-prompt"]');
await expect(genericModal).toBeVisible();
await genericModal.locator('button[aria-label="Delete"]').click();
// After deletion, we should be back on the settings page.
await expect(page.locator('tr.item', { hasText: 'testuser2' })).not.toBeVisible();
checkForErrors()
await expect(page.locator('tr.item', { hasText: username })).not.toBeVisible();
// usersApi.update tries PUT without X-Password first; server returns 401, then the UI retries with password (204).
checkForErrors(0, 1);
})
test("two factor auth check", async ({ page, checkForErrors, context }) => {
test("two factor auth check", async ({ page, checkForErrors }) => {
// go to settings
await page.goto("/settings");
await expect(page).toHaveURL(/\/settings/);
@@ -77,9 +101,9 @@ test("two factor auth check", async ({ page, checkForErrors, context }) => {
// click the edit button for testuser
const userRow = page.locator('tr.item', { hasText: 'admin' })
await userRow.locator('td[aria-label="Edit User"] .clickable').click();
await expect(page.locator('.card.floating')).toBeVisible();
await expect(page.locator('div[aria-label="user-edit-prompt"]')).toBeVisible();
const modal = page.locator('.card.floating');
const modal = page.locator('div[aria-label="user-edit-prompt"]');
// Toggle the two factor authentication switch
const twoFactorCheckbox = modal.locator('.toggle-container:has-text("Two-Factor Authentication") input[type="checkbox"]');
@@ -91,11 +115,18 @@ test("two factor auth check", async ({ page, checkForErrors, context }) => {
// Verify it changed state
await expect(twoFactorCheckbox).toBeChecked();
await modal.locator('button[aria-label="Generate Code"]').click();
const passwordModal = page.locator('div[aria-label="password-prompt"]');
// Must match the admin password used in tests/playwright/global-setup.ts (not testuser passwords).
await passwordModal.locator('input').fill('admin');
await passwordModal.locator('button[aria-label="Confirm"]').click();
const totpModal = page.locator('div[aria-label="totp-prompt"]');
// check for the otp url
await expect(modal.locator('p[aria-label="otp-url"]')).toBeVisible();
await expect(totpModal.locator('p[aria-label="otp-url"]')).toBeVisible();
// check that the otp-url is not empty
const otpUrl = await modal.locator('p[aria-label="otp-url"]').textContent();
const otpUrl = await totpModal.locator('p[aria-label="otp-url"]').textContent();
expect(otpUrl).not.toBe("");
checkForErrors();
});
@@ -109,7 +140,7 @@ test.describe("User Settings Persistence", () => {
async function checkTogglePersistence(page: Page, settingName: string) {
const userRow = page.locator("tr.item", { hasText: username });
const modal = page.locator('.card.floating');
const modal = page.locator('div[aria-label="user-edit-prompt"]');
// --- Open modal and check initial state (should be OFF) ---
// Debug: Check if the user row exists
@@ -127,6 +158,7 @@ test.describe("User Settings Persistence", () => {
const toggleSwitch = modal.locator(".toggle-container", { hasText: settingName }).locator("label.switch");
await toggleSwitch.click();
await modal.locator('button[aria-label="Save"]').click();
await confirmActorPasswordPrompt(page);
await expect(modal).not.toBeVisible();
// --- Re-open and check persisted state (should be ON) ---
@@ -139,6 +171,7 @@ test.describe("User Settings Persistence", () => {
const toggleSwitchOn = modal.locator(".toggle-container", { hasText: settingName }).locator("label.switch");
await toggleSwitchOn.click();
await modal.locator('button[aria-label="Save"]').click();
await confirmActorPasswordPrompt(page);
await expect(modal).not.toBeVisible();
// --- Re-open and check state is restored (should be OFF) ---
@@ -177,7 +210,7 @@ test.describe("User Settings Persistence", () => {
test('should persist "allowed login method" setting', async ({ page, checkForErrors }) => {
const userRow = page.locator("tr.item", { hasText: username });
await userRow.locator('td[aria-label="Edit User"] .clickable').click();
const modal = page.locator('.card.floating');
const modal = page.locator('div[aria-label="user-edit-prompt"]');
await expect(modal).toBeVisible();
const loginMethodSelector = modal.locator("#loginMethod");
+3 -3
View File
@@ -48,8 +48,8 @@ build-backend:
# New dev target with hot-reloading for frontend and backend
dev: generate-docs
@echo "Starting dev servers... Press Ctrl+C to stop."
pkill -f 'test_config.yaml' || true
pkill -f 'go tool air' || true
pkill -f '[t]est_config.yaml' || true
pkill -f '[g]o tool air' || true
@cd frontend && DEV_BUILD=true npm run watch & \
FRONTEND_PID=$$!; \
cd backend && export FILEBROWSER_DEVMODE=true && go tool air $$([ "$(OS)" = "Windows_NT" ] && echo "-c .air.windows.toml" || echo "") & \
@@ -116,12 +116,12 @@ test-frontend:
test-playwright: build-frontend
cd backend && GOOS=linux go build -o filebrowser .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-settings .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-noauth .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-general .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-jwt .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-proxy .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-previews .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-settings .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-oidc .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-sharing .
docker build -t filebrowser-playwright-tests -f _docker/Dockerfile.playwright-no-config .