mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-06 17:42:42 -05:00
Compare commits
13
Commits
dev/v2.1.4
...
dev/v2.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43682dc809 | ||
|
|
80fba42076 | ||
|
|
a01ab6901c | ||
|
|
e652538ea8 | ||
|
|
c974da7002 | ||
|
|
eaee271b96 | ||
|
|
0d6b592046 | ||
|
|
1791bb7a36 | ||
|
|
86d4994152 | ||
|
|
8415d5f1ff | ||
|
|
85f934208c | ||
|
|
1022ecafef | ||
|
|
062ac9fb2d |
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))
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -18,3 +20,19 @@ func hasEmbeddedPreview(fileType, fileName string) bool {
|
||||
func isJPEG(data []byte) bool {
|
||||
return len(data) >= 2 && data[0] == 0xff && data[1] == 0xd8
|
||||
}
|
||||
|
||||
// maxEmbeddedPreviewPixels bounds the embedded JPEG that is decoded for orientation and resizing, so a
|
||||
// crafted or corrupt file can't make the decode allocate memory proportional to a huge declared size.
|
||||
// Camera previews are at most full sensor resolution (a 100MP medium format JPEG is about 102M pixels).
|
||||
const maxEmbeddedPreviewPixels = 150_000_000
|
||||
|
||||
// embeddedPreviewWithinLimits reports whether a JPEG's declared dimensions are small enough to decode.
|
||||
// Only the header is read. A JPEG whose header can't be parsed is rejected too: its size can't be
|
||||
// checked, and the decode steps that follow would run on it unbounded.
|
||||
func embeddedPreviewWithinLimits(data []byte) bool {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return int64(cfg.Width)*int64(cfg.Height) <= maxEmbeddedPreviewPixels
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package preview
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHasEmbeddedPreview(t *testing.T) {
|
||||
if !hasEmbeddedPreview("image/x-canon-cr2", "photo.cr2") {
|
||||
@@ -19,3 +24,48 @@ func TestIsJPEGMarker(t *testing.T) {
|
||||
t.Fatal("expected JPEG SOI marker")
|
||||
}
|
||||
}
|
||||
|
||||
// jpegWithDeclaredSize returns a valid small JPEG whose SOF header declares the given size.
|
||||
// Only the header is rewritten, so building it costs nothing even for a huge declared size.
|
||||
func jpegWithDeclaredSize(t *testing.T, width, height int) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, image.NewGray(image.Rect(0, 0, 8, 8)), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := buf.Bytes()
|
||||
sof := bytes.Index(data, []byte{0xff, 0xc0})
|
||||
if sof < 0 {
|
||||
t.Fatal("no SOF0 marker in test JPEG")
|
||||
}
|
||||
// marker(2) length(2) precision(1) height(2) width(2)
|
||||
data[sof+5], data[sof+6] = byte(height>>8), byte(height)
|
||||
data[sof+7], data[sof+8] = byte(width>>8), byte(width)
|
||||
return data
|
||||
}
|
||||
|
||||
func TestEmbeddedPreviewWithinLimits(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
width, height int
|
||||
want bool
|
||||
}{
|
||||
{"normal camera preview", 6000, 4000, true},
|
||||
{"100MP medium format preview", 11648, 8736, true},
|
||||
{"declared size far beyond any camera", 60000, 60000, false},
|
||||
{"just over the limit", 12248, 12248, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := embeddedPreviewWithinLimits(jpegWithDeclaredSize(t, tt.width, tt.height)); got != tt.want {
|
||||
t.Fatalf("embeddedPreviewWithinLimits(%dx%d) = %v, want %v", tt.width, tt.height, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedPreviewWithinLimitsRejectsAnUnreadableHeader(t *testing.T) {
|
||||
if embeddedPreviewWithinLimits([]byte{0xff, 0xd8, 0xff, 0xe0, 0x00}) {
|
||||
t.Fatal("a header that can't be read can't be size-checked, so it is rejected")
|
||||
}
|
||||
}
|
||||
@@ -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,6 +7,7 @@ import (
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"math"
|
||||
|
||||
exif "github.com/dsoprea/go-exif/v3"
|
||||
exifcommon "github.com/dsoprea/go-exif/v3/common"
|
||||
@@ -198,6 +199,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 {
|
||||
@@ -275,7 +320,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:
|
||||
|
||||
@@ -65,7 +65,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)
|
||||
@@ -449,7 +449,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)
|
||||
@@ -467,7 +469,7 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
|
||||
var imageBytes []byte
|
||||
fromEmbeddedPreview := false
|
||||
if hasEmbeddedPreview(file.Type, file.Name) {
|
||||
if previewBytes, _ := imagemeta.ExtractEmbeddedPreview(ctx, file.RealPath); len(previewBytes) >= minPreviewSize && isJPEG(previewBytes) {
|
||||
if previewBytes, _ := imagemeta.ExtractEmbeddedPreview(ctx, file.RealPath); len(previewBytes) >= minPreviewSize && isJPEG(previewBytes) && embeddedPreviewWithinLimits(previewBytes) {
|
||||
imageBytes = previewBytes
|
||||
fromEmbeddedPreview = true
|
||||
if orient := imagemeta.GetOrientation(ctx, file.RealPath); orient != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,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 +80,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 +112,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 +139,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" {
|
||||
|
||||
@@ -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,140 @@
|
||||
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)"
|
||||
darkTrue := true
|
||||
defaults.UI.DarkMode = &darkTrue
|
||||
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 {
|
||||
t.Errorf("darkMode = true, want false")
|
||||
}
|
||||
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)")
|
||||
}
|
||||
}
|
||||
@@ -1589,7 +1589,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -78,11 +78,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
|
||||
|
||||
@@ -6535,6 +6535,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": [
|
||||
|
||||
@@ -6524,6 +6524,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": [
|
||||
|
||||
@@ -890,6 +890,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'
|
||||
|
||||
+2
-1
@@ -47,7 +47,8 @@
|
||||
"useGenericFontNames": "off",
|
||||
"useKeyWithClickEvents": "off",
|
||||
"useMediaCaption": "off",
|
||||
"useSemanticElements": "off"
|
||||
"useSemanticElements": "off",
|
||||
"useValidAnchor": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "on",
|
||||
|
||||
@@ -50,6 +50,7 @@ server:
|
||||
configured: false # true when explicitly set (allows intentional deny-all)
|
||||
cacheDir: "tmp" # path to the cache directory, used for thumbnails and other cached files
|
||||
cacheDirCleanup: false # whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
|
||||
cacheDirMaxSizeMB: 0 # 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)
|
||||
maxArchiveSize: 20 # maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
|
||||
filesystem: # filesystem settings
|
||||
createFilePermission: "644" # Unix permissions like 644, 755, 2755 (default: 644) validate:required,file_permission
|
||||
|
||||
@@ -63,4 +63,13 @@ describe("ContextMenu in advanced search", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -423,6 +423,8 @@ export default {
|
||||
},
|
||||
showShareAction() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
// Advanced search results have no folder of their own to share, only the selected results do.
|
||||
if (this.isAdvancedSearch && this.selectedCount === 0) return false;
|
||||
return this.selectedCount <= 1 && this.showShare;
|
||||
},
|
||||
showSendToApp() {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, h } from "vue";
|
||||
|
||||
vi.mock("@/store", () => ({ state: { sources: {} }, mutations: { showPrompt: vi.fn() } }));
|
||||
vi.mock("@/store/eventBus", () => ({ eventBus: { on: vi.fn(), off: vi.fn() } }));
|
||||
import PathPickerButton from "./PathPickerButton.vue";
|
||||
|
||||
describe("PathPickerButton keyboard access", () => {
|
||||
let app;
|
||||
afterEach(() => app?.unmount());
|
||||
|
||||
it("can be focused and is exposed as a button, so its Enter and Space handlers can run", () => {
|
||||
const el = document.createElement("div");
|
||||
app = createApp({ render: () => h(PathPickerButton, { path: "/", source: "" }) });
|
||||
app.config.globalProperties.$t = (key) => key;
|
||||
app.mount(el);
|
||||
const picker = el.querySelector(".unified-path-picker");
|
||||
expect(picker.getAttribute("tabindex")).toBe("0");
|
||||
expect(picker.getAttribute("role")).toBe("button");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div
|
||||
:aria-label="resolvedAriaLabel"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="searchContext clickable button unified-path-picker"
|
||||
@click="openPicker"
|
||||
@keydown.enter.prevent="openPicker"
|
||||
@@ -170,4 +172,10 @@ export default {
|
||||
overflow-wrap: break-word;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* .button removes the outline, so keyboard focus needs its own indicator. */
|
||||
.unified-path-picker:focus-visible {
|
||||
outline: 2px solid var(--primaryColor);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -17,7 +17,7 @@
|
||||
>close</i>
|
||||
</div>
|
||||
<div v-if="showPwaInstall" class="button release-banner">
|
||||
<button type="button" @click="installPwa">{{ $t("pwa.install") }}</button>
|
||||
<a type="button" @click="installPwa">{{ $t("pwa.install") }}</a>
|
||||
<i @click="dismissPwaInstall" role="button" tabindex="0" aria-label="close-banner" class="material-symbols">close</i>
|
||||
</div>
|
||||
<SidebarSettings v-if="isSettings"></SidebarSettings>
|
||||
|
||||
+347
-347
File diff suppressed because it is too large.
Load diff
@@ -637,7 +637,7 @@
|
||||
"newUser": "Nouvel utilisateur",
|
||||
"newFolder": "Nouveau dossier",
|
||||
"profileSettings": "Paramètres du profil",
|
||||
"shareSettings": "Partager les paramètres",
|
||||
"shareSettings": "Paramètres des partages",
|
||||
"globalSettings": "Paramètres généraux",
|
||||
"userManagement": "Gestion des utilisateurs",
|
||||
"shareManagement": "Gestion des actions",
|
||||
|
||||
+241
-241
@@ -11,6 +11,9 @@
|
||||
"general": {
|
||||
"import": "Importa",
|
||||
"export": "Esporta",
|
||||
"to": "a{suffix}",
|
||||
"and": "e{suffix}",
|
||||
"or": "oppure{suffix}",
|
||||
"divider": "Divisore{suffix}",
|
||||
"office": "Office{suffix}",
|
||||
"format": "Formato{suffix}",
|
||||
@@ -34,6 +37,7 @@
|
||||
"permanent": "Permanente{suffix}",
|
||||
"hash": "Hash{suffix}",
|
||||
"of": "di{suffix}",
|
||||
"source": "{prefix}Fonte{suffix}",
|
||||
"percentage": "{percentage}%",
|
||||
"type": "Tipo{suffix}",
|
||||
"size": "Dimensione{suffix}",
|
||||
@@ -43,6 +47,7 @@
|
||||
"logout": "Esci{suffix}",
|
||||
"preview": "Anteprima{suffix}",
|
||||
"settings": "Impostazioni{suffix}",
|
||||
"sources": "{prefix}Fonti{suffix}",
|
||||
"links": "Link{suffix}",
|
||||
"tool": "Strumento{suffix}",
|
||||
"status": "Stato{suffix}",
|
||||
@@ -51,6 +56,7 @@
|
||||
"users": "Utenti{suffix}",
|
||||
"scopes": "Ambiti{suffix}",
|
||||
"photos": "Foto",
|
||||
"audio": "Audio{suffix}",
|
||||
"videos": "Video{suffix}",
|
||||
"documents": "Documenti{suffix}",
|
||||
"archives": "Archivi{suffix}",
|
||||
@@ -69,6 +75,7 @@
|
||||
"navigation": "Navigazione{suffix}",
|
||||
"loading": "Caricamento{suffix}",
|
||||
"add": "Aggiungi{suffix}",
|
||||
"back": "Indietro{suffix}",
|
||||
"stay": "Rimani{suffix}",
|
||||
"cancel": "Annulla{suffix}",
|
||||
"change": "Cambia{suffix}",
|
||||
@@ -82,6 +89,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}",
|
||||
@@ -108,16 +117,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}",
|
||||
@@ -130,7 +133,11 @@
|
||||
"created": "Creato{suffix}",
|
||||
"actions": "Azioni{suffix}",
|
||||
"enabled": "Abilitato{suffix}",
|
||||
"disabled": "Disabile{suffix}",
|
||||
"disabled": "Disabilitato{suffix}",
|
||||
"limit": "Limite{suffix}",
|
||||
"remove": "Rimuovi{suffix}",
|
||||
"saved": "Salvato{suffix}",
|
||||
"removed": "Rimosso{suffix}",
|
||||
"permissions": "Autorizzazioni{suffix}",
|
||||
"management": "Gestione{suffix}",
|
||||
"profile": "Profilo{suffix}",
|
||||
@@ -139,44 +146,38 @@
|
||||
"pinnedFolders": "Cartelle in evidenza",
|
||||
"pinnedFiles": "File in evidenza",
|
||||
"selectionSingle": "elemento selezionato",
|
||||
"selectionMultiple": "articoli selezionati",
|
||||
"selectionMultiple": "elementi selezionati",
|
||||
"newUser": "Nuovo utente",
|
||||
"newFolder": "Nuova cartella",
|
||||
"profileSettings": "Impostazioni del profilo",
|
||||
"shareSettings": "Condividi le impostazioni",
|
||||
"shareSettings": "Impostazioni condivisione",
|
||||
"globalSettings": "Impostazioni generali",
|
||||
"userManagement": "Gestione degli utenti",
|
||||
"shareManagement": "Gestione delle azioni",
|
||||
"shareHash": "Condividi hash",
|
||||
"shareManagement": "Gestione condivisioni",
|
||||
"shareHash": "Hash condivisione",
|
||||
"downloadFiles": "Scarica i file",
|
||||
"viewFiles": "Visualizza ed elenca i file",
|
||||
"editFiles": "Modifica file",
|
||||
"createFiles": "Crea file",
|
||||
"deleteFiles": "Elimina i file",
|
||||
"shareFiles": "Condividi file",
|
||||
"shareTheme": "Condividi il tema",
|
||||
"shareTitle": "Condividi titolo",
|
||||
"shareTheme": "Tema condivisione",
|
||||
"shareTitle": "Titolo condivisione",
|
||||
"allUsers": "Tutti gli utenti",
|
||||
"allSources": "Tutte le fonti",
|
||||
"allShares": "Tutte le azioni",
|
||||
"allShares": "Tutte le condivisioni",
|
||||
"playPause": "Riproduci/Pausa",
|
||||
"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}"
|
||||
"enforce": "Applica{suffix}"
|
||||
},
|
||||
"buttons": {
|
||||
"copySuccess": "Copiato negli appunti!",
|
||||
"copyFailed": "Copia negli appunti fallita",
|
||||
"generateNewOtp": "Resetta e genera nuovo codice a due fattori",
|
||||
"copyFile": "Copia file",
|
||||
"copyPath": "Copia percorso",
|
||||
"copyToClipboard": "Copia negli appunti",
|
||||
"copyDownloadLinkToClipboard": "Copia link di download negli appunti",
|
||||
"moveFile": "Sposta file",
|
||||
@@ -200,11 +201,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.",
|
||||
@@ -216,8 +216,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}",
|
||||
@@ -228,34 +226,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",
|
||||
@@ -265,6 +267,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",
|
||||
@@ -278,11 +282,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": "العربية",
|
||||
@@ -304,10 +304,10 @@
|
||||
"pl": "Polski",
|
||||
"pt": "Português",
|
||||
"ptBR": "Português (Brasil)",
|
||||
"ro": "Romanian",
|
||||
"ro": "Rumeno",
|
||||
"ru": "Русский",
|
||||
"sk": "Slovenčina",
|
||||
"svSE": "Swedish (Sweden)",
|
||||
"svSE": "Svedese (Svezia)",
|
||||
"tr": "Türkçe",
|
||||
"ua": "Українська",
|
||||
"zhCN": "中文 (简体)",
|
||||
@@ -329,8 +329,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. Si applica anche alla quota della sorgente del proprietario.",
|
||||
"scopeLimit": "Limite di archiviazione per questa sorgente",
|
||||
"scopeLimitDescription": "Limita lo spazio di archiviazione assegnato a questo utente su questa sorgente.",
|
||||
"indexingDisabledMeterHint": "L'indicizzazione è disabilitata su questa sorgente. È disponibile solo il conteggio dell'utilizzo tracciato.",
|
||||
"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. I link di condivisione con caricamento e i permessi che modificano i file sono disabilitati.",
|
||||
"disableAnonymous": "Disabilita accesso anonimo",
|
||||
"disableThumbnails": "Disabilita miniature",
|
||||
"enableAllowedUsernames": "Condividi solo con determinati utenti",
|
||||
@@ -380,6 +401,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": "Configura le opzioni predefinite per le nuove condivisioni e applica criteri vincolanti per gli utenti non amministratori.",
|
||||
"browseFiles": "Sfoglia file",
|
||||
"selectBannerImage": "Seleziona immagine banner",
|
||||
"selectFaviconImage": "Seleziona favicon",
|
||||
@@ -396,10 +419,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": {
|
||||
"noTemplate": "Il tuo modello è vuoto!",
|
||||
@@ -423,25 +443,26 @@
|
||||
"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):",
|
||||
"archiveDestination": "Scegli la cartella di destinazione (poi inserisci il nome dell'archivio in basso):",
|
||||
"archiveName": "Nome archivio (default archive.zip):",
|
||||
"archiveCompression": "Livello compressione (0–9):",
|
||||
"archiveCompression": "Livello di compressione (0-9):",
|
||||
"archiveSuccess": "Archivio creato con successo!",
|
||||
"unarchive": "Estrai archivio",
|
||||
"unarchiveMessage": "Scegli dove estrarre l'archivio.",
|
||||
"unarchiveDestination": "Estrai in:",
|
||||
"unarchiveSuccess": "Archivio estratto con successo!",
|
||||
"zipFilenameEncoding": "Filename encoding",
|
||||
"zipEncodingLoading": "Checking ZIP filenames…",
|
||||
"zipEncodingError": "Could not read the ZIP filename preview.",
|
||||
"zipEncodingRetry": "Retry preview",
|
||||
"zipEncodingChoose": "Choose an encoding",
|
||||
"zipEncodingSuggested": "suggested",
|
||||
"zipEncodingHint": "Check the filenames below before extracting. Suggestions may be incorrect; ZIP Unicode metadata takes precedence.",
|
||||
"zipFilenamePreview": "Filename preview",
|
||||
"zipEncodingUnavailable": "No supported encoding can decode all filenames in this ZIP.",
|
||||
"zipFilenameEncoding": "Codifica dei nomi dei file",
|
||||
"zipEncodingLoading": "Controllo dei nomi dei file nello ZIP…",
|
||||
"zipEncodingError": "Impossibile leggere l'anteprima dei nomi dei file dello ZIP.",
|
||||
"zipEncodingRetry": "Riprova anteprima",
|
||||
"zipEncodingChoose": "Scegli una codifica",
|
||||
"zipEncodingSuggested": "consigliata",
|
||||
"zipEncodingHint": "Verifica i nomi dei file sottostanti prima di estrarre. I suggerimenti potrebbero non essere corretti; i metadati Unicode dello ZIP hanno la precedenza.",
|
||||
"zipFilenamePreview": "Anteprima nomi dei file",
|
||||
"zipEncodingUnavailable": "Nessuna codifica supportata può decodificare tutti i nomi dei file in questo ZIP.",
|
||||
"dragAndDrop": "Trascina i file qui",
|
||||
"error": "Qualcosa è andato storto",
|
||||
"completed": "Completato",
|
||||
@@ -455,7 +476,7 @@
|
||||
"renameMessageConflict": "Un elemento chiamato \"{filename}\" esiste già!",
|
||||
"renameMessage": "Inserisci un nuovo nome:",
|
||||
"renameMessageInvalid": "Un file non può contenere \"/\" o \"\\\"",
|
||||
"renameFailed": "Rinomina fallita",
|
||||
"renameFailed": "Rinomina non riuscita",
|
||||
"replaceMessage": "Uno dei file che stai caricando è in conflitto a causa del nome. Vuoi continuare il caricamento o sostituire quello esistente?\n",
|
||||
"saveBeforeExit": "Modifiche non salvate",
|
||||
"saveBeforeExitMessage": "Hai modifiche non salvate nell'editor. Cosa vorresti fare?",
|
||||
@@ -483,7 +504,7 @@
|
||||
"additionalInfo": "Informazioni aggiuntive",
|
||||
"hidden": "Nascosto",
|
||||
"hasPreview": "Ha anteprima",
|
||||
"checksums": "Checksums",
|
||||
"checksums": "Checksum",
|
||||
"hashAlgorithm": "Algoritmo Hash",
|
||||
"hashValue": "Valore Hash",
|
||||
"selectHashAlgorithm": "Seleziona un algoritmo hash",
|
||||
@@ -497,45 +518,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.",
|
||||
@@ -546,16 +567,24 @@
|
||||
"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",
|
||||
"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.",
|
||||
@@ -566,6 +595,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.",
|
||||
@@ -580,9 +617,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.",
|
||||
@@ -594,83 +628,81 @@
|
||||
"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",
|
||||
"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",
|
||||
"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": "Configura le impostazioni predefinite applicate ai nuovi utenti e i criteri applicati a tutti gli utenti.",
|
||||
"editAsYaml": "Modifica come YAML",
|
||||
"userDefaultFieldLockedFromConfig": "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",
|
||||
"analyticsEnabledDescription": "L'invio facoltativo delle statistiche di utilizzo invierà informazioni anonime ai server degli sviluppatori di FileBrowser Quantum per contribuire a migliorare l'applicazione.",
|
||||
"analyticsLoadFailed": "Impossibile caricare le impostazioni di analisi",
|
||||
"analyticsUpdateFailed": "Impossibile aggiornare le impostazioni di analisi",
|
||||
"analyticsPreviewFailed": "Impossibile caricare le informazioni diagnostiche relative all'analisi",
|
||||
"enterPassword": "Inserisci password",
|
||||
"enterPasswordAgain": "Inserisci di nuovo la password",
|
||||
"passwordRequirementsMatch": "Le password corrispondono",
|
||||
"passwordRequirementsMinLength": "Almeno {min} caratteri",
|
||||
"loginMethod": "Metodo di accesso",
|
||||
"loginMethods": {
|
||||
"password": "Password"
|
||||
},
|
||||
"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",
|
||||
"scopes": "Definisci le sorgenti consentite e l'ambito di accesso (scope) dell'utente al loro interno",
|
||||
"scopePath": "Percorso dell'ambito (scope)",
|
||||
"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 per l'utente si applicano ovunque, incluse tutte le sorgenti e le condivisioni.",
|
||||
"sourcePermissionsHelp": "Espandi la riga di una sorgente per impostare le autorizzazioni di visualizzazione, download, modifica, creazione ed eliminazione per quella specifica sorgente.",
|
||||
"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!",
|
||||
"modifyOtherUserTitle": "Modifica utente: {username}",
|
||||
"configViewerOpen": "Visualizza configurazione",
|
||||
"configViewerDescription": "Apri una vista in sola lettura della configurazione corrente del server.",
|
||||
"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",
|
||||
"analyticsEnabledDescription": "L'analisi dell'implementazione con consenso esplicito invierà informazioni anonime ai server degli sviluppatori di FileBrowser Quantum per contribuire a migliorare l'applicazione.",
|
||||
"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",
|
||||
"userDefaultFieldLockedFromConfig": "Questa impostazione predefinita è configurata nel file di configurazione e non può essere modificata in questa sede.",
|
||||
"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",
|
||||
"userEditPreferences": "Preferenze utente",
|
||||
"userEditTools": "Accesso agli strumenti",
|
||||
"userEditToolsDescription": "Configurare a quali strumenti questo utente può accedere",
|
||||
"userEditSidebarLinks": "Link nella barra laterale",
|
||||
"passwordRequirementsMatch": "Le password corrispondono",
|
||||
"passwordRequirementsMinLength": "Almeno {min} caratteri",
|
||||
"requirePasswordChange": "Richiedi la modifica della password al prossimo accesso"
|
||||
"userUpdated": "Utente aggiornato!"
|
||||
},
|
||||
"time": {
|
||||
"time": "{prefix}Ora{suffix}",
|
||||
"minute": "{prefix}minuto{suffix}",
|
||||
"minutes": "{prefix}Minuti{suffix}",
|
||||
"hour": "{prefix}ora{suffix}",
|
||||
"hours": "{prefix}Ore{suffix}",
|
||||
"day": "{prefix}giorno{suffix}",
|
||||
"days": "{prefix}Giorni{suffix}",
|
||||
"months": "{prefix}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.",
|
||||
@@ -685,6 +717,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",
|
||||
@@ -701,16 +737,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."
|
||||
"sidebarLinkDefaultSourceHelp": "Si applica solo se l'utente ha accesso a questa sorgente.",
|
||||
"sidebarLinkDefaultsYamlInvalid": "YAML non valido. Controlla il formato e riprova."
|
||||
},
|
||||
"api": {
|
||||
"title": "Token API",
|
||||
@@ -764,8 +795,32 @@
|
||||
},
|
||||
"access": {
|
||||
"access": "Accesso",
|
||||
"groups": "Gruppi",
|
||||
"newGroup": "Nuovo gruppo",
|
||||
"editGroup": "Modifica gruppo",
|
||||
"groupName": "Nome del gruppo",
|
||||
"userGroups": "Gruppi",
|
||||
"userGroupsDescription": "Gruppi di cui fa parte questo utente",
|
||||
"groupMembers": "Membri",
|
||||
"membersCount": "Membri: {count}",
|
||||
"userGroupsCount": "Gruppi: {count}",
|
||||
"groupNameRequired": "Il nome del gruppo è obbligatorio.",
|
||||
"groupNameTooShort": "Il nome del gruppo deve contenere almeno {min} caratteri.",
|
||||
"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 SSO (OIDC, LDAP, JWT, proxy) vengono reimpostati dal provider di identità a ogni accesso di tali utenti.",
|
||||
"selectEntities": "Seleziona",
|
||||
"selectUser": "Seleziona utente",
|
||||
"selectUsers": "Seleziona gli utenti",
|
||||
"selectGroup": "Seleziona un gruppo",
|
||||
"selectGroups": "Seleziona i gruppi",
|
||||
"refineSearch": "Visualizzazione dei primi {count} risultati. Affina la ricerca per vederne altri.",
|
||||
"all": "Nega Tutto",
|
||||
"rules": "Regole di accesso",
|
||||
"hasRules": "Regole di accesso configurate",
|
||||
"manageDescription": "Apri le regole di accesso per questo percorso.",
|
||||
"deny": "Nega",
|
||||
"totalDenied": "Negati",
|
||||
"totalAllowed": "Consentiti",
|
||||
@@ -775,32 +830,8 @@
|
||||
"accessManagement": "Gestione Accessi",
|
||||
"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.",
|
||||
"hasRules": "Regole di accesso configurate",
|
||||
"manageDescription": "Regole di libero accesso per questo percorso.",
|
||||
"groups": "Gruppi",
|
||||
"newGroup": "Nuovo gruppo",
|
||||
"editGroup": "Modifica gruppo",
|
||||
"groupName": "Nome del gruppo",
|
||||
"userGroups": "Gruppi",
|
||||
"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.",
|
||||
"selectEntities": "Seleziona",
|
||||
"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."
|
||||
"cascadeDelete": "Eliminazione ricorsiva",
|
||||
"cascadeDeleteDescription": "Se abilitato, l'eliminazione di una regola di accesso la rimuoverà anche da tutte le sottocartelle. Ciò consente di rimuovere completamente un utente o un gruppo dall'intero albero delle cartelle."
|
||||
},
|
||||
"fileLoading": {
|
||||
"title": "Caricamenti e Download",
|
||||
@@ -891,8 +922,8 @@
|
||||
"undo": "Annulla",
|
||||
"redo": "Ripeti",
|
||||
"moreOptions": "Altre opzioni",
|
||||
"inlineMath": "Matematica in linea",
|
||||
"displayMath": "Matematica a blocco",
|
||||
"inlineMath": "Formula matematica in linea",
|
||||
"displayMath": "Formula matematica a blocco",
|
||||
"highlight": "Evidenzia",
|
||||
"superscript": "Apice",
|
||||
"subscript": "Pedice",
|
||||
@@ -907,7 +938,7 @@
|
||||
"fontColor": "Colore del carattere"
|
||||
},
|
||||
"json": {
|
||||
"formatJSON": "Formato JSON",
|
||||
"formatJSON": "Formatta JSON",
|
||||
"formatJSONSuccess": "Formattazione JSON completata con successo",
|
||||
"invalidJSON": "JSON non valido: {message}"
|
||||
}
|
||||
@@ -962,14 +993,14 @@
|
||||
"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",
|
||||
@@ -988,6 +1019,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"
|
||||
@@ -996,6 +1031,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",
|
||||
@@ -1015,33 +1064,38 @@
|
||||
"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": "Creazione condivisione",
|
||||
"eventShareUpdate": "Modifica condivisione",
|
||||
"eventShareDelete": "Eliminazione condivisione",
|
||||
"eventUserCreate": "Creazione utente",
|
||||
"eventUserUpdate": "Aggiornamento utente",
|
||||
"eventUserDelete": "Eliminazione utente",
|
||||
"eventAccessCreate": "Creazione regola di accesso",
|
||||
"eventAccessUpdate": "Modifica regola di accesso",
|
||||
"eventAccessDelete": "Eliminazione regola di accesso",
|
||||
"eventTokenCreate": "Creazione di un token",
|
||||
"eventTokenDelete": "Eliminazione del token",
|
||||
"webSessionNamed": "Sessione web ({name})",
|
||||
"activityScope": "Categoria di attività",
|
||||
"timeRange": "Intervallo di tempo",
|
||||
"last1h": "Ultima ora",
|
||||
"last24h": "Ultime 24 ore",
|
||||
"last7d": "Ultimi 7 giorni",
|
||||
"last30d": "Ultimi 30 giorni",
|
||||
"customRange": "Gamma personalizzata",
|
||||
"customRange": "Intervallo personalizzato",
|
||||
"eventType": "Tipo di evento",
|
||||
"eventSummary": "Evento",
|
||||
"passkeyRegister": "Passkey aggiunto",
|
||||
"passkeyDelete": "Passkey rimosso",
|
||||
"interval": "Intervallo di tempo",
|
||||
"splitBy": "Suddiviso per",
|
||||
"ungrouped": "Nessun raggruppamento (totali)",
|
||||
@@ -1053,6 +1107,15 @@
|
||||
"summaryView": "Riepilogo (barra orizzontale)",
|
||||
"tableView": "Tabella",
|
||||
"exportCsv": "Esporta CSV",
|
||||
"webSession": "Sessione web",
|
||||
"actorToken": "Token attore",
|
||||
"affectedToken": "Token interessato",
|
||||
"activityOverTime": "Andamento nel tempo",
|
||||
"activityOverTimeBy": "Andamento nel tempo per {subject}",
|
||||
"activityBy": "Attività di {subject}",
|
||||
"eventTotals": "Totale eventi",
|
||||
"activityTotalsBy": "Totale 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})",
|
||||
@@ -1060,58 +1123,26 @@
|
||||
"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",
|
||||
"activityOverTime": "Andamento nel tempo",
|
||||
"activityOverTimeBy": "Andamento nel tempo per {subject}",
|
||||
"activityBy": "Attività di {subject}",
|
||||
"eventTotals": "Totali dell'evento",
|
||||
"activityTotalsBy": "Totali delle attività per {subject}",
|
||||
"subjectEventType": "tipo di evento",
|
||||
"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",
|
||||
"description": "Visualizza tutte le notifiche della sessione corrente",
|
||||
"empty": "Ancora nessuna notifica",
|
||||
"clearAll": "Pulisci tutto",
|
||||
"appEnabled": "Notifiche delle app",
|
||||
"appEnabledDescription": "Mostra le notifiche del browser al termine delle operazioni sui file quando questa scheda è in secondo piano. Richiede l'autorizzazione del browser. Memorizzato solo in questo browser.",
|
||||
"appUnsupported": "Le notifiche delle app non sono supportate in questo browser.",
|
||||
"permissionGranted": "ammesso",
|
||||
"empty": "Nessuna notifica",
|
||||
"clearAll": "Cancella tutte",
|
||||
"appEnabled": "Notifiche dell'app",
|
||||
"appEnabledDescription": "Mostra le notifiche del browser al termine delle operazioni sui file quando questa scheda è in background. Richiede l'autorizzazione del browser. Memorizzato solo in questo browser.",
|
||||
"appUnsupported": "Le notifiche dell'app non sono supportate in questo browser.",
|
||||
"permissionGranted": "consentito",
|
||||
"permissionDeniedHelp": "Le notifiche sono bloccate. Abilitale nelle impostazioni del browser per questo sito.",
|
||||
"uploadTitle": "Caricamento completato",
|
||||
"downloadTitle": "Download completato",
|
||||
"moveTitle": "Operazione completata",
|
||||
"moveTitle": "Spostamento completato",
|
||||
"copyTitle": "Copia completata",
|
||||
"uploadFailedTitle": "Caricamento non riuscito",
|
||||
"downloadFailedTitle": "Il download non è andato a buon fine",
|
||||
@@ -1179,36 +1210,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -480,7 +480,7 @@
|
||||
"settingsHover": "用户设置",
|
||||
"toggleClick": "单击切换",
|
||||
"toggleDark": "切换黑暗模式",
|
||||
"toggleSticky": "切换粘贴模式",
|
||||
"toggleSticky": "切换固定侧边栏",
|
||||
"noSources": "该用户没有可用的信息源。要么为该用户添加一个信息源,要么配置一个默认为 true 的信息源。",
|
||||
"unknown": "未知",
|
||||
"simple": "简单",
|
||||
@@ -516,11 +516,11 @@
|
||||
},
|
||||
"colors": {
|
||||
"red": "红",
|
||||
"green": "绿化",
|
||||
"green": "绿",
|
||||
"blue": "蓝",
|
||||
"yellow": "黄的",
|
||||
"violet": "紫色",
|
||||
"orange": "橘色"
|
||||
"yellow": "黄",
|
||||
"violet": "紫",
|
||||
"orange": "橙"
|
||||
},
|
||||
"profileSettings": {
|
||||
"showTypeColumn": "显示类型列",
|
||||
|
||||
@@ -450,7 +450,14 @@ class UploadManager {
|
||||
this.activeUploads < maxConcurrent &&
|
||||
this.hasPending()
|
||||
) {
|
||||
const upload = this.queue.find((item) => item.status === "pending");
|
||||
// Create directories one at a time (parent first) before any files: the
|
||||
// backend uses MkdirAll, so a child finishing first makes its parent 409.
|
||||
if (this.queue.some((item) => item.type === "directory" && item.status === "uploading")) {
|
||||
break;
|
||||
}
|
||||
const upload =
|
||||
this.queue.find((item) => item.type === "directory" && item.status === "pending") ||
|
||||
this.queue.find((item) => item.status === "pending");
|
||||
if (upload) {
|
||||
if (this.overwriteAll) {
|
||||
upload.overwrite = true;
|
||||
|
||||
@@ -79,6 +79,27 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("directory upload ordering", () => {
|
||||
it("waits for each directory before starting its children", async () => {
|
||||
// A populated listing avoids the separate top-level directory probe.
|
||||
state.req.items = [{ name: "unrelated.txt" }];
|
||||
const parent = deferred();
|
||||
resourcesApi.post.mockReturnValueOnce(parent.promise).mockResolvedValue("");
|
||||
|
||||
await uploadManager.add("/base/", [
|
||||
{ file: new File(["a"], "a.txt"), relativePath: "folder/child/a.txt" },
|
||||
]);
|
||||
expect(resourcesApi.post.mock.calls.map((call) => call[1])).toEqual(["/base/folder/"]);
|
||||
|
||||
parent.resolve("");
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(resourcesApi.post.mock.calls.map((call) => call[1])).toEqual([
|
||||
"/base/folder/", "/base/folder/child/", "/base/folder/child/a.txt",
|
||||
]);
|
||||
state.req.items = [];
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload stall detection", () => {
|
||||
it("allows an upload to wait while another progresses, then detects a batch stall", async () => {
|
||||
const waiting = addUpload();
|
||||
|
||||
Reference in new issue
Block a user