mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-15 05:31:59 -05:00
Compare commits
135
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a996b39117 | ||
|
|
2180d6625f | ||
|
|
f9c31d321b | ||
|
|
493f8f7417 | ||
|
|
449f39ddf9 | ||
|
|
885adcb3a2 | ||
|
|
aaa34e9bb3 | ||
|
|
844f8f9fdd | ||
|
|
9aba7b55ca | ||
|
|
7964b30e5d | ||
|
|
618de3fbcc | ||
|
|
a68fa0ea15 | ||
|
|
3bf3d2ce92 | ||
|
|
f6cc16d1c8 | ||
|
|
8a1b735e0d | ||
|
|
10a26832dd | ||
|
|
0dfab77d6d | ||
|
|
7bd9c67eb7 | ||
|
|
009be1a06d | ||
|
|
272a0fab9d | ||
|
|
63a517de1f | ||
|
|
e39b534531 | ||
|
|
5d25220e94 | ||
|
|
8355682773 | ||
|
|
48c1ce709b | ||
|
|
43682dc809 | ||
|
|
80fba42076 | ||
|
|
a01ab6901c | ||
|
|
e652538ea8 | ||
|
|
c974da7002 | ||
|
|
eaee271b96 | ||
|
|
0d6b592046 | ||
|
|
1791bb7a36 | ||
|
|
86d4994152 | ||
|
|
8415d5f1ff | ||
|
|
85f934208c | ||
|
|
1022ecafef | ||
|
|
062ac9fb2d | ||
|
|
cecdd79fcc | ||
|
|
ac50ecfd18 | ||
|
|
71cc2f3f1b | ||
|
|
1b3f47f176 | ||
|
|
219b64b5ec | ||
|
|
3ef4928ed7 | ||
|
|
df0a5ad10c | ||
|
|
a43d0ab2d3 | ||
|
|
9de09e5ec8 | ||
|
|
9d38c686a1 | ||
|
|
1cfca112e4 | ||
|
|
f540e22dc9 | ||
|
|
dade856ebe | ||
|
|
2f1da21253 | ||
|
|
de8d2acd43 | ||
|
|
6375be202e | ||
|
|
816b99cee0 | ||
|
|
bf349a7dcd | ||
|
|
db6504bf80 | ||
|
|
2019d573f8 | ||
|
|
d716fc15ec | ||
|
|
56e16608e4 | ||
|
|
c471fc135b | ||
|
|
5112cf87b1 | ||
|
|
3d7bac47ee | ||
|
|
2a38d2fa1e | ||
|
|
82e110c08d | ||
|
|
f4c96078a1 | ||
|
|
975adedabb | ||
|
|
14ec0eba0d | ||
|
|
307d5f6591 | ||
|
|
ff9df43356 | ||
|
|
802e91aa05 | ||
|
|
28ef44cdec | ||
|
|
0ee0f35301 | ||
|
|
cc99aa542e | ||
|
|
8514da4a9a | ||
|
|
4625b89528 | ||
|
|
b2a07196b7 | ||
|
|
0a8698ee4d | ||
|
|
ec8ad36c95 | ||
|
|
2c0c1b4bb2 | ||
|
|
6fa71bd13d | ||
|
|
06aa16e7d7 | ||
|
|
b0f6d53f49 | ||
|
|
d27ab6a00b | ||
|
|
f0fb859479 | ||
|
|
4c8a913377 | ||
|
|
c1684baffd | ||
|
|
bb45ebcce1 | ||
|
|
18138b1ab5 | ||
|
|
26665efc13 | ||
|
|
98851e029f | ||
|
|
4a1a3bbe09 | ||
|
|
a6d7f7c64c | ||
|
|
ada7a18620 | ||
|
|
4f4ea2370e | ||
|
|
52aa314e1d | ||
|
|
1d6f67cc20 | ||
|
|
d89b7e7deb | ||
|
|
e26bd29848 | ||
|
|
9c2b98655a | ||
|
|
a379f047d1 | ||
|
|
f4879be6bf | ||
|
|
7751da745d | ||
|
|
80cedcb4d5 | ||
|
|
ac19abf7d2 | ||
|
|
38e868e5af | ||
|
|
b3af5cc2d7 | ||
|
|
b399de3d92 | ||
|
|
99f1a14e7b | ||
|
|
84afd8b0b7 | ||
|
|
78be5d8c0f | ||
|
|
5e29e1ee60 | ||
|
|
367a7f019c | ||
|
|
fa9c094160 | ||
|
|
36a9a6dc49 | ||
|
|
8fe6f1d5c3 | ||
|
|
6dc3935c57 | ||
|
|
5e73036657 | ||
|
|
e505900975 | ||
|
|
26f320ee43 | ||
|
|
184ae798c4 | ||
|
|
7ae8bab062 | ||
|
|
1af606e751 | ||
|
|
3683bc607c | ||
|
|
08d3e43c22 | ||
|
|
d76f9373c4 | ||
|
|
a72651e7aa | ||
|
|
73d0b20821 | ||
|
|
3e447307f7 | ||
|
|
264cc81c41 | ||
|
|
aa5b24119a | ||
|
|
52cf624711 | ||
|
|
5753f63970 | ||
|
|
8f1cf54cb0 | ||
|
|
a8cfcdd6eb |
No files matched your search
@@ -9,7 +9,10 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -21,9 +24,28 @@ type Cache interface {
|
||||
Set(key string, value []byte) error
|
||||
}
|
||||
|
||||
const (
|
||||
// evictTargetPercent is the size, as a percentage of the limit, that eviction shrinks the cache to.
|
||||
// Stopping below the limit avoids running an eviction on every write once the cache is full.
|
||||
evictTargetPercent = 90
|
||||
// touchInterval is how old a file's modification time has to be before a read refreshes it. Reads
|
||||
// refresh it so the files in use are the last ones evicted, but not on every read.
|
||||
touchInterval = time.Hour
|
||||
)
|
||||
|
||||
// FileCache struct for file-based caching
|
||||
type FileCache struct {
|
||||
dir string
|
||||
// maxBytes is the size limit of the cache in bytes. 0 means no limit.
|
||||
maxBytes int64
|
||||
// size is the total size of the cached files in bytes. Only tracked when maxBytes > 0.
|
||||
size atomic.Int64
|
||||
// sizeMu keeps size consistent with the files on disk: stores and deletes hold it for reading while
|
||||
// they change a file and its size, and eviction holds it for writing while it measures the directory,
|
||||
// so a store can never fall between a measurement and the size being set from it.
|
||||
sizeMu sync.RWMutex
|
||||
// evicting is set while an eviction is running so only one runs at a time.
|
||||
evicting atomic.Bool
|
||||
// granular locks
|
||||
scopedLocks struct {
|
||||
sync.Mutex
|
||||
@@ -33,6 +55,12 @@ type FileCache struct {
|
||||
}
|
||||
|
||||
func NewFileCache(dir string) (*FileCache, error) {
|
||||
return NewFileCacheWithLimit(dir, 0)
|
||||
}
|
||||
|
||||
// NewFileCacheWithLimit is like NewFileCache but keeps the cache under maxBytes, deleting the least
|
||||
// recently used files when it grows past it. 0 means no limit.
|
||||
func NewFileCacheWithLimit(dir string, maxBytes int64) (*FileCache, error) {
|
||||
cacheDir := filepath.Join(dir, "diskcache")
|
||||
|
||||
// Migrate existing cache files from old structure (if any)
|
||||
@@ -44,7 +72,13 @@ func NewFileCache(dir string) (*FileCache, error) {
|
||||
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
return &FileCache{dir: cacheDir}, nil
|
||||
cache := &FileCache{dir: cacheDir}
|
||||
if maxBytes > 0 {
|
||||
cache.maxBytes = maxBytes
|
||||
// measures the cache and trims it right away if it is already over the limit
|
||||
cache.evictPass()
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
// migrateOldCacheStructure moves cache files from old structure (dir/a/...) to new structure (dir/diskcache/a/...)
|
||||
@@ -99,19 +133,50 @@ func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if f.maxBytes > 0 {
|
||||
f.sizeMu.RLock()
|
||||
defer f.sizeMu.RUnlock()
|
||||
}
|
||||
previousSize := f.fileSize(fileName)
|
||||
|
||||
if err := os.WriteFile(fileName, value, fileutils.PermFile); err != nil {
|
||||
// a failed write can leave a truncated or partial file, so count what is actually there
|
||||
f.sizeChanged(f.fileSize(fileName) - previousSize)
|
||||
return err
|
||||
}
|
||||
|
||||
f.sizeChanged(int64(len(value)) - previousSize)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileSize returns the size of a cached file, or 0 if it doesn't exist or the size isn't tracked.
|
||||
func (f *FileCache) fileSize(fileName string) int64 {
|
||||
if f.maxBytes <= 0 {
|
||||
return 0
|
||||
}
|
||||
info, err := os.Stat(fileName)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
// sizeChanged adds delta to the tracked size and starts an eviction if that put the cache over its limit.
|
||||
func (f *FileCache) sizeChanged(delta int64) {
|
||||
if f.maxBytes <= 0 {
|
||||
return
|
||||
}
|
||||
if f.size.Add(delta) > f.maxBytes {
|
||||
f.evictAsync()
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FileCache) Load(ctx context.Context, key string) (value []byte, exist bool, err error) {
|
||||
r, ok, err := f.open(key)
|
||||
if err != nil || !ok {
|
||||
return nil, ok, err
|
||||
}
|
||||
defer r.Close()
|
||||
f.touch(r)
|
||||
|
||||
value, err = io.ReadAll(r)
|
||||
if err != nil {
|
||||
@@ -125,13 +190,119 @@ func (f *FileCache) Delete(ctx context.Context, key string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if f.maxBytes > 0 {
|
||||
f.sizeMu.RLock()
|
||||
defer f.sizeMu.RUnlock()
|
||||
}
|
||||
fileName := f.getFileName(key)
|
||||
removedSize := f.fileSize(fileName)
|
||||
if err := os.Remove(fileName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if removedSize > 0 {
|
||||
f.size.Add(-removedSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// touch refreshes the modification time of a file that was just read, which is what eviction orders by.
|
||||
func (f *FileCache) touch(file *os.File) {
|
||||
if f.maxBytes <= 0 {
|
||||
return
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil || time.Since(info.ModTime()) < touchInterval {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
_ = os.Chtimes(file.Name(), now, now)
|
||||
}
|
||||
|
||||
type cachedFile struct {
|
||||
path string
|
||||
size int64
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
// scan lists the cached files and their total size.
|
||||
func (f *FileCache) scan() ([]cachedFile, int64) {
|
||||
var files []cachedFile
|
||||
var total int64
|
||||
_ = filepath.WalkDir(f.dir, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil || entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
files = append(files, cachedFile{path: path, size: info.Size(), modTime: info.ModTime()})
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
return files, total
|
||||
}
|
||||
|
||||
// evictAsync starts an eviction in the background unless one is already running, so the request that
|
||||
// stored the file doesn't wait for it.
|
||||
func (f *FileCache) evictAsync() {
|
||||
if !f.evicting.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
removed := f.evictPass()
|
||||
f.evicting.Store(false)
|
||||
if removed == 0 {
|
||||
return // under the limit, or nothing could be removed: don't spin
|
||||
}
|
||||
// Files stored while the pass was running were counted but not necessarily seen by it, and a
|
||||
// store that came in just before the flag was cleared saw it set and didn't start another
|
||||
// pass, so check again before stopping.
|
||||
if f.size.Load() <= f.maxBytes || !f.evicting.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// evictPass measures the cache and, if it is over the limit, deletes the least recently used files until
|
||||
// it is under evictTargetPercent of the limit. Deleting a file that is being read or written is harmless:
|
||||
// readers keep their open file and a removed entry is simply rebuilt the next time it is requested.
|
||||
func (f *FileCache) evictPass() (removed int) {
|
||||
f.sizeMu.Lock()
|
||||
files, total := f.scan()
|
||||
f.size.Store(total)
|
||||
f.sizeMu.Unlock()
|
||||
|
||||
if total <= f.maxBytes {
|
||||
return 0
|
||||
}
|
||||
target := f.maxBytes / 100 * evictTargetPercent
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].modTime.Before(files[j].modTime) })
|
||||
var removedBytes int64
|
||||
for _, file := range files {
|
||||
if f.size.Load() <= target {
|
||||
break
|
||||
}
|
||||
// Hold the lock so no store or delete changes this file between measuring it and removing it, and
|
||||
// subtract the size it has now: the listing is from the scan and the file may have been rewritten since.
|
||||
f.sizeMu.Lock()
|
||||
info, err := os.Stat(file.path)
|
||||
if err == nil {
|
||||
err = os.Remove(file.path)
|
||||
}
|
||||
if err == nil {
|
||||
f.size.Add(-info.Size())
|
||||
removed++
|
||||
removedBytes += info.Size()
|
||||
}
|
||||
f.sizeMu.Unlock()
|
||||
}
|
||||
logger.Debugf("preview cache limit: removed %d files (%d MB), now %d MB of %d MB", removed, removedBytes>>20, f.size.Load()>>20, f.maxBytes>>20)
|
||||
return removed
|
||||
}
|
||||
|
||||
func (f *FileCache) open(key string) (*os.File, bool, error) {
|
||||
fileName := f.getFileName(key)
|
||||
file, err := os.Open(fileName)
|
||||
|
||||
@@ -2,9 +2,12 @@ package diskcache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -70,3 +73,168 @@ func fileExists(filename string) bool {
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
|
||||
// storeAged stores a value and sets its modification time, so the eviction order is deterministic.
|
||||
func storeAged(t *testing.T, cache *FileCache, key string, size int, age time.Duration) {
|
||||
t.Helper()
|
||||
require.NoError(t, cache.Store(context.Background(), key, make([]byte, size)))
|
||||
when := time.Now().Add(-age)
|
||||
require.NoError(t, os.Chtimes(cache.getFileName(key), when, when))
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, condition func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !condition() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("condition not met in time")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func newLimitedCache(t *testing.T, maxBytes int64) *FileCache {
|
||||
t.Helper()
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
cache, err := NewFileCacheWithLimit(t.TempDir(), maxBytes)
|
||||
require.NoError(t, err)
|
||||
return cache
|
||||
}
|
||||
|
||||
func TestFileCacheEvictsLeastRecentlyUsedOverLimit(t *testing.T) {
|
||||
const kb = 1024
|
||||
cache := newLimitedCache(t, 10*kb)
|
||||
// 3 files of 3KB are under the 10KB limit
|
||||
storeAged(t, cache, "oldest", 3*kb, 3*time.Hour)
|
||||
storeAged(t, cache, "old", 3*kb, 2*time.Hour)
|
||||
storeAged(t, cache, "recent", 3*kb, time.Hour)
|
||||
require.True(t, fileExists(cache.getFileName("oldest")), "nothing is removed under the limit")
|
||||
|
||||
// the 4th file goes over the limit: the oldest ones are removed until it is under 90% of it
|
||||
require.NoError(t, cache.Store(context.Background(), "newest", make([]byte, 3*kb)))
|
||||
waitFor(t, func() bool { return !fileExists(cache.getFileName("oldest")) })
|
||||
waitFor(t, func() bool { return !cache.evicting.Load() })
|
||||
|
||||
require.False(t, fileExists(cache.getFileName("oldest")))
|
||||
require.False(t, fileExists(cache.getFileName("old")))
|
||||
require.True(t, fileExists(cache.getFileName("recent")))
|
||||
require.True(t, fileExists(cache.getFileName("newest")))
|
||||
require.LessOrEqual(t, cache.size.Load(), int64(9*kb))
|
||||
}
|
||||
|
||||
func TestFileCacheTrimsAnOversizedCacheAtStartup(t *testing.T) {
|
||||
const kb = 1024
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
dir := t.TempDir()
|
||||
unlimited, err := NewFileCache(dir)
|
||||
require.NoError(t, err)
|
||||
storeAged(t, &FileCache{dir: unlimited.dir}, "a", 4*kb, 4*time.Hour)
|
||||
storeAged(t, &FileCache{dir: unlimited.dir}, "b", 4*kb, 3*time.Hour)
|
||||
storeAged(t, &FileCache{dir: unlimited.dir}, "c", 4*kb, 2*time.Hour)
|
||||
|
||||
cache, err := NewFileCacheWithLimit(dir, 10*kb)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 12KB over a 10KB limit: only the oldest file is removed, which already brings it under 90% (9KB)
|
||||
require.False(t, fileExists(cache.getFileName("a")))
|
||||
require.True(t, fileExists(cache.getFileName("b")))
|
||||
require.True(t, fileExists(cache.getFileName("c")))
|
||||
require.Equal(t, int64(8*kb), cache.size.Load())
|
||||
}
|
||||
|
||||
func TestFileCacheWithoutLimitKeepsEverything(t *testing.T) {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
cache, err := NewFileCache(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
for i := 0; i < 5; i++ {
|
||||
storeAged(t, cache, fmt.Sprintf("key-%d", i), 1<<20, time.Duration(i)*time.Hour)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
require.True(t, fileExists(cache.getFileName(fmt.Sprintf("key-%d", i))))
|
||||
}
|
||||
require.Zero(t, cache.size.Load(), "size is only tracked when there is a limit")
|
||||
}
|
||||
|
||||
func TestFileCacheReadRefreshesModTime(t *testing.T) {
|
||||
cache := newLimitedCache(t, 1<<20)
|
||||
storeAged(t, cache, "used", 100, 3*time.Hour)
|
||||
|
||||
_, ok, err := cache.Load(context.Background(), "used")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
info, err := os.Stat(cache.getFileName("used"))
|
||||
require.NoError(t, err)
|
||||
require.WithinDuration(t, time.Now(), info.ModTime(), time.Minute, "a file that is read is the last to be evicted")
|
||||
}
|
||||
|
||||
func TestFileCacheDeleteAndOverwriteKeepTheSizeAccurate(t *testing.T) {
|
||||
const kb = 1024
|
||||
cache := newLimitedCache(t, 1<<20)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, cache.Store(ctx, "k", make([]byte, 5*kb)))
|
||||
require.Equal(t, int64(5*kb), cache.size.Load())
|
||||
require.NoError(t, cache.Store(ctx, "k", make([]byte, 2*kb)))
|
||||
require.Equal(t, int64(2*kb), cache.size.Load(), "overwriting replaces the old size")
|
||||
require.NoError(t, cache.Delete(ctx, "k"))
|
||||
require.Zero(t, cache.size.Load())
|
||||
}
|
||||
|
||||
// Stores that land while an eviction is measuring or deleting must not be lost from the tracked size,
|
||||
// and the cache must end up under its limit even though more files kept arriving during the eviction.
|
||||
func TestFileCacheStaysUnderTheLimitWithConcurrentStores(t *testing.T) {
|
||||
const (
|
||||
kb = 1024
|
||||
limit = 100 * kb
|
||||
workers = 16
|
||||
stores = 40
|
||||
)
|
||||
cache := newLimitedCache(t, limit)
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < stores; i++ {
|
||||
_ = cache.Store(context.Background(), fmt.Sprintf("worker-%d-key-%d", w, i), make([]byte, 4*kb))
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
waitFor(t, func() bool { return !cache.evicting.Load() })
|
||||
|
||||
_, onDisk := cache.scan()
|
||||
require.LessOrEqual(t, onDisk, int64(limit), "the files left on disk are within the limit")
|
||||
require.Equal(t, onDisk, cache.size.Load(), "the tracked size matches what is on disk")
|
||||
}
|
||||
|
||||
// Files that are rewritten while an eviction is running must be subtracted at the size they have when
|
||||
// they are removed, not at the size the scan saw, or the tracked size drifts from the disk.
|
||||
func TestFileCacheKeepsTheSizeAccurateWhenFilesAreRewrittenDuringEviction(t *testing.T) {
|
||||
const (
|
||||
kb = 1024
|
||||
limit = 60 * kb
|
||||
workers = 8
|
||||
rounds = 300
|
||||
keys = 40
|
||||
)
|
||||
cache := newLimitedCache(t, limit)
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < rounds; i++ {
|
||||
// the same keys again and again, with a different size each time
|
||||
key := fmt.Sprintf("key-%d", (w+i)%keys)
|
||||
_ = cache.Store(context.Background(), key, make([]byte, (1+(w*7+i)%8)*kb))
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
waitFor(t, func() bool { return !cache.evicting.Load() })
|
||||
|
||||
_, onDisk := cache.scan()
|
||||
require.Equal(t, onDisk, cache.size.Load(), "the tracked size matches what is on disk")
|
||||
require.LessOrEqual(t, onDisk, int64(limit))
|
||||
}
|
||||
@@ -790,6 +790,7 @@ func DeleteFiles(source, boundIndexPath, indexPath string, isDir bool) error {
|
||||
if utils.CleanIndexPathSegment(indexPath) == "" {
|
||||
return fmt.Errorf("refusing to delete source root directory: %s", indexPath)
|
||||
}
|
||||
deleteIndexPath := indexPath
|
||||
realPath, err := index.GetRealPathForRemoval(boundIndexPath, indexPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -808,9 +809,13 @@ func DeleteFiles(source, boundIndexPath, indexPath string, isDir bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear cache entries
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.IsDirCache.Delete(realPath + ":isdir")
|
||||
if isDir {
|
||||
indexing.ClearRealPathCaches()
|
||||
}
|
||||
index.InvalidateRealPathCache(deleteIndexPath, boundIndexPath)
|
||||
if parent := utils.GetParentDirectoryPath(deleteIndexPath); parent != "" && parent != "/" {
|
||||
index.InvalidateRealPathCache(parent, boundIndexPath)
|
||||
}
|
||||
|
||||
// Remove metadata from index
|
||||
deleteSuccess := index.DeleteMetadata(indexPath, isDir, isDir)
|
||||
@@ -862,8 +867,7 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
|
||||
if !Exists(realPath) {
|
||||
// Directory no longer exists, remove it from the index
|
||||
// This clears both Directories and DirectoriesLedger maps
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.IsDirCache.Delete(realPath + ":isdir")
|
||||
idx.InvalidateRealPathCache(path)
|
||||
idx.DeleteMetadata(path, true, false)
|
||||
return nil
|
||||
}
|
||||
@@ -941,6 +945,9 @@ func moveResourceImpl(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst st
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isSrcDir {
|
||||
indexing.ClearRealPathCaches()
|
||||
}
|
||||
|
||||
// Handle SOURCE cleanup (treat as deletion)
|
||||
// Run async to avoid blocking the HTTP response
|
||||
@@ -1101,6 +1108,11 @@ func WriteDirectory(boundIndexPath string, opts utils.FileOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
idx.InvalidateRealPathCache(opts.Path, boundIndexPath)
|
||||
if parent := utils.GetParentDirectoryPath(opts.Path); parent != "" && parent != "/" {
|
||||
idx.InvalidateRealPathCache(parent, boundIndexPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1176,6 +1188,11 @@ func writeFile(source, boundIndexPath, path string, in io.Reader) error {
|
||||
}
|
||||
}
|
||||
|
||||
idx.InvalidateRealPathCache(path, boundIndexPath)
|
||||
if parent := utils.GetParentDirectoryPath(path); parent != "" && parent != "/" {
|
||||
idx.InvalidateRealPathCache(parent, boundIndexPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
)
|
||||
|
||||
// PermittedAtRealPath reports whether username may access realPath after symlink
|
||||
// resolution. DENY rules apply to the target index path, not only the link path.
|
||||
func (s *Storage) PermittedAtRealPath(index *indexing.Index, username, realPath string, isDir bool) bool {
|
||||
if s == nil || index == nil || username == "" || realPath == "" {
|
||||
return false
|
||||
}
|
||||
lexical := realPath
|
||||
if !strings.HasPrefix(realPath, index.Path) {
|
||||
root, err := filepath.EvalSymlinks(index.Path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(root, realPath)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return false
|
||||
}
|
||||
lexical = index.Path
|
||||
if rel != "." {
|
||||
lexical = filepath.Join(index.Path, rel)
|
||||
}
|
||||
}
|
||||
indexPath := index.MakeIndexPath(lexical, isDir).String()
|
||||
return s.Permitted(index.Path, utils.IndexPathFromNormalized(indexPath, true), username)
|
||||
}
|
||||
|
||||
// IsDirAtRealPath reports whether an existing path is a directory; missing paths are treated as files.
|
||||
func IsDirAtRealPath(realPath string) bool {
|
||||
st, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return st.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func setupSymlinkDenyFixture(t *testing.T) (sourceRoot string, idx *indexing.Index, store *Storage, username string) {
|
||||
t.Helper()
|
||||
sourceRoot = filepath.Join(t.TempDir(), "data")
|
||||
aliceDir := filepath.Join(sourceRoot, "alice")
|
||||
bobDir := filepath.Join(sourceRoot, "bob")
|
||||
for _, dir := range []string{aliceDir, bobDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(bobDir, "secret.txt"), []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("../bob/secret.txt", filepath.Join(aliceDir, "link.txt")); err != nil {
|
||||
t.Skipf("symlinks required: %v", err)
|
||||
}
|
||||
if err := os.Symlink("../bob", filepath.Join(aliceDir, "bobdir")); err != nil {
|
||||
t.Skipf("symlinks required: %v", err)
|
||||
}
|
||||
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
sourceRoot: {Path: sourceRoot, Config: settings.SourceConfig{DenyByDefault: false}},
|
||||
}
|
||||
store = NewStorage(nil)
|
||||
username = "scoped-user"
|
||||
if err := store.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/bob", true), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx = &indexing.Index{Source: settings.Source{Path: sourceRoot}}
|
||||
return sourceRoot, idx, store, username
|
||||
}
|
||||
|
||||
func TestPermittedAtRealPath_AllowsLinkPathDeniesResolvedTarget(t *testing.T) {
|
||||
_, idx, store, user := setupSymlinkDenyFixture(t)
|
||||
|
||||
if !store.Permitted(idx.Path, utils.IndexPathFromNormalized("/alice/link.txt", false), user) {
|
||||
t.Fatal("index path at the symlink should be allowed")
|
||||
}
|
||||
if store.Permitted(idx.Path, utils.IndexPathFromNormalized("/bob/secret.txt", false), user) {
|
||||
t.Fatal("direct path under /bob should be denied")
|
||||
}
|
||||
|
||||
realPath, _, err := idx.GetRealPathScoped("/", "/alice/link.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.PermittedAtRealPath(idx, user, realPath, false) {
|
||||
t.Fatal("resolved target under denied /bob must not be permitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermittedAtRealPath_DeniesWriteThroughDirectorySymlink(t *testing.T) {
|
||||
_, idx, store, user := setupSymlinkDenyFixture(t)
|
||||
|
||||
realPath, err := idx.GetRealPathForMutation("/", "/alice/bobdir/planted.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.PermittedAtRealPath(idx, user, realPath, false) {
|
||||
t.Fatal("resolved write target under denied /bob must not be permitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermittedAtRealPath_DeniesCopySourceResolved(t *testing.T) {
|
||||
_, idx, store, user := setupSymlinkDenyFixture(t)
|
||||
|
||||
realSrc, isDir, err := idx.GetRealPathScoped("/", "/alice/link.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isDir {
|
||||
t.Fatal("expected file")
|
||||
}
|
||||
if store.PermittedAtRealPath(idx, user, realSrc, isDir) {
|
||||
t.Fatal("copy source resolved to denied target must not be permitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermittedAtRealPath_SymlinkedSourceRoot(t *testing.T) {
|
||||
sourceRoot, _, _, user := setupSymlinkDenyFixture(t)
|
||||
linkRoot := filepath.Join(filepath.Dir(sourceRoot), "root-link")
|
||||
if err := os.Symlink(sourceRoot, linkRoot); err != nil {
|
||||
t.Skipf("symlinks required: %v", err)
|
||||
}
|
||||
if _, err := filepath.EvalSymlinks(linkRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
linkRoot: {Path: linkRoot, Config: settings.SourceConfig{DenyByDefault: false}},
|
||||
}
|
||||
store := NewStorage(nil)
|
||||
if err := store.DenyUser(linkRoot, utils.IndexPathFromNormalized("/bob", true), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx := &indexing.Index{Source: settings.Source{Path: linkRoot}}
|
||||
|
||||
realPath, _, err := idx.GetRealPathScoped("/", "/alice/link.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realPath, err = filepath.EvalSymlinks(realPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.PermittedAtRealPath(idx, user, realPath, false) {
|
||||
t.Fatal("resolved target under denied /bob must not be permitted with symlinked source root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDirAtRealPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "nested"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(dir, "file.txt")
|
||||
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !IsDirAtRealPath(dir) {
|
||||
t.Fatal("expected directory")
|
||||
}
|
||||
if IsDirAtRealPath(file) {
|
||||
t.Fatal("expected file")
|
||||
}
|
||||
if IsDirAtRealPath(filepath.Join(dir, "missing")) {
|
||||
t.Fatal("missing path should not be treated as directory")
|
||||
}
|
||||
}
|
||||
@@ -346,10 +346,11 @@ type NonAdminEditable struct {
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
MaxConcurrent int `json:"maxConcurrentUpload"`
|
||||
UploadChunkSize int `json:"uploadChunkSizeMb"`
|
||||
ClearAll bool `json:"clearAll"`
|
||||
DownloadChunkSize int `json:"downloadChunkSizeMb"`
|
||||
MaxConcurrent int `json:"maxConcurrentUpload"`
|
||||
UploadChunkSize int `json:"uploadChunkSizeMb"`
|
||||
ClearAll bool `json:"clearAll"`
|
||||
DownloadChunkSize int `json:"downloadChunkSizeMb"`
|
||||
UploadStallTimeoutSeconds int `json:"uploadStallTimeoutSeconds"` // seconds without progress before an upload is paused, 0 uses the default (10)
|
||||
}
|
||||
|
||||
// SidebarLink represents a customizable link in the sidebar.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/kovidgoyal/imaging"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// patternYCbCr is a 4:2:0 image (what a camera JPEG decodes to) with a pattern that differs in
|
||||
// every row and column, so a crop off by one pixel shows up in the resized output.
|
||||
func patternYCbCr(w, h int) *image.YCbCr {
|
||||
return patternYCbCrRatio(w, h, image.YCbCrSubsampleRatio420)
|
||||
}
|
||||
|
||||
// patternYCbCrRatio is patternYCbCr with the given chroma subsampling.
|
||||
func patternYCbCrRatio(w, h int, r image.YCbCrSubsampleRatio) *image.YCbCr {
|
||||
img := image.NewYCbCr(image.Rect(0, 0, w, h), r)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Y[img.YOffset(x, y)] = uint8((x*7 + y*13) % 251)
|
||||
}
|
||||
}
|
||||
for i := range img.Cb {
|
||||
img.Cb[i] = uint8((i*3 + i/img.CStride) % 256)
|
||||
img.Cr[i] = uint8((i + 5*(i/img.CStride)) % 256)
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// patternNRGBA is an NRGBA image whose pixels differ in every row and column.
|
||||
func patternNRGBA(w, h int) *image.NRGBA {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.SetNRGBA(x, y, color.NRGBA{uint8(x % 256), uint8(y % 256), uint8((x + y) % 256), 255})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// requireSamePixels fails unless want and got are the same size and every pixel is equal,
|
||||
// comparing from each image's own origin.
|
||||
func requireSamePixels(t *testing.T, want, got image.Image) {
|
||||
t.Helper()
|
||||
require.Equal(t, want.Bounds().Size(), got.Bounds().Size())
|
||||
wb, gb := want.Bounds().Min, got.Bounds().Min
|
||||
for y := 0; y < want.Bounds().Dy(); y++ {
|
||||
for x := 0; x < want.Bounds().Dx(); x++ {
|
||||
wr, wg, wbl, wa := want.At(wb.X+x, wb.Y+y).RGBA()
|
||||
gr, gg, gbl, ga := got.At(gb.X+x, gb.Y+y).RGBA()
|
||||
if wr != gr || wg != gg || wbl != gbl || wa != ga {
|
||||
t.Fatalf("pixel (%d,%d) differs: imaging.Fill %v, fillCenter %v", x, y,
|
||||
[4]uint32{wr, wg, wbl, wa}, [4]uint32{gr, gg, gbl, ga})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fillCenter must produce exactly what imaging.Fill(…, imaging.Center, …) produces: the same
|
||||
// pixels and the same image type, so the encoded preview is the same bytes.
|
||||
func TestFillCenter_MatchesImagingFill(t *testing.T) {
|
||||
sources := map[string]image.Image{
|
||||
"ycbcr landscape": patternYCbCr(1200, 801),
|
||||
"ycbcr portrait": patternYCbCr(601, 1000),
|
||||
"nrgba square": patternNRGBA(700, 700),
|
||||
"nrgba wide": patternNRGBA(1500, 300),
|
||||
"small (falls back to imaging.Fill)": patternNRGBA(90, 60),
|
||||
"exact target size": patternNRGBA(256, 256),
|
||||
"offset origin": patternNRGBA(900, 700).SubImage(image.Rect(37, 11, 837, 611)),
|
||||
}
|
||||
// Every subsampling ratio, in shapes whose centered crop has an x (or y) offset and reaches
|
||||
// the source's last row (or column) -- where a SubImage's buffer ends short of a full stride.
|
||||
for _, r := range []image.YCbCrSubsampleRatio{
|
||||
image.YCbCrSubsampleRatio444, image.YCbCrSubsampleRatio422, image.YCbCrSubsampleRatio420,
|
||||
image.YCbCrSubsampleRatio440, image.YCbCrSubsampleRatio411, image.YCbCrSubsampleRatio410,
|
||||
} {
|
||||
for _, sz := range [][2]int{{600, 264}, {264, 600}, {601, 263}} {
|
||||
sources[fmt.Sprintf("ycbcr %v %dx%d", r, sz[0], sz[1])] = patternYCbCrRatio(sz[0], sz[1], r)
|
||||
}
|
||||
}
|
||||
sources["nycbcra 444 600x264"] = &image.NYCbCrA{YCbCr: *patternYCbCrRatio(600, 264, image.YCbCrSubsampleRatio444), A: make([]uint8, 600*264), AStride: 600}
|
||||
// 264x264 is the crop of the 600x264 and 264x600 sources itself: nothing to scale.
|
||||
targets := [][2]int{{256, 256}, {320, 180}, {180, 320}, {264, 264}}
|
||||
filters := map[string]imaging.ResampleFilter{
|
||||
"nearest": imaging.NearestNeighbor, "box": imaging.Box, "catmullrom": imaging.CatmullRom,
|
||||
}
|
||||
for name, src := range sources {
|
||||
for _, tg := range targets {
|
||||
for fname, f := range filters {
|
||||
t.Run(fmt.Sprintf("%s/%dx%d/%s", name, tg[0], tg[1], fname), func(t *testing.T) {
|
||||
want := imaging.Fill(src, tg[0], tg[1], imaging.Center, f)
|
||||
got := fillCenter(src, tg[0], tg[1], f)
|
||||
require.Equal(t, fmt.Sprintf("%T", want), fmt.Sprintf("%T", got), "image type")
|
||||
requireSamePixels(t, want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A square thumbnail of a large photo must not allocate a full-resolution copy of it.
|
||||
func TestFillCenter_NoFullSizeCopy(t *testing.T) {
|
||||
const w, h = 4000, 3000
|
||||
src := patternYCbCr(w, h)
|
||||
fullCopy := uint64(h * h * 4) // the square crop imaging.Fill materializes as NRGBA
|
||||
|
||||
var before, after runtime.MemStats
|
||||
runtime.ReadMemStats(&before)
|
||||
out := fillCenter(src, 256, 256, imaging.Box)
|
||||
runtime.ReadMemStats(&after)
|
||||
|
||||
require.Equal(t, image.Pt(256, 256), out.Bounds().Size())
|
||||
allocated := after.TotalAlloc - before.TotalAlloc
|
||||
require.Less(t, allocated, fullCopy/4,
|
||||
"fillCenter allocated %d bytes; a copy of the crop would be %d", allocated, fullCopy)
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"math"
|
||||
|
||||
exif "github.com/dsoprea/go-exif/v3"
|
||||
exifcommon "github.com/dsoprea/go-exif/v3/common"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/embeddedpreview"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
|
||||
@@ -203,6 +205,50 @@ func (s *Service) ResizeWithSize(ctx context.Context, in io.Reader, out io.Write
|
||||
return err
|
||||
}
|
||||
|
||||
// fillCenter is imaging.Fill(img, width, height, imaging.Center, filter) without a full-size copy
|
||||
// of the source. For sources of 100px or more, imaging.Fill first crops into a new NRGBA image
|
||||
// (4 bytes per pixel of the cropped area, about 144 MB for a 48 MP photo) and only then resizes.
|
||||
// Here the crop is a SubImage view into the decoded pixels, so only the resize allocates. The
|
||||
// crop rectangle is computed as imaging's cropAndResize computes it, so the output is the same.
|
||||
//
|
||||
// Two cases still go through imaging.Fill: a crop that is already the target size (nothing to
|
||||
// scale, and Fill's small copy is what keeps the output type the same), and 4:4:4 YCbCr sources,
|
||||
// whose scanner in imaging v1.9.0 advances one full parent stride past the end of a SubImage's
|
||||
// last row and panics when the crop has an x offset and reaches the bottom row.
|
||||
func fillCenter(img image.Image, width, height int, filter imaging.ResampleFilter) image.Image {
|
||||
b := img.Bounds()
|
||||
sub, ok := img.(interface {
|
||||
SubImage(image.Rectangle) image.Image
|
||||
})
|
||||
if !ok || is444(img) || width <= 0 || height <= 0 || b.Dx() < 100 || b.Dy() < 100 || (b.Dx() == width && b.Dy() == height) {
|
||||
return imaging.Fill(img, width, height, imaging.Center, filter)
|
||||
}
|
||||
cropW, cropH := b.Dx(), b.Dy()
|
||||
if float64(b.Dx())/float64(b.Dy()) < float64(width)/float64(height) {
|
||||
cropH = int(math.Max(1, float64(b.Dx())*float64(height)/float64(width)) + 0.5)
|
||||
} else {
|
||||
cropW = int(math.Max(1, float64(b.Dy())*float64(width)/float64(height)) + 0.5)
|
||||
}
|
||||
if cropW == width && cropH == height {
|
||||
return imaging.Fill(img, width, height, imaging.Center, filter)
|
||||
}
|
||||
x0 := b.Min.X + (b.Dx()-cropW)/2
|
||||
y0 := b.Min.Y + (b.Dy()-cropH)/2
|
||||
crop := image.Rect(x0, y0, x0+cropW, y0+cropH).Intersect(b)
|
||||
return imaging.Resize(sub.SubImage(crop), width, height, filter)
|
||||
}
|
||||
|
||||
// is444 reports whether img is a YCbCr image without chroma subsampling.
|
||||
func is444(img image.Image) bool {
|
||||
switch p := img.(type) {
|
||||
case *image.YCbCr:
|
||||
return p.SubsampleRatio == image.YCbCrSubsampleRatio444
|
||||
case *image.NYCbCrA:
|
||||
return p.SubsampleRatio == image.YCbCrSubsampleRatio444
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) resizeWithSize(in io.Reader, out io.Writer, fileSize int64, opts ResizeOptions) error {
|
||||
// Set defaults
|
||||
if opts.ResizeMode == 0 {
|
||||
@@ -280,7 +326,7 @@ func (s *Service) resizeWithSize(in io.Reader, out io.Writer, fileSize int64, op
|
||||
resampleFilter := opts.Quality.resampleFilter()
|
||||
switch opts.ResizeMode {
|
||||
case ResizeModeFill:
|
||||
img = imaging.Fill(img, opts.Width, opts.Height, imaging.Center, resampleFilter)
|
||||
img = fillCenter(img, opts.Width, opts.Height, resampleFilter)
|
||||
case ResizeModeFit:
|
||||
fallthrough
|
||||
default:
|
||||
@@ -406,8 +452,32 @@ func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
|
||||
return nil, wrappedReader, exif.ErrNoThumbnail
|
||||
}
|
||||
|
||||
// Read IFD1's Orientation first; fall back to IFD0's Orientation if not specified (per MWG guidelines)
|
||||
orientStr := ""
|
||||
tags1, err1 := ifd.FindTagWithName("Orientation")
|
||||
if err1 == nil && len(tags1) > 0 {
|
||||
orientStr, _ = tags1[0].FormatFirst()
|
||||
}
|
||||
if orientStr == "" {
|
||||
tags0, err0 := index.RootIfd.FindTagWithName("Orientation")
|
||||
if err0 == nil && len(tags0) > 0 {
|
||||
orientStr, _ = tags0[0].FormatFirst()
|
||||
}
|
||||
}
|
||||
|
||||
thm, err := ifd.Thumbnail()
|
||||
return thm, wrappedReader, err
|
||||
if err != nil {
|
||||
return nil, wrappedReader, err
|
||||
}
|
||||
|
||||
if orientStr != "" && orientStr != "1" {
|
||||
if !embeddedpreview.Usable(thm) {
|
||||
return nil, wrappedReader, fmt.Errorf("embedded thumbnail exceeds decode limits")
|
||||
}
|
||||
thm = applyOrientationToPreviewBytes(thm, orientStr)
|
||||
}
|
||||
|
||||
return thm, wrappedReader, nil
|
||||
}
|
||||
|
||||
// CreateThumbnail decodes an image and creates a fixed-size thumbnail.
|
||||
|
||||
@@ -549,3 +549,41 @@ func TestReadImageFileDimensions(t *testing.T) {
|
||||
require.Greater(t, width, 0)
|
||||
require.Greater(t, height, 0)
|
||||
}
|
||||
|
||||
func TestApplyOrientationToPreviewBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Generate a 100x60 test JPEG image in memory (landscape)
|
||||
img := image.NewRGBA(image.Rect(0, 0, 100, 60))
|
||||
var buf bytes.Buffer
|
||||
err := jpeg.Encode(&buf, img, nil)
|
||||
require.NoError(t, err)
|
||||
rawBytes := buf.Bytes()
|
||||
|
||||
tests := []struct {
|
||||
orientation string
|
||||
wantWidth int
|
||||
wantHeight int
|
||||
}{
|
||||
{orientation: "1", wantWidth: 100, wantHeight: 60},
|
||||
{orientation: "Horizontal (normal)", wantWidth: 100, wantHeight: 60},
|
||||
{orientation: "8", wantWidth: 60, wantHeight: 100},
|
||||
{orientation: "Rotate 270 CW", wantWidth: 60, wantHeight: 100},
|
||||
{orientation: "6", wantWidth: 60, wantHeight: 100},
|
||||
{orientation: "Rotate 90 CW", wantWidth: 60, wantHeight: 100},
|
||||
{orientation: "3", wantWidth: 100, wantHeight: 60},
|
||||
{orientation: "Rotate 180", wantWidth: 100, wantHeight: 60},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.orientation, func(t *testing.T) {
|
||||
rotated := applyOrientationToPreviewBytes(rawBytes, tt.orientation)
|
||||
require.NotEmpty(t, rotated)
|
||||
|
||||
cfg, _, decodeErr := image.DecodeConfig(bytes.NewReader(rotated))
|
||||
require.NoError(t, decodeErr)
|
||||
require.Equal(t, tt.wantWidth, cfg.Width)
|
||||
require.Equal(t, tt.wantHeight, cfg.Height)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func NewPreviewGenerator(concurrencyLimit int, cacheDir string) *Service {
|
||||
var fileCache diskcache.Interface
|
||||
// Use file cache if cacheDir is specified
|
||||
var err error
|
||||
fileCache, err = diskcache.NewFileCache(actualCacheDir)
|
||||
fileCache, err = diskcache.NewFileCacheWithLimit(actualCacheDir, max(settings.Config.Server.CacheDirMaxSizeMB, 0)*1024*1024)
|
||||
if err != nil {
|
||||
logger.Error("The cache dir could not be created. Make sure the user that you executed the program with has access to create directories in the local path. See ")
|
||||
logger.Fatalf("failed to create file cache path, which is now require to run the server: %v", err)
|
||||
@@ -504,7 +504,9 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
|
||||
}
|
||||
|
||||
// Enforce file size limit for image preview generation to prevent memory exhaustion
|
||||
if strings.HasPrefix(file.Type, "image") && file.Size > iteminfo.LargeFileSizeThreshold {
|
||||
// Camera RAW is exempt: its preview is the embedded JPEG and the RAW data is never decoded.
|
||||
if strings.HasPrefix(file.Type, "image") && file.Size > iteminfo.LargeFileSizeThreshold &&
|
||||
!iteminfo.IsRawImage(strings.ToLower(filepath.Ext(file.Name))) {
|
||||
message := fmt.Sprintf("Image file too large for preview: %s (size: %d bytes, limit: %d bytes)",
|
||||
file.Name, file.Size, iteminfo.LargeFileSizeThreshold)
|
||||
logger.Warning(message)
|
||||
|
||||
@@ -19,6 +19,15 @@ func AccessPermitted(sourcePath string, indexPath utils.IndexPath, username stri
|
||||
return accessDb.Permitted(sourcePath, indexPath, username)
|
||||
}
|
||||
|
||||
// AccessPermittedAtRealPath reports whether username may access realPath after symlink
|
||||
// resolution on the given index. When accessDb is not initialized, access is allowed.
|
||||
func AccessPermittedAtRealPath(idx *indexing.Index, username, realPath string, isDir bool) bool {
|
||||
if accessDb == nil {
|
||||
return true
|
||||
}
|
||||
return accessDb.PermittedAtRealPath(idx, username, realPath, isDir)
|
||||
}
|
||||
|
||||
func AllowUser(sourcePath string, indexPath utils.IndexPath, username string) error {
|
||||
return accessDb.AllowUser(sourcePath, indexPath, username)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -297,6 +298,17 @@ func ApplyUserDefaults(u *users.User) {
|
||||
applyToolAccessDefaults(u)
|
||||
}
|
||||
|
||||
// PrefillUserProfileDefaults seeds u with the effective profile defaults so a JSON decode
|
||||
// overlays only the keys the request sent (encoding/json leaves absent keys untouched).
|
||||
// Permissions are left zero so omitted permissions stay distinguishable; CreateUser merges them.
|
||||
func PrefillUserProfileDefaults(u *users.User) {
|
||||
settings.ApplyFullProfileFromDefaults(u, EffectiveUserDefaults())
|
||||
// json.Unmarshal reuses an existing slice's backing array, so clone it or a request
|
||||
// would overwrite the shared in-memory defaults.
|
||||
u.NewFileTemplate = slices.Clone(u.NewFileTemplate)
|
||||
u.Permissions = users.Permissions{}
|
||||
}
|
||||
|
||||
func applyUserSettingsDefaults(u *users.User) {
|
||||
settings.ApplyUserDefaultsFrom(u, EffectiveUserDefaults())
|
||||
}
|
||||
|
||||
@@ -202,6 +202,18 @@ func UserForShareOwner(link share.Share) (users.User, error) {
|
||||
// may apply default-enabled sources. In-memory/cache users always keep FrontendScopes nil; GET handlers use
|
||||
// PrepForFrontend to repopulate FrontendScopes from BackendScopes for responses.
|
||||
func CreateUser(user *users.User, plaintextPassword string) error {
|
||||
return createUser(user, plaintextPassword, false)
|
||||
}
|
||||
|
||||
// CreateUserKeepingProfile is CreateUser for callers whose user was seeded with
|
||||
// PrefillUserProfileDefaults before decoding the request: the profile fields already hold
|
||||
// defaults overlaid with the request, so they are not replaced by userDefaults again.
|
||||
// Enforced defaults still win.
|
||||
func CreateUserKeepingProfile(user *users.User, plaintextPassword string) error {
|
||||
return createUser(user, plaintextPassword, true)
|
||||
}
|
||||
|
||||
func createUser(user *users.User, plaintextPassword string, keepProfile bool) error {
|
||||
if err := users.ValidateUsername(user.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -256,12 +268,19 @@ func CreateUser(user *users.User, plaintextPassword string) error {
|
||||
requestedScopePermissions[backendScopeKeyFor(scope)] = scope.Permissions
|
||||
}
|
||||
}
|
||||
var requestedProfile settings.UserProfile
|
||||
if keepProfile {
|
||||
requestedProfile = settings.ProfileFromUser(user)
|
||||
}
|
||||
// Apply profile/scope defaults using the final account permissions (admin flag drives scope defaults).
|
||||
if permissionsRequested {
|
||||
settings.ApplyUserDefaultsFromKeepingAccountPermissions(user, EffectiveUserDefaults())
|
||||
} else {
|
||||
applyUserSettingsDefaults(user)
|
||||
}
|
||||
if keepProfile {
|
||||
settings.ExpandProfileIntoUser(user, requestedProfile)
|
||||
}
|
||||
if preserveRequirePasswordChange {
|
||||
user.RequirePasswordChange = true
|
||||
}
|
||||
|
||||
@@ -15,12 +15,18 @@ type SubtitleTrack struct {
|
||||
Embedded bool `json:"embedded"` // true for embedded subtitles, false for external files
|
||||
}
|
||||
|
||||
// ErrSubtitleSidecarNotRegular is returned when an external subtitle path is not a regular file (e.g. symlink).
|
||||
var ErrSubtitleSidecarNotRegular = errors.New("subtitle sidecar is not a regular file")
|
||||
|
||||
func GetSubtitleSidecarContent(subtitlePath string) (string, error) {
|
||||
// validate the size is not too large
|
||||
info, err := os.Stat(subtitlePath)
|
||||
// Sidecars sit beside the media file; do not follow symlink leaves (see lyricsSidecar).
|
||||
info, err := os.Lstat(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", ErrSubtitleSidecarNotRegular
|
||||
}
|
||||
if info.Size() > 1024*1024*50 { // 50MB
|
||||
return "", errors.New("subtitle file is too large")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
func setupAccessHTTPTest(t *testing.T) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
dbPath := filepath.Join(t.TempDir(), "access-http-test.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -279,19 +279,21 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
|
||||
if fullDestParent != fullDestFile && !state.AccessPermitted(idxTo.Path, utils.IndexPathFromNormalized(fullDestParent, true), d.User.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination parent of %s", req.Destination)
|
||||
}
|
||||
parentReal, err := idxTo.GetRealPathForMutation(userScopeTo, fullDestParent)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination parent path invalid: %v", err)
|
||||
}
|
||||
if err = os.MkdirAll(parentReal, fileutils.EffectiveDirPerm()); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination parent directory: %v", err)
|
||||
}
|
||||
destRel := strings.TrimLeft(filepath.ToSlash(req.Destination), "/")
|
||||
fileName := path.Base(destRel)
|
||||
if fileName == "." || fileName == "/" {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid destination file name")
|
||||
}
|
||||
destFileReal := filepath.Join(parentReal, fileName)
|
||||
destFileReal, err := idxTo.GetRealPathForMutation(userScopeTo, fullDestFile)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination path invalid: %v", err)
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idxTo, d.User.Username, destFileReal, false) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(destFileReal), fileutils.EffectiveDirPerm()); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination parent directory: %v", err)
|
||||
}
|
||||
|
||||
format := strings.ToLower(strings.TrimSpace(req.Format))
|
||||
if format == "" {
|
||||
@@ -1270,6 +1272,7 @@ func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time
|
||||
logger.Debugf("unarchive: symlink: %v", err)
|
||||
return fmt.Errorf("failed to create symlink from archive entry")
|
||||
}
|
||||
indexing.ClearRealPathCaches()
|
||||
return applyArchivedTimesAndPerm(destPath, fs.ModeSymlink, modTime)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestArchiveCreate_DeniesResolvedSymlinkDestination(t *testing.T) {
|
||||
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/denied", true), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowedDir := filepath.Join(sourceRoot, "allowed")
|
||||
deniedDir := filepath.Join(sourceRoot, "denied")
|
||||
for _, dir := range []string{allowedDir, deniedDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
victim := filepath.Join(deniedDir, "victim")
|
||||
if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceRoot, "a.txt"), []byte("payload"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(allowedDir, "report.zip")
|
||||
if err := os.Symlink(filepath.Join("..", "denied", "victim"), link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user, err := state.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Config.Server.SourceMap[sourceRoot].Config.DenyByDefault = false
|
||||
|
||||
body := `{"fromSource":"` + sourceName + `","paths":["/a.txt"],"destination":"/allowed/report.zip","format":"zip"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources/archive", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := archiveCreateHandler(rec, req, &Context{User: &user})
|
||||
if status != http.StatusForbidden || handlerErr == nil {
|
||||
t.Fatalf("status=%d err=%v", status, handlerErr)
|
||||
}
|
||||
got, err := os.ReadFile(victim)
|
||||
if err != nil || string(got) != "keep" {
|
||||
t.Fatalf("victim content=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveCreate_AllowsNonSymlinkDestination(t *testing.T) {
|
||||
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
dest := filepath.Join(sourceRoot, "out.zip")
|
||||
if err := os.WriteFile(filepath.Join(sourceRoot, "a.txt"), []byte("payload"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user, err := state.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := `{"fromSource":"` + sourceName + `","paths":["/a.txt"],"destination":"/out.zip","format":"zip"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources/archive", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := archiveCreateHandler(rec, req, &Context{User: &user})
|
||||
if status != http.StatusOK || handlerErr != nil {
|
||||
t.Fatalf("status=%d err=%v body=%s", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(dest); err != nil {
|
||||
t.Fatalf("expected archive at %s: %v", dest, err)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
func setupExtractAccessPolicyTest(t *testing.T) (destDir, sourceRoot, sourceName, username string) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "access.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
|
||||
@@ -3,11 +3,16 @@ package web
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestNormalizeArchiveEntryName(t *testing.T) {
|
||||
@@ -557,3 +562,19 @@ func TestExtractZip_symlinkDestination(t *testing.T) {
|
||||
t.Fatalf("victim.txt should be unchanged: %q, %v", victimData, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveCreateDestination_RejectsEscapingSymlinkLeaf(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(filepath.Dir(root), "outside-archive")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(root, "report.zip")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx := &indexing.Index{Source: settings.Source{Path: root}}
|
||||
if _, err := idx.GetRealPathForMutation("", "/report.zip"); !errors.Is(err, commonerrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
|
||||
func setupShareArchiveDownloadTest(t *testing.T) (sourceRoot string, sourceName string, owner *users.User) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
dbPath := filepath.Join(t.TempDir(), "share-dl-test.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -187,10 +187,10 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to file")
|
||||
}
|
||||
|
||||
// Get real file path
|
||||
realPath, _, err := idx.GetRealPath(scopePath)
|
||||
// Get real file path (scoped) and enforce ACL on the resolved target
|
||||
realPath, _, err := idx.GetRealPathScoped(userScope, scopePath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
return realPathErrStatus(err), err
|
||||
}
|
||||
|
||||
// Get file/directory info
|
||||
@@ -198,6 +198,9 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idx, d.User.Username, realPath, info.IsDir()) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to file")
|
||||
}
|
||||
|
||||
// Get MIME type from the index if available
|
||||
mimeType := "application/octet-stream"
|
||||
@@ -335,10 +338,10 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to file")
|
||||
}
|
||||
|
||||
// Get real file path
|
||||
realPath, _, err := idx.GetRealPath(scopePath)
|
||||
// Get real file path (scoped) and enforce ACL on the resolved target
|
||||
realPath, _, err := idx.GetRealPathScoped(userScope, scopePath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
|
||||
return realPathErrStatus(err), err
|
||||
}
|
||||
|
||||
// Get file/directory info
|
||||
@@ -346,6 +349,9 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idx, d.User.Username, realPath, info.IsDir()) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to file")
|
||||
}
|
||||
|
||||
// Set up SSE headers
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
|
||||
@@ -22,3 +22,37 @@ func TestErrToStatusWrapsUnsupportedPreviewFormat(t *testing.T) {
|
||||
t.Fatalf("ErrToStatus() = %d, want %d", status, http.StatusUnsupportedMediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsViewerOriginalPreview(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
size string
|
||||
file string
|
||||
fileType string
|
||||
docs bool // the server can render documents to an image
|
||||
want bool
|
||||
}{
|
||||
{"Fuji RAW viewer", "original", "DSCF0308.RAF", "image/x-fuji-raf", false, true},
|
||||
{"Canon RAW viewer", "original", "IMG_0001.CR3", "image/x-canon-cr3", false, true},
|
||||
{"Adobe DNG viewer", "original", "a.dng", "image/x-adobe-dng", false, true},
|
||||
{"HEIC viewer", "original", "a.heic", "image/heic", false, true},
|
||||
{"HEIF viewer", "original", "a.heif", "image/heif", false, true},
|
||||
{"rendered document viewer", "original", "a.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", true, true},
|
||||
{"documents need the server to be able to render them", "original", "a.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", false, false},
|
||||
{"RAW thumbnail stays disabled", "small", "DSCF0308.RAF", "image/x-fuji-raf", true, false},
|
||||
{"RAW large stays disabled", "large", "DSCF0308.RAF", "image/x-fuji-raf", true, false},
|
||||
{"no size stays disabled", "", "DSCF0308.RAF", "image/x-fuji-raf", true, false},
|
||||
{"video poster stays disabled", "original", "a.mov", "video/quicktime", true, false},
|
||||
{"audio cover stays disabled", "original", "a.mp3", "audio/mpeg", true, false},
|
||||
{"JPEG is opened directly, not through previews", "original", "a.jpg", "image/jpeg", true, false},
|
||||
{"PDF is opened directly, not through previews", "original", "a.pdf", "application/pdf", true, false},
|
||||
{"text stays disabled", "original", "a.txt", "text/plain", true, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isViewerOriginalPreview(tt.size, tt.file, tt.fileType, tt.docs); got != tt.want {
|
||||
t.Fatalf("isViewerOriginalPreview(%q, %q, %q, %v) = %v, want %v", tt.size, tt.file, tt.fileType, tt.docs, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package web
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
@@ -13,7 +14,9 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/gomedia"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
@@ -107,9 +110,34 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
|
||||
var content string
|
||||
if !embedded {
|
||||
subtitlePath := filepath.Join(filepath.Dir(fileInfo.RealPath), filepath.Base(track.Name))
|
||||
var subtitlePath string
|
||||
if d.Share.Hash != "" {
|
||||
if d.Share.Path == "" {
|
||||
return http.StatusForbidden, fmt.Errorf("share has no path scope")
|
||||
}
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
bound, sidecarIndexPath, scopeErr := shareSubtitleSidecarIndexPaths(d, source, path, track.Name)
|
||||
if scopeErr != nil {
|
||||
return http.StatusForbidden, scopeErr
|
||||
}
|
||||
subtitlePath, _, err = idx.GetRealPathScoped(bound, sidecarIndexPath)
|
||||
if err != nil {
|
||||
return realPathErrStatus(err), err
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idx, d.ShareUser.Username, subtitlePath, false) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied")
|
||||
}
|
||||
} else {
|
||||
subtitlePath = filepath.Join(filepath.Dir(fileInfo.RealPath), filepath.Base(track.Name))
|
||||
}
|
||||
content, err = utils.GetSubtitleSidecarContent(subtitlePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, utils.ErrSubtitleSidecarNotRegular) {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to get subtitle sidecar content: %v", err)
|
||||
}
|
||||
} else {
|
||||
@@ -162,6 +190,34 @@ func findSubtitleTrack(subtitles []utils.SubtitleTrack, name string, embedded bo
|
||||
return nil
|
||||
}
|
||||
|
||||
// shareSubtitleSidecarIndexPaths maps middleware IndexPath (user-scope-relative) and Share.Path
|
||||
// to scoped index paths for sidecar containment (see publicDeleteHandler).
|
||||
func shareSubtitleSidecarIndexPaths(d *Context, sourceName, videoIndexPath, sidecarFileName string) (bound, sidecarIndexPath string, err error) {
|
||||
userScope, scopeErr := d.ShareUser.GetScopeForSourceName(sourceName)
|
||||
if scopeErr != nil || userScope == "" {
|
||||
return "", "", fmt.Errorf("user has no access to source: %s", sourceName)
|
||||
}
|
||||
if d.Share.Path == "" {
|
||||
return "", "", fmt.Errorf("share has no path scope")
|
||||
}
|
||||
shareRel := strings.TrimPrefix(d.Share.Path, userScope)
|
||||
bound = utils.JoinScopedIndexPath(userScope, shareRel)
|
||||
videoRel := strings.Trim(strings.TrimSpace(videoIndexPath), "/")
|
||||
fullVideo := utils.JoinScopedIndexPath(userScope, videoRel)
|
||||
if strings.TrimSuffix(bound, "/") == strings.TrimSuffix(fullVideo, "/") {
|
||||
parent := utils.GetParentDirectoryPath(strings.TrimSuffix(bound, "/"))
|
||||
if parent == "" {
|
||||
parent = "/"
|
||||
}
|
||||
bound = parent
|
||||
}
|
||||
sidecarIndexPath = utils.JoinPathAsUnix(
|
||||
utils.GetParentDirectoryPath(strings.TrimSuffix(fullVideo, "/")),
|
||||
filepath.Base(sidecarFileName),
|
||||
)
|
||||
return bound, sidecarIndexPath, nil
|
||||
}
|
||||
|
||||
// metadataHandler returns the same directory resource shape as GET /api/resources with metadata enabled,
|
||||
// for client-side patching after a fast listing load.
|
||||
// @Summary Directory with media metadata
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func setupSubtitlesHTTPTest(t *testing.T) (downloadsPath string, admin *users.User) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
downloadsPath = filepath.Join(t.TempDir(), "downloads")
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "subtitles-http-test.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.MustWireServices(state.Default())
|
||||
t.Cleanup(func() {
|
||||
state.Close()
|
||||
})
|
||||
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
downloadsPath: {
|
||||
Path: downloadsPath,
|
||||
Name: "Downloads",
|
||||
},
|
||||
}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{
|
||||
"Downloads": settings.Config.Server.SourceMap[downloadsPath],
|
||||
}
|
||||
settings.InitializeUserResolvers()
|
||||
|
||||
adminUser := &users.User{
|
||||
ID: 1,
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
Permissions: users.Permissions{Admin: true},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: downloadsPath, Scope: "/"},
|
||||
},
|
||||
BackendSourcePermissions: map[string]users.SourceFilePermissions{
|
||||
downloadsPath: {
|
||||
View: true,
|
||||
Download: true,
|
||||
Modify: true,
|
||||
Create: true,
|
||||
Delete: true,
|
||||
},
|
||||
},
|
||||
Version: users.SourcePermissionsMigrationVersion,
|
||||
}
|
||||
if err := state.CreateUser(adminUser, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminUser.Permissions = users.Permissions{Admin: true}
|
||||
if err := state.UpdateUser(adminUser, "", "permissions"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := state.UpdateUser(adminUser, "", "backendScopes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := state.UpdateUser(adminUser, "", "backendSourcePermissions"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(downloadsPath, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
indexing.SetTestIndex("Downloads", downloadsPath)
|
||||
t.Cleanup(func() {
|
||||
indexing.ClearTestIndices()
|
||||
})
|
||||
|
||||
loaded, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return downloadsPath, &loaded
|
||||
}
|
||||
|
||||
func mockVideoWithExternalSubtitle(t *testing.T, trackName, videoRealPath string) {
|
||||
t.Helper()
|
||||
prev := files.FileInfoFasterFunc
|
||||
t.Cleanup(func() { files.FileInfoFasterFunc = prev })
|
||||
files.FileInfoFasterFunc = func(opts utils.FileOptions, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return &iteminfo.ExtendedFileInfo{
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
Path: opts.Path,
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Type: "video/mp4",
|
||||
},
|
||||
},
|
||||
Subtitles: []utils.SubtitleTrack{
|
||||
{Name: trackName, Embedded: false},
|
||||
},
|
||||
RealPath: videoRealPath,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitlesHandler_RejectsEscapingSidecarSymlink(t *testing.T) {
|
||||
downloadsPath, adminUser := setupSubtitlesHTTPTest(t)
|
||||
videoPath := filepath.Join(downloadsPath, "clip.mov")
|
||||
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
|
||||
|
||||
outside := filepath.Join(filepath.Dir(downloadsPath), "outside-secret")
|
||||
if err := os.MkdirAll(outside, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := filepath.Join(outside, "secret.srt")
|
||||
if err := os.WriteFile(secret, []byte("SECRET_OUTSIDE_SUBTITLE"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(secret, filepath.Join(downloadsPath, "clip.srt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/media/subtitles?source=Downloads&path=/clip.mov&name=clip.srt&embedded=false", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := subtitlesHandler(rec, req, &Context{User: adminUser})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "SECRET_OUTSIDE_SUBTITLE") {
|
||||
t.Fatalf("response leaked outside subtitle content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitlesHandler_ReturnsInScopeSidecar(t *testing.T) {
|
||||
downloadsPath, adminUser := setupSubtitlesHTTPTest(t)
|
||||
videoPath := filepath.Join(downloadsPath, "clip.mov")
|
||||
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
|
||||
|
||||
srtBody := "1\n00:00:01,000 --> 00:00:02,000\nHello in scope"
|
||||
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(downloadsPath, "clip.srt"), []byte(srtBody), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/media/subtitles?source=Downloads&path=/clip.mov&name=clip.srt&embedded=false", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := subtitlesHandler(rec, req, &Context{User: adminUser})
|
||||
if status != http.StatusOK || handlerErr != nil {
|
||||
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Hello in scope") {
|
||||
t.Fatalf("body=%q want in-scope subtitle text", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitlesHandler_ShareScopedOwnerLoadsSidecar(t *testing.T) {
|
||||
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
|
||||
aliceRoot := filepath.Join(sourceRoot, "users", "alice", "shared")
|
||||
if err := os.MkdirAll(aliceRoot, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srtBody := "1\n00:00:01,000 --> 00:00:02,000\nScoped share OK"
|
||||
videoPath := filepath.Join(aliceRoot, "clip.mov")
|
||||
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(aliceRoot, "clip.srt"), []byte(srtBody), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
|
||||
|
||||
adminUser.BackendScopes = []users.BackendScope{{Path: sourceRoot, Scope: "/users/alice"}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=h&path=clip.mov&name=clip.srt&embedded=false", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := subtitlesHandler(rec, req, &Context{
|
||||
User: adminUser,
|
||||
ShareUser: adminUser,
|
||||
IndexPath: "/shared/clip.mov/",
|
||||
Share: share.Share{
|
||||
ShareColumns: share.ShareColumns{
|
||||
Hash: "h",
|
||||
Path: "/users/alice/shared",
|
||||
},
|
||||
SourcePath: sourceRoot,
|
||||
},
|
||||
})
|
||||
if status != http.StatusOK || handlerErr != nil {
|
||||
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Scoped share OK") {
|
||||
t.Fatalf("body=%q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitlesHandler_ShareSingleFileLoadsSidecar(t *testing.T) {
|
||||
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
|
||||
srtBody := "single-file share subtitle"
|
||||
videoPath := filepath.Join(sourceRoot, "clip.mov")
|
||||
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceRoot, "clip.srt"), []byte(srtBody), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=h&path=/&name=clip.srt&embedded=false", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := subtitlesHandler(rec, req, &Context{
|
||||
User: adminUser,
|
||||
ShareUser: adminUser,
|
||||
IndexPath: "/clip.mov/",
|
||||
Share: share.Share{
|
||||
ShareColumns: share.ShareColumns{
|
||||
Hash: "h",
|
||||
Path: "/clip.mov",
|
||||
},
|
||||
SourcePath: sourceRoot,
|
||||
},
|
||||
})
|
||||
if status != http.StatusOK || handlerErr != nil {
|
||||
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "single-file share subtitle") {
|
||||
t.Fatalf("body=%q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitlesHandler_ShareDeniesSidecarSymlinkToDeniedTarget(t *testing.T) {
|
||||
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
|
||||
sharedDir := filepath.Join(sourceRoot, "shared")
|
||||
privateDir := filepath.Join(sourceRoot, "private")
|
||||
for _, dir := range []string{sharedDir, privateDir} {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
secret := filepath.Join(privateDir, "clip.srt")
|
||||
if err := os.WriteFile(secret, []byte("SECRET_DENIED_SUBTITLE"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
videoPath := filepath.Join(sharedDir, "clip.mov")
|
||||
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join("..", "private", "clip.srt"), filepath.Join(sharedDir, "clip.srt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/private", true), "admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
|
||||
|
||||
adminUser.BackendScopes = []users.BackendScope{{Path: sourceRoot, Scope: "/"}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=h&path=clip.mov&name=clip.srt&embedded=false", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := subtitlesHandler(rec, req, &Context{
|
||||
User: adminUser,
|
||||
ShareUser: adminUser,
|
||||
IndexPath: "/shared/clip.mov/",
|
||||
Share: share.Share{
|
||||
ShareColumns: share.ShareColumns{
|
||||
Hash: "h",
|
||||
Path: "/shared",
|
||||
},
|
||||
SourcePath: sourceRoot,
|
||||
},
|
||||
})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "SECRET_DENIED_SUBTITLE") {
|
||||
t.Fatalf("response leaked denied subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitlesHandler_ShareRejectsSidecarPastEscapingDirSymlink(t *testing.T) {
|
||||
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
|
||||
videoIndexPath := "/shared/link/clip.mov/"
|
||||
mockVideoWithExternalSubtitle(t, "clip.srt", filepath.Join(sourceRoot, "shared", "link", "clip.mov"))
|
||||
|
||||
sharedDir := filepath.Join(sourceRoot, "shared")
|
||||
if err := os.MkdirAll(sharedDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(sourceRoot), "outside-share")
|
||||
if err := os.MkdirAll(outside, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "clip.srt"), []byte("SECRET_PAST_DIR_SYMLINK"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(sharedDir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=h&path=link/clip.mov&name=clip.srt&embedded=false", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := subtitlesHandler(rec, req, &Context{
|
||||
User: adminUser,
|
||||
ShareUser: adminUser,
|
||||
IndexPath: videoIndexPath,
|
||||
Share: share.Share{
|
||||
ShareColumns: share.ShareColumns{
|
||||
Hash: "h",
|
||||
Path: "/shared",
|
||||
},
|
||||
SourcePath: sourceRoot,
|
||||
},
|
||||
})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "SECRET_PAST_DIR_SYMLINK") {
|
||||
t.Fatalf("response leaked subtitle past directory symlink")
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,10 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
pathWithoutUserScope = pathWithoutUserScope + "/"
|
||||
}
|
||||
data.IndexPath = pathWithoutUserScope
|
||||
// skip file fetch for certain apis
|
||||
// Skip FileInfoFaster prefetch for performance; handlers must apply equivalent
|
||||
// checks (access rules + GetRealPathScoped with d.Share.Path where bytes are read).
|
||||
// TODO: align all skipped public-share routes on one bound (Share.Path) or stop
|
||||
// skipping FileInfoFaster here for media/metadata/items.
|
||||
if (r.Method == "POST" && strings.Contains(r.URL.Path, "/resources")) ||
|
||||
(r.Method == "POST" && strings.Contains(r.URL.Path, "/resources/view-token")) ||
|
||||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items")) ||
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -22,6 +24,7 @@ import (
|
||||
)
|
||||
|
||||
func setupTestEnv(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
utils.BcryptCost = bcrypt.MinCost
|
||||
dbPath := filepath.Join(t.TempDir(), "test.sqlite")
|
||||
|
||||
@@ -535,6 +538,88 @@ func newHTTPRequest(t *testing.T, hash string, requestModifiers ...func(*http.Re
|
||||
return r
|
||||
}
|
||||
|
||||
func TestPublicGetResourceChecksum(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "a.txt")
|
||||
if err := os.WriteFile(filePath, []byte("hello\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalFileInfoFaster := FileInfoFasterFunc
|
||||
t.Cleanup(func() { FileInfoFasterFunc = originalFileInfoFaster })
|
||||
FileInfoFasterFunc = func(opts utils.FileOptions, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return &iteminfo.ExtendedFileInfo{
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
Path: opts.Path,
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "a.txt",
|
||||
Type: "text/plain",
|
||||
},
|
||||
},
|
||||
RealPath: filePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
newOwner := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "checksum_owner",
|
||||
Permissions: users.Permissions{Admin: false},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{{Path: "/srv", Scope: "/"}},
|
||||
}
|
||||
if err := state.CreateUser(newOwner, ""); err != nil {
|
||||
t.Fatal("failed to save user:", err)
|
||||
}
|
||||
ownerValue, err := state.GetUserByUsername("checksum_owner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner := &ownerValue
|
||||
|
||||
if err := state.CreateShare(&share.Share{
|
||||
ShareSettings: share.ShareSettings{ShareLimits: share.ShareLimits{SourceName: "srv"}},
|
||||
ShareColumns: share.ShareColumns{Hash: "checksum_hash", Path: "/"},
|
||||
SourcePath: "/srv",
|
||||
UserID: owner.ID,
|
||||
}); err != nil {
|
||||
t.Fatal("failed to create share:", err)
|
||||
}
|
||||
|
||||
originalAuthKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
|
||||
|
||||
t.Run("anonymous", func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := newTestRequest(t, "checksum_hash", "", "", nil)
|
||||
q := req.URL.Query()
|
||||
q.Set("checksum", "md5")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
withHashFile(publicGetResourceHandler)(recorder, req)
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected status %d, got %d body=%s", http.StatusUnauthorized, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("authenticated", func(t *testing.T) {
|
||||
token := testSessionToken(t, owner, time.Hour)
|
||||
recorder := httptest.NewRecorder()
|
||||
req := newTestRequest(t, "checksum_hash", "", "", map[string]string{
|
||||
"Authorization": "Bearer " + token,
|
||||
})
|
||||
q := req.URL.Query()
|
||||
q.Set("checksum", "md5")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
withHashFile(publicGetResourceHandler)(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "b1946ac92492d2347c6235b4d2611184") {
|
||||
t.Fatalf("checksum missing from response: %s", recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithTimeoutReturnsJSONTimeoutReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
timeout := 50 * time.Millisecond
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -38,6 +39,31 @@ func isClientCancellation(ctx context.Context, err error) bool {
|
||||
return errors.Is(err, context.Canceled)
|
||||
}
|
||||
|
||||
// viewerConvertedDocExtensions are the documents the viewer opens as a rendered image through the
|
||||
// "original" preview (keep in sync with pdfConvertable in frontend Preview.vue).
|
||||
var viewerConvertedDocExtensions = map[string]bool{
|
||||
".xps": true, ".mobi": true, ".fb2": true, ".cbz": true, ".svg": true,
|
||||
".docx": true, ".pptx": true, ".xlsx": true, ".hwp": true, ".hwpx": true,
|
||||
}
|
||||
|
||||
// isViewerOriginalPreview reports whether a preview request is the viewer opening a file the browser can't
|
||||
// display by itself (camera RAW, HEIC/HEIF, rendered documents). Unlike a thumbnail, without it the file can't
|
||||
// be opened at all, so it is served even when thumbnails are disabled. documentRenderable tells whether the
|
||||
// server can render documents of this kind to an image (see iteminfo.HasDocConvertableExtension).
|
||||
func isViewerOriginalPreview(previewSize, fileName, fileType string, documentRenderable bool) bool {
|
||||
if previewSize != "original" {
|
||||
return false
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
if iteminfo.IsRawImage(ext) {
|
||||
return true
|
||||
}
|
||||
if fileType == "image/heic" || fileType == "image/heif" {
|
||||
return true
|
||||
}
|
||||
return documentRenderable && viewerConvertedDocExtensions[ext]
|
||||
}
|
||||
|
||||
// previewHandler handles the preview request for images.
|
||||
// @Summary Get image preview
|
||||
// @Description Returns a preview image based on the requested path and size.
|
||||
@@ -55,7 +81,9 @@ func isClientCancellation(ctx context.Context, err error) bool {
|
||||
// @Failure 501 {object} map[string]string "Preview generation not implemented"
|
||||
// @Router /api/resources/preview [get]
|
||||
func previewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
|
||||
if settings.Config.Server.DisablePreviews {
|
||||
previewsDisabled := settings.Config.Server.DisablePreviews
|
||||
// Only the viewer's "original" requests can pass when previews are disabled (checked below once the file is known).
|
||||
if previewsDisabled && r.URL.Query().Get("size") != "original" {
|
||||
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
|
||||
}
|
||||
path := r.URL.Query().Get("path")
|
||||
@@ -85,6 +113,9 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, er
|
||||
return ErrToStatus(err), err
|
||||
}
|
||||
d.FileInfo = *fileInfo
|
||||
if previewsDisabled && !isViewerOriginalPreview(r.URL.Query().Get("size"), fileInfo.Name, fileInfo.Type, iteminfo.HasDocConvertableExtension(fileInfo.Name, fileInfo.Type)) {
|
||||
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
|
||||
}
|
||||
status, err := PreviewHelperFunc(w, r, d)
|
||||
if err != nil {
|
||||
// Error already logged in PreviewHelperFunc or its callees
|
||||
@@ -109,7 +140,9 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, er
|
||||
// @Failure 501 {object} map[string]string "Previews disabled globally, for this share, or for upload shares"
|
||||
// @Router /public/api/resources/preview [get]
|
||||
func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
|
||||
if settings.Config.Server.DisablePreviews || d.Share.DisableThumbnails {
|
||||
previewSize := r.URL.Query().Get("size")
|
||||
if (settings.Config.Server.DisablePreviews || d.Share.DisableThumbnails) &&
|
||||
!isViewerOriginalPreview(previewSize, d.FileInfo.Name, d.FileInfo.Type, iteminfo.HasDocConvertableExtension(d.FileInfo.Name, d.FileInfo.Type)) {
|
||||
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
|
||||
}
|
||||
if d.Share.ShareType == "upload" {
|
||||
@@ -196,13 +229,35 @@ func getDirectoryPreview(ctx context.Context, r *http.Request, d *Context, frame
|
||||
// force an ffprobe probe and a subtitle scan per frame on the fast path.
|
||||
fileInfo, err := files.FileInfoFaster(
|
||||
utils.FileOptions{
|
||||
Path: path,
|
||||
Source: source,
|
||||
AlbumArt: true,
|
||||
Path: path,
|
||||
Source: source,
|
||||
AlbumArt: true,
|
||||
FollowSymlinks: true,
|
||||
}, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.Share.Hash != "" {
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return nil, fmt.Errorf("source not available")
|
||||
}
|
||||
userScope, scopeErr := d.ShareUser.GetScopeForSourceName(source)
|
||||
if scopeErr != nil {
|
||||
return nil, scopeErr
|
||||
}
|
||||
shareRel := strings.TrimPrefix(d.Share.Path, userScope)
|
||||
bound := utils.JoinScopedIndexPath(userScope, shareRel)
|
||||
childIndexPath := utils.JoinScopedIndexPath(userScope, path)
|
||||
realPath, _, rerr := idx.GetRealPathScoped(bound, childIndexPath)
|
||||
if rerr != nil {
|
||||
if index != 0 {
|
||||
return getDirectoryPreview(ctx, r, d, 0)
|
||||
}
|
||||
return nil, rerr
|
||||
}
|
||||
fileInfo.RealPath = realPath
|
||||
}
|
||||
tempCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
_, previewErr := preview.GetPreviewForFile(tempCtx, *fileInfo, "small", "", 0)
|
||||
cancel()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func TestGetDirectoryPreview_ShareRejectsSymlinkChildOutsideShare(t *testing.T) {
|
||||
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
|
||||
sharedDir := filepath.Join(sourceRoot, "shared")
|
||||
privateDir := filepath.Join(sourceRoot, "private")
|
||||
for _, dir := range []string{sharedDir, privateDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(privateDir, "secret.jpg"), []byte{0xff, 0xd8, 0xff, 0xd9}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join("..", "private", "secret.jpg"), filepath.Join(sharedDir, "link.jpg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
adminUser.BackendScopes = []users.BackendScope{{Path: sourceRoot, Scope: "/"}}
|
||||
d := &Context{
|
||||
User: adminUser,
|
||||
ShareUser: adminUser,
|
||||
IndexPath: "/shared/",
|
||||
Share: share.Share{
|
||||
ShareColumns: share.ShareColumns{Hash: "h", Path: "/shared"},
|
||||
SourcePath: sourceRoot,
|
||||
},
|
||||
FileInfo: iteminfo.ExtendedFileInfo{
|
||||
Source: "Downloads",
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
Path: "/shared/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "link.jpg", Type: "image/jpeg", HasPreview: true}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
idx := indexing.GetIndex("Downloads")
|
||||
if idx == nil {
|
||||
t.Fatal("missing index")
|
||||
}
|
||||
|
||||
_, err := getDirectoryPreview(ctx, req, d, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected scope error for share child symlink outside share root")
|
||||
}
|
||||
if !errors.Is(err, commonerrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("error=%v want ErrPathEscapesScope", err)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func TestGetRemoteIPUsesRightmostForwardedFor(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -27,6 +28,7 @@ func TestGetRemoteIPUsesRightmostForwardedFor(t *testing.T) {
|
||||
// proxy hops so multi-hop deployments (CDN -> proxy -> filebrowser) resolve the
|
||||
// real client IP instead of collapsing everyone onto the shared edge address.
|
||||
func TestGetRemoteIPTrustedProxies(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -59,6 +61,7 @@ func TestGetRemoteIPTrustedProxies(t *testing.T) {
|
||||
|
||||
// Bare IPs (not just CIDRs) are accepted in http.trustedProxies.
|
||||
func TestGetRemoteIPTrustedProxiesBareIP(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -74,6 +77,7 @@ func TestGetRemoteIPTrustedProxiesBareIP(t *testing.T) {
|
||||
|
||||
// Without trustProxyHeaders the XFF chain is ignored entirely.
|
||||
func TestGetRemoteIPIgnoresForwardedForWhenUntrusted(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = false
|
||||
@@ -90,6 +94,7 @@ func TestGetRemoteIPIgnoresForwardedForWhenUntrusted(t *testing.T) {
|
||||
// A direct client that is not a trusted proxy cannot spoof its address with
|
||||
// forwarded headers, even when trustProxyHeaders is enabled.
|
||||
func TestGetRemoteIPIgnoresForwardedHeadersFromUntrustedPeer(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -104,8 +109,26 @@ func TestGetRemoteIPIgnoresForwardedHeadersFromUntrustedPeer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRemoteIPUnixSocketHonorsForwardedWithTrustAllLocalPeers(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
settings.Config.Http.Socket = "/var/run/filebrowser.sock"
|
||||
settings.Config.Http.UnixSocketTrustAllLocalPeers = true
|
||||
settings.Config.Http.TrustedProxies = nil
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/", nil)
|
||||
req.RemoteAddr = "@"
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.50")
|
||||
if got := GetRemoteIP(req); got != "203.0.113.50" {
|
||||
t.Fatalf("GetRemoteIP = %q, want 203.0.113.50", got)
|
||||
}
|
||||
}
|
||||
|
||||
// With no trusted proxies configured, forwarded headers are never honored.
|
||||
func TestGetRemoteIPIgnoresForwardedHeadersWithoutTrustedProxies(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -121,6 +144,7 @@ func TestGetRemoteIPIgnoresForwardedHeadersWithoutTrustedProxies(t *testing.T) {
|
||||
|
||||
// An IPv4-mapped IPv6 trusted hop in the XFF chain must still be skipped.
|
||||
func TestGetRemoteIPMappedTrustedHop(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -135,6 +159,7 @@ func TestGetRemoteIPMappedTrustedHop(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetScheme(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
t.Run("trusted proto", func(t *testing.T) {
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
|
||||
@@ -158,6 +183,7 @@ func TestGetScheme(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestSchemeTLSFallback(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
settings.Config.Http.TrustProxyHeaders = false
|
||||
|
||||
req := httptest.NewRequest("GET", "https://example.com/", nil)
|
||||
@@ -168,6 +194,7 @@ func TestRequestSchemeTLSFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestSchemeIgnoresInvalidProto(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
|
||||
|
||||
@@ -179,6 +206,7 @@ func TestRequestSchemeIgnoresInvalidProto(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestHostTrustProxyHeaders(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
|
||||
|
||||
@@ -192,6 +220,7 @@ func TestRequestHostTrustProxyHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestSchemeForPublicURL(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http.TrustProxyHeaders
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = orig })
|
||||
|
||||
@@ -225,6 +254,7 @@ func TestRequestSchemeForPublicURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestPageFullURL(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.ExternalUrl = ""
|
||||
|
||||
@@ -237,6 +237,7 @@ func TestResourcePutHandler_SuccessResponseOmitsRealPath(t *testing.T) {
|
||||
|
||||
func setupPutHTTPTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
downloadsPath := filepath.Join(t.TempDir(), "downloads")
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "quota-http-test.sqlite")
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/quota"
|
||||
@@ -171,6 +172,10 @@ func putResourceWithQuota(w http.ResponseWriter, r *http.Request, d *Context, so
|
||||
if err != nil {
|
||||
return sanitizePutPathError(err)
|
||||
}
|
||||
permUser := accessCheckUsername(d)
|
||||
if !state.AccessPermittedAtRealPath(idx, permUser, realPath, access.IsDirAtRealPath(realPath)) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", fullIndexPath)
|
||||
}
|
||||
if stat, statErr := os.Stat(realPath); statErr == nil && stat.IsDir() {
|
||||
return http.StatusMethodNotAllowed, errPutPathIsDirectory
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
func setupQuotaHTTPTest(t *testing.T) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
dbPath := filepath.Join(t.TempDir(), "quota-http-test.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
@@ -208,7 +209,9 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int
|
||||
// @Param path query string false "Path within the share to retrieve information for. Defaults to share root."
|
||||
// @Param content query string false "Include file content if true"
|
||||
// @Param metadata query string false "Extract audio/video metadata if true"
|
||||
// @Param checksum query string false "Optional checksum algorithm (md5, sha1, sha256, sha512); requires a logged-in session"
|
||||
// @Success 200 {object} iteminfo.FileInfo "File or directory metadata"
|
||||
// @Failure 401 {object} map[string]string "Login required for checksum generation"
|
||||
// @Failure 403 {object} map[string]string "Share unavailable or access denied"
|
||||
// @Failure 404 {object} map[string]string "Share not found or file not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
@@ -218,6 +221,24 @@ func publicGetResourceHandler(w http.ResponseWriter, r *http.Request, d *Context
|
||||
if d.Share.ShareType == "upload" {
|
||||
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
|
||||
}
|
||||
if algo := r.URL.Query().Get("checksum"); algo != "" {
|
||||
if d.User == nil || d.User.Username == "" || d.User.Username == "anonymous" {
|
||||
return http.StatusUnauthorized, fmt.Errorf("login required to generate checksums")
|
||||
}
|
||||
if d.FileInfo.Type == "directory" {
|
||||
return http.StatusBadRequest, fmt.Errorf("checksum is only supported for files")
|
||||
}
|
||||
if d.Share.DisableFileViewer {
|
||||
return http.StatusForbidden, fmt.Errorf("file viewing is disabled for this share")
|
||||
}
|
||||
checksum, err := utils.GetChecksum(d.FileInfo.RealPath, algo)
|
||||
if err == errors.ErrInvalidOption {
|
||||
return http.StatusBadRequest, nil
|
||||
} else if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
d.FileInfo.Checksums = map[string]string{algo: checksum}
|
||||
}
|
||||
return RenderJSON(w, r, d.FileInfo)
|
||||
}
|
||||
|
||||
@@ -792,6 +813,9 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
if !state.AccessPermitted(idx.Path, utils.IndexPathFromNormalized(fullIndexPath, true), filePermUser.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idx, filePermUser.Username, realPath, access.IsDirAtRealPath(realPath)) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
|
||||
}
|
||||
|
||||
// Check for file/folder conflicts before creation
|
||||
if stat, statErr := os.Stat(realPath); statErr == nil {
|
||||
@@ -1528,6 +1552,15 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
|
||||
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
|
||||
continue
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(srcIdx, d.User.Username, realSrc, isSrcDir) {
|
||||
item.Message = "access denied to source path"
|
||||
if d.Share.Hash != "" {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{Message: item.Message})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve the destination: copy must reject escaping symlink leaves; move/rename
|
||||
// must replace an existing symlink entry without following it.
|
||||
@@ -1548,10 +1581,28 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
|
||||
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
|
||||
continue
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(dstIdx, d.User.Username, realDest, access.IsDirAtRealPath(realDest)) {
|
||||
item.Message = "access denied to destination path"
|
||||
if d.Share.Hash != "" {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{Message: item.Message})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
|
||||
continue
|
||||
}
|
||||
|
||||
// Auto-rename if requested
|
||||
if req.Rename {
|
||||
realDest = addVersionSuffix(realDest)
|
||||
if !state.AccessPermittedAtRealPath(dstIdx, d.User.Username, realDest, isSrcDir) {
|
||||
item.Message = "access denied to destination path"
|
||||
if d.Share.Hash != "" {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{Message: item.Message})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Validate move/rename operation to prevent circular references
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
)
|
||||
|
||||
func TestResourcePatchHandler_CopyRename_DeniesDeniedVersionSuffix(t *testing.T) {
|
||||
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
destDir := filepath.Join(sourceRoot, "dest")
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := filepath.Join(sourceRoot, "src.txt")
|
||||
if err := os.WriteFile(src, []byte("src"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := filepath.Join(destDir, "report.txt")
|
||||
if err := os.WriteFile(existing, []byte("existing"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
denied := filepath.Join(destDir, "report(1).txt")
|
||||
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/dest/report(1).txt", false), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user, err := state.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"action":"copy","rename":true,"items":[{"fromSource":"` + sourceName +
|
||||
`","fromPath":"/src.txt","toSource":"` + sourceName + `","toPath":"/dest/report.txt"}]}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/resources", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := ResourcePatchHandler(rec, req, &Context{User: &user})
|
||||
if handlerErr != nil {
|
||||
t.Fatalf("err=%v", handlerErr)
|
||||
}
|
||||
if status != http.StatusInternalServerError && status != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", status, rec.Body.String())
|
||||
}
|
||||
var resp MoveCopyResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.Failed) != 1 || !strings.Contains(resp.Failed[0].Message, "access denied") {
|
||||
t.Fatalf("failed=%+v", resp.Failed)
|
||||
}
|
||||
if _, err := os.Stat(denied); err == nil {
|
||||
t.Fatal("denied destination file should not exist")
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
func setupPutQuotaTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
downloadsPath := filepath.Join(t.TempDir(), "downloads")
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "quota-http-test.sqlite")
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
)
|
||||
|
||||
func TestResourcePostHandler_RejectsEscapingParentSymlinkMkdir(t *testing.T) {
|
||||
downloadsPath := setupPutHTTPTest(t)
|
||||
outside := filepath.Join(filepath.Dir(downloadsPath), "outside-mkdir")
|
||||
if err := os.MkdirAll(outside, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(downloadsPath, "evil")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
adminUser, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources?source=Downloads&path=/evil/newdir&isDir=true", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
status, handlerErr := ResourcePostHandler(rec, req, &requestContext{User: &adminUser})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status=%d err=%v", status, handlerErr)
|
||||
}
|
||||
if handlerErr == nil {
|
||||
t.Fatal("expected mkdir to be forbidden")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "newdir")); !os.IsNotExist(err) {
|
||||
t.Fatal("mkdir escaped outside the source via symlink parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceDeleteHandler_RejectsEscapingIntermediate(t *testing.T) {
|
||||
downloadsPath := setupPutHTTPTest(t)
|
||||
if preview.GetService() == nil {
|
||||
if err := preview.StartPreviewGenerator(1, t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
outsideDir := filepath.Join(filepath.Dir(downloadsPath), "outside-delete")
|
||||
if err := os.MkdirAll(outsideDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
victim := filepath.Join(outsideDir, "victim.txt")
|
||||
if err := os.WriteFile(victim, []byte("keep"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideDir, filepath.Join(downloadsPath, "evil")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
adminUser, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/resources?source=Downloads&path=/evil/victim.txt", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
status, handlerErr := resourceDeleteHandler(rec, req, &requestContext{User: &adminUser})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status=%d err=%v", status, handlerErr)
|
||||
}
|
||||
if handlerErr == nil {
|
||||
t.Fatal("expected delete to be forbidden")
|
||||
}
|
||||
got, err := os.ReadFile(victim)
|
||||
if err != nil || string(got) != "keep" {
|
||||
t.Fatalf("delete escaped: err=%v content=%q", err, got)
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,11 @@ func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int
|
||||
// filesystem. It is the comment/secret-marker source for both the YAML export and the settings
|
||||
// JSON redactor.
|
||||
func readEmbeddedConfigYaml() (string, error) {
|
||||
content, err := fs.ReadFile(assetFs, "config.generated.yaml")
|
||||
path := "config.generated.yaml"
|
||||
if settings.Env.EmbeddedFs {
|
||||
path = "embed/config.generated.yaml"
|
||||
}
|
||||
content, err := fs.ReadFile(assetFs, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -487,10 +487,16 @@ func ServeSingleFile(w http.ResponseWriter, r *http.Request, d *Context, source
|
||||
bound = userScope
|
||||
}
|
||||
|
||||
realPath, _, err := idx.GetRealPathScoped(bound, scopedFilePath)
|
||||
realPath, isDir, err := idx.GetRealPathScoped(bound, scopedFilePath)
|
||||
if err != nil {
|
||||
return realPathErrStatus(err), err
|
||||
}
|
||||
if st, statErr := os.Stat(realPath); statErr == nil {
|
||||
isDir = st.IsDir()
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idx, permUser, realPath, isDir) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", scopedFilePath)
|
||||
}
|
||||
|
||||
isOnlyOffice := IsOnlyOfficeCompatibleFile(displayFileName) && settings.Config.Integrations.OnlyOffice.Url != "" && !settings.Config.Integrations.OnlyOffice.IsCollabora()
|
||||
var documentId string
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// webGlobalTestMu serializes tests that mutate process-wide settings.Config or state.
|
||||
var webGlobalTestMu sync.Mutex
|
||||
|
||||
func lockWebGlobals(t testing.TB) {
|
||||
t.Helper()
|
||||
webGlobalTestMu.Lock()
|
||||
t.Cleanup(webGlobalTestMu.Unlock)
|
||||
}
|
||||
@@ -176,6 +176,8 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
}
|
||||
// Parse the JSON into the UserRequest struct
|
||||
var req UserRequest
|
||||
// Seed with defaults so the decode below only overrides fields the request sent.
|
||||
state.PrefillUserProfileDefaults(&req.User)
|
||||
if err = json.Unmarshal(body, &req); err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
@@ -199,7 +201,7 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
return status, err
|
||||
}
|
||||
plaintextPassword := req.User.Password
|
||||
err = state.CreateUser(&req.User, plaintextPassword)
|
||||
err = state.CreateUserKeepingProfile(&req.User, plaintextPassword)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
// Omitted permissions and omitted profile keys come from userDefaults; a partially
|
||||
// specified fileLoading object only overrides the keys it contains.
|
||||
func TestUsersPostOmittedFieldsFallBackToDefaults(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
defaults := settings.Config.UserDefaults
|
||||
defaults.UI.Locale = "de"
|
||||
defaults.Account.Permissions.Share = true
|
||||
defaults.FileLoading.MaxConcurrent = 7
|
||||
defaults.FileLoading.UploadChunkSize = 9
|
||||
state.SetEffectiveUserDefaultsForTest(t, defaults)
|
||||
|
||||
admin := createPostTestAdmin(t)
|
||||
|
||||
body := []byte(`{"which":[],"data":{"username":"partial","password":"secret","loginMethod":"password",
|
||||
"fileLoading":{"maxConcurrentUpload":3}}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
|
||||
status, err := usersPostHandler(httptest.NewRecorder(), req, &Context{User: admin})
|
||||
if err != nil || status != http.StatusCreated {
|
||||
t.Fatalf("usersPostHandler() status=%d err=%v", status, err)
|
||||
}
|
||||
got, err := state.GetUserByUsername("partial")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Locale != "de" {
|
||||
t.Errorf("locale = %q, want default de", got.Locale)
|
||||
}
|
||||
if !got.Permissions.Share {
|
||||
t.Errorf("share = false, want true from default permissions")
|
||||
}
|
||||
if got.FileLoading.MaxConcurrent != 3 {
|
||||
t.Errorf("maxConcurrentUpload = %d, want requested 3", got.FileLoading.MaxConcurrent)
|
||||
}
|
||||
if got.FileLoading.UploadChunkSize != 9 {
|
||||
t.Errorf("uploadChunkSizeMb = %d, want default 9", got.FileLoading.UploadChunkSize)
|
||||
}
|
||||
}
|
||||
|
||||
// A create request must not mutate the shared in-memory userDefaults (json.Unmarshal
|
||||
// reuses slice backing arrays).
|
||||
func TestUsersPostDoesNotMutateUserDefaults(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
defaults := settings.Config.UserDefaults
|
||||
defaults.Listing.NewFileTemplate = []string{"default.txt"}
|
||||
state.SetEffectiveUserDefaultsForTest(t, defaults)
|
||||
|
||||
admin := createPostTestAdmin(t)
|
||||
|
||||
body := []byte(`{"which":[],"data":{"username":"nomutate","password":"secret","loginMethod":"password",
|
||||
"newFileTemplate":["custom.txt"]}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
|
||||
status, err := usersPostHandler(httptest.NewRecorder(), req, &Context{User: admin})
|
||||
if err != nil || status != http.StatusCreated {
|
||||
t.Fatalf("usersPostHandler() status=%d err=%v", status, err)
|
||||
}
|
||||
if got := state.EffectiveUserDefaults().Listing.NewFileTemplate; len(got) != 1 || got[0] != "default.txt" {
|
||||
t.Errorf("userDefaults newFileTemplate = %v, want [default.txt]", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func createPostTestAdmin(t *testing.T) *users.User {
|
||||
t.Helper()
|
||||
admin := &users.User{
|
||||
ID: 1,
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
Permissions: users.Permissions{Admin: true},
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(admin, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return admin
|
||||
}
|
||||
|
||||
// Issue #3322: profile/account values in POST /api/users must not be replaced by userDefaults.
|
||||
func TestUsersPostKeepsRequestedProfileValues(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
defaults := settings.Config.UserDefaults
|
||||
defaults.UI.Locale = "en"
|
||||
defaults.UI.ThemeColor = "var(--blue)"
|
||||
defaults.UI.DarkMode = users.DarkModeDark
|
||||
defaults.Account.LockPassword = false
|
||||
defaults.Account.DisableSettings = false
|
||||
defaults.Account.ShowAdvancedProfile = false
|
||||
defaults.Listing.PromptRightCloseButton = false
|
||||
defaults.Listing.NewFileTemplate = []string{""}
|
||||
defaults.Listing.ShowHidden = true
|
||||
state.SetEffectiveUserDefaultsForTest(t, defaults)
|
||||
|
||||
admin := createPostTestAdmin(t)
|
||||
|
||||
body := []byte(`{"which":[],"data":{
|
||||
"username":"profiletest","password":"secret","loginMethod":"password",
|
||||
"locale":"zhCN","themeColor":"var(--green)","darkMode":false,
|
||||
"lockPassword":true,"disableSettings":true,"showAdvancedProfile":true,
|
||||
"promptRightCloseButton":true,"newFileTemplate":["custom.txt"],
|
||||
"preview":{"image":false},
|
||||
"permissions":{"admin":false,"api":true,"share":true,"realtime":false},
|
||||
"scopes":[{"name":"/","scope":"/","permissions":{"view":true,"download":true,"modify":true,"create":true,"delete":true}}]
|
||||
}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, err := usersPostHandler(rec, req, &Context{User: admin})
|
||||
if err != nil || status != http.StatusCreated {
|
||||
t.Fatalf("usersPostHandler() status=%d err=%v", status, err)
|
||||
}
|
||||
|
||||
got, err := state.GetUserByUsername("profiletest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Locale != "zhCN" {
|
||||
t.Errorf("locale = %q, want zhCN", got.Locale)
|
||||
}
|
||||
if got.ThemeColor != "var(--green)" {
|
||||
t.Errorf("themeColor = %q, want var(--green)", got.ThemeColor)
|
||||
}
|
||||
if got.DarkMode != users.DarkModeLight {
|
||||
t.Errorf("darkMode = %q, want light", got.DarkMode)
|
||||
}
|
||||
if !got.LockPassword {
|
||||
t.Errorf("lockPassword = false, want true")
|
||||
}
|
||||
if !got.DisableSettings {
|
||||
t.Errorf("disableSettings = false, want true")
|
||||
}
|
||||
if !got.ShowAdvancedProfile {
|
||||
t.Errorf("showAdvancedProfile = false, want true")
|
||||
}
|
||||
if !got.PromptRightCloseButton {
|
||||
t.Errorf("promptRightCloseButton = false, want true")
|
||||
}
|
||||
if len(got.NewFileTemplate) != 1 || got.NewFileTemplate[0] != "custom.txt" {
|
||||
t.Errorf("newFileTemplate = %v, want [custom.txt]", got.NewFileTemplate)
|
||||
}
|
||||
if got.Preview.Image {
|
||||
t.Errorf("preview.image = true, want false")
|
||||
}
|
||||
// Omitted fields still come from userDefaults.
|
||||
if !got.ShowHidden {
|
||||
t.Errorf("showHidden = false, want true from userDefaults")
|
||||
}
|
||||
if !got.Preview.Video {
|
||||
t.Errorf("preview.video = false, want true from userDefaults")
|
||||
}
|
||||
wantPerms := users.Permissions{Api: true, Share: true}
|
||||
if got.Permissions.Admin != wantPerms.Admin || got.Permissions.Api != wantPerms.Api ||
|
||||
got.Permissions.Share != wantPerms.Share || got.Permissions.Realtime != wantPerms.Realtime {
|
||||
t.Errorf("permissions = %+v, want %+v", got.Permissions, wantPerms)
|
||||
}
|
||||
}
|
||||
|
||||
// Enforced userDefaults still win over request values on create.
|
||||
func TestUsersPostEnforcedDefaultsWinOverRequest(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
defaults := settings.Config.UserDefaults
|
||||
defaults.UI.Locale = "en"
|
||||
state.SetEffectiveUserDefaultsForTest(t, defaults)
|
||||
if err := state.PatchUserDefaultsEnforced([]byte(`{"ui":{"locale":true}}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
admin := createPostTestAdmin(t)
|
||||
|
||||
body := []byte(`{"which":[],"data":{"username":"enforced","password":"secret","loginMethod":"password","locale":"zhCN","lockPassword":true}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, err := usersPostHandler(rec, req, &Context{User: admin})
|
||||
if err != nil || status != http.StatusCreated {
|
||||
t.Fatalf("usersPostHandler() status=%d err=%v", status, err)
|
||||
}
|
||||
got, err := state.GetUserByUsername("enforced")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Locale != "en" {
|
||||
t.Errorf("locale = %q, want enforced en", got.Locale)
|
||||
}
|
||||
if !got.LockPassword {
|
||||
t.Errorf("lockPassword = false, want true (not enforced)")
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
@@ -237,6 +238,35 @@ func (ffs *filteredFileSystem) checkScopePath(requestPath string, forRemoval boo
|
||||
return err
|
||||
}
|
||||
|
||||
// checkResolvedAccess enforces per-path DENY rules on the symlink-resolved target.
|
||||
func (ffs *filteredFileSystem) checkResolvedAccess(requestPath string, forRemoval bool) error {
|
||||
idx := indexing.GetIndex(ffs.source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("source not found")
|
||||
}
|
||||
userScope, err := ffs.user.GetScopeForSourceName(ffs.source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fullIndexPath := utils.JoinScopedIndexPath(userScope, strings.TrimPrefix(requestPath, "/"))
|
||||
var realPath string
|
||||
if forRemoval {
|
||||
realPath, err = idx.GetRealPathForRemoval(userScope, fullIndexPath)
|
||||
} else {
|
||||
realPath, err = idx.GetRealPathForMutation(userScope, fullIndexPath)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, commonerrors.ErrPathEscapesScope) {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idx, ffs.user.Username, realPath, access.IsDirAtRealPath(realPath)) {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ffs *filteredFileSystem) requireCreatePermissionForNewFile(requestPath string) error {
|
||||
idx := indexing.GetIndex(ffs.source)
|
||||
if idx == nil {
|
||||
@@ -412,6 +442,9 @@ func (ffs *filteredFileSystem) Mkdir(ctx context.Context, name string, perm os.F
|
||||
logger.Debugf("Mkdir: scope check denied for %s: %v", name, err)
|
||||
return err
|
||||
}
|
||||
if err := ffs.checkResolvedAccess(name, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ffs.fs.Mkdir(ctx, name, perm); err != nil {
|
||||
return err
|
||||
@@ -450,6 +483,9 @@ func (ffs *filteredFileSystem) OpenFile(ctx context.Context, requestPath string,
|
||||
logger.Debugf("OpenFile: scope check denied for %s: %v", requestPath, err)
|
||||
return nil, err
|
||||
}
|
||||
if err := ffs.checkResolvedAccess(requestPath, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if (flag&os.O_CREATE) != 0 && !ffs.sourceFilePerms().Create {
|
||||
if err := ffs.requireCreatePermissionForNewFile(requestPath); err != nil {
|
||||
@@ -462,6 +498,9 @@ func (ffs *filteredFileSystem) OpenFile(ctx context.Context, requestPath string,
|
||||
logger.Debugf("OpenFile: read denied by scope for %s", requestPath)
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
if err := ffs.checkResolvedAccess(requestPath, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var desiredMtime time.Time
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
|
||||
// setupWebDAVTestEnv sets up the test environment with multiple sources and users
|
||||
func setupWebDAVTestEnv(t *testing.T) (string, string) {
|
||||
lockWebGlobals(t)
|
||||
// Create temp directories for test sources
|
||||
tempDir := t.TempDir()
|
||||
source1Path := filepath.Join(tempDir, "source1")
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
@@ -24,8 +25,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
RealPathCache = cache.NewCache[string](48*time.Hour, 72*time.Hour)
|
||||
IsDirCache = cache.NewCache[bool](48*time.Hour, 72*time.Hour)
|
||||
RealPathCache = cache.NewCache[string](48*time.Hour, 72*time.Hour)
|
||||
IsDirCache = cache.NewCache[bool](48*time.Hour, 72*time.Hour)
|
||||
realPathGeneration atomic.Uint64
|
||||
)
|
||||
|
||||
// getDiskUsage returns the actual disk space used by a file in bytes
|
||||
@@ -1222,6 +1224,7 @@ func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool,
|
||||
if ok && cached != "" {
|
||||
return cached, isDir, nil
|
||||
}
|
||||
generation := realPathGeneration.Load()
|
||||
absolutePath, err := filepath.Abs(joinedPath)
|
||||
if err != nil {
|
||||
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err)
|
||||
@@ -1239,9 +1242,77 @@ func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool,
|
||||
}
|
||||
RealPathCache.Set(cacheKey, realPath)
|
||||
IsDirCache.Set(cacheKey+":isdir", isDir)
|
||||
if realPathGeneration.Load() != generation {
|
||||
RealPathCache.Delete(cacheKey)
|
||||
IsDirCache.Delete(cacheKey + ":isdir")
|
||||
}
|
||||
return realPath, isDir, nil
|
||||
}
|
||||
|
||||
// realPathCacheJoinedPath builds the filesystem join used in RealPathCache keys.
|
||||
func realPathCacheJoinedPath(sourceRoot, indexPath string) string {
|
||||
seg := utils.CleanIndexPathSegment(indexPath)
|
||||
if seg == "" {
|
||||
return sourceRoot
|
||||
}
|
||||
return filepath.Join(sourceRoot, seg)
|
||||
}
|
||||
|
||||
func realPathCacheIndexAncestors(indexPath string) []string {
|
||||
rel := utils.CleanIndexPathSegment(indexPath)
|
||||
if rel == "" {
|
||||
return []string{"/"}
|
||||
}
|
||||
parts := strings.Split(rel, "/")
|
||||
ancestors := []string{"/"}
|
||||
for i := range parts {
|
||||
ancestors = append(ancestors, "/"+strings.Join(parts[:i+1], "/"))
|
||||
}
|
||||
return ancestors
|
||||
}
|
||||
|
||||
// ClearRealPathCaches drops every cached symlink resolution. The cache library has no
|
||||
// prefix delete, so subtree invalidation uses a full clear; entries repopulate lazily.
|
||||
func ClearRealPathCaches() {
|
||||
realPathGeneration.Add(1)
|
||||
RealPathCache.ClearAll()
|
||||
IsDirCache.ClearAll()
|
||||
}
|
||||
|
||||
// InvalidateRealPathCache drops cached symlink resolutions for indexPath and its
|
||||
// ancestors. scopeBounds should include any caller scope prefixes used with
|
||||
// GetRealPathScoped (e.g. user or share path).
|
||||
func (idx *Index) InvalidateRealPathCache(indexPath string, scopeBounds ...string) {
|
||||
realPathGeneration.Add(1)
|
||||
boundSet := map[string]struct{}{"/": {}, "": {}}
|
||||
for _, b := range scopeBounds {
|
||||
b = strings.TrimRight(strings.TrimSpace(b), "/")
|
||||
if b == "" || b == "/" {
|
||||
boundSet["/"] = struct{}{}
|
||||
boundSet[""] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(b, "/") {
|
||||
b = "/" + b
|
||||
}
|
||||
boundSet[b] = struct{}{}
|
||||
}
|
||||
bounds := make([]string, 0, len(boundSet))
|
||||
for b := range boundSet {
|
||||
bounds = append(bounds, b)
|
||||
}
|
||||
for _, ip := range realPathCacheIndexAncestors(indexPath) {
|
||||
joined := realPathCacheJoinedPath(idx.Path, ip)
|
||||
for _, enforceScope := range []bool{false, true} {
|
||||
for _, bound := range bounds {
|
||||
key := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", enforceScope, bound, joined)
|
||||
RealPathCache.Delete(key)
|
||||
IsDirCache.Delete(key + ":isdir")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetRealPathForMutation resolves an index path for a mutating filesystem
|
||||
// operation (create/overwrite/mkdir/move-into). When the leaf exists it is fully
|
||||
// resolved; otherwise the deepest existing ancestor is resolved and the missing
|
||||
@@ -1603,7 +1674,10 @@ func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
|
||||
}
|
||||
switch simpleType {
|
||||
case "image":
|
||||
if fileInfo.Size > iteminfo.LargeFileSizeThreshold {
|
||||
// Camera RAW previews are the embedded JPEG, which is read without decoding the RAW data,
|
||||
// so large RAW files (medium format, uncompressed) don't need the size limit.
|
||||
isRaw := iteminfo.IsRawImage(strings.ToLower(filepath.Ext(fileInfo.Name)))
|
||||
if fileInfo.Size > iteminfo.LargeFileSizeThreshold && !isRaw {
|
||||
fileInfo.HasPreview = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func TestSetFilePreviewFlagsLargeImages(t *testing.T) {
|
||||
large := iteminfo.LargeFileSizeThreshold + 1
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
typ string
|
||||
size int64
|
||||
want bool
|
||||
}{
|
||||
{"normal image under the limit", "a.jpg", "image/jpeg", 5 << 20, true},
|
||||
{"normal image over the limit", "a.jpg", "image/jpeg", int64(large), false},
|
||||
{"RAW under the limit", "a.raf", "image/x-fuji-raf", 26 << 20, true},
|
||||
{"large RAW keeps its preview, the embedded JPEG is read without decoding", "a.RAF", "image/x-fuji-raf", int64(large), true},
|
||||
{"large DNG keeps its preview", "a.dng", "image/x-adobe-dng", int64(large), true},
|
||||
{"empty file has no preview", "a.raf", "image/x-fuji-raf", 0, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
info := &iteminfo.ItemInfo{Name: tt.file, Type: tt.typ, Size: tt.size}
|
||||
setFilePreviewFlags(info, "/nonexistent/"+tt.file)
|
||||
if info.HasPreview != tt.want {
|
||||
t.Fatalf("HasPreview = %v, want %v", info.HasPreview, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package indexing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -163,6 +164,66 @@ func TestGetRealPathScoped_CacheKeyDoesNotCollideWithUnscoped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealPathCacheGeneration_BumpsOnInvalidateAndClear(t *testing.T) {
|
||||
idx := symlinkTestIndex(t.TempDir())
|
||||
gen := realPathGeneration.Load()
|
||||
idx.InvalidateRealPathCache("/any")
|
||||
if realPathGeneration.Load() != gen+1 {
|
||||
t.Fatalf("InvalidateRealPathCache: gen=%d want %d", realPathGeneration.Load(), gen+1)
|
||||
}
|
||||
gen = realPathGeneration.Load()
|
||||
ClearRealPathCaches()
|
||||
if realPathGeneration.Load() != gen+1 {
|
||||
t.Fatalf("ClearRealPathCaches: gen=%d want %d", realPathGeneration.Load(), gen+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearRealPathCaches_ClearsWarmEntries(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
userDir := filepath.Join(sourceRoot, "user1")
|
||||
if err := os.WriteFile(filepath.Join(userDir, "foo"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := idx.GetRealPathScoped(userScope, "/user1/foo"); err != nil {
|
||||
t.Fatalf("warm cache: %v", err)
|
||||
}
|
||||
joined := filepath.Join(sourceRoot, "user1", "foo")
|
||||
key := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", true, userScope, joined)
|
||||
if _, ok := RealPathCache.Get(key); !ok {
|
||||
t.Fatalf("expected cache key %q", key)
|
||||
}
|
||||
ClearRealPathCaches()
|
||||
if _, ok := RealPathCache.Get(key); ok {
|
||||
t.Fatal("ClearRealPathCaches should drop warm entries")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateRealPathCache_ClearsGetRealPathEntry(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
userDir := filepath.Join(sourceRoot, "user1")
|
||||
if err := os.WriteFile(filepath.Join(userDir, "foo"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, _, err := idx.GetRealPath("/user1/foo"); err != nil {
|
||||
t.Fatalf("warm cache: %v", err)
|
||||
}
|
||||
joined := realPathCacheJoinedPath(sourceRoot, "/user1/foo")
|
||||
key := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", false, "/", joined)
|
||||
if _, ok := RealPathCache.Get(key); !ok {
|
||||
t.Fatalf("expected cache key %q", key)
|
||||
}
|
||||
|
||||
idx.InvalidateRealPathCache("/user1/foo", userScope)
|
||||
if _, ok := RealPathCache.Get(key); ok {
|
||||
t.Fatal("InvalidateRealPathCache should drop unscoped cache entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealPathForMutation_ExistingInScope(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
@@ -120,7 +120,11 @@ func warnHttpProxyConfig() {
|
||||
}
|
||||
if Config.Http.TrustProxyHeaders && Config.Http.Socket != "" && !strings.HasPrefix(Config.Http.Socket, "@") &&
|
||||
len(Config.Http.UnixSocketTrustedUIDs) == 0 && len(Config.Http.UnixSocketTrustedGIDs) == 0 {
|
||||
logger.Warning("http.trustProxyHeaders is true and http.socket is a filesystem Unix socket, but http.unixSocketTrustedUIDs and http.unixSocketTrustedGIDs are empty; X-Forwarded-For and X-Real-IP are ignored until you list the reverse proxy's UID or GID (Linux peer credentials).")
|
||||
if Config.Http.UnixSocketTrustAllLocalPeers {
|
||||
logger.Warning("http.unixSocketTrustAllLocalPeers is true: any local process that can connect to the filesystem socket may present X-Forwarded-For and X-Real-IP. Prefer http.unixSocketTrustedUIDs and/or http.unixSocketTrustedGIDs so only peers with allowed Linux credentials can set forwarded headers.")
|
||||
} else {
|
||||
logger.Warning("http.trustProxyHeaders is true on a filesystem Unix socket, but http.unixSocketTrustedUIDs and http.unixSocketTrustedGIDs are empty; forwarded client headers from socket peers are ignored. List allowed peer UIDs and/or GIDs (Linux SO_PEERCRED), or set http.unixSocketTrustAllLocalPeers: true only if you accept legacy trust-all behavior.")
|
||||
}
|
||||
}
|
||||
if !Env.IsDevMode {
|
||||
if u := Config.Http.ExternalUrl; u != "" && strings.HasPrefix(strings.ToLower(u), "http://") {
|
||||
@@ -1044,9 +1048,10 @@ func SetDefaults(generate bool) Settings {
|
||||
Locale: "en",
|
||||
},
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 10,
|
||||
UploadChunkSize: 10, // 10MB
|
||||
DownloadChunkSize: 0, // 0MB, default to no chunking
|
||||
MaxConcurrent: 10,
|
||||
UploadChunkSize: 10, // 10MB
|
||||
DownloadChunkSize: 0, // 0MB, default to no chunking
|
||||
UploadStallTimeoutSeconds: 10, // pause an upload after 10 seconds without progress
|
||||
},
|
||||
Account: UserDefaultsAccount{
|
||||
Permissions: UserDefaultsAccountPermissions{
|
||||
|
||||
@@ -71,9 +71,10 @@ type UserDefaultsUIEnforcement struct {
|
||||
}
|
||||
|
||||
type UserDefaultsFileLoadingEnforcement struct {
|
||||
MaxConcurrent bool `json:"maxConcurrentUpload,omitempty"`
|
||||
UploadChunkSize bool `json:"uploadChunkSizeMb,omitempty"`
|
||||
DownloadChunkSize bool `json:"downloadChunkSizeMb,omitempty"`
|
||||
MaxConcurrent bool `json:"maxConcurrentUpload,omitempty"`
|
||||
UploadChunkSize bool `json:"uploadChunkSizeMb,omitempty"`
|
||||
DownloadChunkSize bool `json:"downloadChunkSizeMb,omitempty"`
|
||||
UploadStallTimeoutSeconds bool `json:"uploadStallTimeoutSeconds,omitempty"`
|
||||
}
|
||||
|
||||
type UserDefaultsAccountEnforcement struct {
|
||||
|
||||
@@ -37,11 +37,14 @@ type Http struct {
|
||||
TrustProxyHeaders bool `json:"trustProxyHeaders"` // honor X-Forwarded-* and X-Real-IP from a reverse proxy (default: false)
|
||||
TrustedProxies []string `json:"trustedProxies"` // IPs/CIDRs of trusted proxies; forwarded client headers are only honored when the socket peer matches one of these (default: empty = forwarded headers ignored)
|
||||
// UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to
|
||||
// present X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers
|
||||
// are not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.
|
||||
UnixSocketTrustedUIDs []int `json:"unixSocketTrustedUIDs"`
|
||||
UnixSocketTrustedGIDs []int `json:"unixSocketTrustedGIDs"`
|
||||
SocketFileMode string `json:"socketFileMode"` // octal mode for filesystem http.socket (default "0660"); ignored for abstract sockets
|
||||
// present X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted
|
||||
// unless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts
|
||||
// every peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust
|
||||
// forwarded headers.
|
||||
UnixSocketTrustedUIDs []int `json:"unixSocketTrustedUIDs"`
|
||||
UnixSocketTrustedGIDs []int `json:"unixSocketTrustedGIDs"`
|
||||
UnixSocketTrustAllLocalPeers bool `json:"unixSocketTrustAllLocalPeers"` // default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty
|
||||
SocketFileMode string `json:"socketFileMode"` // octal mode for filesystem http.socket (default "0660"); ignored for abstract sockets
|
||||
SocketGroup string `json:"socketGroup"` // optional group name or numeric gid for the socket file (filesystem sockets only)
|
||||
DisableRateLimit bool `json:"disableRateLimit"` // turns off built-in auth route rate limiting and failed-login lockout (default false).
|
||||
}
|
||||
@@ -79,11 +82,12 @@ type Server struct {
|
||||
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
|
||||
Logging []LogConfig `json:"logging" yaml:"logging"`
|
||||
Sources []*Source `json:"sources" validate:"required,dive"`
|
||||
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
|
||||
CacheDirCleanup bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
|
||||
Filesystem Filesystem `json:"filesystem"` // filesystem settings
|
||||
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
|
||||
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
|
||||
CacheDirCleanup bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
|
||||
CacheDirMaxSizeMB int64 `json:"cacheDirMaxSizeMB"` // maximum size in MB of the preview cache (cacheDir/diskcache). Past it, the least recently used files are deleted. 0 means no limit (default: 0)
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
|
||||
Filesystem Filesystem `json:"filesystem"` // filesystem settings
|
||||
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
|
||||
// not exposed to config
|
||||
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
|
||||
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
|
||||
|
||||
@@ -113,10 +113,11 @@ func ipMatchesPrefixes(ip string, prefixes []netip.Prefix) bool {
|
||||
// proxy headers. Forwarded client headers must only be honored when this returns
|
||||
// true; otherwise any client that can reach the listener directly could spoof its address.
|
||||
//
|
||||
// When http.socket is set the server listens only on that Unix socket. Filesystem
|
||||
// socket peers are trusted only when their UID or GID matches
|
||||
// http.unixSocketTrustedUIDs / http.unixSocketTrustedGIDs (Linux peer credentials).
|
||||
// Abstract sockets ("@name") have no filesystem permissions and their peers are never trusted.
|
||||
// When http.socket is set the server listens only on that Unix socket. Abstract
|
||||
// sockets ("@name") have no filesystem permissions and their peers are never trusted.
|
||||
// On filesystem sockets, when either allowlist is set, only peers whose Linux SO_PEERCRED
|
||||
// UID or GID matches may present forwarded headers. When both allowlists are empty,
|
||||
// peers are trusted only if http.unixSocketTrustAllLocalPeers is true (legacy opt-in).
|
||||
func IsTrustedProxyPeer(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
@@ -125,6 +126,9 @@ func IsTrustedProxyPeer(r *http.Request) bool {
|
||||
if strings.HasPrefix(socket, "@") {
|
||||
return false
|
||||
}
|
||||
if len(Config.Http.UnixSocketTrustedUIDs) == 0 && len(Config.Http.UnixSocketTrustedGIDs) == 0 {
|
||||
return Config.Http.UnixSocketTrustAllLocalPeers
|
||||
}
|
||||
cred, ok := UnixPeerFromContext(r.Context())
|
||||
if !ok {
|
||||
return false
|
||||
|
||||
@@ -132,7 +132,6 @@ func TestIsTrustedProxyPeer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// With http.socket set, forwarded headers require matching unixSocketTrustedUIDs/GIDs.
|
||||
func TestIsTrustedProxyPeerUnixSocket(t *testing.T) {
|
||||
resetTrustedProxyCache()
|
||||
Config.Http.TrustedProxies = nil
|
||||
@@ -141,15 +140,23 @@ func TestIsTrustedProxyPeerUnixSocket(t *testing.T) {
|
||||
Config.Http.Socket = ""
|
||||
Config.Http.UnixSocketTrustedUIDs = nil
|
||||
Config.Http.UnixSocketTrustedGIDs = nil
|
||||
Config.Http.UnixSocketTrustAllLocalPeers = false
|
||||
resetTrustedProxyCache()
|
||||
})
|
||||
for _, peer := range []string{"", "@"} {
|
||||
if IsTrustedProxyPeer(&http.Request{RemoteAddr: peer}) {
|
||||
t.Fatalf("unix socket peer %q must not be trusted without peer credentials", peer)
|
||||
}
|
||||
if IsTrustedProxyPeer(&http.Request{RemoteAddr: "@"}) {
|
||||
t.Fatal("filesystem unix socket with empty allowlists must not trust forwarded headers without opt-in")
|
||||
}
|
||||
Config.Http.UnixSocketTrustAllLocalPeers = true
|
||||
if !IsTrustedProxyPeer(&http.Request{RemoteAddr: "@"}) {
|
||||
t.Fatal("unixSocketTrustAllLocalPeers should restore legacy trust-all behavior")
|
||||
}
|
||||
Config.Http.UnixSocketTrustAllLocalPeers = false
|
||||
|
||||
Config.Http.UnixSocketTrustedUIDs = []int{33}
|
||||
reqNoCred := &http.Request{RemoteAddr: "@"}
|
||||
if IsTrustedProxyPeer(reqNoCred) {
|
||||
t.Fatal("with allowlists set, peer without credentials must not be trusted")
|
||||
}
|
||||
req := (&http.Request{}).WithContext(ContextWithUnixPeerForTest(context.Background(), 33, 1000))
|
||||
if !IsTrustedProxyPeer(req) {
|
||||
t.Fatal("peer with allowed UID should be trusted")
|
||||
|
||||
@@ -7,6 +7,10 @@ import (
|
||||
"net"
|
||||
)
|
||||
|
||||
func peerCredentialFromConn(net.Conn) (UnixPeerCredential, bool) {
|
||||
return UnixPeerCredential{}, false
|
||||
}
|
||||
|
||||
func attachUnixPeerContext(ctx context.Context, _ net.Conn) context.Context {
|
||||
return ctx
|
||||
}
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
"os/user"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var unixListenUmaskMu sync.Mutex
|
||||
|
||||
type unixPeerCredKey struct{}
|
||||
|
||||
// UnixPeerCredential holds the peer process identity for a Unix domain connection.
|
||||
@@ -69,14 +72,15 @@ func ListenUnixFilesystem(socketPath string) (net.Listener, error) {
|
||||
}
|
||||
setGroup = true
|
||||
}
|
||||
unixListenUmaskMu.Lock()
|
||||
oldMask := unixUmask(0o077)
|
||||
defer unixUmask(oldMask)
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
unixUmask(oldMask)
|
||||
unixListenUmaskMu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closeListener := func() { _ = listener.Close() }
|
||||
// Assign group ownership while the socket is still owner-only (umask 077), then grant mode bits.
|
||||
if setGroup {
|
||||
if err := os.Chown(socketPath, -1, gid); err != nil {
|
||||
closeListener()
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package settings
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSocketFileModeDefaults(t *testing.T) {
|
||||
mode, err := parseSocketFileMode("")
|
||||
@@ -51,3 +56,29 @@ func TestUnixPeerAllowed(t *testing.T) {
|
||||
t.Fatal("empty allowlists must deny")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenUnixFilesystemSetsModeWithRestrictiveUmask(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("filesystem unix sockets are not used on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
socketPath := filepath.Join(dir, "fb.sock")
|
||||
orig := Config.Http
|
||||
Config.Http.SocketFileMode = ""
|
||||
t.Cleanup(func() {
|
||||
Config.Http = orig
|
||||
_ = os.Remove(socketPath)
|
||||
})
|
||||
ln, err := ListenUnixFilesystem(socketPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
st, err := os.Stat(socketPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Mode().Perm() != 0o660 {
|
||||
t.Fatalf("socket perm = %o, want 0660", st.Mode().Perm())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !unix
|
||||
|
||||
package settings
|
||||
|
||||
func unixUmask(mask int) int {
|
||||
return 0
|
||||
}
|
||||
@@ -61,9 +61,10 @@ var previewJSONSubfields = map[string]string{
|
||||
}
|
||||
|
||||
var fileLoadingJSONSubfields = map[string]string{
|
||||
"maxConcurrentUpload": "fileLoading.maxConcurrentUpload",
|
||||
"uploadChunkSizeMb": "fileLoading.uploadChunkSizeMb",
|
||||
"downloadChunkSizeMb": "fileLoading.downloadChunkSizeMb",
|
||||
"maxConcurrentUpload": "fileLoading.maxConcurrentUpload",
|
||||
"uploadChunkSizeMb": "fileLoading.uploadChunkSizeMb",
|
||||
"downloadChunkSizeMb": "fileLoading.downloadChunkSizeMb",
|
||||
"uploadStallTimeoutSeconds": "fileLoading.uploadStallTimeoutSeconds",
|
||||
}
|
||||
|
||||
// EnforcedPathsTouchedByUserJSONField returns enforcement paths a user JSON field name may affect.
|
||||
|
||||
@@ -4552,6 +4552,12 @@ const docTemplate = `{
|
||||
"description": "Extract audio/video metadata if true",
|
||||
"name": "metadata",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Optional checksum algorithm (md5, sha1, sha256, sha512); requires a logged-in session",
|
||||
"name": "checksum",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -4561,6 +4567,15 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/iteminfo.FileInfo"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Login required for checksum generation",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Share unavailable or access denied",
|
||||
"schema": {
|
||||
@@ -6431,6 +6446,10 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"unixSocketTrustAllLocalPeers": {
|
||||
"description": "default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty",
|
||||
"type": "boolean"
|
||||
},
|
||||
"unixSocketTrustedGIDs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -6438,7 +6457,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"unixSocketTrustedUIDs": {
|
||||
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers\nare not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.",
|
||||
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted\nunless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts\nevery peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust\nforwarded headers.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
@@ -6965,6 +6984,10 @@ const docTemplate = `{
|
||||
"description": "whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"cacheDirMaxSizeMB": {
|
||||
"description": "maximum size in MB of the preview cache (cacheDir/diskcache). Past it, the least recently used files are deleted. 0 means no limit (default: 0)",
|
||||
"type": "integer"
|
||||
},
|
||||
"database": {
|
||||
"description": "SQLite database configuration",
|
||||
"allOf": [
|
||||
@@ -8146,6 +8169,10 @@ const docTemplate = `{
|
||||
},
|
||||
"uploadChunkSizeMb": {
|
||||
"type": "integer"
|
||||
},
|
||||
"uploadStallTimeoutSeconds": {
|
||||
"description": "seconds without progress before an upload is paused, 0 uses the default (10)",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4541,6 +4541,12 @@
|
||||
"description": "Extract audio/video metadata if true",
|
||||
"name": "metadata",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Optional checksum algorithm (md5, sha1, sha256, sha512); requires a logged-in session",
|
||||
"name": "checksum",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -4550,6 +4556,15 @@
|
||||
"$ref": "#/definitions/iteminfo.FileInfo"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Login required for checksum generation",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Share unavailable or access denied",
|
||||
"schema": {
|
||||
@@ -6420,6 +6435,10 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"unixSocketTrustAllLocalPeers": {
|
||||
"description": "default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty",
|
||||
"type": "boolean"
|
||||
},
|
||||
"unixSocketTrustedGIDs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -6427,7 +6446,7 @@
|
||||
}
|
||||
},
|
||||
"unixSocketTrustedUIDs": {
|
||||
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers\nare not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.",
|
||||
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted\nunless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts\nevery peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust\nforwarded headers.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
@@ -6954,6 +6973,10 @@
|
||||
"description": "whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"cacheDirMaxSizeMB": {
|
||||
"description": "maximum size in MB of the preview cache (cacheDir/diskcache). Past it, the least recently used files are deleted. 0 means no limit (default: 0)",
|
||||
"type": "integer"
|
||||
},
|
||||
"database": {
|
||||
"description": "SQLite database configuration",
|
||||
"allOf": [
|
||||
@@ -8135,6 +8158,10 @@
|
||||
},
|
||||
"uploadChunkSizeMb": {
|
||||
"type": "integer"
|
||||
},
|
||||
"uploadStallTimeoutSeconds": {
|
||||
"description": "seconds without progress before an upload is paused, 0 uses the default (10)",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -574,6 +574,10 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
unixSocketTrustAllLocalPeers:
|
||||
description: 'default false: set true to trust forwarded headers from any
|
||||
local filesystem socket peer when allowlists are empty'
|
||||
type: boolean
|
||||
unixSocketTrustedGIDs:
|
||||
items:
|
||||
type: integer
|
||||
@@ -581,8 +585,10 @@ definitions:
|
||||
unixSocketTrustedUIDs:
|
||||
description: |-
|
||||
UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to
|
||||
present X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers
|
||||
are not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.
|
||||
present X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted
|
||||
unless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts
|
||||
every peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust
|
||||
forwarded headers.
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
@@ -1006,6 +1012,11 @@ definitions:
|
||||
docker must also mount a persistent volume to persist the cache (default:
|
||||
false)'
|
||||
type: boolean
|
||||
cacheDirMaxSizeMB:
|
||||
description: 'maximum size in MB of the preview cache (cacheDir/diskcache).
|
||||
Past it, the least recently used files are deleted. 0 means no limit (default:
|
||||
0)'
|
||||
type: integer
|
||||
database:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.Database'
|
||||
@@ -1832,6 +1843,10 @@ definitions:
|
||||
type: integer
|
||||
uploadChunkSizeMb:
|
||||
type: integer
|
||||
uploadStallTimeoutSeconds:
|
||||
description: seconds without progress before an upload is paused, 0 uses the
|
||||
default (10)
|
||||
type: integer
|
||||
type: object
|
||||
users.FrontendScope:
|
||||
properties:
|
||||
@@ -5699,6 +5714,11 @@ paths:
|
||||
in: query
|
||||
name: metadata
|
||||
type: string
|
||||
- description: Optional checksum algorithm (md5, sha1, sha256, sha512); requires
|
||||
a logged-in session
|
||||
in: query
|
||||
name: checksum
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -5706,6 +5726,12 @@ paths:
|
||||
description: File or directory metadata
|
||||
schema:
|
||||
$ref: '#/definitions/iteminfo.FileInfo'
|
||||
"401":
|
||||
description: Login required for checksum generation
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Share unavailable or access denied
|
||||
schema:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
22
|
||||
22.22.2
|
||||
Generated
+266
-1097
File diff suppressed because it is too large.
Load diff
+16
-14
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"npm": ">=9.0.0",
|
||||
"node": "^22.13.0 || ^24.0.0 || >=26.0.0"
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
@@ -30,35 +30,36 @@
|
||||
"test": "vitest run "
|
||||
},
|
||||
"dependencies": {
|
||||
"@likecoin/epub-ts": "^0.7.2",
|
||||
"@likecoin/epub-ts": "^0.7.3",
|
||||
"@onlyoffice/document-editor-vue": "^1.7.0",
|
||||
"ace-builds": "^1.45.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"ace-builds": "^1.45.2",
|
||||
"chart.js": "^4.6.1",
|
||||
"dompurify": "^3.4.17",
|
||||
"highlight.js": "^11.12.0",
|
||||
"hls.js": "^1.7.2",
|
||||
"js-yaml": "^5.5.0",
|
||||
"mammoth": "^1.13.0",
|
||||
"marked": "^18.1.1",
|
||||
"marked": "^18.2.0",
|
||||
"plyr": "^3.8.5",
|
||||
"qrcode.vue": "^3.11.0",
|
||||
"temml": "^0.14.0",
|
||||
"three": "^0.186.1",
|
||||
"vue": "^3.5.44",
|
||||
"vue-i18n": "^11.4.16",
|
||||
"vue-router": "^5.4.0"
|
||||
"three": "^0.187.0",
|
||||
"vue": "^3.5.45",
|
||||
"vue-i18n": "^11.4.19",
|
||||
"vue-router": "^5.5.0"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": "^10.2.6",
|
||||
"stylelint-config-recommended": "^19.0.0",
|
||||
"minimatch": "^11.0.8",
|
||||
"@xmldom/xmldom": "^0.8.15",
|
||||
"mammoth": {
|
||||
"argparse": "^2.0.1"
|
||||
"argparse": "^3.0.4"
|
||||
},
|
||||
"@intlify/vue-i18n-extensions": {
|
||||
"vue-i18n": "$vue-i18n"
|
||||
},
|
||||
"@intlify/eslint-plugin-vue-i18n": {
|
||||
"glob": "^13.0.0"
|
||||
"glob": "^14.0.6"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -70,12 +71,13 @@
|
||||
"@typescript-eslint/parser": "^8.72.0",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"deepl-node": "^1.28.1",
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-plugin-vue": "^10.11.1",
|
||||
"fs-extra": "^11.4.1",
|
||||
"jsdom": "^30.1.3",
|
||||
"stylelint": "^17.16.0",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"stylelint": "^18.0.0",
|
||||
"stylelint-config-standard": "^41.0.0",
|
||||
"stylelint-config-standard-vue": "^2.0.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.4",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import path from 'node:path';
|
||||
import { globSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Translator } from 'deepl-node';
|
||||
import { DeepLClient } from 'deepl-node';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
// Parse command line arguments
|
||||
@@ -27,7 +27,7 @@ if (requireApiKey && !DEEPL_API_KEY) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const translator = requireApiKey ? new Translator(DEEPL_API_KEY) : null;
|
||||
const translator = requireApiKey ? new DeepLClient(DEEPL_API_KEY) : null;
|
||||
|
||||
const deeplLangMap = {
|
||||
'zh-cn': 'ZH-HANS',
|
||||
|
||||
@@ -813,7 +813,8 @@ export function post(
|
||||
overwrite = false,
|
||||
onupload,
|
||||
headers = {},
|
||||
isDir = false
|
||||
isDir = false,
|
||||
onsent
|
||||
) {
|
||||
if (!source || source === undefined || source === null) {
|
||||
throw new Error('no source provided')
|
||||
@@ -863,6 +864,10 @@ export function post(
|
||||
}
|
||||
};
|
||||
}
|
||||
if (typeof onsent === "function") {
|
||||
// The whole body has been sent; from here the server answers when it is done saving it.
|
||||
request.upload.onload = () => onsent();
|
||||
}
|
||||
|
||||
request.onload = () => {
|
||||
if (request.status >= 200 && request.status < 300) {
|
||||
@@ -1066,6 +1071,21 @@ export async function checksum(source, path, algo) {
|
||||
throw new Error('no source provided')
|
||||
}
|
||||
try {
|
||||
if (getters.isShare()) {
|
||||
if (!getters.isLoggedIn()) {
|
||||
throw new Error('login required to generate checksums')
|
||||
}
|
||||
const hash = state.shareInfo?.hash
|
||||
if (!hash) {
|
||||
throw new Error('no share hash provided')
|
||||
}
|
||||
const params = { path, hash, checksum: algo }
|
||||
const res = await fetchURL(getPublicApiPath('resources', params), {
|
||||
headers: sharePublicAuthHeaders(hash),
|
||||
})
|
||||
const data = await res.json()
|
||||
return getObjectProperty(data.checksums, algo)
|
||||
}
|
||||
const params = {
|
||||
path: path,
|
||||
source: source,
|
||||
@@ -1420,7 +1440,8 @@ export function postPublic(
|
||||
overwrite = false,
|
||||
onupload,
|
||||
headers = {},
|
||||
isDir = false
|
||||
isDir = false,
|
||||
onsent
|
||||
) {
|
||||
if (!hash || hash === undefined || hash === null) {
|
||||
throw new Error('no hash provided')
|
||||
@@ -1454,6 +1475,10 @@ export function postPublic(
|
||||
}
|
||||
};
|
||||
}
|
||||
if (typeof onsent === "function") {
|
||||
// The whole body has been sent; from here the server answers when it is done saving it.
|
||||
request.upload.onload = () => onsent();
|
||||
}
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
request.onload = () => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { store } = vi.hoisted(() => ({ store: { tool: null, showSelectMultiple: false, mobile: false } }));
|
||||
vi.mock("@/store", () => ({
|
||||
state: {
|
||||
get user() {
|
||||
return { showSelectMultiple: store.showSelectMultiple };
|
||||
},
|
||||
},
|
||||
mutations: {},
|
||||
getters: {
|
||||
currentTool: () => store.tool,
|
||||
isMobile: () => store.mobile,
|
||||
},
|
||||
}));
|
||||
vi.mock("@/api", () => ({ resourcesApi: {}, shareApi: {}, usersApi: {} }));
|
||||
vi.mock("@/components/Action.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/notify", () => ({ notify: {} }));
|
||||
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
|
||||
vi.mock("@/utils", () => ({ url: {} }));
|
||||
vi.mock("@/utils/clipboard", () => ({ copyToClipboard: vi.fn() }));
|
||||
vi.mock("@/utils/constants.js", () => ({ globalVars: {} }));
|
||||
vi.mock("@/utils/download", () => ({ default: vi.fn() }));
|
||||
vi.mock("@/utils/nativeShare", () => ({ canNativeShare: vi.fn(), nativeShareFile: vi.fn() }));
|
||||
vi.mock("@/utils/mimetype", () => ({ isRichTextPreviewMimeType: vi.fn(), getTypeInfoFromExt: vi.fn() }));
|
||||
vi.mock("@/utils/mediaFile", () => ({ isMediaRequest: vi.fn() }));
|
||||
vi.mock("@/utils/expandTransition", () => ({ expandBeforeEnter: vi.fn(), expandEnter: vi.fn(), expandLeave: vi.fn() }));
|
||||
import ContextMenu from "./ContextMenu.vue";
|
||||
|
||||
const { computed } = ContextMenu;
|
||||
const inAdvancedSearch = () => computed.isAdvancedSearch.call({});
|
||||
|
||||
describe("ContextMenu in advanced search", () => {
|
||||
beforeEach(() => {
|
||||
store.tool = null;
|
||||
store.showSelectMultiple = false;
|
||||
store.mobile = false;
|
||||
});
|
||||
|
||||
it("detects the advanced search tool", () => {
|
||||
expect(inAdvancedSearch()).toBe(false);
|
||||
store.tool = { component: "DuplicateFinder" };
|
||||
expect(inAdvancedSearch()).toBe(false);
|
||||
store.tool = { component: "AdvancedSearch" };
|
||||
expect(inAdvancedSearch()).toBe(true);
|
||||
});
|
||||
|
||||
it("offers Select multiple on desktop even when the user preference is off", () => {
|
||||
const menu = (isAdvancedSearch) => ({ showLimitedOptions: false, isMultiple: false, isSearchActive: false, isAdvancedSearch });
|
||||
expect(computed.showSelectMultiple.call(menu(false))).toBe(false);
|
||||
expect(computed.showSelectMultiple.call(menu(true))).toBe(true);
|
||||
});
|
||||
|
||||
it("doesn't offer Select multiple once multiple mode is on", () => {
|
||||
expect(
|
||||
computed.showSelectMultiple.call({ showLimitedOptions: false, isMultiple: true, isSearchActive: false, isAdvancedSearch: true }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the create (+) toggle, since results have no single folder to create in", () => {
|
||||
const toggle = (isAdvancedSearch) =>
|
||||
computed.showCreateToggle.call({ showLimitedOptions: false, createOnly: false, isSearchActive: false, isAdvancedSearch });
|
||||
expect(toggle(false)).toBe(true);
|
||||
expect(toggle(true)).toBe(false);
|
||||
});
|
||||
|
||||
it("hides Share when nothing is selected, since the results listing has no folder to share", () => {
|
||||
const share = (isAdvancedSearch, selectedCount) =>
|
||||
computed.showShareAction.call({ showLimitedOptions: false, isAdvancedSearch, selectedCount, showShare: true });
|
||||
expect(share(true, 0)).toBe(false);
|
||||
expect(share(true, 1)).toBe(true);
|
||||
expect(share(true, 2)).toBe(false);
|
||||
expect(share(false, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { store } = vi.hoisted(() => ({
|
||||
store: { share: false, creatable: [], user: { scopes: [{ name: "data" }, { name: "inbox" }] } },
|
||||
}));
|
||||
vi.mock("@/store", () => ({
|
||||
state: { get user() { return store.user; } },
|
||||
mutations: {},
|
||||
getters: {
|
||||
isShare: () => store.share,
|
||||
canCreateInSource: (source) => store.creatable.includes(source),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/api", () => ({ resourcesApi: {}, shareApi: {}, usersApi: {} }));
|
||||
vi.mock("@/components/Action.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/notify", () => ({ notify: {} }));
|
||||
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
|
||||
vi.mock("@/utils", () => ({ url: {} }));
|
||||
vi.mock("@/utils/clipboard", () => ({ copyToClipboard: vi.fn() }));
|
||||
vi.mock("@/utils/constants.js", () => ({ globalVars: {} }));
|
||||
vi.mock("@/utils/download", () => ({ default: vi.fn() }));
|
||||
vi.mock("@/utils/nativeShare", () => ({ canNativeShare: vi.fn(), nativeShareFile: vi.fn() }));
|
||||
vi.mock("@/utils/mimetype", () => ({ isRichTextPreviewMimeType: vi.fn(), getTypeInfoFromExt: vi.fn() }));
|
||||
vi.mock("@/utils/mediaFile", () => ({ isMediaRequest: vi.fn() }));
|
||||
vi.mock("@/utils/expandTransition", () => ({ expandBeforeEnter: vi.fn(), expandEnter: vi.fn(), expandLeave: vi.fn() }));
|
||||
import ContextMenu from "./ContextMenu.vue";
|
||||
|
||||
const showCopy = (permissions) =>
|
||||
ContextMenu.computed.showCopy.call({ showLimitedOptions: false, showCreate: false, selectedCount: 1, permissions });
|
||||
|
||||
describe("ContextMenu Copy", () => {
|
||||
beforeEach(() => {
|
||||
store.share = false;
|
||||
store.creatable = ["inbox"];
|
||||
});
|
||||
|
||||
it("is offered in a read-only source, since copying only reads it", () => {
|
||||
expect(showCopy({ download: true, modify: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("is hidden when no source accepts the copy", () => {
|
||||
store.creatable = [];
|
||||
expect(showCopy({ download: true, modify: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("is hidden without download", () => {
|
||||
expect(showCopy({ download: false, modify: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("in a share, still requires modify, which the server checks for every change", () => {
|
||||
store.share = true;
|
||||
expect(showCopy({ download: true, modify: false })).toBe(false);
|
||||
expect(showCopy({ download: true, modify: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createApp, h, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchFiles, state } = vi.hoisted(() => ({
|
||||
fetchFiles: vi.fn(),
|
||||
state: { req: {}, prompts: [], selected: [], sources: { info: { data: {} } } },
|
||||
}));
|
||||
vi.mock("@/api", () => ({ resourcesApi: { fetchFiles } }));
|
||||
vi.mock("@/store", () => ({
|
||||
state,
|
||||
mutations: {},
|
||||
getters: {
|
||||
isShare: () => false,
|
||||
isMobile: () => false,
|
||||
currentPromptName: () => null,
|
||||
pickerSorting: () => ({ by: "name", asc: true }),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/utils", async () => {
|
||||
const { removeLastDir } = await import("@/utils/url.js");
|
||||
return { url: { removeLastDir } };
|
||||
});
|
||||
vi.mock("@/router/index.ts", () => ({ router: {} }));
|
||||
vi.mock("@/components/files/ListingHeader.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/files/ListingItem.vue", () => ({
|
||||
default: {
|
||||
props: ["name"],
|
||||
render() {
|
||||
return h("button", {}, this.name);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import FileList from "./FileList.vue";
|
||||
|
||||
let app, root;
|
||||
const selection = vi.fn();
|
||||
function mount(items = [], path = "/child/") {
|
||||
state.req = { source: "data", path, items };
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(FileList, { hidePathChrome: true, "onUpdate:selected": selection });
|
||||
app.config.globalProperties.$t = (key) => key;
|
||||
app.mount(root);
|
||||
}
|
||||
|
||||
function row(name) {
|
||||
return Array.from(root.querySelectorAll("button")).find((button) => button.textContent === name);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
root?.remove();
|
||||
fetchFiles.mockReset();
|
||||
selection.mockReset();
|
||||
});
|
||||
|
||||
describe("file picker parent navigation", () => {
|
||||
it("loads the parent and shows sibling folders after one click on '..'", async () => {
|
||||
mount();
|
||||
fetchFiles.mockResolvedValue({
|
||||
source: "data",
|
||||
items: [{ name: "sibling", path: "/sibling/", type: "directory" }],
|
||||
});
|
||||
await nextTick();
|
||||
row("..").click();
|
||||
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ path: "/", source: "data" }));
|
||||
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/");
|
||||
await vi.waitFor(() => expect(row("sibling")).toBeDefined());
|
||||
});
|
||||
|
||||
it("keeps single-click selection and double-click navigation for ordinary folders", async () => {
|
||||
mount([{ name: "nested", path: "/child/nested/", type: "directory" }]);
|
||||
fetchFiles.mockResolvedValue({ source: "data", items: [] });
|
||||
await nextTick();
|
||||
row("nested").click();
|
||||
expect(fetchFiles).not.toHaveBeenCalled();
|
||||
row("nested").dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
|
||||
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/child/nested/");
|
||||
});
|
||||
|
||||
it("does not fetch the parent again when the same row receives a double-click", async () => {
|
||||
mount();
|
||||
fetchFiles.mockResolvedValue({ source: "data", items: [] });
|
||||
await nextTick();
|
||||
const parent = row("..");
|
||||
parent.click();
|
||||
parent.click();
|
||||
parent.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
|
||||
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/");
|
||||
});
|
||||
|
||||
it("ignores the rest of a double-click on a replacement parent row", async () => {
|
||||
mount([], "/parent/child/");
|
||||
fetchFiles.mockResolvedValue({ source: "data", items: [] });
|
||||
await nextTick();
|
||||
const firstParent = row("..");
|
||||
firstParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
|
||||
await vi.waitFor(() => {
|
||||
expect(row("..")).toBeDefined();
|
||||
expect(row("..")).not.toBe(firstParent);
|
||||
});
|
||||
const nextParent = row("..");
|
||||
nextParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 2 }));
|
||||
nextParent.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, detail: 2 }));
|
||||
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/parent/");
|
||||
|
||||
nextParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
|
||||
expect(fetchFiles).toHaveBeenLastCalledWith("data", "/");
|
||||
expect(fetchFiles).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
<UserProfilePreferences
|
||||
v-model="preferenceSections"
|
||||
enforceable
|
||||
hide-account-options
|
||||
:enforced="enforced"
|
||||
:config-locked-paths="lockedFromConfigPaths"
|
||||
show-extension-inputs
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createUserEditSession, destroyUserEditSession, getUserEditSession } from "@/utils/userEditSession.js";
|
||||
import { applySectionsToFlatUser, sectionsFromFlatUser } from "@/utils/userProfileSections.js";
|
||||
|
||||
const { fetchURL, fetchJSON, state, mutations } = vi.hoisted(() => ({
|
||||
fetchURL: vi.fn(),
|
||||
fetchJSON: vi.fn(),
|
||||
state: { user: { username: "admin" }, enforcedUserDefaults: {} },
|
||||
mutations: { closeTopPrompt: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/api", async () => ({
|
||||
usersApi: await vi.importActual("@/api/users.js"),
|
||||
settingsApi: {},
|
||||
authApi: {},
|
||||
accessApi: {},
|
||||
}));
|
||||
vi.mock("@/api/utils.ts", () => ({ fetchURL, fetchJSON }));
|
||||
vi.mock("@/router/index.ts", () => ({ router: {} }));
|
||||
vi.mock("@/store", () => ({ state, mutations }));
|
||||
vi.mock("@/store/state.ts", () => ({ state }));
|
||||
vi.mock("@/store/mutations.ts", () => ({ mutations }));
|
||||
vi.mock("@/store/eventBus", () => ({ eventBus: { emit: vi.fn() } }));
|
||||
vi.mock("@/notify", () => ({ notify: { showSuccessToast: vi.fn(), showError: vi.fn() } }));
|
||||
vi.mock("@/i18n/index.ts", () => ({ default: { global: { t: (key) => key } } }));
|
||||
vi.mock("@/utils/auth", () => ({ validateLogin: vi.fn() }));
|
||||
vi.mock("@/components/settings/UserProfilePreferences.vue", () => ({ default: { render: () => null } }));
|
||||
|
||||
import { get } from "@/api/users.js";
|
||||
import UserEdit from "./UserEdit.vue";
|
||||
import UserEditPreferences from "./UserEditPreferences.vue";
|
||||
|
||||
let app, root;
|
||||
function openPreferences(user) {
|
||||
createUserEditSession({ user, profileUser: { ...user } });
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(UserEditPreferences);
|
||||
app.config.globalProperties.$t = (key) => key;
|
||||
app.mount(root);
|
||||
}
|
||||
|
||||
function passwordChangeToggle() {
|
||||
return root.querySelector('input[aria-label="settings.requirePasswordChange"]');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
app = undefined;
|
||||
root?.remove();
|
||||
destroyUserEditSession();
|
||||
state.enforcedUserDefaults = {};
|
||||
fetchURL.mockReset();
|
||||
fetchJSON.mockReset();
|
||||
});
|
||||
|
||||
describe("password changes in user edit preferences", () => {
|
||||
it.each([false, true])("passes the changed requirement to the parent save (initially %s)", async (initial) => {
|
||||
const user = { username: "user", loginMethod: "password", requirePasswordChange: initial };
|
||||
applySectionsToFlatUser(user, sectionsFromFlatUser(user));
|
||||
const editor = {
|
||||
...UserEdit.data(),
|
||||
user: { ...user },
|
||||
profileUser: { ...user },
|
||||
loaded: true,
|
||||
groupsReady: true,
|
||||
isNew: false,
|
||||
enforcedAccount: {},
|
||||
enforcedAccountPermissions: {},
|
||||
enforcedPreferences: {},
|
||||
$t: (key) => key,
|
||||
};
|
||||
for (const [name, method] of Object.entries(UserEdit.methods)) {
|
||||
editor[name] = method.bind(editor);
|
||||
}
|
||||
editor.saveGroups = vi.fn();
|
||||
editor.syncEditAccountForm();
|
||||
editor.originalSnapshot = editor.buildEditableSnapshot();
|
||||
|
||||
openPreferences(user);
|
||||
await nextTick();
|
||||
const toggle = passwordChangeToggle();
|
||||
expect(toggle?.checked).toBe(initial);
|
||||
toggle.click();
|
||||
await nextTick();
|
||||
expect(getUserEditSession().user.requirePasswordChange).toBe(!initial);
|
||||
expect(getUserEditSession().profileUser.requirePasswordChange).toBe(!initial);
|
||||
|
||||
await editor.save({ preventDefault: vi.fn() });
|
||||
expect(fetchURL).toHaveBeenCalledTimes(1);
|
||||
const [url, request] = fetchURL.mock.calls[0];
|
||||
expect(url).toMatch(/\/api\/users\?username=user$/);
|
||||
expect(request.method).toBe("PATCH");
|
||||
const body = JSON.parse(request.body);
|
||||
expect(body).toEqual({
|
||||
which: ["requirePasswordChange"],
|
||||
data: { requirePasswordChange: !initial },
|
||||
});
|
||||
|
||||
app.unmount();
|
||||
root.remove();
|
||||
fetchJSON.mockResolvedValue({ ...user, ...body.data });
|
||||
openPreferences(await get(user.username));
|
||||
await nextTick();
|
||||
expect(passwordChangeToggle()?.checked).toBe(!initial);
|
||||
});
|
||||
|
||||
it("hides the toggle for externally authenticated users", async () => {
|
||||
openPreferences({ username: "user", loginMethod: "oidc" });
|
||||
await nextTick();
|
||||
expect(passwordChangeToggle()).toBeNull();
|
||||
});
|
||||
|
||||
it("disables an enforced requirement", async () => {
|
||||
state.enforcedUserDefaults = { account: { requirePasswordChange: true } };
|
||||
openPreferences({ username: "user", loginMethod: "password", requirePasswordChange: true });
|
||||
await nextTick();
|
||||
expect(passwordChangeToggle()?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,14 @@
|
||||
:account="editAccount"
|
||||
:enforced="enforcedAccount"
|
||||
:enforced-permissions="enforcedAccountPermissions"
|
||||
:show-require-password-change="session.user?.loginMethod === 'password'"
|
||||
respect-enforced-policy
|
||||
@account-change="onEditAccountChange"
|
||||
/>
|
||||
<UserProfilePreferences
|
||||
v-if="session"
|
||||
v-model="profileSections"
|
||||
hide-account-options
|
||||
:enforced="enforcedPreferences"
|
||||
:default-expanded-section="null"
|
||||
respect-enforced-policy
|
||||
@@ -61,6 +63,7 @@ export default {
|
||||
sessionUnsubscribe: null,
|
||||
editAccount: {
|
||||
lockPassword: false,
|
||||
requirePasswordChange: false,
|
||||
disableSettings: false,
|
||||
disableUpdateNotifications: false,
|
||||
showAdvancedProfile: false,
|
||||
@@ -113,6 +116,7 @@ export default {
|
||||
const user = this.session?.user || {};
|
||||
const permissions = user.permissions || {};
|
||||
this.editAccount.lockPassword = !!user.lockPassword;
|
||||
this.editAccount.requirePasswordChange = !!user.requirePasswordChange;
|
||||
this.editAccount.disableSettings = !!user.disableSettings;
|
||||
this.editAccount.disableUpdateNotifications = !!user.disableUpdateNotifications;
|
||||
this.editAccount.showAdvancedProfile = !!user.showAdvancedProfile;
|
||||
@@ -130,6 +134,7 @@ export default {
|
||||
const user = {
|
||||
...this.session.user,
|
||||
lockPassword: this.editAccount.lockPassword,
|
||||
requirePasswordChange: this.editAccount.requirePasswordChange,
|
||||
disableSettings: this.editAccount.disableSettings,
|
||||
disableUpdateNotifications: this.editAccount.disableUpdateNotifications,
|
||||
showAdvancedProfile: this.editAccount.showAdvancedProfile,
|
||||
@@ -143,6 +148,7 @@ export default {
|
||||
sections.account = {
|
||||
...(sections.account || {}),
|
||||
lockPassword: this.editAccount.lockPassword,
|
||||
requirePasswordChange: this.editAccount.requirePasswordChange,
|
||||
disableSettings: this.editAccount.disableSettings,
|
||||
disableUpdateNotifications: this.editAccount.disableUpdateNotifications,
|
||||
showAdvancedProfile: this.editAccount.showAdvancedProfile,
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
/>
|
||||
</template>
|
||||
</ProfileEnforceableField>
|
||||
<div class="preference-field-block">
|
||||
<div v-if="!hideAccountOptions" class="preference-field-block">
|
||||
<h4>{{ $t("profileSettings.accountOptions") }}</h4>
|
||||
<div class="settings-items">
|
||||
<ProfilePreferenceToggle
|
||||
@@ -736,7 +736,7 @@
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
v-if="sectionVisible('accountOptions')"
|
||||
v-if="sectionVisible('accountOptions') && !hideAccountOptions"
|
||||
aria-label="accountOptions"
|
||||
name="accountOptions"
|
||||
:accordion="!sectionKey"
|
||||
@@ -847,6 +847,11 @@ export default {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
/** Suppress the account options block when a parent renders UserDefaultsAccountSection alongside this component. */
|
||||
hideAccountOptions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "change", "enforced-change", "theme-color", "locale-change"],
|
||||
data() {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { store, mutations } = vi.hoisted(() => ({
|
||||
store: { tool: null, share: false, user: {} },
|
||||
mutations: { resetSelected: vi.fn(), showPrompt: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/utils/auth", () => ({ logout: vi.fn() }));
|
||||
vi.mock("@/utils/constants", () => ({ globalVars: {} }));
|
||||
vi.mock("@/store", () => ({
|
||||
state: {
|
||||
get user() {
|
||||
return store.user;
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
currentTool: () => store.tool,
|
||||
isShare: () => store.share,
|
||||
},
|
||||
mutations,
|
||||
}));
|
||||
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
|
||||
vi.mock("./Links.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/utils", () => ({ url: {} }));
|
||||
import General from "./General.vue";
|
||||
|
||||
const { computed, methods } = General;
|
||||
|
||||
describe("sidebar File actions in advanced search", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
store.tool = null;
|
||||
store.share = false;
|
||||
store.user = {};
|
||||
});
|
||||
|
||||
it("detects the advanced search tool", () => {
|
||||
expect(computed.isAdvancedSearch()).toBe(false);
|
||||
store.tool = { component: "AdvancedSearch" };
|
||||
expect(computed.isAdvancedSearch()).toBe(true);
|
||||
});
|
||||
|
||||
it("is shown in advanced search even without create permissions", () => {
|
||||
const ctx = { isAdvancedSearch: true, hideSidebarFileActions: true, isListingView: false };
|
||||
expect(computed.showFileActions.call(ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it("respects the user's choice to hide the sidebar file actions", () => {
|
||||
store.user = { hideSidebarFileActions: true };
|
||||
expect(computed.showFileActions.call({ isAdvancedSearch: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("is not shown in a share's advanced search", () => {
|
||||
store.share = true;
|
||||
expect(computed.showFileActions.call({ isAdvancedSearch: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the old rule in the listing", () => {
|
||||
expect(computed.showFileActions.call({ isAdvancedSearch: false, hideSidebarFileActions: false, isListingView: true })).toBe(true);
|
||||
expect(computed.showFileActions.call({ isAdvancedSearch: false, hideSidebarFileActions: true, isListingView: true })).toBe(false);
|
||||
expect(computed.showFileActions.call({ isAdvancedSearch: false, hideSidebarFileActions: false, isListingView: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("opens the menu on the current selection in advanced search, without the create-only mode", () => {
|
||||
methods.openContextMenu.call({ isAdvancedSearch: true });
|
||||
expect(mutations.resetSelected).not.toHaveBeenCalled();
|
||||
expect(mutations.showPrompt).toHaveBeenCalledWith({ name: "ContextMenu", props: { showCentered: true } });
|
||||
});
|
||||
|
||||
it("opens the create-only menu in the listing, as before", () => {
|
||||
methods.openContextMenu.call({ isAdvancedSearch: false });
|
||||
expect(mutations.resetSelected).toHaveBeenCalled();
|
||||
expect(mutations.showPrompt).toHaveBeenCalledWith({
|
||||
name: "ContextMenu",
|
||||
props: { showCentered: true, createOnly: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/utils/auth", () => ({ logout: vi.fn() }));
|
||||
vi.mock("@/utils/constants", () => ({ globalVars: {} }));
|
||||
vi.mock("@/store", () => ({ state: {}, getters: {}, mutations: {} }));
|
||||
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
|
||||
vi.mock("./Links.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/utils", () => ({ url: {} }));
|
||||
import General from "./General.vue";
|
||||
|
||||
describe("sidebar File actions leave transition", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("finishes even when no transitionend ever fires", () => {
|
||||
vi.useFakeTimers();
|
||||
const el = document.createElement("div");
|
||||
const done = vi.fn();
|
||||
General.methods.leave(el, done);
|
||||
expect(done).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(done).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,7 @@ export default {
|
||||
},
|
||||
isAdvancedSearch: () => getters.currentTool()?.component === "AdvancedSearch",
|
||||
showFileActions() {
|
||||
// Advanced search offers the actions for the selected results (not create), so it doesn't depend on create permissions.
|
||||
if (this.isAdvancedSearch) {
|
||||
return !state.user?.hideSidebarFileActions && !getters.isShare();
|
||||
}
|
||||
@@ -169,10 +170,12 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
openContextMenu() {
|
||||
// In advanced search the menu acts on the current selection of results, so keep it.
|
||||
if (this.isAdvancedSearch) {
|
||||
mutations.showPrompt({ name: "ContextMenu", props: { showCentered: true } });
|
||||
return;
|
||||
}
|
||||
mutations.resetSelected();
|
||||
mutations.showPrompt({
|
||||
name: "ContextMenu",
|
||||
props: {
|
||||
|
||||
@@ -30,6 +30,21 @@
|
||||
aria-label="Edit Links"
|
||||
@click="openSidebarLinksPrompt" class="material-symbols action button">edit</i>
|
||||
</div>
|
||||
<!-- Source switcher only in navigation mode and when multiple sources exist (hidden in shares).
|
||||
Kept outside the scrollable content area so it does not scroll horizontally with the folder tree. -->
|
||||
<div
|
||||
v-if="mode === 'navigation' && !isShare && sourceNames.length > 1"
|
||||
class="source-card-container"
|
||||
ref="sourceCardContainer"
|
||||
>
|
||||
<ExpandDropdown
|
||||
class="sidebar-source-switcher"
|
||||
:model-value="activeSource"
|
||||
:options="sourceDropdownOptions"
|
||||
:aria-label="$t('general.source')"
|
||||
@update:model-value="navigateToSource"
|
||||
/>
|
||||
</div>
|
||||
<!-- Scrollable Content Area -->
|
||||
<div class="sidebar-links-content">
|
||||
<!-- Links Mode -->
|
||||
@@ -131,20 +146,6 @@
|
||||
</template>
|
||||
<!-- Navigation Mode -->
|
||||
<template v-else>
|
||||
<!-- Source switcher only when multiple sources (hidden in shares) -->
|
||||
<div
|
||||
v-if="!isShare && sourceNames.length > 1"
|
||||
class="source-card-container"
|
||||
ref="sourceCardContainer"
|
||||
>
|
||||
<ExpandDropdown
|
||||
class="sidebar-source-switcher"
|
||||
:model-value="activeSource"
|
||||
:options="sourceDropdownOptions"
|
||||
:aria-label="$t('general.source')"
|
||||
@update:model-value="navigateToSource"
|
||||
/>
|
||||
</div>
|
||||
<FileTree
|
||||
:currentPath="currentPath"
|
||||
:currentSource="currentSource"
|
||||
@@ -862,11 +863,17 @@ a.sidebar-link-button {
|
||||
|
||||
.source-card-container {
|
||||
position: relative;
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
/* When the source switcher sits above the scroll area, the content keeps its tree flush under the divider. */
|
||||
.source-card-container + .sidebar-links-content {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.sidebar-source-switcher {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
+688
-686
File diff suppressed because it is too large.
Load diff
+169
-167
@@ -39,6 +39,7 @@
|
||||
"permanent": "Постоянен{suffix}",
|
||||
"hash": "Хеш{suffix}",
|
||||
"of": "от{suffix}",
|
||||
"source": "{prefix}Източник{suffix}",
|
||||
"percentage": "{percentage}%",
|
||||
"type": "Тип{suffix}",
|
||||
"size": "Размер{suffix}",
|
||||
@@ -48,6 +49,7 @@
|
||||
"logout": "Изход{suffix}",
|
||||
"preview": "Преглед{suffix}",
|
||||
"settings": "Настройки{suffix}",
|
||||
"sources": "{prefix}Източници{suffix}",
|
||||
"links": "Връзки{suffix}",
|
||||
"tool": "Инструмент{suffix}",
|
||||
"status": "Статус{suffix}",
|
||||
@@ -75,8 +77,10 @@
|
||||
"navigation": "Навигация{suffix}",
|
||||
"loading": "Зареждане{suffix}",
|
||||
"add": "Добавяне{suffix}",
|
||||
"back": "Назад{suffix}",
|
||||
"stay": "Остани{suffix}",
|
||||
"cancel": "Отказ{suffix}",
|
||||
"default": "По подразбиране{suffix}",
|
||||
"change": "Промяна{suffix}",
|
||||
"verify": "Потвърждаване{suffix}",
|
||||
"close": "Затваряне{suffix}",
|
||||
@@ -108,6 +112,11 @@
|
||||
"pause": "Пауза{suffix}",
|
||||
"resume": "Възобновяване{suffix}",
|
||||
"retry": "Повторен опит{suffix}",
|
||||
"transcode": "Транскодиране{suffix}",
|
||||
"off": "Изключено{suffix}",
|
||||
"quality": "Качество{suffix}",
|
||||
"balanced": "Балансиран{suffix}",
|
||||
"dataSaver": "Икономия на данни{suffix}",
|
||||
"disable": "Изключване{suffix}",
|
||||
"confirm": "Потвърждаване{suffix}",
|
||||
"uploading": "Качване{suffix}",
|
||||
@@ -133,6 +142,10 @@
|
||||
"actions": "Действия{suffix}",
|
||||
"enabled": "Включен{suffix}",
|
||||
"disabled": "Изключен{suffix}",
|
||||
"limit": "Лимит{suffix}",
|
||||
"remove": "Премахни{suffix}",
|
||||
"saved": "Запазено{suffix}",
|
||||
"removed": "Премахнато{suffix}",
|
||||
"permissions": "Права{suffix}",
|
||||
"management": "Управление{suffix}",
|
||||
"profile": "Профил{suffix}",
|
||||
@@ -151,6 +164,7 @@
|
||||
"shareManagement": "Управление на споделяния",
|
||||
"shareHash": "Хеш на споделяне",
|
||||
"downloadFiles": "Изтегляне на файлове",
|
||||
"viewFiles": "Преглед и списък на файловете",
|
||||
"editFiles": "Редактиране на файлове",
|
||||
"createFiles": "Създаване на файлове",
|
||||
"deleteFiles": "Изтриване на файлове",
|
||||
@@ -164,28 +178,14 @@
|
||||
"clearSelection": "Изчистване на избора",
|
||||
"interval": "Интервал{suffix}",
|
||||
"none": "Няма",
|
||||
"viewFiles": "Преглед и списък на файловете",
|
||||
"source": "{prefix}Източник{suffix}",
|
||||
"sources": "{prefix}Източници{suffix}",
|
||||
"enforce": "Прилагане{suffix}",
|
||||
"limit": "Лимит{suffix}",
|
||||
"remove": "Премахни{suffix}",
|
||||
"saved": "Запазено{suffix}",
|
||||
"removed": "Премахнато{suffix}",
|
||||
"back": "Назад{suffix}",
|
||||
"media": "Медии",
|
||||
"unknown": "Неизвестен",
|
||||
"simple": "Прост",
|
||||
"normal": "Нормален",
|
||||
"complex": "Сложен",
|
||||
"access": "Достъп",
|
||||
"groups": "Групи",
|
||||
"transcode": "Транскодиране{suffix}",
|
||||
"off": "Изключено{suffix}",
|
||||
"quality": "Качество{suffix}",
|
||||
"balanced": "Балансиран{suffix}",
|
||||
"dataSaver": "Икономия на данни{suffix}",
|
||||
"media": "Медии",
|
||||
"default": "По подразбиране{suffix}"
|
||||
"groups": "Групи"
|
||||
},
|
||||
"buttons": {
|
||||
"copySuccess": "Копирано в клипборда!",
|
||||
@@ -231,8 +231,6 @@
|
||||
"requestTimedOut": "Изтече времето за изчакване на заявката. Проверете настройките на мрежата си или на обратния прокси сървър."
|
||||
},
|
||||
"files": {
|
||||
"sortByCreationTime": "Сортиране по време на създаване",
|
||||
"creationTime": "Време на създаване",
|
||||
"emptyFile": "Празен файл",
|
||||
"lastModified": "Последна промяна{suffix}",
|
||||
"duration": "Продължителност{suffix}",
|
||||
@@ -243,15 +241,17 @@
|
||||
"sortBySize": "Сортиране по размер",
|
||||
"sortByType": "Сортиране по тип",
|
||||
"sortByDuration": "Сортиране по продължителност",
|
||||
"sortByCreationTime": "Сортиране по време на създаване",
|
||||
"creationTime": "Време на създаване",
|
||||
"noPreview": "Преглед не е наличен за този файл.",
|
||||
"mobilePdfPreviewHint": "Вашият браузър не може да покаже този PDF файл в прегледа на приложението.",
|
||||
"noDownloadAccess": "Изтегляне не е налично.",
|
||||
"newFile": "Нов файл{suffix}",
|
||||
"newFolder": "Нова папка{suffix}",
|
||||
"sameFolder": "Не може да се премести в същата папка",
|
||||
"cannotAccesDir": "Няма достъп до папката",
|
||||
"newFolderMessage": "Напишете името на новата папка.",
|
||||
"newFolderSuccess": "Папката беше създадена успешно!",
|
||||
"mobilePdfPreviewHint": "Вашият браузър не може да покаже този PDF файл в прегледа на приложението."
|
||||
"newFolderSuccess": "Папката беше създадена успешно!"
|
||||
},
|
||||
"help": {
|
||||
"functionKeys": "Функционални клавиши",
|
||||
@@ -344,6 +344,26 @@
|
||||
"requirePasswordChangeSuccess": "Паролата е актуализирана. Вече сте влезли в системата.",
|
||||
"requirePasswordChangeMustDiffer": "Новата парола трябва да е различна от настоящата ви парола"
|
||||
},
|
||||
"quotas": {
|
||||
"title": "Квота за съхранение",
|
||||
"openDescription": "Отворете диалоговия прозорец за квотата за съхранение на тази папка.",
|
||||
"folderDescription": "Ограничаване на капацитета за съхранение на тази папка.",
|
||||
"shareLimit": "Ограничаване на капацитета за съхранение чрез този ресурс",
|
||||
"shareLimitDescription": "Отчита качванията през този линк. Това се отразява и на квотата на собственика.",
|
||||
"scopeLimit": "Ограничение за съхранение за този източник",
|
||||
"scopeLimitDescription": "Капацитет за съхранение за заданието на този потребител по този източник.",
|
||||
"indexingDisabledMeterHint": "Индексирането е деактивирано за този източник. Достъпна е само статистиката за използване.",
|
||||
"meterIndexHint": "Използва общия размер на индексираните файлове в тази папка. Включва промените, направени извън FileBrowser при актуализиране на индекса.",
|
||||
"meterAccountedHint": "Отчита само качвания, запазвания и изтривания, извършени чрез FileBrowser.",
|
||||
"meterIndexScope": "Индексиран размер",
|
||||
"meterAccounted": "Проследяване на използването",
|
||||
"usageCounting": "Преброяване на употребата по",
|
||||
"usageSyncPending": "Данните за използването все още се синхронизират с индекса.",
|
||||
"accountedFallback": "Индексираните данни все още не са готови; докато индексът не бъде наличен, се използва информация за проследяване на използването.",
|
||||
"errors": {
|
||||
"exceeded": "Превишена квота за съхранение"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"notice": "Забележка: когато споделянето е създадено, то става по-достъпно за обществеността и има различни правила за достъп. Ако вашето споделяне не е сигурно конфигурирано, всеки може да получи достъп до него.",
|
||||
"readOnlySourceNotice": "Този източник е само за четене. Споделянията за качване и разрешенията за промяна на файлове са изключени.",
|
||||
@@ -394,6 +414,8 @@
|
||||
"disableLoginOption": "Скриване на опцията за вход",
|
||||
"disableLoginOptionDescription": "Скрива бутона за вход в страничната лента за анонимни потребители. Когато е включено, анонимните потребители няма да виждат опцията за вписване.",
|
||||
"customizeSidebarLinksButton": "Персонализиране на връзките",
|
||||
"shareDefaults": "Настройки по подразбиране за споделяне",
|
||||
"shareDefaultsDescription": "Конфигурирайте опциите по подразбиране за нови споделени ресурси и наложете правилата за потребители, които не са администратори.",
|
||||
"browseFiles": "Разглеждане на файлове",
|
||||
"selectBannerImage": "Избор на изображение за банер",
|
||||
"selectFaviconImage": "Избор на изображение за фавикон",
|
||||
@@ -410,9 +432,7 @@
|
||||
"shareInfo": "QR код и информация за споделянето",
|
||||
"shareInfoDescription": "Този линк показва информацията за споделянето, включително заглавие, описание, банер и QR код в изскачащ прозорец.",
|
||||
"downloadDescription": "Този линк позволява изтегляне на цялото съдържание на споделянето като компресиран файл.",
|
||||
"deleteFailed": "Неуспешно изтриване на споделяне",
|
||||
"shareDefaults": "Настройки по подразбиране за споделяне",
|
||||
"shareDefaultsDescription": "Конфигурирайте опциите по подразбиране за нови споделени ресурси и наложете правилата за потребители, които не са администратори."
|
||||
"deleteFailed": "Неуспешно изтриване на споделяне"
|
||||
},
|
||||
"prompts": {
|
||||
"mediaInfo": "Информация за медията",
|
||||
@@ -548,18 +568,19 @@
|
||||
"openInAdvancedSearch": "Отваряне в разширено търсене"
|
||||
},
|
||||
"profileSettings": {
|
||||
"showTypeColumn": "Показване на колоната Тип",
|
||||
"showTypeColumnDescription": "Показване на колоната Тип в изглед списък и компактен изглед в основния списък.",
|
||||
"showCreationDateColumn": "Показване на колоната Дата на създаване",
|
||||
"showCreationDateColumnDescription": "Показване на колоната Дата на създаване в изглед списък и компактен изглед в основния списък.",
|
||||
"promptRightCloseButton": "Показване на бутона за затваряне на прозорците вдясно",
|
||||
"promptRightCloseButtonDescription": "Показване на бутона за затваряне на прозорците от дясната страна вместо от лявата",
|
||||
"enforcedByAdmin": "Вашият администратор е ограничил възможността на потребителите да променят това свойство",
|
||||
"hideFileExt": "Скриване на определени файлове (по разширение)",
|
||||
"hideFileExtDescription": "Списък на файлови разширения, разделени с интервали. Разширенията в това поле ще бъдат скрити от целия потребителски интерфейс (визуално, като в списъка или FileTree) -- Файловете все още са достъпни.",
|
||||
"preferEditorForMarkdown": "Предпочитане на редактор за Markdown файлове",
|
||||
"preferEditorForMarkdownDescription": "Когато е включено, отваряйте Markdown файловете в редактора вместо в прегледача на Markdown.",
|
||||
"deleteAfterArchive": "Изтриване на изходните файлове след създаване/извличане на архиви",
|
||||
"deleteAfterArchiveDescription": "Когато е включено, ще изтрие изходните файлове след успешно създаване/извличане на архиви.",
|
||||
"defaultViewMode": "Режим на изглед по подразбиране",
|
||||
"defaultViewModeDescription": "Режим на първоначално показване в нови браузъри или устройства. Не отменя вече запазените локално настройки за показване.",
|
||||
"defaultGallerySize": "Стандартен размер на миниатюрите",
|
||||
"defaultGallerySizeDescription": "Начален мащаб на миниатюрите в галерията или списъка (1–9) при нови браузъри или устройства. Не отменя размерите, които вече са запазени локално.",
|
||||
"hideFilesInTree": "Скриване на файлове в дървовидната навигация",
|
||||
"hideFilesInTreeDescription": "Когато е включено, само папки се показват в дървовидната навигация на страничната лента.",
|
||||
"previewFolderDescription": "Показване на миниатюри за папки с първия елемент като корица -- или няколко елемента, ако е включен преглед на движение.",
|
||||
@@ -570,6 +591,12 @@
|
||||
"customTheme": "Избор на вашата тема",
|
||||
"defaultThemeDescription": "по подразбиране - Темата по подразбиране",
|
||||
"setDateFormat": "Задаване на точен формат на датата",
|
||||
"singleClick": "Отваряне на файлове с едно кликване",
|
||||
"stickySidebar": "Залепена странична лента",
|
||||
"darkMode": "Тъмен режим",
|
||||
"darkModeSystem": "Според системата",
|
||||
"darkModeLight": "Светъл",
|
||||
"darkModeDark": "Тъмен",
|
||||
"showHiddenFiles": "Показване на скрити файлове",
|
||||
"showHiddenFilesDescription": "Показване на скрити файлове в списъка (напр. .thumbnails, ProgramData), но те все още са достъпни ръчно, ако са конфигурирани за индексиране.",
|
||||
"showQuickDownload": "Показване на икона за бързо изтегляне",
|
||||
@@ -581,6 +608,10 @@
|
||||
"popupPreviewDescription": "Изскачащ прозорец ще се покаже върху потребителския интерфейс при задържане върху елементи с миниатюри.",
|
||||
"disableUpdateNotifications": "Изключване на известията за актуализации",
|
||||
"disableUpdateNotificationsDescription": "Изключва банера за известие за актуализация, който се показва за администраторите в страничната лента.",
|
||||
"accountOptions": "Опции за профила",
|
||||
"showAdvancedProfile": "Покажи разширени настройки на профила",
|
||||
"showAdvancedProfileDescription": "Показване на настройките на профила по категории в страничната лента вместо опростения изглед на профила.",
|
||||
"disableHideSidebar": "Винаги показвай страничната лента",
|
||||
"disableHideSidebarDescription": "Когато е включено, страничната лента няма да се скрива автоматично при преглед или редактиране на файлове.",
|
||||
"autoplayMedia": "Автоматично възпроизвеждане на медийни файлове при преглед",
|
||||
"autoplayMediaDescription": "Автоматично възпроизвеждане на медийни файлове при преглед. Това ще работи само ако файлът е медиен файл.",
|
||||
@@ -595,6 +626,10 @@
|
||||
"showToolsInSidebarDescription": "Винаги показва връзката за инструменти в страничната лента.",
|
||||
"showCopyPath": "Показване на копиране на път в контекстното меню",
|
||||
"showCopyPathDescription": "Показва действие за копиране на път в контекстното меню за файлове и папки.",
|
||||
"showTypeColumn": "Показване на колоната Тип",
|
||||
"showTypeColumnDescription": "Показване на колоната Тип в изглед списък и компактен изглед в основния списък.",
|
||||
"showCreationDateColumn": "Показване на колоната Дата на създаване",
|
||||
"showCreationDateColumnDescription": "Показване на колоната Дата на създаване в изглед списък и компактен изглед в основния списък.",
|
||||
"deleteWithoutConfirming": "Изтриване на файлове без прозорец за потвърждение",
|
||||
"deleteWithoutConfirmingDescription": "Изтриване на файлове без прозорец за потвърждение. Това ще работи само ако файлът е медиен файл.",
|
||||
"previewOfficeDescription": "Показване на миниатюри за офис файлове в изглед списък.",
|
||||
@@ -620,30 +655,36 @@
|
||||
"passkeyDeleteFailed": "Неуспешно изтриване на passkey",
|
||||
"created": "Създаден",
|
||||
"lastUsed": "Последно използван",
|
||||
"passkeyDefaultName": "Passkey",
|
||||
"enforcedByAdmin": "Вашият администратор е ограничил възможността на потребителите да променят това свойство",
|
||||
"singleClick": "Отваряне на файлове с едно кликване",
|
||||
"stickySidebar": "Залепена странична лента",
|
||||
"darkMode": "Тъмен режим",
|
||||
"darkModeSystem": "Според системата",
|
||||
"darkModeLight": "Светъл",
|
||||
"darkModeDark": "Тъмен",
|
||||
"defaultViewMode": "Режим на изглед по подразбиране",
|
||||
"defaultViewModeDescription": "Режим на първоначално показване в нови браузъри или устройства. Не отменя вече запазените локално настройки за показване.",
|
||||
"defaultGallerySize": "Стандартен размер на миниатюрите",
|
||||
"defaultGallerySizeDescription": "Начален мащаб на миниатюрите в галерията или списъка (1–9) при нови браузъри или устройства. Не отменя размерите, които вече са запазени локално.",
|
||||
"disableHideSidebar": "Винаги показвай страничната лента",
|
||||
"showAdvancedProfile": "Покажи разширени настройки на профила",
|
||||
"showAdvancedProfileDescription": "Показване на настройките на профила по категории в страничната лента вместо опростения изглед на профила.",
|
||||
"accountOptions": "Опции за профила"
|
||||
"passkeyDefaultName": "Passkey"
|
||||
},
|
||||
"settings": {
|
||||
"systemAdmin": "Система и администратор",
|
||||
"configViewer": "Прегледач на конфигурация",
|
||||
"configViewerShowFull": "Показване на пълната конфигурация",
|
||||
"configViewerShowComments": "Показване на коментари",
|
||||
"configViewerOpen": "Преглед на конфигурацията",
|
||||
"configViewerDescription": "Отворете изглед „само за четене“ на текущата конфигурация на сървъра.",
|
||||
"userDefaults": "Настройки по подразбиране за потребителя",
|
||||
"userEditPreferences": "Потребителски настройки",
|
||||
"userEditTools": "Достъп до инструменти",
|
||||
"userEditToolsDescription": "Настройте до кои инструменти този потребител има достъп",
|
||||
"userEditSidebarLinks": "Връзки в страничната лента",
|
||||
"userDefaultsDescription": "Конфигурирайте настройките по подразбиране, които се прилагат за новите потребители, и политиката, която се налага за всички потребители.",
|
||||
"editAsYaml": "Редактирай като YAML",
|
||||
"lockedFromConfig": "Тази настройка по подразбиране е зададена във файла с конфигурацията и не може да бъде променена тук.",
|
||||
"accountDefaults": "Настройки по подразбиране на акаунта",
|
||||
"disableUserSettings": "Деактивиране на страницата с потребителските настройки",
|
||||
"analyticsView": "Преглед на аналитичните данни",
|
||||
"analyticsViewDescription": "Отворете изглед „само за четене“ на моменталната снимка на аналитичните данни за внедряването, събрана от този сървър.",
|
||||
"analyticsEnabled": "Изпращане на аналитични данни за внедряването",
|
||||
"analyticsEnabledDescription": "Аналитиката за внедряване с изрично съгласие ще изпраща анонимна информация към сървърите на разработчиците на FileBrowser Quantum, за да допринесе за подобряването на приложението.",
|
||||
"analyticsLoadFailed": "Не успяхме да заредим настройките за анализи",
|
||||
"analyticsUpdateFailed": "Не успях да актуализирам настройките за анализи",
|
||||
"analyticsPreviewFailed": "Не успяхме да заредим диагностичната информация за анализите",
|
||||
"enterPassword": "Въведете парола",
|
||||
"enterPasswordAgain": "Въведете паролата отново",
|
||||
"passwordRequirementsMatch": "Паролите съвпадат",
|
||||
"passwordRequirementsMinLength": "Най-малко {min} символа",
|
||||
"loginMethod": "Метод за вход",
|
||||
"modifyOtherUser": "Промяна на потребител:",
|
||||
"modifyOtherUserTitle": "Промяна на потребител: {username}",
|
||||
@@ -652,44 +693,63 @@
|
||||
"searchOptions": "Опции за търсене",
|
||||
"emptyGlobal": "В момента няма глобални конфигурационни опции. Можете да редактирате конфигурационния yaml файл, за да коригирате системните настройки.",
|
||||
"scopes": "Дефиниране на разрешени източници и обхвата на потребителя в тях",
|
||||
"scopePath": "Пътят на обхвата",
|
||||
"userNotAdmin": "Потребителят не е администратор",
|
||||
"avoidChanges": "също маркирайте 'промяна на парола', за да приложите тази парола",
|
||||
"lockPassword": "Предотвратяване на промяна на паролата от потребителя",
|
||||
"requirePasswordChange": "Да се изисква промяна на паролата при следващото влизане",
|
||||
"permissions": {
|
||||
"admin": "Администратор",
|
||||
"api": "Създаване и управление на дълготрайни API токени",
|
||||
"realtime": "Включване на връзки и актуализации в реално време"
|
||||
},
|
||||
"permissionsHelp": "Общите права на достъп за потребителя важат навсякъде, включително за всички източници и споделени ресурси.",
|
||||
"sourcePermissionsHelp": "Разгънете реда на източника, за да зададете права за преглед, изтегляне, промяна, създаване и изтриване за този източник.",
|
||||
"sourceAccessDefaultsHelp": "Прилагат се стандартните права за достъп до файлове за изходните файлове и новите потребители. Опцията „Enforce“ фиксира определена стойност за всички потребители, които не са администратори.",
|
||||
"settingsUpdated": "Настройките бяха обновени успешно!",
|
||||
"shareDeleted": "Споделянето беше изтрито!",
|
||||
"userDeleted": "Потребителят беше изтрит!",
|
||||
"userUpdated": "Потребителят беше обновен!",
|
||||
"configViewerOpen": "Преглед на конфигурацията",
|
||||
"configViewerDescription": "Отворете изглед „само за четене“ на текущата конфигурация на сървъра.",
|
||||
"analyticsView": "Преглед на аналитичните данни",
|
||||
"analyticsViewDescription": "Отворете изглед „само за четене“ на моменталната снимка на аналитичните данни за внедряването, събрана от този сървър.",
|
||||
"analyticsEnabled": "Изпращане на аналитични данни за внедряването",
|
||||
"analyticsEnabledDescription": "Аналитиката за внедряване с изрично съгласие ще изпраща анонимна информация към сървърите на разработчиците на FileBrowser Quantum, за да допринесе за подобряването на приложението.",
|
||||
"analyticsLoadFailed": "Не успяхме да заредим настройките за анализи",
|
||||
"analyticsUpdateFailed": "Не успях да актуализирам настройките за анализи",
|
||||
"analyticsPreviewFailed": "Не успяхме да заредим диагностичната информация за анализите",
|
||||
"scopePath": "Пътят на обхвата",
|
||||
"sourcePermissionsHelp": "Разгънете реда на източника, за да зададете права за преглед, изтегляне, промяна, създаване и изтриване за този източник.",
|
||||
"permissionsHelp": "Общите права на достъп за потребителя важат навсякъде, включително за всички източници и споделени ресурси.",
|
||||
"userDefaults": "Настройки по подразбиране за потребителя",
|
||||
"userDefaultsDescription": "Конфигурирайте настройките по подразбиране, които се прилагат за новите потребители, и политиката, която се налага за всички потребители.",
|
||||
"accountDefaults": "Настройки по подразбиране на акаунта",
|
||||
"disableUserSettings": "Деактивиране на страницата с потребителските настройки",
|
||||
"sourceAccessDefaultsHelp": "Прилагат се стандартните права за достъп до файлове за изходните файлове и новите потребители. Опцията „Enforce“ фиксира определена стойност за всички потребители, които не са администратори.",
|
||||
"editAsYaml": "Редактирай като YAML",
|
||||
"lockedFromConfig": "Тази настройка по подразбиране е зададена във файла с конфигурацията и не може да бъде променена тук.",
|
||||
"userEditPreferences": "Потребителски настройки",
|
||||
"userEditTools": "Достъп до инструменти",
|
||||
"userEditToolsDescription": "Настройте до кои инструменти този потребител има достъп",
|
||||
"userEditSidebarLinks": "Връзки в страничната лента",
|
||||
"passwordRequirementsMatch": "Паролите съвпадат",
|
||||
"passwordRequirementsMinLength": "Най-малко {min} символа",
|
||||
"requirePasswordChange": "Да се изисква промяна на паролата при следващото влизане"
|
||||
"userUpdated": "Потребителят беше обновен!"
|
||||
},
|
||||
"media": {
|
||||
"accelDevice": "Устройство за ускорение",
|
||||
"accelDeviceDescription": "GPU устройство, използвано за хардуерно ускорена обработка на мултимедия. CPU (софтуер) деактивира хардуерното ускорение.",
|
||||
"encoderBackend": "Бекенд на енкодера",
|
||||
"transcodeProfileEncoderBackendHelp": "Бекенд на енкодера за тази настройка; по подразбиране се използва откритият предпочитан бекенд.",
|
||||
"alwaysUseFfmpeg": "Винаги използвайте ffmpeg",
|
||||
"alwaysUseFfmpegDescription": "Винаги използвайте ffmpeg за проверка, метаданни, субтитри и миниатюри на видеоклипове. Това ще отнеме повече време, но може да се окаже по-надеждно.",
|
||||
"alwaysUseOffice": "Винаги използвайте офисния сървър за миниатюри на документи",
|
||||
"alwaysUseOfficeDescription": "Винаги използвайте офисния сървър за създаване на миниатюри на документи, вместо да ги генерирате динамично. Това ще отнеме повече време, но може да се окаже по-надеждно.",
|
||||
"debug": "Регистриране на отладочни събития",
|
||||
"debugDescription": "Изходът от stdout на ffmpeg за интегриране на мултимедията. Предупреждение: генерира голям обем изходни данни.",
|
||||
"imagePreview": "Формати за предварителен преглед на изображенията",
|
||||
"videoPreview": "Формати за предварително преглеждане на видеоклипове",
|
||||
"imageVideoConversionFormats": "Формати за конвертиране на изображения и видео",
|
||||
"imageVideoConversionFormatsDescription": "Изберете кои формати на изображения и видеоклипове ffmpeg да конвертира за предварителен преглед.",
|
||||
"updateFailed": "Не успяхме да актуализираме настройките.",
|
||||
"maxConcurrentTranscodes": "Максимален брой едновременни транскодирания",
|
||||
"maxConcurrentTranscodesHelp": "Максималният брой едновременни сесии за възпроизвеждане с прекодиране HLS за този потребител.",
|
||||
"transcode": "Прекодиране",
|
||||
"transcodeEnabled": "Активиране на транскодирането",
|
||||
"transcodeEnabledDescription": "Разрешаване на транскодиране по HLS от страна на сървъра за възпроизвеждане след удостоверяване.",
|
||||
"transcodeSettings": "Настройки за транскодиране",
|
||||
"transcodeSettingsDescription": "Ограничения за едновременния достъп, дисковият кеш, както и ограниченията за резолюция и битрейт за всеки профил.",
|
||||
"transcodeMaxConcurrent": "Общ брой едновременни сесии",
|
||||
"transcodeMaxConcurrentHelp": "Максимален брой едновременни сесии за транскодиране за всички потребители.",
|
||||
"transcodeMaxConcurrentPerUser": "Ограничение за сесиите на потребител",
|
||||
"transcodeMaxConcurrentPerUserHelp": "Строго ограничение за броя на едновременните сесии за транскодиране за всеки отделен потребител. Настройките на потребителя не могат да надвишават това ограничение.",
|
||||
"transcodeCacheMaxSizeMB": "Ограничение на размера на кеша (МБ)",
|
||||
"transcodeCacheMaxSizeMBHelp": "Максимално дисково пространство за кеша за транскодиране в МБ. 0 означава неограничено.",
|
||||
"transcodeCacheRetentionH": "Време на съхранение в кеша (часа)",
|
||||
"transcodeCacheRetentionHHelp": "Колко дълго се съхраняват неактивните поколения на кеша за транскодиране, преди да бъдат изтрити.",
|
||||
"transcodeProfileEnabled": "Активирано",
|
||||
"transcodeProfileEnabledDescription": "Покажи тази настройка за качество в менюто на плейъра.",
|
||||
"transcodeProfileMaxResolution": "Максимална резолюция",
|
||||
"transcodeProfileMaxResolutionHelp": "Максимална височина на изхода в пиксели. Стойността 0 запазва резолюцията на източника.",
|
||||
"transcodeProfileAvgBitrate": "Средна скорост на предаване (kbps)",
|
||||
"transcodeProfileAvgBitrateHelp": "Целева средна скорост на предаване на видеото в kbps. Стойността 0 запазва скоростта на предаване на изходния файл.",
|
||||
"transcodeReadRate": "Producer rate limit (× realtime)",
|
||||
"transcodeReadRateHelp": "Caps the transcode producer at N× realtime input pacing (ffmpeg -readrate) to limit CPU. Minimum 1; defaults to 2. Applies to transcode jobs only; stream-copy runs unthrottled."
|
||||
},
|
||||
"time": {
|
||||
"time": "{prefix}Време{suffix}",
|
||||
@@ -706,6 +766,7 @@
|
||||
"dividerNamePlaceholder": "Текст на разделителя (оставете празно за линия)",
|
||||
"updateIsAvailable": "Налична е актуализация!",
|
||||
"fileActions": "Действия с файлове",
|
||||
"switchMode": "Смяна на режима на страничната лента",
|
||||
"customizeLinks": "Персонализиране на връзките в страничната лента",
|
||||
"customizeLinksDescription": "Персонализирайте връзките, показани във вашата странична лента. Плъзнете и пуснете за пренареждане.",
|
||||
"customizeShareLinksDescription": "Персонализирайте връзките, показани в страничната лента за това споделяне. Плъзнете и пуснете за пренареждане.",
|
||||
@@ -722,6 +783,8 @@
|
||||
"usageTextDisk": "използване според диска/раздела",
|
||||
"showIndexedUsage": "Показване на използване според индекса",
|
||||
"showDiskUsage": "Показване на използване според диска/раздела",
|
||||
"rootFilesystemOnly": "Ограничаване на използването на дисково пространство до изходната файлова система",
|
||||
"rootFilesystemOnlyDescription": "Когато тази опция е активирана, се отчита само файловата система, съдържаща изходния път. Когато е деактивирана (по подразбиране), общият обем на диска включва и файловите системи, монтирани под изходния път, като споделените пулове, например набори от данни в ZFS, се отчитат само веднъж.",
|
||||
"selectSource": "Избор на източник",
|
||||
"chooseSource": "Изберете източник",
|
||||
"selectShare": "Избор на споделяне",
|
||||
@@ -742,10 +805,7 @@
|
||||
"sidebarLinkDefaultsDescription": "Конфигурирайте връзките по подразбиране и задължителните връзки в страничната лента за всички потребители.",
|
||||
"sidebarLinkDefaultsHelp": "Това са връзките по подразбиране, които ще бъдат добавени за всички нови потребители. Ако наложите дадена връзка, тя ще бъде добавена за всички потребители и няма да може да бъде премахната от потребители, които не са администратори.",
|
||||
"sidebarLinkDefaultSourceHelp": "Приложимо е само когато потребителят има достъп до този източник.",
|
||||
"sidebarLinkDefaultsYamlInvalid": "Невалиден YAML. Проверете формата и опитайте отново.",
|
||||
"switchMode": "Смяна на режима на страничната лента",
|
||||
"rootFilesystemOnly": "Ограничаване на използването на дисково пространство до изходната файлова система",
|
||||
"rootFilesystemOnlyDescription": "Когато тази опция е активирана, се отчита само файловата система, съдържаща изходния път. Когато е деактивирана (по подразбиране), общият обем на диска включва и файловите системи, монтирани под изходния път, като споделените пулове, например набори от данни в ZFS, се отчитат само веднъж."
|
||||
"sidebarLinkDefaultsYamlInvalid": "Невалиден YAML. Проверете формата и опитайте отново."
|
||||
},
|
||||
"api": {
|
||||
"title": "API токени",
|
||||
@@ -794,6 +854,25 @@
|
||||
"passwordRequiredForGeneration": "Вашата парола е необходима за генериране на код за удостоверяване."
|
||||
},
|
||||
"access": {
|
||||
"newGroup": "Нова група",
|
||||
"editGroup": "Редактиране на група",
|
||||
"groupName": "Име на групата",
|
||||
"userGroupsDescription": "Групи, към които принадлежи този потребител",
|
||||
"groupMembers": "Членове",
|
||||
"membersCount": "Членове: {count}",
|
||||
"userGroupsCount": "Групи: {count}",
|
||||
"groupNameRequired": "Името на групата е задължително.",
|
||||
"groupNameTooShort": "Group name must be at least {min} characters.",
|
||||
"groupNameTooLong": "Името на групата трябва да съдържа не повече от {max} символа.",
|
||||
"groupNameInvalid": "Името на групата не трябва да съдържа контролни символи.",
|
||||
"groupExists": "Вече съществува група с това име.",
|
||||
"deleteGroupConfirm": "Да се изтрие ли групата „{name}“? Тя ще бъде премахната и от всички правила за достъп.",
|
||||
"groupsOidcNote": "Членовете на групи, синхронизирани чрез OIDC, се нулират от доставчика на идентичност при всяко влизане на тези потребители.",
|
||||
"selectUser": "Изберете потребител",
|
||||
"selectUsers": "Изберете потребители",
|
||||
"selectGroup": "Изберете група",
|
||||
"selectGroups": "Изберете групи",
|
||||
"refineSearch": "Показват се първите резултати за „ {count} “. Уточнете търсенето си, за да видите още.",
|
||||
"all": "Забрана на всички",
|
||||
"rules": "Правила за достъп",
|
||||
"deny": "Забрана",
|
||||
@@ -805,26 +884,7 @@
|
||||
"defaultBehavior": "Действие по подразбиране{suffix}",
|
||||
"defaultBehaviorDescription": "Това показва какво се случва, когато потребител се опита да получи достъп до път, където няма специфични правила за достъп. 'Разрешаване' означава, че потребителите могат да имат достъп по подразбиране, 'Забрана' означава, че потребителите са блокирани по подразбиране, освен ако не са изрично разрешени.",
|
||||
"cascadeDelete": "Рекурсивно изтриване",
|
||||
"cascadeDeleteDescription": "Когато е включено, изтриването на правило за достъп ще го премахне и от всички подпапки. Това ви позволява напълно да премахнете потребител или група от цялото дърво на папките.",
|
||||
"newGroup": "Нова група",
|
||||
"editGroup": "Редактиране на група",
|
||||
"groupName": "Име на групата",
|
||||
"groupMembers": "Членове",
|
||||
"deleteGroupConfirm": "Да се изтрие ли групата „{name}“? Тя ще бъде премахната и от всички правила за достъп.",
|
||||
"groupsOidcNote": "Членовете на групи, синхронизирани чрез OIDC, се нулират от доставчика на идентичност при всяко влизане на тези потребители.",
|
||||
"userGroupsDescription": "Групи, към които принадлежи този потребител",
|
||||
"membersCount": "Членове: {count}",
|
||||
"userGroupsCount": "Групи: {count}",
|
||||
"groupNameRequired": "Името на групата е задължително.",
|
||||
"groupNameTooShort": "Group name must be at least {min} characters.",
|
||||
"groupNameTooLong": "Името на групата трябва да съдържа не повече от {max} символа.",
|
||||
"groupNameInvalid": "Името на групата не трябва да съдържа контролни символи.",
|
||||
"groupExists": "Вече съществува група с това име.",
|
||||
"selectUser": "Изберете потребител",
|
||||
"selectUsers": "Изберете потребители",
|
||||
"selectGroup": "Изберете група",
|
||||
"selectGroups": "Изберете групи",
|
||||
"refineSearch": "Показват се първите резултати за „ {count} “. Уточнете търсенето си, за да видите още."
|
||||
"cascadeDeleteDescription": "Когато е включено, изтриването на правило за достъп ще го премахне и от всички подпапки. Това ви позволява напълно да премахнете потребител или група от цялото дърво на папките."
|
||||
},
|
||||
"fileLoading": {
|
||||
"title": "Качвания и изтегляния",
|
||||
@@ -834,6 +894,8 @@
|
||||
"uploadChunkSizeMbHelp": "Обикновено 5 до 50 MB, по-малък е по-добър за нестабилни или ограничени връзки. Задаването на 0 ще изключи качването на части.",
|
||||
"downloadChunkSizeMb": "Размер на частите за изтегляне в MB",
|
||||
"downloadChunkSizeMbHelp": "Размер на частите за изтегляне на части. Файлове, по-големи от този размер, ще бъдат изтегляни на части, за да се предотвратят проблеми с изтичане на време (напр. Cloudflare 524 грешки). Задаването на 0 изключва изтеглянето на части.",
|
||||
"uploadStallTimeoutSeconds": "Време за изчакване при блокирало качване (секунди)",
|
||||
"uploadStallTimeoutSecondsHelp": "Секунди, през които едно качване може да е без напредък, преди да бъде поставено на пауза. Увеличете стойността при бавни или нестабилни връзки или когато файловете се четат от бавни дискове. Стойности под 1 използват стойността по подразбиране от 10 секунди.",
|
||||
"clearAll": "Изчистването на завършените трябва да премахва грешки, паузи и конфликти",
|
||||
"clearAllDescription": "Бутонът за изчистване на завършените ще изчисти всички качвания в завършено състояние, включително грешки, паузи и конфликти.",
|
||||
"uploadSettings": "Настройки за качване"
|
||||
@@ -963,6 +1025,11 @@
|
||||
"toggleLyrics": "Превключване на текста",
|
||||
"closePanel": "Затваряне на панела",
|
||||
"openPanel": "Отваряне на панела",
|
||||
"transcodeFailed": "Прекодирането се провали",
|
||||
"transcodeLoading": "Подготовка на транскодиран поток",
|
||||
"transcodeUnsupportedFormat": "Този видеоформат не се поддържа от вашия браузър.",
|
||||
"transcodeUserLimitReached": "Достигнат е лимитът за транскодиране. Вече имате активна сесия за транскодиране:",
|
||||
"transcodeSystemLimitReached": "Достигнат е лимитът за транскодиране на сървъра ({limit} едновременни). Опитайте отново, когато приключи някоя сесия.",
|
||||
"visualizer": {
|
||||
"title": "Визуализатор",
|
||||
"settings": "Настройки на визуализатора",
|
||||
@@ -985,12 +1052,7 @@
|
||||
"maxDecibelsDescription": "Най-силното ниво (dBFS), което лентата регистрира",
|
||||
"showScalesDescription": "Показване на оси и линии на мрежата върху визуализатора",
|
||||
"showPeaksDescription": "Малки бели индикатори за пикове над всяка лента"
|
||||
},
|
||||
"transcodeFailed": "Прекодирането се провали",
|
||||
"transcodeLoading": "Подготовка на транскодиран поток",
|
||||
"transcodeUnsupportedFormat": "Този видеоформат не се поддържа от вашия браузър.",
|
||||
"transcodeUserLimitReached": "Достигнат е лимитът за транскодиране. Вече имате активна сесия за транскодиране:",
|
||||
"transcodeSystemLimitReached": "Достигнат е лимитът за транскодиране на сървъра ({limit} едновременни). Опитайте отново, когато приключи някоя сесия."
|
||||
}
|
||||
},
|
||||
"fileSizeAnalyzer": {
|
||||
"title": "Анализатор на размер на файлове",
|
||||
@@ -1022,6 +1084,10 @@
|
||||
"tools": {
|
||||
"title": "Инструменти",
|
||||
"description": "Помощни програми и инструменти за анализ на вашите файлове",
|
||||
"toolAccessDefaults": "Настройки по подразбиране за достъп до инструменти",
|
||||
"toolAccessDefaultsDescription": "Настройте до кои инструменти новите потребители да имат достъп по подразбиране",
|
||||
"toolAccessDefaultsHelp": "Активирайте инструментите за новите потребители и, ако желаете, наложете ограничения на достъпа, така че потребителите без администраторски права да не могат да ги променят.",
|
||||
"accessDenied": "Нямате достъп до този инструмент.",
|
||||
"sizeAnalyzer": {
|
||||
"name": "Анализатор на размер на файлове",
|
||||
"description": "Визуализиране на големи файлове и папки с интерактивна дървовидна карта"
|
||||
@@ -1071,6 +1137,7 @@
|
||||
"scopeAccess": "Активност на достъп",
|
||||
"scopeShares": "Активност на споделяния",
|
||||
"eventBulkDelete": "Групово изтриване",
|
||||
"eventFileModify": "Промяна на файл",
|
||||
"eventShareCreate": "Създаване на споделяне",
|
||||
"eventShareUpdate": "Обновяване на споделяне",
|
||||
"eventShareDelete": "Изтриване на споделяне",
|
||||
@@ -1124,14 +1191,9 @@
|
||||
"shareFilter": "Филтър по споделяне",
|
||||
"pathGlob": "Модел на път (glob)",
|
||||
"viewActivity": "Преглед на активност",
|
||||
"tableColumns": "Колони на таблицата",
|
||||
"eventFileModify": "Промяна на файл"
|
||||
"tableColumns": "Колони на таблицата"
|
||||
},
|
||||
"toolNotFound": "Инструментът не е намерен",
|
||||
"toolAccessDefaults": "Настройки по подразбиране за достъп до инструменти",
|
||||
"toolAccessDefaultsDescription": "Настройте до кои инструменти новите потребители да имат достъп по подразбиране",
|
||||
"toolAccessDefaultsHelp": "Активирайте инструментите за новите потребители и, ако желаете, наложете ограничения на достъпа, така че потребителите без администраторски права да не могат да ги променят.",
|
||||
"accessDenied": "Нямате достъп до този инструмент."
|
||||
"toolNotFound": "Инструментът не е намерен"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Известия",
|
||||
@@ -1214,65 +1276,5 @@
|
||||
},
|
||||
"pwa": {
|
||||
"install": "Инсталиране на приложението"
|
||||
},
|
||||
"quotas": {
|
||||
"title": "Квота за съхранение",
|
||||
"openDescription": "Отворете диалоговия прозорец за квотата за съхранение на тази папка.",
|
||||
"folderDescription": "Ограничаване на капацитета за съхранение на тази папка.",
|
||||
"shareLimit": "Ограничаване на капацитета за съхранение чрез този ресурс",
|
||||
"shareLimitDescription": "Отчита качванията през този линк. Това се отразява и на квотата на собственика.",
|
||||
"scopeLimit": "Ограничение за съхранение за този източник",
|
||||
"scopeLimitDescription": "Капацитет за съхранение за заданието на този потребител по този източник.",
|
||||
"indexingDisabledMeterHint": "Индексирането е деактивирано за този източник. Достъпна е само статистиката за използване.",
|
||||
"meterIndexHint": "Използва общия размер на индексираните файлове в тази папка. Включва промените, направени извън FileBrowser при актуализиране на индекса.",
|
||||
"meterAccountedHint": "Отчита само качвания, запазвания и изтривания, извършени чрез FileBrowser.",
|
||||
"meterIndexScope": "Индексиран размер",
|
||||
"meterAccounted": "Проследяване на използването",
|
||||
"usageCounting": "Преброяване на употребата по",
|
||||
"usageSyncPending": "Данните за използването все още се синхронизират с индекса.",
|
||||
"accountedFallback": "Индексираните данни все още не са готови; докато индексът не бъде наличен, се използва информация за проследяване на използването.",
|
||||
"errors": {
|
||||
"exceeded": "Превишена квота за съхранение"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"debug": "Регистриране на отладочни събития",
|
||||
"debugDescription": "Изходът от stdout на ffmpeg за интегриране на мултимедията. Предупреждение: генерира голям обем изходни данни.",
|
||||
"imagePreview": "Формати за предварителен преглед на изображенията",
|
||||
"videoPreview": "Формати за предварително преглеждане на видеоклипове",
|
||||
"imageVideoConversionFormats": "Формати за конвертиране на изображения и видео",
|
||||
"imageVideoConversionFormatsDescription": "Изберете кои формати на изображения и видеоклипове ffmpeg да конвертира за предварителен преглед.",
|
||||
"updateFailed": "Не успяхме да актуализираме настройките.",
|
||||
"maxConcurrentTranscodes": "Максимален брой едновременни транскодирания",
|
||||
"maxConcurrentTranscodesHelp": "Максималният брой едновременни сесии за възпроизвеждане с прекодиране HLS за този потребител.",
|
||||
"transcode": "Прекодиране",
|
||||
"transcodeEnabled": "Активиране на транскодирането",
|
||||
"transcodeEnabledDescription": "Разрешаване на транскодиране по HLS от страна на сървъра за възпроизвеждане след удостоверяване.",
|
||||
"transcodeSettings": "Настройки за транскодиране",
|
||||
"transcodeSettingsDescription": "Ограничения за едновременния достъп, дисковият кеш, както и ограниченията за резолюция и битрейт за всеки профил.",
|
||||
"transcodeMaxConcurrent": "Общ брой едновременни сесии",
|
||||
"transcodeMaxConcurrentHelp": "Максимален брой едновременни сесии за транскодиране за всички потребители.",
|
||||
"transcodeMaxConcurrentPerUser": "Ограничение за сесиите на потребител",
|
||||
"transcodeMaxConcurrentPerUserHelp": "Строго ограничение за броя на едновременните сесии за транскодиране за всеки отделен потребител. Настройките на потребителя не могат да надвишават това ограничение.",
|
||||
"transcodeCacheMaxSizeMB": "Ограничение на размера на кеша (МБ)",
|
||||
"transcodeCacheMaxSizeMBHelp": "Максимално дисково пространство за кеша за транскодиране в МБ. 0 означава неограничено.",
|
||||
"transcodeCacheRetentionH": "Време на съхранение в кеша (часа)",
|
||||
"transcodeCacheRetentionHHelp": "Колко дълго се съхраняват неактивните поколения на кеша за транскодиране, преди да бъдат изтрити.",
|
||||
"transcodeProfileEnabled": "Активирано",
|
||||
"transcodeProfileEnabledDescription": "Покажи тази настройка за качество в менюто на плейъра.",
|
||||
"transcodeProfileMaxResolution": "Максимална резолюция",
|
||||
"transcodeProfileMaxResolutionHelp": "Максимална височина на изхода в пиксели. Стойността 0 запазва резолюцията на източника.",
|
||||
"transcodeProfileAvgBitrate": "Средна скорост на предаване (kbps)",
|
||||
"transcodeProfileAvgBitrateHelp": "Целева средна скорост на предаване на видеото в kbps. Стойността 0 запазва скоростта на предаване на изходния файл.",
|
||||
"transcodeReadRate": "Producer rate limit (× realtime)",
|
||||
"transcodeReadRateHelp": "Caps the transcode producer at N× realtime input pacing (ffmpeg -readrate) to limit CPU. Minimum 1; defaults to 2. Applies to transcode jobs only; stream-copy runs unthrottled.",
|
||||
"accelDevice": "Устройство за ускорение",
|
||||
"accelDeviceDescription": "GPU устройство, използвано за хардуерно ускорена обработка на мултимедия. CPU (софтуер) деактивира хардуерното ускорение.",
|
||||
"encoderBackend": "Бекенд на енкодера",
|
||||
"transcodeProfileEncoderBackendHelp": "Бекенд на енкодера за тази настройка; по подразбиране се използва откритият предпочитан бекенд.",
|
||||
"alwaysUseFfmpeg": "Винаги използвайте ffmpeg",
|
||||
"alwaysUseFfmpegDescription": "Винаги използвайте ffmpeg за проверка, метаданни, субтитри и миниатюри на видеоклипове. Това ще отнеме повече време, но може да се окаже по-надеждно.",
|
||||
"alwaysUseOffice": "Винаги използвайте офисния сървър за миниатюри на документи",
|
||||
"alwaysUseOfficeDescription": "Винаги използвайте офисния сървър за създаване на миниатюри на документи, вместо да ги генерирате динамично. Това ще отнеме повече време, но може да се окаже по-надеждно."
|
||||
}
|
||||
}
|
||||
+610
-608
File diff suppressed because it is too large.
Load diff
+688
-686
File diff suppressed because it is too large.
Load diff
+686
-684
File diff suppressed because it is too large.
Load diff
@@ -894,6 +894,8 @@
|
||||
"uploadChunkSizeMbHelp": "Typically 5 to 50 MB, smaller is better for unstable or restricted connections. Setting to 0 will disable upload chunking.",
|
||||
"downloadChunkSizeMb": "Download chunk size in MB",
|
||||
"downloadChunkSizeMbHelp": "Chunk size for chunked downloads. Files larger than this size will be downloaded in chunks to prevent timeout issues (e.g., Cloudflare 524 errors). Setting to 0 disables chunked downloads.",
|
||||
"uploadStallTimeoutSeconds": "Upload stall timeout in seconds",
|
||||
"uploadStallTimeoutSecondsHelp": "Seconds an upload can go without any progress before it is paused. Increase it on slow or unstable connections, or when the files are read from slow disks. Values below 1 use the default of 10 seconds.",
|
||||
"clearAll": "Clear completed should remove error, pause, and conflict",
|
||||
"clearAllDescription": "The clear completed button will clear all uploads in a finished state, including error, paused, and conflict states.",
|
||||
"uploadSettings": "Upload Settings"
|
||||
|
||||
+693
-691
File diff suppressed because it is too large.
Load diff
+58
-56
@@ -80,6 +80,7 @@
|
||||
"back": "Atzera{suffix}",
|
||||
"stay": "Geratu{suffix}",
|
||||
"cancel": "Utzi{suffix}",
|
||||
"default": "Lehenetsia{suffix}",
|
||||
"change": "Aldatu{suffix}",
|
||||
"verify": "Egiaztatu{suffix}",
|
||||
"close": "Itxi{suffix}",
|
||||
@@ -111,6 +112,11 @@
|
||||
"pause": "Pausatu{suffix}",
|
||||
"resume": "Jarraitu{suffix}",
|
||||
"retry": "Saiatu berriro{suffix}",
|
||||
"transcode": "Bidali transkodetuta{suffix}",
|
||||
"off": "Itzali{suffix}",
|
||||
"quality": "Kalitatea{suffix}",
|
||||
"balanced": "Orekatuta{suffix}",
|
||||
"dataSaver": "Datu-gordetzailea{suffix}",
|
||||
"disable": "Desgaitu{suffix}",
|
||||
"confirm": "Berretsi{suffix}",
|
||||
"uploading": "Igotzen{suffix}",
|
||||
@@ -173,19 +179,13 @@
|
||||
"interval": "Tartea{suffix}",
|
||||
"none": "Bat ere ez",
|
||||
"enforce": "Derrigortu{suffix}",
|
||||
"media": "Komunikabideak",
|
||||
"unknown": "Ezezaguna",
|
||||
"simple": "Sinplea",
|
||||
"normal": "Normala",
|
||||
"complex": "Konplexua",
|
||||
"access": "Sarbidea",
|
||||
"groups": "Taldeak",
|
||||
"transcode": "Bidali transkodetuta{suffix}",
|
||||
"off": "Itzali{suffix}",
|
||||
"quality": "Kalitatea{suffix}",
|
||||
"balanced": "Orekatuta{suffix}",
|
||||
"dataSaver": "Datu-gordetzailea{suffix}",
|
||||
"media": "Komunikabideak",
|
||||
"default": "Lehenetsia{suffix}"
|
||||
"groups": "Taldeak"
|
||||
},
|
||||
"buttons": {
|
||||
"copySuccess": "Arbelean kopiatu da!",
|
||||
@@ -711,6 +711,46 @@
|
||||
"userDeleted": "Erabiltzailea ezabatu da!",
|
||||
"userUpdated": "Erabiltzailea eguneratu da!"
|
||||
},
|
||||
"media": {
|
||||
"accelDevice": "Azelerazio-gailua",
|
||||
"accelDeviceDescription": "GPU gailua hardware bidez azeleratutako multimedia prozesamendurako erabiltzen da. CPUk (softwareak) hardware azelerazioa desgaitzen du.",
|
||||
"encoderBackend": "Enkodatzaile atzealdea",
|
||||
"transcodeProfileEncoderBackendHelp": "Aurrezarpen honen enkoder atzeko plana; hautemandako atzeko plan lehenetsira lehenetsita dago.",
|
||||
"alwaysUseFfmpeg": "Erabili beti ffmpeg",
|
||||
"alwaysUseFfmpegDescription": "Erabili beti ffmpeg probak egiteko, metadaturako, azpitituluetarako eta bideo-miniaturentzako. Hau motelagoa izango da, baina fidagarriagoa izan daiteke.",
|
||||
"alwaysUseOffice": "Erabili beti bulegoko zerbitzaria dokumentu-miniaturak sortzeko.",
|
||||
"alwaysUseOfficeDescription": "Erabili beti bulegoko zerbitzaria dokumentu-miniaturak sortzeko, prozesuan bertan miniaturak sortzearen ordez. Horrek motelagoa izango da, baina fidagarriagoa izan daiteke.",
|
||||
"debug": "Akatsen erregistroa",
|
||||
"debugDescription": "Media integrazioarako ffmpeg-en stdout irteera. Oharra: irteera asko sortzen du.",
|
||||
"imagePreview": "Irudi aurrebistako formatuak",
|
||||
"videoPreview": "Bideo aurrebista formatuak",
|
||||
"imageVideoConversionFormats": "Irudi eta bideo bihurketa formatuak",
|
||||
"imageVideoConversionFormatsDescription": "Aukeratu ffmpeg-ek aurrebistetarako bihur ditzakeen irudi eta bideo formatuak.",
|
||||
"updateFailed": "Ezarpenak eguneratzeko huts egin da.",
|
||||
"maxConcurrentTranscodes": "Maximo aldi bereko transkodetzeak",
|
||||
"maxConcurrentTranscodesHelp": "Erabiltzaile honentzako aldi berean exekutatu daitezkeen HLS transkodetze-erreprodukzio saioen kopuru maximoa.",
|
||||
"transcode": "Bihurketa",
|
||||
"transcodeEnabled": "Transkodaketa gaitzea",
|
||||
"transcodeEnabledDescription": "Baimentu zerbitzari-aldiko HLS transkodaketa erreprodukzio autentifikaturako.",
|
||||
"transcodeSettings": "Transkodifikazioko ezarpenak",
|
||||
"transcodeSettingsDescription": "Paralelismo-mugak, disko-cachea eta profil bakoitzeko bereizmen- eta bitrate-mugak.",
|
||||
"transcodeMaxConcurrent": "Munduko aldi bereko saioak",
|
||||
"transcodeMaxConcurrentHelp": "Erabiltzaile guztietan zehar aldi berean exekutatzen diren transkodetze saioen kopuru maximoa.",
|
||||
"transcodeMaxConcurrentPerUser": "Erabiltzaile bakoitzeko saio-muga",
|
||||
"transcodeMaxConcurrentPerUserHelp": "Erabiltzaile bakar baterako aldi berean exekutatzen diren transkodetze saioetarako muga zorrotza. Erabiltzailearen ezarpenek ezin dute hori gainditu.",
|
||||
"transcodeCacheMaxSizeMB": "Kaxearen tamaina-muga (MB)",
|
||||
"transcodeCacheMaxSizeMBHelp": "Transkodetze-cache-rako disko-espazio maximoa MB-tan. 0k mugagabea adierazten du.",
|
||||
"transcodeCacheRetentionH": "Gordailuaren iraupena (ordu)",
|
||||
"transcodeCacheRetentionHHelp": "Zenbat denbora gordetzen dira inaktibo dauden transkodaketa-cache-generazioak kanporatu aurretik?",
|
||||
"transcodeProfileEnabled": "Gaitu",
|
||||
"transcodeProfileEnabledDescription": "Eman kalitatezko aurrezarpen hau erreproduzitzaile-menuan ikusteko.",
|
||||
"transcodeProfileMaxResolution": "Maximo bereizmena",
|
||||
"transcodeProfileMaxResolutionHelp": "Pixeletan irteerako altuera maximoa. 0-k iturburuaren bereizmena mantentzen du.",
|
||||
"transcodeProfileAvgBitrate": "Batez besteko bitratea (kbps)",
|
||||
"transcodeProfileAvgBitrateHelp": "Helburu bideoaren batez besteko bitratea kbps-tan. 0-k iturburuaren bitratea mantentzen du.",
|
||||
"transcodeReadRate": "Producer rate limit (× realtime)",
|
||||
"transcodeReadRateHelp": "Caps the transcode producer at N× realtime input pacing (ffmpeg -readrate) to limit CPU. Minimum 1; defaults to 2. Applies to transcode jobs only; stream-copy runs unthrottled."
|
||||
},
|
||||
"time": {
|
||||
"time": "{prefix}Ordua{suffix}",
|
||||
"minute": "{prefix}minutu{suffix}",
|
||||
@@ -854,6 +894,8 @@
|
||||
"uploadChunkSizeMbHelp": "Normalean 5 eta 50 MB artean; txikiagoa hobea da konexio ezegonkor edo mugatuetarako. 0 ezarriz gero, zatikako igoera desgaitzen da.",
|
||||
"downloadChunkSizeMb": "Deskarga-zatien tamaina (MB)",
|
||||
"downloadChunkSizeMbHelp": "Zatikako deskargetarako zatien tamaina. Tamaina hau baino handiagoak diren fitxategiak zatika deskargatuko dira denbora-mugako arazoak saihesteko (adib. Cloudflare-ren 524 akatsak). 0 ezarriz gero, zatikako deskargak desgaitzen dira.",
|
||||
"uploadStallTimeoutSeconds": "Igoeraren geldialdi-denbora segundotan",
|
||||
"uploadStallTimeoutSecondsHelp": "Igoera bat aurrerapenik gabe egon daitekeen segundo kopurua, pausatu aurretik. Handitu ezazu konexio motel edo ezegonkorretan, edo fitxategiak disko motelretatik irakurtzen direnean. 1 baino txikiagoko balioek 10 segundoko balio lehenetsia erabiltzen dute.",
|
||||
"clearAll": "\"Garbitu osatuak\" aukerak akatsak, pausak eta gatazkak ere kendu behar ditu",
|
||||
"clearAllDescription": "\"Garbitu osatuak\" botoiak amaitutako egoeran dauden igoera guztiak garbituko ditu, akats, pausa eta gatazka egoerak barne.",
|
||||
"uploadSettings": "Igoeraren ezarpenak"
|
||||
@@ -983,6 +1025,11 @@
|
||||
"toggleLyrics": "Erakutsi/ezkutatu letra",
|
||||
"closePanel": "Itxi panela",
|
||||
"openPanel": "Ireki panela",
|
||||
"transcodeFailed": "Transkodaketa huts egin du",
|
||||
"transcodeLoading": "Transkodatutako korrontea prestatzen",
|
||||
"transcodeUnsupportedFormat": "Bideo-formatua ez dago zure nabigatzailean onartuta.",
|
||||
"transcodeUserLimitReached": "Transkodetze-muga gainditu da. Dagoeneko transkodetze-saio aktibo bat duzu:",
|
||||
"transcodeSystemLimitReached": "Zerbitzariaren transkodetze-muga gainditu da ({limit} aldi berean). Saiatu berriro saio bat amaitzean.",
|
||||
"visualizer": {
|
||||
"title": "Bistaratzailea",
|
||||
"settings": "Bistaratzailearen ezarpenak",
|
||||
@@ -1005,12 +1052,7 @@
|
||||
"maxDecibelsDescription": "Barrak erregistratzen duen mailarik altuena (dBFS)",
|
||||
"showScalesDescription": "Erakutsi ardatzak eta sareta-lerroak bistaratzailean",
|
||||
"showPeaksDescription": "Gailur-adierazle zuri txikiak barra bakoitzaren gainean"
|
||||
},
|
||||
"transcodeFailed": "Transkodaketa huts egin du",
|
||||
"transcodeLoading": "Transkodatutako korrontea prestatzen",
|
||||
"transcodeUnsupportedFormat": "Bideo-formatua ez dago zure nabigatzailean onartuta.",
|
||||
"transcodeUserLimitReached": "Transkodetze-muga gainditu da. Dagoeneko transkodetze-saio aktibo bat duzu:",
|
||||
"transcodeSystemLimitReached": "Zerbitzariaren transkodetze-muga gainditu da ({limit} aldi berean). Saiatu berriro saio bat amaitzean."
|
||||
}
|
||||
},
|
||||
"fileSizeAnalyzer": {
|
||||
"title": "Fitxategi-tamainen analizatzailea",
|
||||
@@ -1095,6 +1137,7 @@
|
||||
"scopeAccess": "Sarbide-jarduera",
|
||||
"scopeShares": "Partekatze-jarduera",
|
||||
"eventBulkDelete": "Ezabatze masiboa",
|
||||
"eventFileModify": "Fitxategia aldatu",
|
||||
"eventShareCreate": "Partekatzea sortzea",
|
||||
"eventShareUpdate": "Partekatzea eguneratzea",
|
||||
"eventShareDelete": "Partekatzea ezabatzea",
|
||||
@@ -1148,8 +1191,7 @@
|
||||
"shareFilter": "Partekatze-iragazkia",
|
||||
"pathGlob": "Bide-eredua (glob)",
|
||||
"viewActivity": "Ikusi jarduera",
|
||||
"tableColumns": "Taularen zutabeak",
|
||||
"eventFileModify": "Fitxategia aldatu"
|
||||
"tableColumns": "Taularen zutabeak"
|
||||
},
|
||||
"toolNotFound": "Ez da tresna aurkitu"
|
||||
},
|
||||
@@ -1234,45 +1276,5 @@
|
||||
},
|
||||
"pwa": {
|
||||
"install": "Instalatu aplikazioa"
|
||||
},
|
||||
"media": {
|
||||
"debug": "Akatsen erregistroa",
|
||||
"debugDescription": "Media integrazioarako ffmpeg-en stdout irteera. Oharra: irteera asko sortzen du.",
|
||||
"imagePreview": "Irudi aurrebistako formatuak",
|
||||
"videoPreview": "Bideo aurrebista formatuak",
|
||||
"imageVideoConversionFormats": "Irudi eta bideo bihurketa formatuak",
|
||||
"imageVideoConversionFormatsDescription": "Aukeratu ffmpeg-ek aurrebistetarako bihur ditzakeen irudi eta bideo formatuak.",
|
||||
"updateFailed": "Ezarpenak eguneratzeko huts egin da.",
|
||||
"maxConcurrentTranscodes": "Maximo aldi bereko transkodetzeak",
|
||||
"maxConcurrentTranscodesHelp": "Erabiltzaile honentzako aldi berean exekutatu daitezkeen HLS transkodetze-erreprodukzio saioen kopuru maximoa.",
|
||||
"transcode": "Bihurketa",
|
||||
"transcodeEnabled": "Transkodaketa gaitzea",
|
||||
"transcodeEnabledDescription": "Baimentu zerbitzari-aldiko HLS transkodaketa erreprodukzio autentifikaturako.",
|
||||
"transcodeSettings": "Transkodifikazioko ezarpenak",
|
||||
"transcodeSettingsDescription": "Paralelismo-mugak, disko-cachea eta profil bakoitzeko bereizmen- eta bitrate-mugak.",
|
||||
"transcodeMaxConcurrent": "Munduko aldi bereko saioak",
|
||||
"transcodeMaxConcurrentHelp": "Erabiltzaile guztietan zehar aldi berean exekutatzen diren transkodetze saioen kopuru maximoa.",
|
||||
"transcodeMaxConcurrentPerUser": "Erabiltzaile bakoitzeko saio-muga",
|
||||
"transcodeMaxConcurrentPerUserHelp": "Erabiltzaile bakar baterako aldi berean exekutatzen diren transkodetze saioetarako muga zorrotza. Erabiltzailearen ezarpenek ezin dute hori gainditu.",
|
||||
"transcodeCacheMaxSizeMB": "Kaxearen tamaina-muga (MB)",
|
||||
"transcodeCacheMaxSizeMBHelp": "Transkodetze-cache-rako disko-espazio maximoa MB-tan. 0k mugagabea adierazten du.",
|
||||
"transcodeCacheRetentionH": "Gordailuaren iraupena (ordu)",
|
||||
"transcodeCacheRetentionHHelp": "Zenbat denbora gordetzen dira inaktibo dauden transkodaketa-cache-generazioak kanporatu aurretik?",
|
||||
"transcodeProfileEnabled": "Gaitu",
|
||||
"transcodeProfileEnabledDescription": "Eman kalitatezko aurrezarpen hau erreproduzitzaile-menuan ikusteko.",
|
||||
"transcodeProfileMaxResolution": "Maximo bereizmena",
|
||||
"transcodeProfileMaxResolutionHelp": "Pixeletan irteerako altuera maximoa. 0-k iturburuaren bereizmena mantentzen du.",
|
||||
"transcodeProfileAvgBitrate": "Batez besteko bitratea (kbps)",
|
||||
"transcodeProfileAvgBitrateHelp": "Helburu bideoaren batez besteko bitratea kbps-tan. 0-k iturburuaren bitratea mantentzen du.",
|
||||
"transcodeReadRate": "Producer rate limit (× realtime)",
|
||||
"transcodeReadRateHelp": "Caps the transcode producer at N× realtime input pacing (ffmpeg -readrate) to limit CPU. Minimum 1; defaults to 2. Applies to transcode jobs only; stream-copy runs unthrottled.",
|
||||
"accelDevice": "Azelerazio-gailua",
|
||||
"accelDeviceDescription": "GPU gailua hardware bidez azeleratutako multimedia prozesamendurako erabiltzen da. CPUk (softwareak) hardware azelerazioa desgaitzen du.",
|
||||
"encoderBackend": "Enkodatzaile atzealdea",
|
||||
"transcodeProfileEncoderBackendHelp": "Aurrezarpen honen enkoder atzeko plana; hautemandako atzeko plan lehenetsira lehenetsita dago.",
|
||||
"alwaysUseFfmpeg": "Erabili beti ffmpeg",
|
||||
"alwaysUseFfmpegDescription": "Erabili beti ffmpeg probak egiteko, metadaturako, azpitituluetarako eta bideo-miniaturentzako. Hau motelagoa izango da, baina fidagarriagoa izan daiteke.",
|
||||
"alwaysUseOffice": "Erabili beti bulegoko zerbitzaria dokumentu-miniaturak sortzeko.",
|
||||
"alwaysUseOfficeDescription": "Erabili beti bulegoko zerbitzaria dokumentu-miniaturak sortzeko, prozesuan bertan miniaturak sortzearen ordez. Horrek motelagoa izango da, baina fidagarriagoa izan daiteke."
|
||||
}
|
||||
}
|
||||
+687
-685
File diff suppressed because it is too large.
Load diff
+688
-686
File diff suppressed because it is too large.
Load diff
+688
-686
File diff suppressed because it is too large.
Load diff
+265
-263
@@ -13,6 +13,9 @@
|
||||
"hide": "Nascondi",
|
||||
"import": "Importa",
|
||||
"export": "Esporta",
|
||||
"to": "a{suffix}",
|
||||
"and": "e{suffix}",
|
||||
"or": "oppure{suffix}",
|
||||
"divider": "Divisore{suffix}",
|
||||
"office": "Office{suffix}",
|
||||
"format": "Formato{suffix}",
|
||||
@@ -36,6 +39,7 @@
|
||||
"permanent": "Permanente{suffix}",
|
||||
"hash": "Hash{suffix}",
|
||||
"of": "di{suffix}",
|
||||
"source": "{prefix}Fonte{suffix}",
|
||||
"percentage": "{percentage}%",
|
||||
"type": "Tipo{suffix}",
|
||||
"size": "Dimensione{suffix}",
|
||||
@@ -45,6 +49,7 @@
|
||||
"logout": "Esci{suffix}",
|
||||
"preview": "Anteprima{suffix}",
|
||||
"settings": "Impostazioni{suffix}",
|
||||
"sources": "{prefix}Fonti{suffix}",
|
||||
"links": "Link{suffix}",
|
||||
"tool": "Strumento{suffix}",
|
||||
"status": "Stato{suffix}",
|
||||
@@ -53,6 +58,7 @@
|
||||
"users": "Utenti{suffix}",
|
||||
"scopes": "Ambiti{suffix}",
|
||||
"photos": "Foto",
|
||||
"audio": "Audio{suffix}",
|
||||
"videos": "Video{suffix}",
|
||||
"documents": "Documenti{suffix}",
|
||||
"archives": "Archivi{suffix}",
|
||||
@@ -71,8 +77,10 @@
|
||||
"navigation": "Navigazione{suffix}",
|
||||
"loading": "Caricamento{suffix}",
|
||||
"add": "Aggiungi{suffix}",
|
||||
"back": "Indietro{suffix}",
|
||||
"stay": "Rimani{suffix}",
|
||||
"cancel": "Annulla{suffix}",
|
||||
"default": "Predefinito{suffix}",
|
||||
"change": "Cambia{suffix}",
|
||||
"verify": "Verifica{suffix}",
|
||||
"close": "Chiudi{suffix}",
|
||||
@@ -84,6 +92,8 @@
|
||||
"download": "Scarica{suffix}",
|
||||
"file": "File{suffix}",
|
||||
"folder": "Cartella{suffix}",
|
||||
"camera": "Fotocamera{suffix}",
|
||||
"video": "Video{suffix}",
|
||||
"info": "Info{suffix}",
|
||||
"move": "Sposta{suffix}",
|
||||
"new": "Nuovo{suffix}",
|
||||
@@ -102,6 +112,11 @@
|
||||
"pause": "Pausa{suffix}",
|
||||
"resume": "Riprendi{suffix}",
|
||||
"retry": "Riprova{suffix}",
|
||||
"transcode": "Trascodifica{suffix}",
|
||||
"off": "Spento{suffix}",
|
||||
"quality": "Qualità{suffix}",
|
||||
"balanced": "Equilibrato{suffix}",
|
||||
"dataSaver": "Risparmio dati{suffix}",
|
||||
"disable": "Disabilita{suffix}",
|
||||
"confirm": "Conferma{suffix}",
|
||||
"uploading": "Caricamento in corso{suffix}",
|
||||
@@ -110,16 +125,10 @@
|
||||
"lines": "Linee{suffix}",
|
||||
"lastUpdate": "Ultimo aggiornamento{suffix}",
|
||||
"latency": "Latenza{suffix}",
|
||||
"audio": "Audio{suffix}",
|
||||
"readOnly": "Solo lettura{suffix}",
|
||||
"private": "Privato{suffix}",
|
||||
"yes": "Sì{suffix}",
|
||||
"no": "No{suffix}",
|
||||
"to": "a{suffix}",
|
||||
"and": "e{suffix}",
|
||||
"or": "oppure{suffix}",
|
||||
"camera": "Fotocamera{suffix}",
|
||||
"video": "Video{suffix}",
|
||||
"anonymous": "anonimo",
|
||||
"details": "Dettagli{suffix}",
|
||||
"ipAddress": "Indirizzo IP{suffix}",
|
||||
@@ -133,6 +142,10 @@
|
||||
"actions": "Azioni{suffix}",
|
||||
"enabled": "Abilitato{suffix}",
|
||||
"disabled": "Disabile{suffix}",
|
||||
"limit": "Limite{suffix}",
|
||||
"remove": "Rimuovi{suffix}",
|
||||
"saved": "Salvato{suffix}",
|
||||
"removed": "Rimosso{suffix}",
|
||||
"permissions": "Autorizzazioni{suffix}",
|
||||
"management": "Gestione{suffix}",
|
||||
"profile": "Profilo{suffix}",
|
||||
@@ -151,6 +164,7 @@
|
||||
"shareManagement": "Gestione delle azioni",
|
||||
"shareHash": "Condividi hash",
|
||||
"downloadFiles": "Scarica i file",
|
||||
"viewFiles": "Visualizza ed elenca i file",
|
||||
"editFiles": "Modifica file",
|
||||
"createFiles": "Crea file",
|
||||
"deleteFiles": "Elimina i file",
|
||||
@@ -164,34 +178,21 @@
|
||||
"clearSelection": "Deseleziona",
|
||||
"interval": "Intervallo{suffix}",
|
||||
"none": "Nessuno",
|
||||
"viewFiles": "Visualizza ed elenca i file",
|
||||
"source": "{prefix}Fonte{suffix}",
|
||||
"sources": "{prefix}Fonti{suffix}",
|
||||
"enforce": "Applicare{suffix}",
|
||||
"limit": "Limite{suffix}",
|
||||
"remove": "Rimuovi{suffix}",
|
||||
"saved": "Salvato{suffix}",
|
||||
"removed": "Rimosso{suffix}",
|
||||
"back": "Indietro{suffix}",
|
||||
"media": "Media",
|
||||
"unknown": "Sconosciuto",
|
||||
"simple": "Semplice",
|
||||
"normal": "Normale",
|
||||
"complex": "Complesso",
|
||||
"access": "Accesso",
|
||||
"groups": "Gruppi",
|
||||
"transcode": "Trascodifica{suffix}",
|
||||
"off": "Spento{suffix}",
|
||||
"quality": "Qualità{suffix}",
|
||||
"balanced": "Equilibrato{suffix}",
|
||||
"dataSaver": "Risparmio dati{suffix}",
|
||||
"media": "Media",
|
||||
"default": "Predefinito{suffix}"
|
||||
"groups": "Gruppi"
|
||||
},
|
||||
"buttons": {
|
||||
"copySuccess": "Copiato negli appunti!",
|
||||
"copyFailed": "Copia negli appunti fallita",
|
||||
"generateNewOtp": "Resetta e genera nuovo codice a due fattori",
|
||||
"copyFile": "Copia file",
|
||||
"copyPath": "Percorso di copia",
|
||||
"copyToClipboard": "Copia negli appunti",
|
||||
"copyDownloadLinkToClipboard": "Copia link di download negli appunti",
|
||||
"moveFile": "Sposta file",
|
||||
@@ -215,11 +216,10 @@
|
||||
"closeAnyway": "Chiudi comunque",
|
||||
"goToItem": "Vai all'elemento",
|
||||
"goToSource": "Vai alla posizione sorgente",
|
||||
"copyPath": "Percorso di copia",
|
||||
"refresh": "Aggiorna",
|
||||
"pinItem": "Appunta",
|
||||
"unpinItem": "Rimuovi puntina",
|
||||
"sendToApp": "Invia all'app",
|
||||
"refresh": "Aggiorna"
|
||||
"sendToApp": "Invia all'app"
|
||||
},
|
||||
"errors": {
|
||||
"forbidden": "Non hai i permessi per accedere a questa risorsa.",
|
||||
@@ -231,8 +231,6 @@
|
||||
"requestTimedOut": "La richiesta ha superato il limite di tempo. Controlla le impostazioni di rete o del proxy inverso."
|
||||
},
|
||||
"files": {
|
||||
"sortByCreationTime": "Ordina per data di creazione",
|
||||
"creationTime": "Data di creazione",
|
||||
"emptyFile": "File vuoto",
|
||||
"lastModified": "Ultima modifica{suffix}",
|
||||
"duration": "Durata{suffix}",
|
||||
@@ -243,34 +241,38 @@
|
||||
"sortBySize": "Ordina per dimensione",
|
||||
"sortByType": "Ordina per tipo",
|
||||
"sortByDuration": "Ordina per durata",
|
||||
"sortByCreationTime": "Ordina per data di creazione",
|
||||
"creationTime": "Data di creazione",
|
||||
"noPreview": "Anteprima non disponibile per questo file.",
|
||||
"mobilePdfPreviewHint": "Il tuo browser non è in grado di visualizzare questo PDF nell'anteprima dell'app.",
|
||||
"noDownloadAccess": "Il download non è disponibile.",
|
||||
"newFile": "Nuovo file{suffix}",
|
||||
"newFolder": "Nuova cartella{suffix}",
|
||||
"sameFolder": "Impossibile spostare nella stessa cartella",
|
||||
"cannotAccesDir": "Impossibile accedere alla cartella",
|
||||
"newFolderMessage": "Inserisci il nome della nuova cartella.",
|
||||
"newFolderSuccess": "La cartella è stata creata con successo!",
|
||||
"mobilePdfPreviewHint": "Il tuo browser non è in grado di visualizzare questo PDF nell'anteprima dell'app."
|
||||
"newFolderSuccess": "La cartella è stata creata con successo!"
|
||||
},
|
||||
"help": {
|
||||
"functionKeys": "Tasti funzione",
|
||||
"mouseActions": "Azioni del mouse",
|
||||
"fileOperations": "Operazioni sui file",
|
||||
"click": "Seleziona un file o una cartella",
|
||||
"dbClick": "Fare doppio clic su uno spazio vuoto per selezionare tutti gli elementi",
|
||||
"marqueeSelect": "seleziona più file o cartelle trascinandone il cursore su uno spazio vuoto",
|
||||
"ctrl": {
|
||||
"f1": "Passa alla vista elenco",
|
||||
"f2": "Passa alla vista normale",
|
||||
"f3": "Passa alla vista galleria",
|
||||
"scroll": "Modificare le dimensioni dell'elenco e il carattere dell'editor",
|
||||
"click": "seleziona più file o cartelle",
|
||||
"c": "copia elementi selezionati",
|
||||
"v": "incolla elementi copiati/tagliati",
|
||||
"x": "taglia elementi selezionati",
|
||||
"d": "scarica elementi selezionati",
|
||||
"b": "attiva/disattiva barra laterale",
|
||||
"comma": "vai alle impostazioni",
|
||||
"e": "Passa da editor a visualizzatore markdown (su file .md)",
|
||||
"click": "seleziona più file o cartelle"
|
||||
"e": "Passa da editor a visualizzatore markdown (su file .md)"
|
||||
},
|
||||
"del": "elimina elementi selezionati",
|
||||
"esc": "pulisci selezione e/o chiudi il prompt",
|
||||
@@ -280,6 +282,8 @@
|
||||
"slash": "attiva ricerca",
|
||||
"description": "Puoi visualizzare le opzioni di navigazione di base qui sotto. Per ulteriori informazioni, visita",
|
||||
"wiki": "Documentazione ufficiale FileBrowser Quantum",
|
||||
"altArrowUp": "Vai alla cartella principale",
|
||||
"altArrowDown": "Apri il file/la cartella selezionati",
|
||||
"media": {
|
||||
"title": "Lettore multimediale",
|
||||
"playPause": "Riproduci/Pausa",
|
||||
@@ -293,11 +297,7 @@
|
||||
"playbackModes": "Passa tra le modalità di riproduzione (escluso loop e singola)",
|
||||
"fullscreen": "Attiva/disattiva schermo intero nei video",
|
||||
"captions": "Attiva/disattiva sottotitoli"
|
||||
},
|
||||
"altArrowUp": "Vai alla cartella principale",
|
||||
"altArrowDown": "Apri il file/la cartella selezionati",
|
||||
"click": "Seleziona un file o una cartella",
|
||||
"marqueeSelect": "seleziona più file o cartelle trascinandone il cursore su uno spazio vuoto"
|
||||
}
|
||||
},
|
||||
"languages": {
|
||||
"ar": "العربية",
|
||||
@@ -344,8 +344,29 @@
|
||||
"requirePasswordChangeSuccess": "La password è stata aggiornata. Ora hai effettuato l'accesso.",
|
||||
"requirePasswordChangeMustDiffer": "La nuova password deve essere diversa da quella attuale"
|
||||
},
|
||||
"quotas": {
|
||||
"title": "Quota di archiviazione",
|
||||
"openDescription": "Apri la finestra di dialogo relativa alla quota di spazio di archiviazione per questa cartella.",
|
||||
"folderDescription": "Limita lo spazio di archiviazione per questa cartella.",
|
||||
"shareLimit": "Limita lo spazio di archiviazione su questa condivisione",
|
||||
"shareLimitDescription": "Conta i caricamenti effettuati tramite questo link. Incide anche sulla quota di origine del proprietario.",
|
||||
"scopeLimit": "Limite di archiviazione per questa fonte",
|
||||
"scopeLimitDescription": "Spazio di archiviazione per il compito di questo utente su questa fonte.",
|
||||
"indexingDisabledMeterHint": "L'indicizzazione è disabilitata per questa fonte. È disponibile solo il monitoraggio dell'utilizzo.",
|
||||
"meterIndexHint": "Utilizza la dimensione totale dei file indicizzati in questa cartella. Include le modifiche apportate al di fuori di FileBrowser al momento dell'aggiornamento dell'indice.",
|
||||
"meterAccountedHint": "Conta solo i caricamenti, i salvataggi e le eliminazioni effettuati tramite FileBrowser.",
|
||||
"meterIndexScope": "Dimensione indicizzata",
|
||||
"meterAccounted": "Monitoraggio dell'utilizzo",
|
||||
"usageCounting": "Conta l'utilizzo in base a",
|
||||
"usageSyncPending": "L'utilizzo è ancora in fase di sincronizzazione con l'indice.",
|
||||
"accountedFallback": "La dimensione indicizzata non è ancora pronta; fino a quando l’indice non sarà disponibile, verrà utilizzato il monitoraggio dell’utilizzo.",
|
||||
"errors": {
|
||||
"exceeded": "Quota di archiviazione superata"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"notice": "Nota: quando viene creata una condivisione, essa diventa pubblica e segue regole di accesso diverse. Se non configurata correttamente, chiunque potrebbe accedervi.",
|
||||
"readOnlySourceNotice": "Questa sorgente è di sola lettura. Le azioni di caricamento e le autorizzazioni che modificano i file sono disabilitate.",
|
||||
"disableAnonymous": "Disabilita accesso anonimo",
|
||||
"disableThumbnails": "Disabilita miniature",
|
||||
"enableAllowedUsernames": "Condividi solo con determinati utenti",
|
||||
@@ -393,6 +414,8 @@
|
||||
"disableLoginOption": "Nascondi opzione di login",
|
||||
"disableLoginOptionDescription": "Nasconde il pulsante di accesso nella barra laterale per gli utenti anonimi.",
|
||||
"customizeSidebarLinksButton": "Personalizza Link",
|
||||
"shareDefaults": "Impostazioni predefinite di condivisione",
|
||||
"shareDefaultsDescription": "Configurare le opzioni predefinite per le nuove condivisioni e applicare le politiche agli utenti non amministratori.",
|
||||
"browseFiles": "Sfoglia file",
|
||||
"selectBannerImage": "Seleziona immagine banner",
|
||||
"selectFaviconImage": "Seleziona favicon",
|
||||
@@ -409,10 +432,7 @@
|
||||
"shareInfo": "QR Code e Info Condivisione",
|
||||
"shareInfoDescription": "Mostra le informazioni della condivisione (titolo, descrizione, banner, QR) in un popup.",
|
||||
"downloadDescription": "Permette di scaricare l'intero contenuto della condivisione come file compresso.",
|
||||
"deleteFailed": "Eliminazione condivisione fallita",
|
||||
"readOnlySourceNotice": "Questa sorgente è di sola lettura. Le azioni di caricamento e le autorizzazioni che modificano i file sono disabilitate.",
|
||||
"shareDefaults": "Impostazioni predefinite di condivisione",
|
||||
"shareDefaultsDescription": "Configurare le opzioni predefinite per le nuove condivisioni e applicare le politiche agli utenti non amministratori."
|
||||
"deleteFailed": "Eliminazione condivisione fallita"
|
||||
},
|
||||
"prompts": {
|
||||
"mediaInfo": "Info multimediali",
|
||||
@@ -447,6 +467,7 @@
|
||||
"deleteTitle": "Elimina file",
|
||||
"displayName": "Nome visualizzato:",
|
||||
"downloadMessage": "Scegli il formato in cui vuoi scaricare.",
|
||||
"downloadFailed": "Il download in chunk non è riuscito: se il problema si ripete, provare a ridurre la dimensione del chunk: {message}",
|
||||
"archive": "Crea archivio",
|
||||
"archiveMessage": "Crea un archivio sul server dagli elementi selezionati.",
|
||||
"archiveDestination": "Scegli la cartella di destinazione (poi inserisci il nome dell'archivio sotto):",
|
||||
@@ -521,45 +542,45 @@
|
||||
"halloweenBody": "Il tema di Halloween è stato applicato. Puoi disabilitarlo o cambiarlo permanentemente nelle impostazioni del profilo.",
|
||||
"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}",
|
||||
"confirmPasswordToSaveUser": "Immettere la password attuale per salvare le modifiche apportate all'utente."
|
||||
},
|
||||
"search": {
|
||||
"searchContext": "Contesto di ricerca: {context}",
|
||||
"onlyFolders": "Solo cartelle",
|
||||
"onlyFiles": "Solo file",
|
||||
"filesAndFolders": "File e cartelle",
|
||||
"allFileTypes": "Tutti i tipi di file",
|
||||
"notEnoughCharacters": "Caratteri insufficienti per la ricerca (minimo {minSearchLength})",
|
||||
"typeToSearch": "Digita {minSearchLength} o più caratteri per iniziare la ricerca.",
|
||||
"noResults": "Nessun risultato trovato.",
|
||||
"smallerThan": "Minore di:",
|
||||
"largerThan": "Maggiore di:",
|
||||
"olderThanDate": "Più vecchio di:",
|
||||
"newerThanDate": "Più recente di:",
|
||||
"showPreviewImages": "Mostra anteprime immagini",
|
||||
"showPreviewImagesDescription": "Mostra miniature nei risultati di ricerca quando disponibili e icone grandi per tutti i risultati.",
|
||||
"noResults": "Nessun risultato trovato.",
|
||||
"helpText1": "La ricerca avviene per ogni carattere digitato ({minSearchLength} minimo).",
|
||||
"helpText2": "Corrispondenza di sottostringhe solo sui nomi di file o cartelle (non sui percorsi completi). case:exact per una corrispondenza sensibile alle maiuscole e alle minuscole.\n\n per OR tra termini usare il carattere pipe; doppi apici per una frase.\n\nAbilitare la ricerca con caratteri jolly per opzioni più avanzate.",
|
||||
"useWildcardSearch": "Ricerca con caratteri jolly (sensibile alle maiuscole)",
|
||||
"useWildcardSearchDescription": "Opzioni jolly supportate:\n\n- Corrisponde a qualsiasi sequenza: *\n\n- Corrisponde a un solo carattere: ?\n\n- Classe di caratteri: [a-z], [0-9]\n\n- Negazione: [^0-9]\n\n- I caratteri letterali *, ?, o [ devono essere racchiusi tra parentesi, ad esempio [*], [?], [[]\n\n- Tutti i risultati sono sensibili alle maiuscole e alle minuscole",
|
||||
"openInAdvancedSearch": "Aprire la ricerca avanzata",
|
||||
"caseExact": "Corrispondenza che distingue tra maiuscole e minuscole",
|
||||
"caseExactDescription": "Abbinare i nomi di file e cartelle utilizzando la distinzione esatta tra maiuscole e minuscole, anziché ignorarla.",
|
||||
"filesAndFolders": "File e cartelle",
|
||||
"allFileTypes": "Tutti i tipi di file"
|
||||
"helpText1": "La ricerca avviene per ogni carattere digitato ({minSearchLength} minimo).",
|
||||
"helpText2": "Corrispondenza di sottostringhe solo sui nomi di file o cartelle (non sui percorsi completi). case:exact per una corrispondenza sensibile alle maiuscole e alle minuscole.\n\n per OR tra termini usare il carattere pipe; doppi apici per una frase.\n\nAbilitare la ricerca con caratteri jolly per opzioni più avanzate.",
|
||||
"openInAdvancedSearch": "Aprire la ricerca avanzata"
|
||||
},
|
||||
"profileSettings": {
|
||||
"showTypeColumn": "Mostra colonna Tipo",
|
||||
"showTypeColumnDescription": "Mostra la colonna Tipo nelle viste elenco e compatta nell'elenco principale.",
|
||||
"showCreationDateColumn": "Mostra colonna Data di creazione",
|
||||
"showCreationDateColumnDescription": "Mostra la colonna Data di creazione nelle viste elenco e compatta nell'elenco principale.",
|
||||
"promptRightCloseButton": "Mostra il pulsante di chiusura dei prompt a destra",
|
||||
"promptRightCloseButtonDescription": "Mostra il pulsante di chiusura dei prompt sul lato destro invece che sul sinistro",
|
||||
"enforcedByAdmin": "L'amministratore ha impedito agli utenti di modificare questa proprietà",
|
||||
"hideFileExt": "Nascondere determinati file (per estensione)",
|
||||
"hideFileExtDescription": "Un elenco di estensioni di file separate da spazi. Le estensioni inserite in questo campo verranno nascoste da tutta l'interfaccia utente (visivamente, come nell'elenco o nell'albero dei file) — Rimangono comunque accessibili.",
|
||||
"preferEditorForMarkdown": "Preferisci editor per file Markdown",
|
||||
"preferEditorForMarkdownDescription": "Se abilitato, apre i file markdown nell'editor invece che nel visualizzatore.",
|
||||
"deleteAfterArchive": "Elimina sorgenti dopo creazione/estrazione archivio",
|
||||
"deleteAfterArchiveDescription": "Se abilitato, eliminerà i file sorgente dopo aver creato o estratto con successo un archivio.",
|
||||
"defaultViewMode": "Modalità di visualizzazione predefinita",
|
||||
"defaultViewModeDescription": "Modalità di visualizzazione iniziale su nuovi browser o dispositivi. Non sovrascrive le preferenze di visualizzazione già salvate localmente.",
|
||||
"defaultGallerySize": "Dimensione predefinita delle miniature",
|
||||
"defaultGallerySizeDescription": "Scala iniziale delle miniature della galleria o dell'elenco (1–9) sui nuovi browser o dispositivi. Non sovrascrive le dimensioni già salvate localmente.",
|
||||
"hideFilesInTree": "Nascondi file nell'albero di navigazione",
|
||||
"hideFilesInTreeDescription": "Se abilitato, solo le cartelle verranno mostrate nella navigazione ad albero laterale.",
|
||||
"previewFolderDescription": "Mostra l'anteprima delle cartelle usando il primo elemento interno come copertina.",
|
||||
@@ -570,16 +591,27 @@
|
||||
"customTheme": "Scegli il tuo tema",
|
||||
"defaultThemeDescription": "default - Il tema predefinito",
|
||||
"setDateFormat": "Imposta formato data esatto",
|
||||
"singleClick": "Basta un clic per aprire i file",
|
||||
"stickySidebar": "Barra laterale fissa",
|
||||
"darkMode": "Modalità scura",
|
||||
"darkModeSystem": "Segui il sistema",
|
||||
"darkModeLight": "Chiaro",
|
||||
"darkModeDark": "Scuro",
|
||||
"showHiddenFiles": "Mostra file nascosti",
|
||||
"showHiddenFilesDescription": "Mostra i file che iniziano con un punto o quelli di sistema.",
|
||||
"showQuickDownload": "Mostra icona download rapido",
|
||||
"showQuickDownloadDescription": "Aggiunge un'icona su ogni file per scaricarlo istantaneamente con un click.",
|
||||
"previewDescription": "Mostra le anteprime per {filetype}, mostrerà un'icona generica se disabilitata.",
|
||||
"previewMotion": "Anteprime animate",
|
||||
"previewMotionVideosDescription": "L'anteprima animata cicla i fotogrammi del video o le immagini di una cartella al passaggio del mouse.",
|
||||
"popupPreview": "Visualizzatore pop-up",
|
||||
"popupPreviewDescription": "Mostra una finestra in sovrapposizione quando si passa sopra elementi con miniature.",
|
||||
"disableUpdateNotifications": "Disabilita notifiche di aggiornamento",
|
||||
"disableUpdateNotificationsDescription": "Nasconde il banner degli aggiornamenti disponibile agli amministratori.",
|
||||
"accountOptions": "Opzioni dell'account",
|
||||
"showAdvancedProfile": "Mostra le impostazioni avanzate del profilo",
|
||||
"showAdvancedProfileDescription": "Mostra le impostazioni del profilo suddivise per categorie nella barra laterale, anziché la visualizzazione semplificata del profilo.",
|
||||
"disableHideSidebar": "Mostra sempre la barra laterale",
|
||||
"disableHideSidebarDescription": "Impedisce alla barra laterale di nascondersi automaticamente.",
|
||||
"autoplayMedia": "Riproduzione automatica media",
|
||||
"autoplayMediaDescription": "Avvia automaticamente i file audio/video all'apertura.",
|
||||
@@ -590,6 +622,14 @@
|
||||
"disableSearchOptions": "Disabilita opzioni di ricerca",
|
||||
"disableSearchOptionsDescription": "Nasconde le opzioni di ricerca nella barra laterale.",
|
||||
"hideSidebarFileActions": "Nascondi azioni file nella barra laterale",
|
||||
"showToolsInSidebar": "Mostra gli strumenti nella barra laterale",
|
||||
"showToolsInSidebarDescription": "Mostrate sempre il link agli strumenti nella barra laterale.",
|
||||
"showCopyPath": "Mostra Copia percorso nel menu contestuale",
|
||||
"showCopyPathDescription": "Mostra l'azione Copia percorso nel menu contestuale di file e cartelle.",
|
||||
"showTypeColumn": "Mostra colonna Tipo",
|
||||
"showTypeColumnDescription": "Mostra la colonna Tipo nelle viste elenco e compatta nell'elenco principale.",
|
||||
"showCreationDateColumn": "Mostra colonna Data di creazione",
|
||||
"showCreationDateColumnDescription": "Mostra la colonna Data di creazione nelle viste elenco e compatta nell'elenco principale.",
|
||||
"deleteWithoutConfirming": "Elimina file senza conferma",
|
||||
"deleteWithoutConfirmingDescription": "Elimina i file istantaneamente (usare con cautela).",
|
||||
"previewOfficeDescription": "Mostra miniature per i file office nell'elenco.",
|
||||
@@ -604,9 +644,6 @@
|
||||
"thumbnailOptions": "Opzioni Miniature",
|
||||
"showThumbnails": "Mostra miniature per elementi supportati",
|
||||
"showThumbnailsDescription": "Spegni per nascondere tutte le miniature nell'elenco.",
|
||||
"showCopyPath": "Mostra Copia percorso nel menu contestuale",
|
||||
"showCopyPathDescription": "Mostra l'azione Copia percorso nel menu contestuale di file e cartelle.",
|
||||
"previewDescription": "Mostra le anteprime per {filetype}, mostrerà un'icona generica se disabilitata.",
|
||||
"passkeys": "Passkey",
|
||||
"passkeysDescription": "Utilizza le passkey per un'autenticazione sicura multi-fattore senza password. Registra una passkey sul tuo dispositivo per utilizzare la verifica biometrica o il PIN invece dei codici OTP.",
|
||||
"noPasskeys": "Nessuna passkey registrata.",
|
||||
@@ -618,54 +655,25 @@
|
||||
"passkeyDeleteFailed": "Impossibile eliminare la passkey",
|
||||
"created": "Creato",
|
||||
"lastUsed": "Ultimo utilizzo",
|
||||
"passkeyDefaultName": "Passkey",
|
||||
"showToolsInSidebar": "Mostra gli strumenti nella barra laterale",
|
||||
"showToolsInSidebarDescription": "Mostrate sempre il link agli strumenti nella barra laterale.",
|
||||
"enforcedByAdmin": "L'amministratore ha impedito agli utenti di modificare questa proprietà",
|
||||
"singleClick": "Basta un clic per aprire i file",
|
||||
"stickySidebar": "Barra laterale fissa",
|
||||
"darkMode": "Modalità scura",
|
||||
"darkModeSystem": "Segui il sistema",
|
||||
"darkModeLight": "Chiaro",
|
||||
"darkModeDark": "Scuro",
|
||||
"defaultViewMode": "Modalità di visualizzazione predefinita",
|
||||
"defaultViewModeDescription": "Modalità di visualizzazione iniziale su nuovi browser o dispositivi. Non sovrascrive le preferenze di visualizzazione già salvate localmente.",
|
||||
"defaultGallerySize": "Dimensione predefinita delle miniature",
|
||||
"defaultGallerySizeDescription": "Scala iniziale delle miniature della galleria o dell'elenco (1–9) sui nuovi browser o dispositivi. Non sovrascrive le dimensioni già salvate localmente.",
|
||||
"disableHideSidebar": "Mostra sempre la barra laterale",
|
||||
"showAdvancedProfile": "Mostra le impostazioni avanzate del profilo",
|
||||
"showAdvancedProfileDescription": "Mostra le impostazioni del profilo suddivise per categorie nella barra laterale, anziché la visualizzazione semplificata del profilo.",
|
||||
"accountOptions": "Opzioni dell'account"
|
||||
"passkeyDefaultName": "Passkey"
|
||||
},
|
||||
"settings": {
|
||||
"systemAdmin": "Sistema e Admin",
|
||||
"configViewer": "Visualizzatore Config",
|
||||
"configViewerShowFull": "Mostra config completa",
|
||||
"configViewerShowComments": "Mostra commenti",
|
||||
"enterPassword": "Inserisci password",
|
||||
"enterPasswordAgain": "Inserisci di nuovo la password",
|
||||
"loginMethod": "Metodo di accesso",
|
||||
"modifyOtherUser": "Modifica utente:",
|
||||
"themeColor": "Colore del tema",
|
||||
"listingOptions": "Opzioni elenco",
|
||||
"searchOptions": "Opzioni ricerca",
|
||||
"emptyGlobal": "Al momento non ci sono opzioni di configurazione globale. Puoi modificare il file yaml per le impostazioni di sistema.",
|
||||
"scopes": "Definisci le sorgenti permesse e l'ambito dell'utente",
|
||||
"userNotAdmin": "L'utente non è un amministratore",
|
||||
"avoidChanges": "seleziona anche 'Cambia Password' per applicarla",
|
||||
"lockPassword": "Impedisci all'utente di cambiare la password",
|
||||
"permissions": {
|
||||
"admin": "Amministratore",
|
||||
"api": "Gestione Token API a lunga durata",
|
||||
"realtime": "Abilita connessioni in tempo reale"
|
||||
},
|
||||
"settingsUpdated": "Impostazioni aggiornate!",
|
||||
"shareDeleted": "Condivisione eliminata!",
|
||||
"userDeleted": "Utente eliminato!",
|
||||
"userUpdated": "Utente aggiornato!",
|
||||
"modifyOtherUserTitle": "Modifica utente: {username}",
|
||||
"configViewerOpen": "Visualizza configurazione",
|
||||
"configViewerDescription": "Apri una vista in sola lettura della configurazione corrente del server.",
|
||||
"userDefaults": "Impostazioni predefinite dell'utente",
|
||||
"userEditPreferences": "Preferenze utente",
|
||||
"userEditTools": "Accesso agli strumenti",
|
||||
"userEditToolsDescription": "Configurare a quali strumenti questo utente può accedere",
|
||||
"userEditSidebarLinks": "Link nella barra laterale",
|
||||
"userDefaultsDescription": "Configurare le impostazioni predefinite da applicare ai nuovi utenti e le politiche da far rispettare a tutti gli utenti.",
|
||||
"editAsYaml": "Modifica come YAML",
|
||||
"lockedFromConfig": "Questa impostazione predefinita è configurata nel file di configurazione e non può essere modificata in questa sede.",
|
||||
"accountDefaults": "Impostazioni predefinite dell'account",
|
||||
"disableUserSettings": "Disattiva la pagina delle impostazioni utente",
|
||||
"analyticsView": "Visualizza le statistiche",
|
||||
"analyticsViewDescription": "Apri una vista in sola lettura dello snapshot delle analisi di distribuzione raccolto da questo server.",
|
||||
"analyticsEnabled": "Invia i dati analitici relativi alla distribuzione",
|
||||
@@ -673,28 +681,92 @@
|
||||
"analyticsLoadFailed": "Impossibile caricare le impostazioni di analisi",
|
||||
"analyticsUpdateFailed": "Impossibile aggiornare le impostazioni di analisi",
|
||||
"analyticsPreviewFailed": "Impossibile caricare le informazioni diagnostiche relative all'analisi",
|
||||
"scopePath": "Percorso di copertura",
|
||||
"sourcePermissionsHelp": "Espandi una riga di origine per impostare le autorizzazioni di visualizzazione, download, modifica, creazione ed eliminazione per quella origine.",
|
||||
"permissionsHelp": "Le autorizzazioni globali dell'utente si applicano ovunque, comprese tutte le fonti e le condivisioni.",
|
||||
"userDefaults": "Impostazioni predefinite dell'utente",
|
||||
"userDefaultsDescription": "Configurare le impostazioni predefinite da applicare ai nuovi utenti e le politiche da far rispettare a tutti gli utenti.",
|
||||
"accountDefaults": "Impostazioni predefinite dell'account",
|
||||
"disableUserSettings": "Disattiva la pagina delle impostazioni utente",
|
||||
"sourceAccessDefaultsHelp": "Permessi predefiniti applicati ai file sorgente e ai nuovi utenti. L'opzione \"Enforce\" blocca un valore per tutti gli utenti non amministratori.",
|
||||
"editAsYaml": "Modifica come YAML",
|
||||
"lockedFromConfig": "Questa impostazione predefinita è configurata nel file di configurazione e non può essere modificata in questa sede.",
|
||||
"userEditPreferences": "Preferenze utente",
|
||||
"userEditTools": "Accesso agli strumenti",
|
||||
"userEditToolsDescription": "Configurare a quali strumenti questo utente può accedere",
|
||||
"userEditSidebarLinks": "Link nella barra laterale",
|
||||
"enterPassword": "Inserisci password",
|
||||
"enterPasswordAgain": "Inserisci di nuovo la password",
|
||||
"passwordRequirementsMatch": "Le password corrispondono",
|
||||
"passwordRequirementsMinLength": "Almeno {min} caratteri",
|
||||
"requirePasswordChange": "Richiedi la modifica della password al prossimo accesso"
|
||||
"loginMethod": "Metodo di accesso",
|
||||
"modifyOtherUser": "Modifica utente:",
|
||||
"modifyOtherUserTitle": "Modifica utente: {username}",
|
||||
"themeColor": "Colore del tema",
|
||||
"listingOptions": "Opzioni elenco",
|
||||
"searchOptions": "Opzioni ricerca",
|
||||
"emptyGlobal": "Al momento non ci sono opzioni di configurazione globale. Puoi modificare il file yaml per le impostazioni di sistema.",
|
||||
"scopes": "Definisci le sorgenti permesse e l'ambito dell'utente",
|
||||
"scopePath": "Percorso di copertura",
|
||||
"userNotAdmin": "L'utente non è un amministratore",
|
||||
"avoidChanges": "seleziona anche 'Cambia Password' per applicarla",
|
||||
"lockPassword": "Impedisci all'utente di cambiare la password",
|
||||
"requirePasswordChange": "Richiedi la modifica della password al prossimo accesso",
|
||||
"permissions": {
|
||||
"admin": "Amministratore",
|
||||
"api": "Gestione Token API a lunga durata",
|
||||
"realtime": "Abilita connessioni in tempo reale"
|
||||
},
|
||||
"permissionsHelp": "Le autorizzazioni globali dell'utente si applicano ovunque, comprese tutte le fonti e le condivisioni.",
|
||||
"sourcePermissionsHelp": "Espandi una riga di origine per impostare le autorizzazioni di visualizzazione, download, modifica, creazione ed eliminazione per quella origine.",
|
||||
"sourceAccessDefaultsHelp": "Permessi predefiniti applicati ai file sorgente e ai nuovi utenti. L'opzione \"Enforce\" blocca un valore per tutti gli utenti non amministratori.",
|
||||
"settingsUpdated": "Impostazioni aggiornate!",
|
||||
"shareDeleted": "Condivisione eliminata!",
|
||||
"userDeleted": "Utente eliminato!",
|
||||
"userUpdated": "Utente aggiornato!"
|
||||
},
|
||||
"media": {
|
||||
"accelDevice": "Dispositivo di accelerazione",
|
||||
"accelDeviceDescription": "Dispositivo GPU utilizzato per l'elaborazione multimediale con accelerazione hardware. La CPU (software) disattiva l'accelerazione hardware.",
|
||||
"encoderBackend": "Backend dell'encoder",
|
||||
"transcodeProfileEncoderBackendHelp": "Backend dell'encoder per questa preimpostazione; per impostazione predefinita viene utilizzato il backend preferito rilevato.",
|
||||
"alwaysUseFfmpeg": "Utilizza sempre ffmpeg",
|
||||
"alwaysUseFfmpegDescription": "Utilizza sempre ffmpeg per l'analisi, i metadati, i sottotitoli e le miniature dei video. Questo metodo sarà più lento, ma può risultare più affidabile.",
|
||||
"alwaysUseOffice": "Utilizzare sempre il server aziendale per le miniature dei documenti",
|
||||
"alwaysUseOfficeDescription": "Utilizza sempre il server dell'ufficio per le miniature dei documenti, anziché la generazione delle miniature durante l'elaborazione. Questa operazione sarà più lenta, ma potrebbe risultare più affidabile.",
|
||||
"debug": "Registrazione dei messaggi di debug",
|
||||
"debugDescription": "Uscita stdout di ffmpeg per l'integrazione multimediale. Attenzione: genera una grande quantità di dati in uscita.",
|
||||
"imagePreview": "Formati di anteprima delle immagini",
|
||||
"videoPreview": "Formati di anteprima video",
|
||||
"imageVideoConversionFormats": "Formati di conversione di immagini e video",
|
||||
"imageVideoConversionFormatsDescription": "Scegli quali formati di immagini e video ffmpeg può convertire per le anteprime.",
|
||||
"updateFailed": "Impossibile aggiornare le impostazioni.",
|
||||
"maxConcurrentTranscodes": "Numero massimo di transcodifiche simultanee",
|
||||
"maxConcurrentTranscodesHelp": "Il numero massimo di sessioni simultanee di riproduzione con transcodifica HLS per questo utente.",
|
||||
"transcode": "Transcodifica",
|
||||
"transcodeEnabled": "Abilita la transcodifica",
|
||||
"transcodeEnabledDescription": "Consenti la transcodifica HLS lato server per la riproduzione con autenticazione.",
|
||||
"transcodeSettings": "Impostazioni di transcodifica",
|
||||
"transcodeSettingsDescription": "Limiti di concorrenza, cache su disco e limiti di risoluzione e bitrate per profilo.",
|
||||
"transcodeMaxConcurrent": "Sessioni simultanee globali",
|
||||
"transcodeMaxConcurrentHelp": "Numero massimo di sessioni di transcodifica simultanee per tutti gli utenti.",
|
||||
"transcodeMaxConcurrentPerUser": "Limite di sessioni per utente",
|
||||
"transcodeMaxConcurrentPerUserHelp": "Limite massimo rigido al numero di sessioni di transcodifica simultanee per ogni singolo utente. Le impostazioni dell'utente non possono superare tale limite.",
|
||||
"transcodeCacheMaxSizeMB": "Limite della dimensione della cache (MB)",
|
||||
"transcodeCacheMaxSizeMBHelp": "Spazio su disco massimo per la cache di transcodifica in MB. 0 indica spazio illimitato.",
|
||||
"transcodeCacheRetentionH": "Durata della cache (ore)",
|
||||
"transcodeCacheRetentionHHelp": "Per quanto tempo vengono conservate le generazioni di cache di transcodifica inattive prima di essere eliminate.",
|
||||
"transcodeProfileEnabled": "Abilitato",
|
||||
"transcodeProfileEnabledDescription": "Mostra questa impostazione predefinita di qualità nel menu del lettore.",
|
||||
"transcodeProfileMaxResolution": "Risoluzione massima",
|
||||
"transcodeProfileMaxResolutionHelp": "Altezza massima di output in pixel. Il valore 0 mantiene la risoluzione originale.",
|
||||
"transcodeProfileAvgBitrate": "Bitrate medio (kbps)",
|
||||
"transcodeProfileAvgBitrateHelp": "Bitrate medio desiderato del video in kbps. Il valore 0 mantiene il bitrate originale.",
|
||||
"transcodeReadRate": "Producer rate limit (× realtime)",
|
||||
"transcodeReadRateHelp": "Caps the transcode producer at N× realtime input pacing (ffmpeg -readrate) to limit CPU. Minimum 1; defaults to 2. Applies to transcode jobs only; stream-copy runs unthrottled."
|
||||
},
|
||||
"time": {
|
||||
"time": "Ora{suffix}",
|
||||
"minute": "minuto{suffix}",
|
||||
"minutes": "Verbale{suffix}",
|
||||
"hour": "ora{suffix}",
|
||||
"hours": "Orari{suffix}",
|
||||
"day": "giorno{suffix}",
|
||||
"days": "Giorni{suffix}",
|
||||
"months": "Mesi{suffix}",
|
||||
"timeUnit": "Unità di tempo"
|
||||
},
|
||||
"sidebar": {
|
||||
"dividerNamePlaceholder": "Testo divisore (vuoto per una linea)",
|
||||
"updateIsAvailable": "Un aggiornamento è disponibile!",
|
||||
"fileActions": "Azioni file",
|
||||
"switchMode": "Passa dai link all'albero di navigazione e viceversa",
|
||||
"customizeLinks": "Personalizza Link Barra Laterale",
|
||||
"customizeLinksDescription": "Personalizza i link mostrati nella barra. Trascina per riordinare.",
|
||||
"customizeShareLinksDescription": "Personalizza i link per questa condivisione.",
|
||||
@@ -709,6 +781,10 @@
|
||||
"usageTextDisplay": "Quale testo di utilizzo visualizzare?",
|
||||
"usageTextIndexed": "utilizzo come indicizzato",
|
||||
"usageTextDisk": "utilizzo come riportato dal disco",
|
||||
"showIndexedUsage": "Mostra l'utilizzo come indicizzato",
|
||||
"showDiskUsage": "Mostra l'utilizzo in base ai dati riportati dal disco/dalla partizione",
|
||||
"rootFilesystemOnly": "Limita l'utilizzo del disco al filesystem di origine",
|
||||
"rootFilesystemOnlyDescription": "Se abilitata, viene segnalato solo il filesystem contenente il percorso di origine. Se disabilitata (impostazione predefinita), il totale del disco include anche i filesystem montati sotto il percorso di origine, con i pool condivisi come i dataset ZFS conteggiati una sola volta.",
|
||||
"selectSource": "Seleziona Sorgente",
|
||||
"chooseSource": "Scegli una sorgente",
|
||||
"selectShare": "Seleziona Condivisione",
|
||||
@@ -725,16 +801,11 @@
|
||||
"pickIcon": "Scegli un'Icona",
|
||||
"pickIconDescription": "Cerca e seleziona un'icona dalla libreria Material Icons",
|
||||
"sourceNotAccessible": "Questa sorgente non è più accessibile",
|
||||
"showIndexedUsage": "Mostra l'utilizzo come indicizzato",
|
||||
"showDiskUsage": "Mostra l'utilizzo in base ai dati riportati dal disco/dalla partizione",
|
||||
"sidebarLinkDefaults": "Impostazioni predefinite dei link nella barra laterale",
|
||||
"sidebarLinkDefaultsDescription": "Configurare i collegamenti predefiniti e obbligatori nella barra laterale per tutti gli utenti.",
|
||||
"sidebarLinkDefaultsHelp": "Questi sono i link predefiniti che verranno aggiunti per tutti i nuovi utenti. Se si imposta un link come obbligatorio, questo verrà aggiunto per tutti gli utenti e non potrà essere rimosso dagli utenti che non sono amministratori.",
|
||||
"sidebarLinkDefaultSourceHelp": "Si applica solo se l'utente ha accesso a questa fonte.",
|
||||
"sidebarLinkDefaultsYamlInvalid": "YAML non valido. Controlla il formato e riprova.",
|
||||
"switchMode": "Passa dai link all'albero di navigazione e viceversa",
|
||||
"rootFilesystemOnly": "Limita l'utilizzo del disco al filesystem di origine",
|
||||
"rootFilesystemOnlyDescription": "Se abilitata, viene segnalato solo il filesystem contenente il percorso di origine. Se disabilitata (impostazione predefinita), il totale del disco include anche i filesystem montati sotto il percorso di origine, con i pool condivisi come i dataset ZFS conteggiati una sola volta."
|
||||
"sidebarLinkDefaultsYamlInvalid": "YAML non valido. Controlla il formato e riprova."
|
||||
},
|
||||
"api": {
|
||||
"title": "Token API",
|
||||
@@ -783,6 +854,25 @@
|
||||
"passwordRequiredForGeneration": "La password è necessaria per generare un codice autenticatore."
|
||||
},
|
||||
"access": {
|
||||
"newGroup": "Nuovo gruppo",
|
||||
"editGroup": "Modifica gruppo",
|
||||
"groupName": "Nome del gruppo",
|
||||
"userGroupsDescription": "Gruppi di cui fa parte questo utente",
|
||||
"groupMembers": "Membri",
|
||||
"membersCount": "Membri: {count}",
|
||||
"userGroupsCount": "Gruppi: {count}",
|
||||
"groupNameRequired": "Il nome del gruppo è obbligatorio.",
|
||||
"groupNameTooShort": "Group name must be at least {min} characters.",
|
||||
"groupNameTooLong": "Il nome del gruppo deve contenere al massimo {max} caratteri.",
|
||||
"groupNameInvalid": "Il nome del gruppo non deve contenere caratteri di controllo.",
|
||||
"groupExists": "Esiste già un gruppo con questo nome.",
|
||||
"deleteGroupConfirm": "Eliminare il gruppo \"{name}\"? Verrà rimosso anche da tutte le regole di accesso.",
|
||||
"groupsOidcNote": "I membri dei gruppi sincronizzati tramite OIDC vengono reimpostati dal provider di identità a ogni accesso di tali utenti.",
|
||||
"selectUser": "Seleziona utente",
|
||||
"selectUsers": "Seleziona gli utenti",
|
||||
"selectGroup": "Seleziona un gruppo",
|
||||
"selectGroups": "Seleziona i gruppi",
|
||||
"refineSearch": "Vengono visualizzati i primi risultati per \" {count} \". Affina la ricerca per visualizzarne altri.",
|
||||
"all": "Nega Tutto",
|
||||
"rules": "Regole di accesso",
|
||||
"deny": "Nega",
|
||||
@@ -794,26 +884,7 @@
|
||||
"defaultBehavior": "Azione predefinita{suffix}",
|
||||
"defaultBehaviorDescription": "Cosa succede quando non esistono regole specifiche. 'Consenti' permette l'accesso, 'Nega' blocca tutto tranne gli esplicitamente autorizzati.",
|
||||
"cascadeDelete": "Eliminazione Ricorsiva",
|
||||
"cascadeDeleteDescription": "Se abilitato, l'eliminazione di una regola rimuoverà anche quelle nelle sottodirectory.",
|
||||
"newGroup": "Nuovo gruppo",
|
||||
"editGroup": "Modifica gruppo",
|
||||
"groupName": "Nome del gruppo",
|
||||
"groupMembers": "Membri",
|
||||
"deleteGroupConfirm": "Eliminare il gruppo \"{name}\"? Verrà rimosso anche da tutte le regole di accesso.",
|
||||
"groupsOidcNote": "I membri dei gruppi sincronizzati tramite OIDC vengono reimpostati dal provider di identità a ogni accesso di tali utenti.",
|
||||
"userGroupsDescription": "Gruppi di cui fa parte questo utente",
|
||||
"membersCount": "Membri: {count}",
|
||||
"userGroupsCount": "Gruppi: {count}",
|
||||
"groupNameRequired": "Il nome del gruppo è obbligatorio.",
|
||||
"groupNameTooShort": "Group name must be at least {min} characters.",
|
||||
"groupNameTooLong": "Il nome del gruppo deve contenere al massimo {max} caratteri.",
|
||||
"groupNameInvalid": "Il nome del gruppo non deve contenere caratteri di controllo.",
|
||||
"groupExists": "Esiste già un gruppo con questo nome.",
|
||||
"selectUser": "Seleziona utente",
|
||||
"selectUsers": "Seleziona gli utenti",
|
||||
"selectGroup": "Seleziona un gruppo",
|
||||
"selectGroups": "Seleziona i gruppi",
|
||||
"refineSearch": "Vengono visualizzati i primi risultati per \" {count} \". Affina la ricerca per visualizzarne altri."
|
||||
"cascadeDeleteDescription": "Se abilitato, l'eliminazione di una regola rimuoverà anche quelle nelle sottodirectory."
|
||||
},
|
||||
"fileLoading": {
|
||||
"title": "Caricamenti e Download",
|
||||
@@ -823,6 +894,8 @@
|
||||
"uploadChunkSizeMbHelp": "In genere 5-50 MB. Valori bassi sono migliori per reti instabili. 0 disabilita il frazionamento.",
|
||||
"downloadChunkSizeMb": "Dimensione chunk download in MB",
|
||||
"downloadChunkSizeMbHelp": "I file più grandi verranno scaricati a pezzi per evitare timeout (es. errori Cloudflare 524). 0 disabilita.",
|
||||
"uploadStallTimeoutSeconds": "Timeout di stallo del caricamento in secondi",
|
||||
"uploadStallTimeoutSecondsHelp": "Secondi per cui un caricamento può restare senza alcun progresso prima di essere messo in pausa. Aumentalo con connessioni lente o instabili, o quando i file vengono letti da dischi lenti. I valori inferiori a 1 usano il valore predefinito di 10 secondi.",
|
||||
"clearAll": "Rimuovi tutto (errori, pause, conflitti)",
|
||||
"clearAllDescription": "Il pulsante pulisci completati rimuoverà anche gli upload in stato di errore, pausa o conflitto.",
|
||||
"uploadSettings": "Impostazioni Caricamento"
|
||||
@@ -952,6 +1025,11 @@
|
||||
"toggleLyrics": "Attiva/disattiva testo",
|
||||
"closePanel": "Chiudi pannello",
|
||||
"openPanel": "Apri pannello",
|
||||
"transcodeFailed": "La transcodifica non è andata a buon fine",
|
||||
"transcodeLoading": "Preparazione dello stream transcodificato",
|
||||
"transcodeUnsupportedFormat": "Questo formato video non è supportato dal tuo browser.",
|
||||
"transcodeUserLimitReached": "Limite di transcodifica raggiunto. Hai già una sessione di transcodifica attiva:",
|
||||
"transcodeSystemLimitReached": "Limite di transcodifica del server raggiunto ({limit} e in contemporanea). Riprovare al termine di una sessione.",
|
||||
"visualizer": {
|
||||
"title": "Visualizzatore",
|
||||
"settings": "Impostazioni visualizzatore",
|
||||
@@ -974,25 +1052,20 @@
|
||||
"maxDecibelsDescription": "Livello più alto (dBFS) registrato dalla barra",
|
||||
"showScalesDescription": "Mostra assi e linee della griglia sul visualizzatore",
|
||||
"showPeaksDescription": "Piccoli indicatori di picco bianchi sopra ogni barra"
|
||||
},
|
||||
"transcodeFailed": "La transcodifica non è andata a buon fine",
|
||||
"transcodeLoading": "Preparazione dello stream transcodificato",
|
||||
"transcodeUnsupportedFormat": "Questo formato video non è supportato dal tuo browser.",
|
||||
"transcodeUserLimitReached": "Limite di transcodifica raggiunto. Hai già una sessione di transcodifica attiva:",
|
||||
"transcodeSystemLimitReached": "Limite di transcodifica del server raggiunto ({limit} e in contemporanea). Riprovare al termine di una sessione."
|
||||
}
|
||||
},
|
||||
"fileSizeAnalyzer": {
|
||||
"title": "Analizzatore Dimensioni File",
|
||||
"largerThan": "Maggiore di (MB)",
|
||||
"includeFolders": "Includi Cartelle",
|
||||
"includeFoldersDescription": "Includi le cartelle nell'analisi",
|
||||
"totalFiles": "File Totali{suffix}",
|
||||
"totalSize": "Dimensione Totale{suffix}",
|
||||
"emptyState": "Inserisci i criteri e clicca Analizza per visualizzare le dimensioni",
|
||||
"completeResults": "Risultati completi mostrati!",
|
||||
"incompleteResults": "I risultati potrebbero essere incompleti",
|
||||
"relativePercentage": "Percentuale Relativa{suffix}",
|
||||
"percentageOfResults": "{percentage}% dei risultati",
|
||||
"includeFoldersDescription": "Includi le cartelle nell'analisi"
|
||||
"percentageOfResults": "{percentage}% dei risultati"
|
||||
},
|
||||
"fileTypes": {
|
||||
"video": "Video",
|
||||
@@ -1011,6 +1084,10 @@
|
||||
"tools": {
|
||||
"title": "Strumenti",
|
||||
"description": "Utility e strumenti di analisi per i tuoi file",
|
||||
"toolAccessDefaults": "Impostazioni predefinite per l'accesso agli strumenti",
|
||||
"toolAccessDefaultsDescription": "Configurare a quali strumenti i nuovi utenti possono accedere per impostazione predefinita",
|
||||
"toolAccessDefaultsHelp": "Abilita gli strumenti per i nuovi utenti e, se lo desideri, rendi l'accesso obbligatorio in modo che gli utenti non amministratori non possano modificarlo.",
|
||||
"accessDenied": "Non hai accesso a questo strumento.",
|
||||
"sizeAnalyzer": {
|
||||
"name": "Analizzatore Dimensioni File",
|
||||
"description": "Visualizza file e cartelle di grandi dimensioni con una mappa ad albero interattiva"
|
||||
@@ -1019,6 +1096,20 @@
|
||||
"name": "Ricerca File Duplicati",
|
||||
"description": "Trova e gestisci i file duplicati per recuperare spazio"
|
||||
},
|
||||
"advancedSearch": {
|
||||
"name": "Ricerca avanzata",
|
||||
"description": "Ricerca nell'indice con pieno controllo delle fonti, dei filtri e delle opzioni",
|
||||
"searchTerms": "Termini di ricerca",
|
||||
"matchAllTerms": "Abbinare tutti i termini",
|
||||
"matchAllTermsDescription": "Se abilitato, tutti i termini di ricerca devono essere trovati per ogni file.",
|
||||
"scopeAria": "Limitare la ricerca a una cartella",
|
||||
"noTerms": "Inserite almeno un termine di ricerca.",
|
||||
"termTooShort": "Ogni termine di ricerca deve essere composto da almeno {minSearchLength} caratteri.",
|
||||
"resultsTitle": "Risultati della ricerca",
|
||||
"invalidSources": "Accendere almeno una sorgente.",
|
||||
"noResults": "Nessun file o cartella corrispondente.",
|
||||
"resultLimit": "Numero massimo di risultati di ricerca"
|
||||
},
|
||||
"materialIconPicker": {
|
||||
"name": "Selettore Icone Material",
|
||||
"description": "Sfoglia e cerca icone Material con anteprima live",
|
||||
@@ -1038,24 +1129,27 @@
|
||||
"emptyState": "Scegli un file e clicca play per iniziare il monitoraggio",
|
||||
"chooseFile": "Scegli un file"
|
||||
},
|
||||
"toolNotFound": "Strumento non trovato",
|
||||
"advancedSearch": {
|
||||
"name": "Ricerca avanzata",
|
||||
"description": "Ricerca nell'indice con pieno controllo delle fonti, dei filtri e delle opzioni",
|
||||
"searchTerms": "Termini di ricerca",
|
||||
"matchAllTerms": "Abbinare tutti i termini",
|
||||
"matchAllTermsDescription": "Se abilitato, tutti i termini di ricerca devono essere trovati per ogni file.",
|
||||
"scopeAria": "Limitare la ricerca a una cartella",
|
||||
"noTerms": "Inserite almeno un termine di ricerca.",
|
||||
"termTooShort": "Ogni termine di ricerca deve essere composto da almeno {minSearchLength} caratteri.",
|
||||
"resultsTitle": "Risultati della ricerca",
|
||||
"invalidSources": "Accendere almeno una sorgente.",
|
||||
"noResults": "Nessun file o cartella corrispondente.",
|
||||
"resultLimit": "Numero massimo di risultati di ricerca"
|
||||
},
|
||||
"activityViewer": {
|
||||
"name": "Visualizzatore attività",
|
||||
"description": "Visualizza ed esporta l'attività degli utenti relativa a download, modifiche ai file, condivisioni e azioni amministrative",
|
||||
"scopeAll": "Tutte le attività",
|
||||
"scopeFiles": "Attività sui file",
|
||||
"scopeAccess": "Attività di accesso",
|
||||
"scopeShares": "Condividi l'attività",
|
||||
"eventBulkDelete": "Eliminazione in blocco",
|
||||
"eventFileModify": "Modifica file",
|
||||
"eventShareCreate": "Condividi Crea",
|
||||
"eventShareUpdate": "Aggiornamento sulla condivisione",
|
||||
"eventShareDelete": "Condividi Elimina",
|
||||
"eventUserCreate": "Creazione utente",
|
||||
"eventUserUpdate": "Aggiornamento utente",
|
||||
"eventUserDelete": "Eliminazione utente",
|
||||
"eventAccessCreate": "Accedi - Crea",
|
||||
"eventAccessUpdate": "Aggiornamento sull'accesso",
|
||||
"eventAccessDelete": "Accedi - Elimina",
|
||||
"eventTokenCreate": "Creazione di un token",
|
||||
"eventTokenDelete": "Eliminazione del token",
|
||||
"webSessionNamed": "Sessione web ({name})",
|
||||
"activityScope": "Categoria di attività",
|
||||
"timeRange": "Intervallo di tempo",
|
||||
"last1h": "Ultima ora",
|
||||
@@ -1065,6 +1159,8 @@
|
||||
"customRange": "Gamma personalizzata",
|
||||
"eventType": "Tipo di evento",
|
||||
"eventSummary": "Evento",
|
||||
"passkeyRegister": "Passkey aggiunto",
|
||||
"passkeyDelete": "Passkey rimosso",
|
||||
"interval": "Intervallo di tempo",
|
||||
"splitBy": "Suddiviso per",
|
||||
"ungrouped": "Nessun raggruppamento (totali)",
|
||||
@@ -1076,17 +1172,6 @@
|
||||
"summaryView": "Riepilogo (barra orizzontale)",
|
||||
"tableView": "Tabella",
|
||||
"exportCsv": "Esporta CSV",
|
||||
"totalEvents": "Numero totale di eventi{suffix}",
|
||||
"cachedResult": "Risultato memorizzato nella cache",
|
||||
"showingPage": "Visualizzazione di {shown} di {total} (pagina {page}/{pages})",
|
||||
"pageOf": "Pagina {page} di {pages}",
|
||||
"pathFilter": "Filtro percorso",
|
||||
"pathFilterMode": "Filtra per",
|
||||
"pathFilterPattern": "Motivo",
|
||||
"pathGlob": "Modello di percorso (glob)",
|
||||
"shareFilter": "Condividi filtro",
|
||||
"passkeyRegister": "Passkey aggiunto",
|
||||
"passkeyDelete": "Passkey rimosso",
|
||||
"webSession": "Sessione web",
|
||||
"actorToken": "Token attore",
|
||||
"affectedToken": "Token interessato",
|
||||
@@ -1096,31 +1181,19 @@
|
||||
"eventTotals": "Totali dell'evento",
|
||||
"activityTotalsBy": "Totali delle attività per {subject}",
|
||||
"subjectEventType": "tipo di evento",
|
||||
"totalEvents": "Numero totale di eventi{suffix}",
|
||||
"cachedResult": "Risultato memorizzato nella cache",
|
||||
"showingPage": "Visualizzazione di {shown} di {total} (pagina {page}/{pages})",
|
||||
"pageOf": "Pagina {page} di {pages}",
|
||||
"pathFilter": "Filtro percorso",
|
||||
"pathFilterMode": "Filtra per",
|
||||
"pathFilterPattern": "Motivo",
|
||||
"shareFilter": "Condividi filtro",
|
||||
"pathGlob": "Modello di percorso (glob)",
|
||||
"viewActivity": "Visualizza attività",
|
||||
"tableColumns": "Colonne della tabella",
|
||||
"scopeAll": "Tutte le attività",
|
||||
"scopeFiles": "Attività sui file",
|
||||
"scopeShares": "Condividi l'attività",
|
||||
"eventBulkDelete": "Eliminazione in blocco",
|
||||
"eventShareCreate": "Condividi Crea",
|
||||
"eventShareUpdate": "Aggiornamento sulla condivisione",
|
||||
"eventShareDelete": "Condividi Elimina",
|
||||
"eventUserCreate": "Creazione utente",
|
||||
"eventUserUpdate": "Aggiornamento utente",
|
||||
"eventUserDelete": "Eliminazione utente",
|
||||
"eventAccessUpdate": "Aggiornamento sull'accesso",
|
||||
"eventTokenCreate": "Creazione di un token",
|
||||
"eventTokenDelete": "Eliminazione del token",
|
||||
"webSessionNamed": "Sessione web ({name})",
|
||||
"scopeAccess": "Attività di accesso",
|
||||
"eventAccessCreate": "Accedi - Crea",
|
||||
"eventAccessDelete": "Accedi - Elimina",
|
||||
"eventFileModify": "Modifica file"
|
||||
"tableColumns": "Colonne della tabella"
|
||||
},
|
||||
"toolAccessDefaults": "Impostazioni predefinite per l'accesso agli strumenti",
|
||||
"toolAccessDefaultsDescription": "Configurare a quali strumenti i nuovi utenti possono accedere per impostazione predefinita",
|
||||
"toolAccessDefaultsHelp": "Abilita gli strumenti per i nuovi utenti e, se lo desideri, rendi l'accesso obbligatorio in modo che gli utenti non amministratori non possano modificarlo.",
|
||||
"accessDenied": "Non hai accesso a questo strumento."
|
||||
"toolNotFound": "Strumento non trovato"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifiche",
|
||||
@@ -1203,76 +1276,5 @@
|
||||
},
|
||||
"pwa": {
|
||||
"install": "Installa l'app"
|
||||
},
|
||||
"time": {
|
||||
"time": "Ora{suffix}",
|
||||
"minute": "minuto{suffix}",
|
||||
"minutes": "Verbale{suffix}",
|
||||
"hour": "ora{suffix}",
|
||||
"hours": "Orari{suffix}",
|
||||
"day": "giorno{suffix}",
|
||||
"days": "Giorni{suffix}",
|
||||
"months": "Mesi{suffix}",
|
||||
"timeUnit": "Unità di tempo"
|
||||
},
|
||||
"quotas": {
|
||||
"title": "Quota di archiviazione",
|
||||
"openDescription": "Apri la finestra di dialogo relativa alla quota di spazio di archiviazione per questa cartella.",
|
||||
"folderDescription": "Limita lo spazio di archiviazione per questa cartella.",
|
||||
"shareLimit": "Limita lo spazio di archiviazione su questa condivisione",
|
||||
"shareLimitDescription": "Conta i caricamenti effettuati tramite questo link. Incide anche sulla quota di origine del proprietario.",
|
||||
"scopeLimit": "Limite di archiviazione per questa fonte",
|
||||
"scopeLimitDescription": "Spazio di archiviazione per il compito di questo utente su questa fonte.",
|
||||
"indexingDisabledMeterHint": "L'indicizzazione è disabilitata per questa fonte. È disponibile solo il monitoraggio dell'utilizzo.",
|
||||
"meterIndexHint": "Utilizza la dimensione totale dei file indicizzati in questa cartella. Include le modifiche apportate al di fuori di FileBrowser al momento dell'aggiornamento dell'indice.",
|
||||
"meterAccountedHint": "Conta solo i caricamenti, i salvataggi e le eliminazioni effettuati tramite FileBrowser.",
|
||||
"meterIndexScope": "Dimensione indicizzata",
|
||||
"meterAccounted": "Monitoraggio dell'utilizzo",
|
||||
"usageCounting": "Conta l'utilizzo in base a",
|
||||
"usageSyncPending": "L'utilizzo è ancora in fase di sincronizzazione con l'indice.",
|
||||
"accountedFallback": "La dimensione indicizzata non è ancora pronta; fino a quando l’indice non sarà disponibile, verrà utilizzato il monitoraggio dell’utilizzo.",
|
||||
"errors": {
|
||||
"exceeded": "Quota di archiviazione superata"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"debug": "Registrazione dei messaggi di debug",
|
||||
"debugDescription": "Uscita stdout di ffmpeg per l'integrazione multimediale. Attenzione: genera una grande quantità di dati in uscita.",
|
||||
"imagePreview": "Formati di anteprima delle immagini",
|
||||
"videoPreview": "Formati di anteprima video",
|
||||
"imageVideoConversionFormats": "Formati di conversione di immagini e video",
|
||||
"imageVideoConversionFormatsDescription": "Scegli quali formati di immagini e video ffmpeg può convertire per le anteprime.",
|
||||
"updateFailed": "Impossibile aggiornare le impostazioni.",
|
||||
"maxConcurrentTranscodes": "Numero massimo di transcodifiche simultanee",
|
||||
"maxConcurrentTranscodesHelp": "Il numero massimo di sessioni simultanee di riproduzione con transcodifica HLS per questo utente.",
|
||||
"transcode": "Transcodifica",
|
||||
"transcodeEnabled": "Abilita la transcodifica",
|
||||
"transcodeEnabledDescription": "Consenti la transcodifica HLS lato server per la riproduzione con autenticazione.",
|
||||
"transcodeSettings": "Impostazioni di transcodifica",
|
||||
"transcodeSettingsDescription": "Limiti di concorrenza, cache su disco e limiti di risoluzione e bitrate per profilo.",
|
||||
"transcodeMaxConcurrent": "Sessioni simultanee globali",
|
||||
"transcodeMaxConcurrentHelp": "Numero massimo di sessioni di transcodifica simultanee per tutti gli utenti.",
|
||||
"transcodeMaxConcurrentPerUser": "Limite di sessioni per utente",
|
||||
"transcodeMaxConcurrentPerUserHelp": "Limite massimo rigido al numero di sessioni di transcodifica simultanee per ogni singolo utente. Le impostazioni dell'utente non possono superare tale limite.",
|
||||
"transcodeCacheMaxSizeMB": "Limite della dimensione della cache (MB)",
|
||||
"transcodeCacheMaxSizeMBHelp": "Spazio su disco massimo per la cache di transcodifica in MB. 0 indica spazio illimitato.",
|
||||
"transcodeCacheRetentionH": "Durata della cache (ore)",
|
||||
"transcodeCacheRetentionHHelp": "Per quanto tempo vengono conservate le generazioni di cache di transcodifica inattive prima di essere eliminate.",
|
||||
"transcodeProfileEnabled": "Abilitato",
|
||||
"transcodeProfileEnabledDescription": "Mostra questa impostazione predefinita di qualità nel menu del lettore.",
|
||||
"transcodeProfileMaxResolution": "Risoluzione massima",
|
||||
"transcodeProfileMaxResolutionHelp": "Altezza massima di output in pixel. Il valore 0 mantiene la risoluzione originale.",
|
||||
"transcodeProfileAvgBitrate": "Bitrate medio (kbps)",
|
||||
"transcodeProfileAvgBitrateHelp": "Bitrate medio desiderato del video in kbps. Il valore 0 mantiene il bitrate originale.",
|
||||
"transcodeReadRate": "Producer rate limit (× realtime)",
|
||||
"transcodeReadRateHelp": "Caps the transcode producer at N× realtime input pacing (ffmpeg -readrate) to limit CPU. Minimum 1; defaults to 2. Applies to transcode jobs only; stream-copy runs unthrottled.",
|
||||
"accelDevice": "Dispositivo di accelerazione",
|
||||
"accelDeviceDescription": "Dispositivo GPU utilizzato per l'elaborazione multimediale con accelerazione hardware. La CPU (software) disattiva l'accelerazione hardware.",
|
||||
"encoderBackend": "Backend dell'encoder",
|
||||
"transcodeProfileEncoderBackendHelp": "Backend dell'encoder per questa preimpostazione; per impostazione predefinita viene utilizzato il backend preferito rilevato.",
|
||||
"alwaysUseFfmpeg": "Utilizza sempre ffmpeg",
|
||||
"alwaysUseFfmpegDescription": "Utilizza sempre ffmpeg per l'analisi, i metadati, i sottotitoli e le miniature dei video. Questo metodo sarà più lento, ma può risultare più affidabile.",
|
||||
"alwaysUseOffice": "Utilizzare sempre il server aziendale per le miniature dei documenti",
|
||||
"alwaysUseOfficeDescription": "Utilizza sempre il server dell'ufficio per le miniature dei documenti, anziché la generazione delle miniature durante l'elaborazione. Questa operazione sarà più lenta, ma potrebbe risultare più affidabile."
|
||||
}
|
||||
}
|
||||
+689
-687
File diff suppressed because it is too large.
Load diff
+688
-686
File diff suppressed because it is too large.
Load diff
+705
-703
File diff suppressed because it is too large.
Load diff
+84
-82
@@ -80,6 +80,7 @@
|
||||
"back": "Terug{suffix}",
|
||||
"stay": "Blijven{suffix}",
|
||||
"cancel": "Annuleren{suffix}",
|
||||
"default": "Standaard{suffix}",
|
||||
"change": "Wijzigen{suffix}",
|
||||
"verify": "Verifiëren{suffix}",
|
||||
"close": "Sluiten{suffix}",
|
||||
@@ -111,6 +112,11 @@
|
||||
"pause": "Pauzeren{suffix}",
|
||||
"resume": "Hervatten{suffix}",
|
||||
"retry": "Opnieuw proberen{suffix}",
|
||||
"transcode": "Transcoderen{suffix}",
|
||||
"off": "Uit{suffix}",
|
||||
"quality": "Kwaliteit{suffix}",
|
||||
"balanced": "Evenwichtig{suffix}",
|
||||
"dataSaver": "Databesparing{suffix}",
|
||||
"disable": "Uitschakelen{suffix}",
|
||||
"confirm": "Bevestigen{suffix}",
|
||||
"uploading": "Uploaden{suffix}",
|
||||
@@ -173,19 +179,13 @@
|
||||
"interval": "Interval{suffix}",
|
||||
"none": "Geen",
|
||||
"enforce": "Handhaven{suffix}",
|
||||
"media": "Media",
|
||||
"unknown": "Onbekend",
|
||||
"simple": "Eenvoudig",
|
||||
"normal": "Normaal",
|
||||
"complex": "Complex",
|
||||
"access": "Toegang",
|
||||
"groups": "Groepen",
|
||||
"transcode": "Transcoderen{suffix}",
|
||||
"off": "Uit{suffix}",
|
||||
"quality": "Kwaliteit{suffix}",
|
||||
"balanced": "Evenwichtig{suffix}",
|
||||
"dataSaver": "Databesparing{suffix}",
|
||||
"media": "Media",
|
||||
"default": "Standaard{suffix}"
|
||||
"groups": "Groepen"
|
||||
},
|
||||
"buttons": {
|
||||
"copySuccess": "Gekopieerd naar klembord!",
|
||||
@@ -231,8 +231,6 @@
|
||||
"requestTimedOut": "De tijdlimiet voor het verzoek is verstreken. Controleer je netwerk- of reverse-proxy-instellingen."
|
||||
},
|
||||
"files": {
|
||||
"sortByCreationTime": "Sorteren op aanmaaktijd",
|
||||
"creationTime": "Aanmaaktijd",
|
||||
"emptyFile": "Leeg bestand",
|
||||
"lastModified": "Laatst gewijzigd{suffix}",
|
||||
"duration": "Duur{suffix}",
|
||||
@@ -243,6 +241,8 @@
|
||||
"sortBySize": "Sorteren op grootte",
|
||||
"sortByType": "Sorteren op type",
|
||||
"sortByDuration": "Sorteren op duur",
|
||||
"sortByCreationTime": "Sorteren op aanmaaktijd",
|
||||
"creationTime": "Aanmaaktijd",
|
||||
"noPreview": "Voorbeeld is niet beschikbaar voor dit bestand.",
|
||||
"mobilePdfPreviewHint": "Je browser kan dit PDF-bestand niet weergeven in het app-voorbeeld.",
|
||||
"noDownloadAccess": "Download is niet beschikbaar.",
|
||||
@@ -568,10 +568,6 @@
|
||||
"openInAdvancedSearch": "Openen in geavanceerd zoeken"
|
||||
},
|
||||
"profileSettings": {
|
||||
"showTypeColumn": "Type-kolom tonen",
|
||||
"showTypeColumnDescription": "Toon de Type-kolom in lijst- en compacte weergaven in de hoofdlijst.",
|
||||
"showCreationDateColumn": "Aanmaakdatum-kolom tonen",
|
||||
"showCreationDateColumnDescription": "Toon de Aanmaakdatum-kolom in lijst- en compacte weergaven in de hoofdlijst.",
|
||||
"promptRightCloseButton": "Toon sluitknop van prompts rechts",
|
||||
"promptRightCloseButtonDescription": "Toon de sluitknop van de prompts aan de rechterkant in plaats van aan de linkerkant",
|
||||
"enforcedByAdmin": "Uw beheerder heeft ervoor gezorgd dat gebruikers deze eigenschap niet kunnen wijzigen",
|
||||
@@ -630,6 +626,10 @@
|
||||
"showToolsInSidebarDescription": "Altijd links naar gereedschap tonen in de zijbalk.",
|
||||
"showCopyPath": "Pad kopiëren tonen in contextmenu",
|
||||
"showCopyPathDescription": "Een actie Pad kopiëren weergeven in het contextmenu van bestanden en mappen.",
|
||||
"showTypeColumn": "Type-kolom tonen",
|
||||
"showTypeColumnDescription": "Toon de Type-kolom in lijst- en compacte weergaven in de hoofdlijst.",
|
||||
"showCreationDateColumn": "Aanmaakdatum-kolom tonen",
|
||||
"showCreationDateColumnDescription": "Toon de Aanmaakdatum-kolom in lijst- en compacte weergaven in de hoofdlijst.",
|
||||
"deleteWithoutConfirming": "Bestanden verwijderen zonder bevestigingsvenster",
|
||||
"deleteWithoutConfirmingDescription": "Bestanden verwijderen zonder bevestigingsvenster. Dit werkt alleen als het bestand een mediabestand is.",
|
||||
"previewOfficeDescription": "Toon miniaturen voor Office-bestandstypen in de listingweergave",
|
||||
@@ -683,6 +683,8 @@
|
||||
"analyticsPreviewFailed": "Het laden van diagnostische gegevens voor Analytics is mislukt",
|
||||
"enterPassword": "Wachtwoord invoeren",
|
||||
"enterPasswordAgain": "Wachtwoord opnieuw invoeren",
|
||||
"passwordRequirementsMatch": "Wachtwoorden komen overeen",
|
||||
"passwordRequirementsMinLength": "{min} -tekens",
|
||||
"loginMethod": "Aanmeldingsmethode",
|
||||
"modifyOtherUser": "Gebruiker wijzigen:",
|
||||
"modifyOtherUserTitle": "Gebruiker aanpassen: {username}",
|
||||
@@ -695,6 +697,7 @@
|
||||
"userNotAdmin": "Gebruiker is geen beheerder",
|
||||
"avoidChanges": "(Leeg laten om wijzigingen te voorkomen)",
|
||||
"lockPassword": "Voorkom dat de gebruiker het wachtwoord wijzigt",
|
||||
"requirePasswordChange": "Vraag om het wachtwoord te wijzigen bij de volgende aanmelding",
|
||||
"permissions": {
|
||||
"admin": "Beheerder",
|
||||
"api": "Langlevende API-sleutels aanmaken en beheren",
|
||||
@@ -703,14 +706,51 @@
|
||||
"permissionsHelp": "Algemene machtigingen voor de gebruiker gelden overal, inclusief alle bronnen en Shares.",
|
||||
"sourcePermissionsHelp": "Vouw een bronrij uit om de rechten voor het bekijken, downloaden, wijzigen, aanmaken en verwijderen van die bron in te stellen.",
|
||||
"sourceAccessDefaultsHelp": "Standaard bestandsrechten die worden toegepast op bronbestanden en nieuwe gebruikers. Met ‘Enforce’ wordt een waarde vastgelegd voor alle gebruikers die geen beheerder zijn.",
|
||||
"passwordRequirementsMatch": "Wachtwoorden komen overeen",
|
||||
"passwordRequirementsMinLength": "{min} -tekens",
|
||||
"requirePasswordChange": "Vraag om het wachtwoord te wijzigen bij de volgende aanmelding",
|
||||
"settingsUpdated": "Instellingen bijgewerkt!",
|
||||
"shareDeleted": "Share verwijderd!",
|
||||
"userDeleted": "Gebruiker verwijderd!",
|
||||
"userUpdated": "Gebruiker bijgewerkt!"
|
||||
},
|
||||
"media": {
|
||||
"accelDevice": "Versnellingsapparaat",
|
||||
"accelDeviceDescription": "GPU-apparaat dat wordt gebruikt voor hardwareversnelde mediaverwerking. De CPU (software) schakelt de hardwareversnelling uit.",
|
||||
"encoderBackend": "Encoder-backend",
|
||||
"transcodeProfileEncoderBackendHelp": "Encoder-backend voor deze voorinstelling; standaard is dit de gedetecteerde voorkeursbackend.",
|
||||
"alwaysUseFfmpeg": "Gebruik altijd ffmpeg",
|
||||
"alwaysUseFfmpegDescription": "Gebruik altijd ffmpeg voor het analyseren van bestanden, metagegevens, ondertitels en videominiaturen. Dit gaat weliswaar langzamer, maar is vaak betrouwbaarder.",
|
||||
"alwaysUseOffice": "Gebruik altijd de kantoorserver voor miniatuurafbeeldingen van documenten",
|
||||
"alwaysUseOfficeDescription": "Gebruik altijd de kantoorserver voor miniatuurafbeeldingen van documenten in plaats van de miniatuurgeneratie tijdens de verwerking. Dit gaat weliswaar langzamer, maar is mogelijk betrouwbaarder.",
|
||||
"debug": "Foutlogboek",
|
||||
"debugDescription": "FFmpeg-stdout-uitvoer voor de media-integratie. Waarschuwing: dit levert veel uitvoer op.",
|
||||
"imagePreview": "Formaten voor afbeeldingsvoorbeelden",
|
||||
"videoPreview": "Formaten voor videovoorbeelden",
|
||||
"imageVideoConversionFormats": "Bestandsformaten voor afbeeldingen en video’s",
|
||||
"imageVideoConversionFormatsDescription": "Kies welke afbeeldings- en videoformaten ffmpeg mag converteren voor voorbeelden.",
|
||||
"updateFailed": "Het bijwerken van de instellingen is mislukt.",
|
||||
"maxConcurrentTranscodes": "Maximaal aantal gelijktijdige transcoderingen",
|
||||
"maxConcurrentTranscodesHelp": "Het maximale aantal gelijktijdige HLS-transcodering- en afspeelsessies voor deze gebruiker.",
|
||||
"transcode": "Transcodering",
|
||||
"transcodeEnabled": "Transcodering inschakelen",
|
||||
"transcodeEnabledDescription": "Schakel HLS-transcodering aan de serverzijde in voor afspelen na authenticatie.",
|
||||
"transcodeSettings": "Instellingen voor transcodering",
|
||||
"transcodeSettingsDescription": "Beperkingen op gelijktijdigheid, schijfcache en de limieten voor resolutie en bitsnelheid per profiel.",
|
||||
"transcodeMaxConcurrent": "Wereldwijde gelijktijdige sessies",
|
||||
"transcodeMaxConcurrentHelp": "Maximaal aantal gelijktijdige transcodersessies voor alle gebruikers samen.",
|
||||
"transcodeMaxConcurrentPerUser": "Maximaal aantal sessies per gebruiker",
|
||||
"transcodeMaxConcurrentPerUserHelp": "Er geldt een strikte limiet voor het aantal gelijktijdige transcodersessies per gebruiker. Gebruikersinstellingen mogen deze limiet niet overschrijden.",
|
||||
"transcodeCacheMaxSizeMB": "Maximale cachegrootte (MB)",
|
||||
"transcodeCacheMaxSizeMBHelp": "Maximale schijfruimte voor de transcodecache in MB. 0 betekent onbeperkt.",
|
||||
"transcodeCacheRetentionH": "Cache-bewaartermijn (uren)",
|
||||
"transcodeCacheRetentionHHelp": "Hoe lang worden inactieve transcode-cachegeneraties bewaard voordat ze worden verwijderd?",
|
||||
"transcodeProfileEnabled": "Ingeschakeld",
|
||||
"transcodeProfileEnabledDescription": "Geef deze kwaliteitsinstelling weer in het menu van de speler.",
|
||||
"transcodeProfileMaxResolution": "Maximale resolutie",
|
||||
"transcodeProfileMaxResolutionHelp": "Maximale uitvoerhoogte in pixels. Bij 0 blijft de bronresolutie behouden.",
|
||||
"transcodeProfileAvgBitrate": "Gemiddelde bitsnelheid (kbps)",
|
||||
"transcodeProfileAvgBitrateHelp": "Beoogde gemiddelde videobitsnelheid in kbps. Bij 0 blijft de bitsnelheid van de bron behouden.",
|
||||
"transcodeReadRate": "Producer rate limit (× realtime)",
|
||||
"transcodeReadRateHelp": "Caps the transcode producer at N× realtime input pacing (ffmpeg -readrate) to limit CPU. Minimum 1; defaults to 2. Applies to transcode jobs only; stream-copy runs unthrottled."
|
||||
},
|
||||
"time": {
|
||||
"time": "Tijd{suffix}",
|
||||
"minute": "minuut{suffix}",
|
||||
@@ -743,6 +783,8 @@
|
||||
"usageTextDisk": "gebruik zoals gerapporteerd door schijf/partitie",
|
||||
"showIndexedUsage": "Gebruik weergeven zoals geïndexeerd",
|
||||
"showDiskUsage": "Weergave van het gebruik zoals gerapporteerd per schijf/partitie",
|
||||
"rootFilesystemOnly": "Het schijfgebruik beperken tot het bronbestandssysteem",
|
||||
"rootFilesystemOnlyDescription": "Indien deze optie is ingeschakeld, wordt alleen het bestandssysteem met het bronpad weergegeven. Indien deze optie is uitgeschakeld (standaard), omvat het totale schijfvolume ook bestandssystemen die onder het bronpad zijn gekoppeld, waarbij gedeelde pools zoals ZFS-datasets slechts één keer worden meegeteld.",
|
||||
"selectSource": "Bron selecteren",
|
||||
"chooseSource": "Kies een bron",
|
||||
"selectShare": "Selecteer Share",
|
||||
@@ -763,9 +805,7 @@
|
||||
"sidebarLinkDefaultsDescription": "Stel de standaard- en verplichte links in de zijbalk in voor alle gebruikers.",
|
||||
"sidebarLinkDefaultsHelp": "Dit zijn de standaardlinks die voor alle nieuwe gebruikers worden toegevoegd. Als je een link verplicht stelt, wordt deze voor alle gebruikers toegevoegd en kan deze niet worden verwijderd door gebruikers die geen beheerder zijn.",
|
||||
"sidebarLinkDefaultSourceHelp": "Dit geldt alleen als de gebruiker toegang heeft tot deze bron.",
|
||||
"sidebarLinkDefaultsYamlInvalid": "Ongeldige YAML. Controleer de opmaak en probeer het opnieuw.",
|
||||
"rootFilesystemOnly": "Het schijfgebruik beperken tot het bronbestandssysteem",
|
||||
"rootFilesystemOnlyDescription": "Indien deze optie is ingeschakeld, wordt alleen het bestandssysteem met het bronpad weergegeven. Indien deze optie is uitgeschakeld (standaard), omvat het totale schijfvolume ook bestandssystemen die onder het bronpad zijn gekoppeld, waarbij gedeelde pools zoals ZFS-datasets slechts één keer worden meegeteld."
|
||||
"sidebarLinkDefaultsYamlInvalid": "Ongeldige YAML. Controleer de opmaak en probeer het opnieuw."
|
||||
},
|
||||
"api": {
|
||||
"title": "API-sleutels",
|
||||
@@ -817,9 +857,22 @@
|
||||
"newGroup": "Nieuwe groep",
|
||||
"editGroup": "Groep bewerken",
|
||||
"groupName": "Groepsnaam",
|
||||
"userGroupsDescription": "Groepen waarvan deze gebruiker lid is",
|
||||
"groupMembers": "Leden",
|
||||
"membersCount": "Leden: {count}",
|
||||
"userGroupsCount": "Groepen: {count}",
|
||||
"groupNameRequired": "De groepsnaam is verplicht.",
|
||||
"groupNameTooShort": "Group name must be at least {min} characters.",
|
||||
"groupNameTooLong": "De groepsnaam mag maximaal {max} tekens lang zijn.",
|
||||
"groupNameInvalid": "De groepsnaam mag geen besturingstekens bevatten.",
|
||||
"groupExists": "Er bestaat al een groep met deze naam.",
|
||||
"deleteGroupConfirm": "Groep \"{name}\" verwijderen? Deze wordt ook uit alle toegangsregels verwijderd.",
|
||||
"groupsOidcNote": "Leden van OIDC-gesynchroniseerde groepen worden bij elke aanmelding van die gebruikers opnieuw ingesteld vanuit de identiteitsprovider.",
|
||||
"selectUser": "Gebruiker selecteren",
|
||||
"selectUsers": "Gebruikers selecteren",
|
||||
"selectGroup": "Groep selecteren",
|
||||
"selectGroups": "Groepen selecteren",
|
||||
"refineSearch": "De eerste {count} -resultaten worden weergegeven. Verfijn je zoekopdracht om meer resultaten te zien.",
|
||||
"all": "Alles weigeren",
|
||||
"rules": "Toegangsregels",
|
||||
"deny": "Weigeren",
|
||||
@@ -831,20 +884,7 @@
|
||||
"defaultBehavior": "Standaardactie{suffix}",
|
||||
"defaultBehaviorDescription": "Dit laat zien wat er gebeurt als een gebruiker toegang probeert te krijgen tot een pad waarvoor geen specifieke toegangsregels bestaan. Toestaan' betekent dat gebruikers standaard toegang hebben, 'Weigeren' betekent dat gebruikers standaard worden geblokkeerd tenzij ze expliciet toestemming krijgen.",
|
||||
"cascadeDelete": "Recursief verwijderen",
|
||||
"cascadeDeleteDescription": "Indien ingeschakeld, zal het verwijderen van een toegangsregel deze ook verwijderen uit alle submappen. Hierdoor kunt u een gebruiker of groep volledig verwijderen uit de hele mappenstructuur.",
|
||||
"userGroupsDescription": "Groepen waarvan deze gebruiker lid is",
|
||||
"membersCount": "Leden: {count}",
|
||||
"userGroupsCount": "Groepen: {count}",
|
||||
"groupNameRequired": "De groepsnaam is verplicht.",
|
||||
"groupNameTooShort": "Group name must be at least {min} characters.",
|
||||
"groupNameTooLong": "De groepsnaam mag maximaal {max} tekens lang zijn.",
|
||||
"groupNameInvalid": "De groepsnaam mag geen besturingstekens bevatten.",
|
||||
"groupExists": "Er bestaat al een groep met deze naam.",
|
||||
"selectUser": "Gebruiker selecteren",
|
||||
"selectUsers": "Gebruikers selecteren",
|
||||
"selectGroup": "Groep selecteren",
|
||||
"selectGroups": "Groepen selecteren",
|
||||
"refineSearch": "De eerste {count} -resultaten worden weergegeven. Verfijn je zoekopdracht om meer resultaten te zien."
|
||||
"cascadeDeleteDescription": "Indien ingeschakeld, zal het verwijderen van een toegangsregel deze ook verwijderen uit alle submappen. Hierdoor kunt u een gebruiker of groep volledig verwijderen uit de hele mappenstructuur."
|
||||
},
|
||||
"fileLoading": {
|
||||
"title": "Uploads & Downloads",
|
||||
@@ -854,6 +894,8 @@
|
||||
"uploadChunkSizeMbHelp": "Gewoonlijk 5 tot 50 MB, kleiner is beter voor onstabiele of beperkte verbindingen. Instellen op 0 schakelt uploads in brokken uit.",
|
||||
"downloadChunkSizeMb": "Grootte downloadbrok in MB",
|
||||
"downloadChunkSizeMbHelp": "Brok-grootte voor downloads in brokken. Bestanden die groter zijn dan deze grootte worden in brokken gedownload om time-outproblemen te voorkomen (bijv. Cloudflare 524 fouten). Instellen op 0 schakelt downloads in brokken uit.",
|
||||
"uploadStallTimeoutSeconds": "Time-out voor vastgelopen uploads in seconden",
|
||||
"uploadStallTimeoutSecondsHelp": "Aantal seconden dat een upload zonder voortgang kan blijven voordat hij wordt gepauzeerd. Verhoog dit bij trage of onstabiele verbindingen, of wanneer de bestanden van trage schijven worden gelezen. Waarden lager dan 1 gebruiken de standaardwaarde van 10 seconden.",
|
||||
"clearAll": "Wissen voltooid moet fout, pauze en conflict verhelpen",
|
||||
"clearAllDescription": "De knop Wissen voltooid wist alle uploads in een voltooide status, inclusief fout-, gepauzeerde en conflictstatussen.",
|
||||
"uploadSettings": "Instellingen uploaden"
|
||||
@@ -983,6 +1025,11 @@
|
||||
"toggleLyrics": "Songtekst in-/uitschakelen",
|
||||
"closePanel": "Paneel sluiten",
|
||||
"openPanel": "Paneel openen",
|
||||
"transcodeFailed": "Het transcoderen is mislukt",
|
||||
"transcodeLoading": "Een getranscodeerde stream voorbereiden",
|
||||
"transcodeUnsupportedFormat": "Dit videoformaat wordt niet ondersteund door je browser.",
|
||||
"transcodeUserLimitReached": "De limiet voor transcoderen is bereikt. Je hebt al een actieve transcodersessie:",
|
||||
"transcodeSystemLimitReached": "De limiet voor transcodering op de server is bereikt ({limit} gelijktijdig). Probeer het opnieuw zodra een sessie is beëindigd.",
|
||||
"visualizer": {
|
||||
"title": "Visualisator",
|
||||
"settings": "Visualisator-instellingen",
|
||||
@@ -1005,12 +1052,7 @@
|
||||
"maxDecibelsDescription": "Luidste niveau (dBFS) dat de balk registreert",
|
||||
"showScalesDescription": "Assen en rasterlijnen weergeven op de visualisator",
|
||||
"showPeaksDescription": "Kleine witte piekindicatoren boven elke balk"
|
||||
},
|
||||
"transcodeFailed": "Het transcoderen is mislukt",
|
||||
"transcodeLoading": "Een getranscodeerde stream voorbereiden",
|
||||
"transcodeUnsupportedFormat": "Dit videoformaat wordt niet ondersteund door je browser.",
|
||||
"transcodeUserLimitReached": "De limiet voor transcoderen is bereikt. Je hebt al een actieve transcodersessie:",
|
||||
"transcodeSystemLimitReached": "De limiet voor transcodering op de server is bereikt ({limit} gelijktijdig). Probeer het opnieuw zodra een sessie is beëindigd."
|
||||
}
|
||||
},
|
||||
"fileSizeAnalyzer": {
|
||||
"title": "Bestandsgrootte analyseren",
|
||||
@@ -1095,6 +1137,7 @@
|
||||
"scopeAccess": "Toegangsactiviteit",
|
||||
"scopeShares": "Activiteit delen",
|
||||
"eventBulkDelete": "In één keer verwijderen",
|
||||
"eventFileModify": "Bestand wijzigen",
|
||||
"eventShareCreate": "Share Aanmaken",
|
||||
"eventShareUpdate": "Share bijwerken",
|
||||
"eventShareDelete": "Share Verwijderen",
|
||||
@@ -1148,8 +1191,7 @@
|
||||
"shareFilter": "Share-filter",
|
||||
"pathGlob": "Padpatroon (glob)",
|
||||
"viewActivity": "Activiteit bekijken",
|
||||
"tableColumns": "Kolommen in de tabel",
|
||||
"eventFileModify": "Bestand wijzigen"
|
||||
"tableColumns": "Kolommen in de tabel"
|
||||
},
|
||||
"toolNotFound": "Gereedschap niet gevonden"
|
||||
},
|
||||
@@ -1234,45 +1276,5 @@
|
||||
},
|
||||
"pwa": {
|
||||
"install": "App installeren"
|
||||
},
|
||||
"media": {
|
||||
"debug": "Foutlogboek",
|
||||
"debugDescription": "FFmpeg-stdout-uitvoer voor de media-integratie. Waarschuwing: dit levert veel uitvoer op.",
|
||||
"imagePreview": "Formaten voor afbeeldingsvoorbeelden",
|
||||
"videoPreview": "Formaten voor videovoorbeelden",
|
||||
"imageVideoConversionFormats": "Bestandsformaten voor afbeeldingen en video’s",
|
||||
"imageVideoConversionFormatsDescription": "Kies welke afbeeldings- en videoformaten ffmpeg mag converteren voor voorbeelden.",
|
||||
"updateFailed": "Het bijwerken van de instellingen is mislukt.",
|
||||
"maxConcurrentTranscodes": "Maximaal aantal gelijktijdige transcoderingen",
|
||||
"maxConcurrentTranscodesHelp": "Het maximale aantal gelijktijdige HLS-transcodering- en afspeelsessies voor deze gebruiker.",
|
||||
"transcode": "Transcodering",
|
||||
"transcodeEnabled": "Transcodering inschakelen",
|
||||
"transcodeEnabledDescription": "Schakel HLS-transcodering aan de serverzijde in voor afspelen na authenticatie.",
|
||||
"transcodeSettings": "Instellingen voor transcodering",
|
||||
"transcodeSettingsDescription": "Beperkingen op gelijktijdigheid, schijfcache en de limieten voor resolutie en bitsnelheid per profiel.",
|
||||
"transcodeMaxConcurrent": "Wereldwijde gelijktijdige sessies",
|
||||
"transcodeMaxConcurrentHelp": "Maximaal aantal gelijktijdige transcodersessies voor alle gebruikers samen.",
|
||||
"transcodeMaxConcurrentPerUser": "Maximaal aantal sessies per gebruiker",
|
||||
"transcodeMaxConcurrentPerUserHelp": "Er geldt een strikte limiet voor het aantal gelijktijdige transcodersessies per gebruiker. Gebruikersinstellingen mogen deze limiet niet overschrijden.",
|
||||
"transcodeCacheMaxSizeMB": "Maximale cachegrootte (MB)",
|
||||
"transcodeCacheMaxSizeMBHelp": "Maximale schijfruimte voor de transcodecache in MB. 0 betekent onbeperkt.",
|
||||
"transcodeCacheRetentionH": "Cache-bewaartermijn (uren)",
|
||||
"transcodeCacheRetentionHHelp": "Hoe lang worden inactieve transcode-cachegeneraties bewaard voordat ze worden verwijderd?",
|
||||
"transcodeProfileEnabled": "Ingeschakeld",
|
||||
"transcodeProfileEnabledDescription": "Geef deze kwaliteitsinstelling weer in het menu van de speler.",
|
||||
"transcodeProfileMaxResolution": "Maximale resolutie",
|
||||
"transcodeProfileMaxResolutionHelp": "Maximale uitvoerhoogte in pixels. Bij 0 blijft de bronresolutie behouden.",
|
||||
"transcodeProfileAvgBitrate": "Gemiddelde bitsnelheid (kbps)",
|
||||
"transcodeProfileAvgBitrateHelp": "Beoogde gemiddelde videobitsnelheid in kbps. Bij 0 blijft de bitsnelheid van de bron behouden.",
|
||||
"transcodeReadRate": "Producer rate limit (× realtime)",
|
||||
"transcodeReadRateHelp": "Caps the transcode producer at N× realtime input pacing (ffmpeg -readrate) to limit CPU. Minimum 1; defaults to 2. Applies to transcode jobs only; stream-copy runs unthrottled.",
|
||||
"accelDevice": "Versnellingsapparaat",
|
||||
"accelDeviceDescription": "GPU-apparaat dat wordt gebruikt voor hardwareversnelde mediaverwerking. De CPU (software) schakelt de hardwareversnelling uit.",
|
||||
"encoderBackend": "Encoder-backend",
|
||||
"transcodeProfileEncoderBackendHelp": "Encoder-backend voor deze voorinstelling; standaard is dit de gedetecteerde voorkeursbackend.",
|
||||
"alwaysUseFfmpeg": "Gebruik altijd ffmpeg",
|
||||
"alwaysUseFfmpegDescription": "Gebruik altijd ffmpeg voor het analyseren van bestanden, metagegevens, ondertitels en videominiaturen. Dit gaat weliswaar langzamer, maar is vaak betrouwbaarder.",
|
||||
"alwaysUseOffice": "Gebruik altijd de kantoorserver voor miniatuurafbeeldingen van documenten",
|
||||
"alwaysUseOfficeDescription": "Gebruik altijd de kantoorserver voor miniatuurafbeeldingen van documenten in plaats van de miniatuurgeneratie tijdens de verwerking. Dit gaat weliswaar langzamer, maar is mogelijk betrouwbaarder."
|
||||
}
|
||||
}
|
||||
+688
-686
File diff suppressed because it is too large.
Load diff
+688
-686
File diff suppressed because it is too large.
Load diff
+705
-703
File diff suppressed because it is too large.
Load diff
+688
-686
File diff suppressed because it is too large.
Load diff
+688
-686
File diff suppressed because it is too large.
Load diff
+688
-686
File diff suppressed because it is too large.
Load diff
+705
-703
File diff suppressed because it is too large.
Load diff
+688
-686
File diff suppressed because it is too large.
Load diff
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user