mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-15 05:31:59 -05:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dce539f015 | ||
|
|
21b2858945 | ||
|
|
c48792bd51 | ||
|
|
f8f6568b01 | ||
|
|
fd3f06babf | ||
|
|
fde5a473bf | ||
|
|
41b6f0cbe4 | ||
|
|
7086b69cab | ||
|
|
39300aa492 | ||
|
|
a5799bc2d1 | ||
|
|
56ee77961e | ||
|
|
82fc156042 | ||
|
|
6f7f472bcd | ||
|
|
9a51bd0fac | ||
|
|
5d5af61886 | ||
|
|
6734f73847 | ||
|
|
45312736b1 | ||
|
|
f37c25f284 | ||
|
|
5cc183c447 | ||
|
|
7cfd47edd7 | ||
|
|
3f4387bec3 | ||
|
|
481dbf41b6 | ||
|
|
25722e484d | ||
|
|
6cc198371d | ||
|
|
5302534975 | ||
|
|
b7474d6b8f |
No files matched your search
@@ -30,7 +30,6 @@
|
||||
"extensions": [
|
||||
"golang.go",
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"ms-azuretools.vscode-docker"
|
||||
]
|
||||
|
||||
@@ -9,10 +9,7 @@ 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"
|
||||
@@ -24,28 +21,9 @@ 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
|
||||
@@ -55,12 +33,6 @@ 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)
|
||||
@@ -72,13 +44,7 @@ func NewFileCacheWithLimit(dir string, maxBytes int64) (*FileCache, error) {
|
||||
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
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
|
||||
return &FileCache{dir: cacheDir}, nil
|
||||
}
|
||||
|
||||
// migrateOldCacheStructure moves cache files from old structure (dir/a/...) to new structure (dir/diskcache/a/...)
|
||||
@@ -133,50 +99,19 @@ 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 {
|
||||
@@ -190,119 +125,13 @@ 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,12 +2,9 @@ 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"
|
||||
@@ -73,168 +70,3 @@ func fileExists(filename string) bool {
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
|
||||
// storeAged stores a value and sets its modification time, so the eviction order is deterministic.
|
||||
func storeAged(t *testing.T, cache *FileCache, key string, size int, age time.Duration) {
|
||||
t.Helper()
|
||||
require.NoError(t, cache.Store(context.Background(), key, make([]byte, size)))
|
||||
when := time.Now().Add(-age)
|
||||
require.NoError(t, os.Chtimes(cache.getFileName(key), when, when))
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, condition func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !condition() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("condition not met in time")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func newLimitedCache(t *testing.T, maxBytes int64) *FileCache {
|
||||
t.Helper()
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
cache, err := NewFileCacheWithLimit(t.TempDir(), maxBytes)
|
||||
require.NoError(t, err)
|
||||
return cache
|
||||
}
|
||||
|
||||
func TestFileCacheEvictsLeastRecentlyUsedOverLimit(t *testing.T) {
|
||||
const kb = 1024
|
||||
cache := newLimitedCache(t, 10*kb)
|
||||
// 3 files of 3KB are under the 10KB limit
|
||||
storeAged(t, cache, "oldest", 3*kb, 3*time.Hour)
|
||||
storeAged(t, cache, "old", 3*kb, 2*time.Hour)
|
||||
storeAged(t, cache, "recent", 3*kb, time.Hour)
|
||||
require.True(t, fileExists(cache.getFileName("oldest")), "nothing is removed under the limit")
|
||||
|
||||
// the 4th file goes over the limit: the oldest ones are removed until it is under 90% of it
|
||||
require.NoError(t, cache.Store(context.Background(), "newest", make([]byte, 3*kb)))
|
||||
waitFor(t, func() bool { return !fileExists(cache.getFileName("oldest")) })
|
||||
waitFor(t, func() bool { return !cache.evicting.Load() })
|
||||
|
||||
require.False(t, fileExists(cache.getFileName("oldest")))
|
||||
require.False(t, fileExists(cache.getFileName("old")))
|
||||
require.True(t, fileExists(cache.getFileName("recent")))
|
||||
require.True(t, fileExists(cache.getFileName("newest")))
|
||||
require.LessOrEqual(t, cache.size.Load(), int64(9*kb))
|
||||
}
|
||||
|
||||
func TestFileCacheTrimsAnOversizedCacheAtStartup(t *testing.T) {
|
||||
const kb = 1024
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
dir := t.TempDir()
|
||||
unlimited, err := NewFileCache(dir)
|
||||
require.NoError(t, err)
|
||||
storeAged(t, &FileCache{dir: unlimited.dir}, "a", 4*kb, 4*time.Hour)
|
||||
storeAged(t, &FileCache{dir: unlimited.dir}, "b", 4*kb, 3*time.Hour)
|
||||
storeAged(t, &FileCache{dir: unlimited.dir}, "c", 4*kb, 2*time.Hour)
|
||||
|
||||
cache, err := NewFileCacheWithLimit(dir, 10*kb)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 12KB over a 10KB limit: only the oldest file is removed, which already brings it under 90% (9KB)
|
||||
require.False(t, fileExists(cache.getFileName("a")))
|
||||
require.True(t, fileExists(cache.getFileName("b")))
|
||||
require.True(t, fileExists(cache.getFileName("c")))
|
||||
require.Equal(t, int64(8*kb), cache.size.Load())
|
||||
}
|
||||
|
||||
func TestFileCacheWithoutLimitKeepsEverything(t *testing.T) {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
cache, err := NewFileCache(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
for i := 0; i < 5; i++ {
|
||||
storeAged(t, cache, fmt.Sprintf("key-%d", i), 1<<20, time.Duration(i)*time.Hour)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
require.True(t, fileExists(cache.getFileName(fmt.Sprintf("key-%d", i))))
|
||||
}
|
||||
require.Zero(t, cache.size.Load(), "size is only tracked when there is a limit")
|
||||
}
|
||||
|
||||
func TestFileCacheReadRefreshesModTime(t *testing.T) {
|
||||
cache := newLimitedCache(t, 1<<20)
|
||||
storeAged(t, cache, "used", 100, 3*time.Hour)
|
||||
|
||||
_, ok, err := cache.Load(context.Background(), "used")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
info, err := os.Stat(cache.getFileName("used"))
|
||||
require.NoError(t, err)
|
||||
require.WithinDuration(t, time.Now(), info.ModTime(), time.Minute, "a file that is read is the last to be evicted")
|
||||
}
|
||||
|
||||
func TestFileCacheDeleteAndOverwriteKeepTheSizeAccurate(t *testing.T) {
|
||||
const kb = 1024
|
||||
cache := newLimitedCache(t, 1<<20)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, cache.Store(ctx, "k", make([]byte, 5*kb)))
|
||||
require.Equal(t, int64(5*kb), cache.size.Load())
|
||||
require.NoError(t, cache.Store(ctx, "k", make([]byte, 2*kb)))
|
||||
require.Equal(t, int64(2*kb), cache.size.Load(), "overwriting replaces the old size")
|
||||
require.NoError(t, cache.Delete(ctx, "k"))
|
||||
require.Zero(t, cache.size.Load())
|
||||
}
|
||||
|
||||
// Stores that land while an eviction is measuring or deleting must not be lost from the tracked size,
|
||||
// and the cache must end up under its limit even though more files kept arriving during the eviction.
|
||||
func TestFileCacheStaysUnderTheLimitWithConcurrentStores(t *testing.T) {
|
||||
const (
|
||||
kb = 1024
|
||||
limit = 100 * kb
|
||||
workers = 16
|
||||
stores = 40
|
||||
)
|
||||
cache := newLimitedCache(t, limit)
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < stores; i++ {
|
||||
_ = cache.Store(context.Background(), fmt.Sprintf("worker-%d-key-%d", w, i), make([]byte, 4*kb))
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
waitFor(t, func() bool { return !cache.evicting.Load() })
|
||||
|
||||
_, onDisk := cache.scan()
|
||||
require.LessOrEqual(t, onDisk, int64(limit), "the files left on disk are within the limit")
|
||||
require.Equal(t, onDisk, cache.size.Load(), "the tracked size matches what is on disk")
|
||||
}
|
||||
|
||||
// Files that are rewritten while an eviction is running must be subtracted at the size they have when
|
||||
// they are removed, not at the size the scan saw, or the tracked size drifts from the disk.
|
||||
func TestFileCacheKeepsTheSizeAccurateWhenFilesAreRewrittenDuringEviction(t *testing.T) {
|
||||
const (
|
||||
kb = 1024
|
||||
limit = 60 * kb
|
||||
workers = 8
|
||||
rounds = 300
|
||||
keys = 40
|
||||
)
|
||||
cache := newLimitedCache(t, limit)
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < rounds; i++ {
|
||||
// the same keys again and again, with a different size each time
|
||||
key := fmt.Sprintf("key-%d", (w+i)%keys)
|
||||
_ = cache.Store(context.Background(), key, make([]byte, (1+(w*7+i)%8)*kb))
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
waitFor(t, func() bool { return !cache.evicting.Load() })
|
||||
|
||||
_, onDisk := cache.scan()
|
||||
require.Equal(t, onDisk, cache.size.Load(), "the tracked size matches what is on disk")
|
||||
require.LessOrEqual(t, onDisk, int64(limit))
|
||||
}
|
||||
@@ -790,7 +790,6 @@ func DeleteFiles(source, boundIndexPath, indexPath string, isDir bool) error {
|
||||
if utils.CleanIndexPathSegment(indexPath) == "" {
|
||||
return fmt.Errorf("refusing to delete source root directory: %s", indexPath)
|
||||
}
|
||||
deleteIndexPath := indexPath
|
||||
realPath, err := index.GetRealPathForRemoval(boundIndexPath, indexPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -809,13 +808,9 @@ func DeleteFiles(source, boundIndexPath, indexPath string, isDir bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if isDir {
|
||||
indexing.ClearRealPathCaches()
|
||||
}
|
||||
index.InvalidateRealPathCache(deleteIndexPath, boundIndexPath)
|
||||
if parent := utils.GetParentDirectoryPath(deleteIndexPath); parent != "" && parent != "/" {
|
||||
index.InvalidateRealPathCache(parent, boundIndexPath)
|
||||
}
|
||||
// Clear cache entries
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.IsDirCache.Delete(realPath + ":isdir")
|
||||
|
||||
// Remove metadata from index
|
||||
deleteSuccess := index.DeleteMetadata(indexPath, isDir, isDir)
|
||||
@@ -867,7 +862,8 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
|
||||
if !Exists(realPath) {
|
||||
// Directory no longer exists, remove it from the index
|
||||
// This clears both Directories and DirectoriesLedger maps
|
||||
idx.InvalidateRealPathCache(path)
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.IsDirCache.Delete(realPath + ":isdir")
|
||||
idx.DeleteMetadata(path, true, false)
|
||||
return nil
|
||||
}
|
||||
@@ -945,9 +941,6 @@ func moveResourceImpl(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst st
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isSrcDir {
|
||||
indexing.ClearRealPathCaches()
|
||||
}
|
||||
|
||||
// Handle SOURCE cleanup (treat as deletion)
|
||||
// Run async to avoid blocking the HTTP response
|
||||
@@ -1108,11 +1101,6 @@ func WriteDirectory(boundIndexPath string, opts utils.FileOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
idx.InvalidateRealPathCache(opts.Path, boundIndexPath)
|
||||
if parent := utils.GetParentDirectoryPath(opts.Path); parent != "" && parent != "/" {
|
||||
idx.InvalidateRealPathCache(parent, boundIndexPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1188,11 +1176,6 @@ func writeFile(source, boundIndexPath, path string, in io.Reader) error {
|
||||
}
|
||||
}
|
||||
|
||||
idx.InvalidateRealPathCache(path, boundIndexPath)
|
||||
if parent := utils.GetParentDirectoryPath(path); parent != "" && parent != "/" {
|
||||
idx.InvalidateRealPathCache(parent, boundIndexPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
)
|
||||
|
||||
// PermittedAtRealPath reports whether username may access realPath after symlink
|
||||
// resolution. DENY rules apply to the target index path, not only the link path.
|
||||
func (s *Storage) PermittedAtRealPath(index *indexing.Index, username, realPath string, isDir bool) bool {
|
||||
if s == nil || index == nil || username == "" || realPath == "" {
|
||||
return false
|
||||
}
|
||||
lexical := realPath
|
||||
if !strings.HasPrefix(realPath, index.Path) {
|
||||
root, err := filepath.EvalSymlinks(index.Path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(root, realPath)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return false
|
||||
}
|
||||
lexical = index.Path
|
||||
if rel != "." {
|
||||
lexical = filepath.Join(index.Path, rel)
|
||||
}
|
||||
}
|
||||
indexPath := index.MakeIndexPath(lexical, isDir).String()
|
||||
return s.Permitted(index.Path, utils.IndexPathFromNormalized(indexPath, true), username)
|
||||
}
|
||||
|
||||
// IsDirAtRealPath reports whether an existing path is a directory; missing paths are treated as files.
|
||||
func IsDirAtRealPath(realPath string) bool {
|
||||
st, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return st.IsDir()
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func setupSymlinkDenyFixture(t *testing.T) (sourceRoot string, idx *indexing.Index, store *Storage, username string) {
|
||||
t.Helper()
|
||||
sourceRoot = filepath.Join(t.TempDir(), "data")
|
||||
aliceDir := filepath.Join(sourceRoot, "alice")
|
||||
bobDir := filepath.Join(sourceRoot, "bob")
|
||||
for _, dir := range []string{aliceDir, bobDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(bobDir, "secret.txt"), []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("../bob/secret.txt", filepath.Join(aliceDir, "link.txt")); err != nil {
|
||||
t.Skipf("symlinks required: %v", err)
|
||||
}
|
||||
if err := os.Symlink("../bob", filepath.Join(aliceDir, "bobdir")); err != nil {
|
||||
t.Skipf("symlinks required: %v", err)
|
||||
}
|
||||
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
sourceRoot: {Path: sourceRoot, Config: settings.SourceConfig{DenyByDefault: false}},
|
||||
}
|
||||
store = NewStorage(nil)
|
||||
username = "scoped-user"
|
||||
if err := store.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/bob", true), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx = &indexing.Index{Source: settings.Source{Path: sourceRoot}}
|
||||
return sourceRoot, idx, store, username
|
||||
}
|
||||
|
||||
func TestPermittedAtRealPath_AllowsLinkPathDeniesResolvedTarget(t *testing.T) {
|
||||
_, idx, store, user := setupSymlinkDenyFixture(t)
|
||||
|
||||
if !store.Permitted(idx.Path, utils.IndexPathFromNormalized("/alice/link.txt", false), user) {
|
||||
t.Fatal("index path at the symlink should be allowed")
|
||||
}
|
||||
if store.Permitted(idx.Path, utils.IndexPathFromNormalized("/bob/secret.txt", false), user) {
|
||||
t.Fatal("direct path under /bob should be denied")
|
||||
}
|
||||
|
||||
realPath, _, err := idx.GetRealPathScoped("/", "/alice/link.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.PermittedAtRealPath(idx, user, realPath, false) {
|
||||
t.Fatal("resolved target under denied /bob must not be permitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermittedAtRealPath_DeniesWriteThroughDirectorySymlink(t *testing.T) {
|
||||
_, idx, store, user := setupSymlinkDenyFixture(t)
|
||||
|
||||
realPath, err := idx.GetRealPathForMutation("/", "/alice/bobdir/planted.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.PermittedAtRealPath(idx, user, realPath, false) {
|
||||
t.Fatal("resolved write target under denied /bob must not be permitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermittedAtRealPath_DeniesCopySourceResolved(t *testing.T) {
|
||||
_, idx, store, user := setupSymlinkDenyFixture(t)
|
||||
|
||||
realSrc, isDir, err := idx.GetRealPathScoped("/", "/alice/link.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isDir {
|
||||
t.Fatal("expected file")
|
||||
}
|
||||
if store.PermittedAtRealPath(idx, user, realSrc, isDir) {
|
||||
t.Fatal("copy source resolved to denied target must not be permitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermittedAtRealPath_SymlinkedSourceRoot(t *testing.T) {
|
||||
sourceRoot, _, _, user := setupSymlinkDenyFixture(t)
|
||||
linkRoot := filepath.Join(filepath.Dir(sourceRoot), "root-link")
|
||||
if err := os.Symlink(sourceRoot, linkRoot); err != nil {
|
||||
t.Skipf("symlinks required: %v", err)
|
||||
}
|
||||
if _, err := filepath.EvalSymlinks(linkRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
linkRoot: {Path: linkRoot, Config: settings.SourceConfig{DenyByDefault: false}},
|
||||
}
|
||||
store := NewStorage(nil)
|
||||
if err := store.DenyUser(linkRoot, utils.IndexPathFromNormalized("/bob", true), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx := &indexing.Index{Source: settings.Source{Path: linkRoot}}
|
||||
|
||||
realPath, _, err := idx.GetRealPathScoped("/", "/alice/link.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realPath, err = filepath.EvalSymlinks(realPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.PermittedAtRealPath(idx, user, realPath, false) {
|
||||
t.Fatal("resolved target under denied /bob must not be permitted with symlinked source root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDirAtRealPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "nested"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(dir, "file.txt")
|
||||
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !IsDirAtRealPath(dir) {
|
||||
t.Fatal("expected directory")
|
||||
}
|
||||
if IsDirAtRealPath(file) {
|
||||
t.Fatal("expected file")
|
||||
}
|
||||
if IsDirAtRealPath(filepath.Join(dir, "missing")) {
|
||||
t.Fatal("missing path should not be treated as directory")
|
||||
}
|
||||
}
|
||||
@@ -346,11 +346,10 @@ type NonAdminEditable struct {
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
MaxConcurrent int `json:"maxConcurrentUpload"`
|
||||
UploadChunkSize int `json:"uploadChunkSizeMb"`
|
||||
ClearAll bool `json:"clearAll"`
|
||||
DownloadChunkSize int `json:"downloadChunkSizeMb"`
|
||||
UploadStallTimeoutSeconds int `json:"uploadStallTimeoutSeconds"` // seconds without progress before an upload is paused, 0 uses the default (10)
|
||||
MaxConcurrent int `json:"maxConcurrentUpload"`
|
||||
UploadChunkSize int `json:"uploadChunkSizeMb"`
|
||||
ClearAll bool `json:"clearAll"`
|
||||
DownloadChunkSize int `json:"downloadChunkSizeMb"`
|
||||
}
|
||||
|
||||
// SidebarLink represents a customizable link in the sidebar.
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
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,11 +7,9 @@ import (
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"math"
|
||||
|
||||
exif "github.com/dsoprea/go-exif/v3"
|
||||
exifcommon "github.com/dsoprea/go-exif/v3/common"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/embeddedpreview"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
|
||||
@@ -205,50 +203,6 @@ 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 {
|
||||
@@ -326,7 +280,7 @@ func (s *Service) resizeWithSize(in io.Reader, out io.Writer, fileSize int64, op
|
||||
resampleFilter := opts.Quality.resampleFilter()
|
||||
switch opts.ResizeMode {
|
||||
case ResizeModeFill:
|
||||
img = fillCenter(img, opts.Width, opts.Height, resampleFilter)
|
||||
img = imaging.Fill(img, opts.Width, opts.Height, imaging.Center, resampleFilter)
|
||||
case ResizeModeFit:
|
||||
fallthrough
|
||||
default:
|
||||
@@ -452,32 +406,8 @@ func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
|
||||
return nil, wrappedReader, exif.ErrNoThumbnail
|
||||
}
|
||||
|
||||
// Read IFD1's Orientation first; fall back to IFD0's Orientation if not specified (per MWG guidelines)
|
||||
orientStr := ""
|
||||
tags1, err1 := ifd.FindTagWithName("Orientation")
|
||||
if err1 == nil && len(tags1) > 0 {
|
||||
orientStr, _ = tags1[0].FormatFirst()
|
||||
}
|
||||
if orientStr == "" {
|
||||
tags0, err0 := index.RootIfd.FindTagWithName("Orientation")
|
||||
if err0 == nil && len(tags0) > 0 {
|
||||
orientStr, _ = tags0[0].FormatFirst()
|
||||
}
|
||||
}
|
||||
|
||||
thm, err := ifd.Thumbnail()
|
||||
if err != nil {
|
||||
return nil, wrappedReader, err
|
||||
}
|
||||
|
||||
if orientStr != "" && orientStr != "1" {
|
||||
if !embeddedpreview.Usable(thm) {
|
||||
return nil, wrappedReader, fmt.Errorf("embedded thumbnail exceeds decode limits")
|
||||
}
|
||||
thm = applyOrientationToPreviewBytes(thm, orientStr)
|
||||
}
|
||||
|
||||
return thm, wrappedReader, nil
|
||||
return thm, wrappedReader, err
|
||||
}
|
||||
|
||||
// CreateThumbnail decodes an image and creates a fixed-size thumbnail.
|
||||
|
||||
@@ -549,41 +549,3 @@ func TestReadImageFileDimensions(t *testing.T) {
|
||||
require.Greater(t, width, 0)
|
||||
require.Greater(t, height, 0)
|
||||
}
|
||||
|
||||
func TestApplyOrientationToPreviewBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Generate a 100x60 test JPEG image in memory (landscape)
|
||||
img := image.NewRGBA(image.Rect(0, 0, 100, 60))
|
||||
var buf bytes.Buffer
|
||||
err := jpeg.Encode(&buf, img, nil)
|
||||
require.NoError(t, err)
|
||||
rawBytes := buf.Bytes()
|
||||
|
||||
tests := []struct {
|
||||
orientation string
|
||||
wantWidth int
|
||||
wantHeight int
|
||||
}{
|
||||
{orientation: "1", wantWidth: 100, wantHeight: 60},
|
||||
{orientation: "Horizontal (normal)", wantWidth: 100, wantHeight: 60},
|
||||
{orientation: "8", wantWidth: 60, wantHeight: 100},
|
||||
{orientation: "Rotate 270 CW", wantWidth: 60, wantHeight: 100},
|
||||
{orientation: "6", wantWidth: 60, wantHeight: 100},
|
||||
{orientation: "Rotate 90 CW", wantWidth: 60, wantHeight: 100},
|
||||
{orientation: "3", wantWidth: 100, wantHeight: 60},
|
||||
{orientation: "Rotate 180", wantWidth: 100, wantHeight: 60},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.orientation, func(t *testing.T) {
|
||||
rotated := applyOrientationToPreviewBytes(rawBytes, tt.orientation)
|
||||
require.NotEmpty(t, rotated)
|
||||
|
||||
cfg, _, decodeErr := image.DecodeConfig(bytes.NewReader(rotated))
|
||||
require.NoError(t, decodeErr)
|
||||
require.Equal(t, tt.wantWidth, cfg.Width)
|
||||
require.Equal(t, tt.wantHeight, cfg.Height)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func NewPreviewGenerator(concurrencyLimit int, cacheDir string) *Service {
|
||||
var fileCache diskcache.Interface
|
||||
// Use file cache if cacheDir is specified
|
||||
var err error
|
||||
fileCache, err = diskcache.NewFileCacheWithLimit(actualCacheDir, max(settings.Config.Server.CacheDirMaxSizeMB, 0)*1024*1024)
|
||||
fileCache, err = diskcache.NewFileCache(actualCacheDir)
|
||||
if err != nil {
|
||||
logger.Error("The cache dir could not be created. Make sure the user that you executed the program with has access to create directories in the local path. See ")
|
||||
logger.Fatalf("failed to create file cache path, which is now require to run the server: %v", err)
|
||||
@@ -504,9 +504,7 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
|
||||
}
|
||||
|
||||
// Enforce file size limit for image preview generation to prevent memory exhaustion
|
||||
// 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))) {
|
||||
if strings.HasPrefix(file.Type, "image") && file.Size > iteminfo.LargeFileSizeThreshold {
|
||||
message := fmt.Sprintf("Image file too large for preview: %s (size: %d bytes, limit: %d bytes)",
|
||||
file.Name, file.Size, iteminfo.LargeFileSizeThreshold)
|
||||
logger.Warning(message)
|
||||
|
||||
@@ -19,15 +19,6 @@ func AccessPermitted(sourcePath string, indexPath utils.IndexPath, username stri
|
||||
return accessDb.Permitted(sourcePath, indexPath, username)
|
||||
}
|
||||
|
||||
// AccessPermittedAtRealPath reports whether username may access realPath after symlink
|
||||
// resolution on the given index. When accessDb is not initialized, access is allowed.
|
||||
func AccessPermittedAtRealPath(idx *indexing.Index, username, realPath string, isDir bool) bool {
|
||||
if accessDb == nil {
|
||||
return true
|
||||
}
|
||||
return accessDb.PermittedAtRealPath(idx, username, realPath, isDir)
|
||||
}
|
||||
|
||||
func AllowUser(sourcePath string, indexPath utils.IndexPath, username string) error {
|
||||
return accessDb.AllowUser(sourcePath, indexPath, username)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -298,17 +297,6 @@ 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,18 +202,6 @@ 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
|
||||
}
|
||||
@@ -268,19 +256,12 @@ func createUser(user *users.User, plaintextPassword string, keepProfile bool) er
|
||||
requestedScopePermissions[backendScopeKeyFor(scope)] = scope.Permissions
|
||||
}
|
||||
}
|
||||
var requestedProfile settings.UserProfile
|
||||
if keepProfile {
|
||||
requestedProfile = settings.ProfileFromUser(user)
|
||||
}
|
||||
// Apply profile/scope defaults using the final account permissions (admin flag drives scope defaults).
|
||||
if permissionsRequested {
|
||||
settings.ApplyUserDefaultsFromKeepingAccountPermissions(user, EffectiveUserDefaults())
|
||||
} else {
|
||||
applyUserSettingsDefaults(user)
|
||||
}
|
||||
if keepProfile {
|
||||
settings.ExpandProfileIntoUser(user, requestedProfile)
|
||||
}
|
||||
if preserveRequirePasswordChange {
|
||||
user.RequirePasswordChange = true
|
||||
}
|
||||
|
||||
@@ -15,18 +15,12 @@ type SubtitleTrack struct {
|
||||
Embedded bool `json:"embedded"` // true for embedded subtitles, false for external files
|
||||
}
|
||||
|
||||
// ErrSubtitleSidecarNotRegular is returned when an external subtitle path is not a regular file (e.g. symlink).
|
||||
var ErrSubtitleSidecarNotRegular = errors.New("subtitle sidecar is not a regular file")
|
||||
|
||||
func GetSubtitleSidecarContent(subtitlePath string) (string, error) {
|
||||
// Sidecars sit beside the media file; do not follow symlink leaves (see lyricsSidecar).
|
||||
info, err := os.Lstat(subtitlePath)
|
||||
// validate the size is not too large
|
||||
info, err := os.Stat(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", ErrSubtitleSidecarNotRegular
|
||||
}
|
||||
if info.Size() > 1024*1024*50 { // 50MB
|
||||
return "", errors.New("subtitle file is too large")
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
|
||||
func setupAccessHTTPTest(t *testing.T) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
dbPath := filepath.Join(t.TempDir(), "access-http-test.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -279,21 +279,19 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
|
||||
if fullDestParent != fullDestFile && !state.AccessPermitted(idxTo.Path, utils.IndexPathFromNormalized(fullDestParent, true), d.User.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination parent of %s", req.Destination)
|
||||
}
|
||||
parentReal, err := idxTo.GetRealPathForMutation(userScopeTo, fullDestParent)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination parent path invalid: %v", err)
|
||||
}
|
||||
if err = os.MkdirAll(parentReal, fileutils.EffectiveDirPerm()); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination parent directory: %v", err)
|
||||
}
|
||||
destRel := strings.TrimLeft(filepath.ToSlash(req.Destination), "/")
|
||||
fileName := path.Base(destRel)
|
||||
if fileName == "." || fileName == "/" {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid destination file name")
|
||||
}
|
||||
destFileReal, err := idxTo.GetRealPathForMutation(userScopeTo, fullDestFile)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination path invalid: %v", err)
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idxTo, d.User.Username, destFileReal, false) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(destFileReal), fileutils.EffectiveDirPerm()); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination parent directory: %v", err)
|
||||
}
|
||||
destFileReal := filepath.Join(parentReal, fileName)
|
||||
|
||||
format := strings.ToLower(strings.TrimSpace(req.Format))
|
||||
if format == "" {
|
||||
@@ -1272,7 +1270,6 @@ func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time
|
||||
logger.Debugf("unarchive: symlink: %v", err)
|
||||
return fmt.Errorf("failed to create symlink from archive entry")
|
||||
}
|
||||
indexing.ClearRealPathCaches()
|
||||
return applyArchivedTimesAndPerm(destPath, fs.ModeSymlink, modTime)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestArchiveCreate_DeniesResolvedSymlinkDestination(t *testing.T) {
|
||||
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/denied", true), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowedDir := filepath.Join(sourceRoot, "allowed")
|
||||
deniedDir := filepath.Join(sourceRoot, "denied")
|
||||
for _, dir := range []string{allowedDir, deniedDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
victim := filepath.Join(deniedDir, "victim")
|
||||
if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceRoot, "a.txt"), []byte("payload"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(allowedDir, "report.zip")
|
||||
if err := os.Symlink(filepath.Join("..", "denied", "victim"), link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user, err := state.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Config.Server.SourceMap[sourceRoot].Config.DenyByDefault = false
|
||||
|
||||
body := `{"fromSource":"` + sourceName + `","paths":["/a.txt"],"destination":"/allowed/report.zip","format":"zip"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources/archive", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := archiveCreateHandler(rec, req, &Context{User: &user})
|
||||
if status != http.StatusForbidden || handlerErr == nil {
|
||||
t.Fatalf("status=%d err=%v", status, handlerErr)
|
||||
}
|
||||
got, err := os.ReadFile(victim)
|
||||
if err != nil || string(got) != "keep" {
|
||||
t.Fatalf("victim content=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveCreate_AllowsNonSymlinkDestination(t *testing.T) {
|
||||
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
dest := filepath.Join(sourceRoot, "out.zip")
|
||||
if err := os.WriteFile(filepath.Join(sourceRoot, "a.txt"), []byte("payload"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user, err := state.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := `{"fromSource":"` + sourceName + `","paths":["/a.txt"],"destination":"/out.zip","format":"zip"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources/archive", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := archiveCreateHandler(rec, req, &Context{User: &user})
|
||||
if status != http.StatusOK || handlerErr != nil {
|
||||
t.Fatalf("status=%d err=%v body=%s", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(dest); err != nil {
|
||||
t.Fatalf("expected archive at %s: %v", dest, err)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
|
||||
func setupExtractAccessPolicyTest(t *testing.T) (destDir, sourceRoot, sourceName, username string) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "access.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
|
||||
@@ -3,16 +3,11 @@ package web
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestNormalizeArchiveEntryName(t *testing.T) {
|
||||
@@ -562,19 +557,3 @@ func TestExtractZip_symlinkDestination(t *testing.T) {
|
||||
t.Fatalf("victim.txt should be unchanged: %q, %v", victimData, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveCreateDestination_RejectsEscapingSymlinkLeaf(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(filepath.Dir(root), "outside-archive")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(root, "report.zip")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx := &indexing.Index{Source: settings.Source{Path: root}}
|
||||
if _, err := idx.GetRealPathForMutation("", "/report.zip"); !errors.Is(err, commonerrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
|
||||
func setupShareArchiveDownloadTest(t *testing.T) (sourceRoot string, sourceName string, owner *users.User) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
dbPath := filepath.Join(t.TempDir(), "share-dl-test.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -187,10 +187,10 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to file")
|
||||
}
|
||||
|
||||
// Get real file path (scoped) and enforce ACL on the resolved target
|
||||
realPath, _, err := idx.GetRealPathScoped(userScope, scopePath)
|
||||
// Get real file path
|
||||
realPath, _, err := idx.GetRealPath(scopePath)
|
||||
if err != nil {
|
||||
return realPathErrStatus(err), err
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
// Get file/directory info
|
||||
@@ -198,9 +198,6 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idx, d.User.Username, realPath, info.IsDir()) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to file")
|
||||
}
|
||||
|
||||
// Get MIME type from the index if available
|
||||
mimeType := "application/octet-stream"
|
||||
@@ -338,10 +335,10 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to file")
|
||||
}
|
||||
|
||||
// Get real file path (scoped) and enforce ACL on the resolved target
|
||||
realPath, _, err := idx.GetRealPathScoped(userScope, scopePath)
|
||||
// Get real file path
|
||||
realPath, _, err := idx.GetRealPath(scopePath)
|
||||
if err != nil {
|
||||
return realPathErrStatus(err), err
|
||||
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
|
||||
}
|
||||
|
||||
// Get file/directory info
|
||||
@@ -349,9 +346,6 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idx, d.User.Username, realPath, info.IsDir()) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to file")
|
||||
}
|
||||
|
||||
// Set up SSE headers
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
|
||||
@@ -22,37 +22,3 @@ func TestErrToStatusWrapsUnsupportedPreviewFormat(t *testing.T) {
|
||||
t.Fatalf("ErrToStatus() = %d, want %d", status, http.StatusUnsupportedMediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsViewerOriginalPreview(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
size string
|
||||
file string
|
||||
fileType string
|
||||
docs bool // the server can render documents to an image
|
||||
want bool
|
||||
}{
|
||||
{"Fuji RAW viewer", "original", "DSCF0308.RAF", "image/x-fuji-raf", false, true},
|
||||
{"Canon RAW viewer", "original", "IMG_0001.CR3", "image/x-canon-cr3", false, true},
|
||||
{"Adobe DNG viewer", "original", "a.dng", "image/x-adobe-dng", false, true},
|
||||
{"HEIC viewer", "original", "a.heic", "image/heic", false, true},
|
||||
{"HEIF viewer", "original", "a.heif", "image/heif", false, true},
|
||||
{"rendered document viewer", "original", "a.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", true, true},
|
||||
{"documents need the server to be able to render them", "original", "a.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", false, false},
|
||||
{"RAW thumbnail stays disabled", "small", "DSCF0308.RAF", "image/x-fuji-raf", true, false},
|
||||
{"RAW large stays disabled", "large", "DSCF0308.RAF", "image/x-fuji-raf", true, false},
|
||||
{"no size stays disabled", "", "DSCF0308.RAF", "image/x-fuji-raf", true, false},
|
||||
{"video poster stays disabled", "original", "a.mov", "video/quicktime", true, false},
|
||||
{"audio cover stays disabled", "original", "a.mp3", "audio/mpeg", true, false},
|
||||
{"JPEG is opened directly, not through previews", "original", "a.jpg", "image/jpeg", true, false},
|
||||
{"PDF is opened directly, not through previews", "original", "a.pdf", "application/pdf", true, false},
|
||||
{"text stays disabled", "original", "a.txt", "text/plain", true, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isViewerOriginalPreview(tt.size, tt.file, tt.fileType, tt.docs); got != tt.want {
|
||||
t.Fatalf("isViewerOriginalPreview(%q, %q, %q, %v) = %v, want %v", tt.size, tt.file, tt.fileType, tt.docs, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package web
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
@@ -14,9 +13,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/gomedia"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/mediacache"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
@@ -110,34 +107,9 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
|
||||
var content string
|
||||
if !embedded {
|
||||
var subtitlePath string
|
||||
if d.Share.Hash != "" {
|
||||
if d.Share.Path == "" {
|
||||
return http.StatusForbidden, fmt.Errorf("share has no path scope")
|
||||
}
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
bound, sidecarIndexPath, scopeErr := shareSubtitleSidecarIndexPaths(d, source, path, track.Name)
|
||||
if scopeErr != nil {
|
||||
return http.StatusForbidden, scopeErr
|
||||
}
|
||||
subtitlePath, _, err = idx.GetRealPathScoped(bound, sidecarIndexPath)
|
||||
if err != nil {
|
||||
return realPathErrStatus(err), err
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idx, d.ShareUser.Username, subtitlePath, false) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied")
|
||||
}
|
||||
} else {
|
||||
subtitlePath = filepath.Join(filepath.Dir(fileInfo.RealPath), filepath.Base(track.Name))
|
||||
}
|
||||
subtitlePath := filepath.Join(filepath.Dir(fileInfo.RealPath), filepath.Base(track.Name))
|
||||
content, err = utils.GetSubtitleSidecarContent(subtitlePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, utils.ErrSubtitleSidecarNotRegular) {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to get subtitle sidecar content: %v", err)
|
||||
}
|
||||
} else {
|
||||
@@ -190,34 +162,6 @@ func findSubtitleTrack(subtitles []utils.SubtitleTrack, name string, embedded bo
|
||||
return nil
|
||||
}
|
||||
|
||||
// shareSubtitleSidecarIndexPaths maps middleware IndexPath (user-scope-relative) and Share.Path
|
||||
// to scoped index paths for sidecar containment (see publicDeleteHandler).
|
||||
func shareSubtitleSidecarIndexPaths(d *Context, sourceName, videoIndexPath, sidecarFileName string) (bound, sidecarIndexPath string, err error) {
|
||||
userScope, scopeErr := d.ShareUser.GetScopeForSourceName(sourceName)
|
||||
if scopeErr != nil || userScope == "" {
|
||||
return "", "", fmt.Errorf("user has no access to source: %s", sourceName)
|
||||
}
|
||||
if d.Share.Path == "" {
|
||||
return "", "", fmt.Errorf("share has no path scope")
|
||||
}
|
||||
shareRel := strings.TrimPrefix(d.Share.Path, userScope)
|
||||
bound = utils.JoinScopedIndexPath(userScope, shareRel)
|
||||
videoRel := strings.Trim(strings.TrimSpace(videoIndexPath), "/")
|
||||
fullVideo := utils.JoinScopedIndexPath(userScope, videoRel)
|
||||
if strings.TrimSuffix(bound, "/") == strings.TrimSuffix(fullVideo, "/") {
|
||||
parent := utils.GetParentDirectoryPath(strings.TrimSuffix(bound, "/"))
|
||||
if parent == "" {
|
||||
parent = "/"
|
||||
}
|
||||
bound = parent
|
||||
}
|
||||
sidecarIndexPath = utils.JoinPathAsUnix(
|
||||
utils.GetParentDirectoryPath(strings.TrimSuffix(fullVideo, "/")),
|
||||
filepath.Base(sidecarFileName),
|
||||
)
|
||||
return bound, sidecarIndexPath, nil
|
||||
}
|
||||
|
||||
// metadataHandler returns the same directory resource shape as GET /api/resources with metadata enabled,
|
||||
// for client-side patching after a fast listing load.
|
||||
// @Summary Directory with media metadata
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func setupSubtitlesHTTPTest(t *testing.T) (downloadsPath string, admin *users.User) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
downloadsPath = filepath.Join(t.TempDir(), "downloads")
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "subtitles-http-test.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.MustWireServices(state.Default())
|
||||
t.Cleanup(func() {
|
||||
state.Close()
|
||||
})
|
||||
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
downloadsPath: {
|
||||
Path: downloadsPath,
|
||||
Name: "Downloads",
|
||||
},
|
||||
}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{
|
||||
"Downloads": settings.Config.Server.SourceMap[downloadsPath],
|
||||
}
|
||||
settings.InitializeUserResolvers()
|
||||
|
||||
adminUser := &users.User{
|
||||
ID: 1,
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
Permissions: users.Permissions{Admin: true},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: downloadsPath, Scope: "/"},
|
||||
},
|
||||
BackendSourcePermissions: map[string]users.SourceFilePermissions{
|
||||
downloadsPath: {
|
||||
View: true,
|
||||
Download: true,
|
||||
Modify: true,
|
||||
Create: true,
|
||||
Delete: true,
|
||||
},
|
||||
},
|
||||
Version: users.SourcePermissionsMigrationVersion,
|
||||
}
|
||||
if err := state.CreateUser(adminUser, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminUser.Permissions = users.Permissions{Admin: true}
|
||||
if err := state.UpdateUser(adminUser, "", "permissions"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := state.UpdateUser(adminUser, "", "backendScopes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := state.UpdateUser(adminUser, "", "backendSourcePermissions"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(downloadsPath, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
indexing.SetTestIndex("Downloads", downloadsPath)
|
||||
t.Cleanup(func() {
|
||||
indexing.ClearTestIndices()
|
||||
})
|
||||
|
||||
loaded, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return downloadsPath, &loaded
|
||||
}
|
||||
|
||||
func mockVideoWithExternalSubtitle(t *testing.T, trackName, videoRealPath string) {
|
||||
t.Helper()
|
||||
prev := files.FileInfoFasterFunc
|
||||
t.Cleanup(func() { files.FileInfoFasterFunc = prev })
|
||||
files.FileInfoFasterFunc = func(opts utils.FileOptions, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return &iteminfo.ExtendedFileInfo{
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
Path: opts.Path,
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Type: "video/mp4",
|
||||
},
|
||||
},
|
||||
Subtitles: []utils.SubtitleTrack{
|
||||
{Name: trackName, Embedded: false},
|
||||
},
|
||||
RealPath: videoRealPath,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitlesHandler_RejectsEscapingSidecarSymlink(t *testing.T) {
|
||||
downloadsPath, adminUser := setupSubtitlesHTTPTest(t)
|
||||
videoPath := filepath.Join(downloadsPath, "clip.mov")
|
||||
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
|
||||
|
||||
outside := filepath.Join(filepath.Dir(downloadsPath), "outside-secret")
|
||||
if err := os.MkdirAll(outside, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := filepath.Join(outside, "secret.srt")
|
||||
if err := os.WriteFile(secret, []byte("SECRET_OUTSIDE_SUBTITLE"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(secret, filepath.Join(downloadsPath, "clip.srt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/media/subtitles?source=Downloads&path=/clip.mov&name=clip.srt&embedded=false", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := subtitlesHandler(rec, req, &Context{User: adminUser})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "SECRET_OUTSIDE_SUBTITLE") {
|
||||
t.Fatalf("response leaked outside subtitle content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitlesHandler_ReturnsInScopeSidecar(t *testing.T) {
|
||||
downloadsPath, adminUser := setupSubtitlesHTTPTest(t)
|
||||
videoPath := filepath.Join(downloadsPath, "clip.mov")
|
||||
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
|
||||
|
||||
srtBody := "1\n00:00:01,000 --> 00:00:02,000\nHello in scope"
|
||||
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(downloadsPath, "clip.srt"), []byte(srtBody), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/media/subtitles?source=Downloads&path=/clip.mov&name=clip.srt&embedded=false", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := subtitlesHandler(rec, req, &Context{User: adminUser})
|
||||
if status != http.StatusOK || handlerErr != nil {
|
||||
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Hello in scope") {
|
||||
t.Fatalf("body=%q want in-scope subtitle text", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitlesHandler_ShareScopedOwnerLoadsSidecar(t *testing.T) {
|
||||
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
|
||||
aliceRoot := filepath.Join(sourceRoot, "users", "alice", "shared")
|
||||
if err := os.MkdirAll(aliceRoot, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srtBody := "1\n00:00:01,000 --> 00:00:02,000\nScoped share OK"
|
||||
videoPath := filepath.Join(aliceRoot, "clip.mov")
|
||||
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(aliceRoot, "clip.srt"), []byte(srtBody), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
|
||||
|
||||
adminUser.BackendScopes = []users.BackendScope{{Path: sourceRoot, Scope: "/users/alice"}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=h&path=clip.mov&name=clip.srt&embedded=false", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := subtitlesHandler(rec, req, &Context{
|
||||
User: adminUser,
|
||||
ShareUser: adminUser,
|
||||
IndexPath: "/shared/clip.mov/",
|
||||
Share: share.Share{
|
||||
ShareColumns: share.ShareColumns{
|
||||
Hash: "h",
|
||||
Path: "/users/alice/shared",
|
||||
},
|
||||
SourcePath: sourceRoot,
|
||||
},
|
||||
})
|
||||
if status != http.StatusOK || handlerErr != nil {
|
||||
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Scoped share OK") {
|
||||
t.Fatalf("body=%q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitlesHandler_ShareSingleFileLoadsSidecar(t *testing.T) {
|
||||
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
|
||||
srtBody := "single-file share subtitle"
|
||||
videoPath := filepath.Join(sourceRoot, "clip.mov")
|
||||
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceRoot, "clip.srt"), []byte(srtBody), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=h&path=/&name=clip.srt&embedded=false", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := subtitlesHandler(rec, req, &Context{
|
||||
User: adminUser,
|
||||
ShareUser: adminUser,
|
||||
IndexPath: "/clip.mov/",
|
||||
Share: share.Share{
|
||||
ShareColumns: share.ShareColumns{
|
||||
Hash: "h",
|
||||
Path: "/clip.mov",
|
||||
},
|
||||
SourcePath: sourceRoot,
|
||||
},
|
||||
})
|
||||
if status != http.StatusOK || handlerErr != nil {
|
||||
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "single-file share subtitle") {
|
||||
t.Fatalf("body=%q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitlesHandler_ShareDeniesSidecarSymlinkToDeniedTarget(t *testing.T) {
|
||||
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
|
||||
sharedDir := filepath.Join(sourceRoot, "shared")
|
||||
privateDir := filepath.Join(sourceRoot, "private")
|
||||
for _, dir := range []string{sharedDir, privateDir} {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
secret := filepath.Join(privateDir, "clip.srt")
|
||||
if err := os.WriteFile(secret, []byte("SECRET_DENIED_SUBTITLE"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
videoPath := filepath.Join(sharedDir, "clip.mov")
|
||||
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join("..", "private", "clip.srt"), filepath.Join(sharedDir, "clip.srt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/private", true), "admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
|
||||
|
||||
adminUser.BackendScopes = []users.BackendScope{{Path: sourceRoot, Scope: "/"}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=h&path=clip.mov&name=clip.srt&embedded=false", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := subtitlesHandler(rec, req, &Context{
|
||||
User: adminUser,
|
||||
ShareUser: adminUser,
|
||||
IndexPath: "/shared/clip.mov/",
|
||||
Share: share.Share{
|
||||
ShareColumns: share.ShareColumns{
|
||||
Hash: "h",
|
||||
Path: "/shared",
|
||||
},
|
||||
SourcePath: sourceRoot,
|
||||
},
|
||||
})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "SECRET_DENIED_SUBTITLE") {
|
||||
t.Fatalf("response leaked denied subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitlesHandler_ShareRejectsSidecarPastEscapingDirSymlink(t *testing.T) {
|
||||
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
|
||||
videoIndexPath := "/shared/link/clip.mov/"
|
||||
mockVideoWithExternalSubtitle(t, "clip.srt", filepath.Join(sourceRoot, "shared", "link", "clip.mov"))
|
||||
|
||||
sharedDir := filepath.Join(sourceRoot, "shared")
|
||||
if err := os.MkdirAll(sharedDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(sourceRoot), "outside-share")
|
||||
if err := os.MkdirAll(outside, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "clip.srt"), []byte("SECRET_PAST_DIR_SYMLINK"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(sharedDir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=h&path=link/clip.mov&name=clip.srt&embedded=false", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := subtitlesHandler(rec, req, &Context{
|
||||
User: adminUser,
|
||||
ShareUser: adminUser,
|
||||
IndexPath: videoIndexPath,
|
||||
Share: share.Share{
|
||||
ShareColumns: share.ShareColumns{
|
||||
Hash: "h",
|
||||
Path: "/shared",
|
||||
},
|
||||
SourcePath: sourceRoot,
|
||||
},
|
||||
})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "SECRET_PAST_DIR_SYMLINK") {
|
||||
t.Fatalf("response leaked subtitle past directory symlink")
|
||||
}
|
||||
}
|
||||
@@ -122,10 +122,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
pathWithoutUserScope = pathWithoutUserScope + "/"
|
||||
}
|
||||
data.IndexPath = pathWithoutUserScope
|
||||
// Skip FileInfoFaster prefetch for performance; handlers must apply equivalent
|
||||
// checks (access rules + GetRealPathScoped with d.Share.Path where bytes are read).
|
||||
// TODO: align all skipped public-share routes on one bound (Share.Path) or stop
|
||||
// skipping FileInfoFaster here for media/metadata/items.
|
||||
// skip file fetch for certain apis
|
||||
if (r.Method == "POST" && strings.Contains(r.URL.Path, "/resources")) ||
|
||||
(r.Method == "POST" && strings.Contains(r.URL.Path, "/resources/view-token")) ||
|
||||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items")) ||
|
||||
|
||||
@@ -5,9 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -24,7 +22,6 @@ import (
|
||||
)
|
||||
|
||||
func setupTestEnv(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
utils.BcryptCost = bcrypt.MinCost
|
||||
dbPath := filepath.Join(t.TempDir(), "test.sqlite")
|
||||
|
||||
@@ -538,88 +535,6 @@ func newHTTPRequest(t *testing.T, hash string, requestModifiers ...func(*http.Re
|
||||
return r
|
||||
}
|
||||
|
||||
func TestPublicGetResourceChecksum(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "a.txt")
|
||||
if err := os.WriteFile(filePath, []byte("hello\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalFileInfoFaster := FileInfoFasterFunc
|
||||
t.Cleanup(func() { FileInfoFasterFunc = originalFileInfoFaster })
|
||||
FileInfoFasterFunc = func(opts utils.FileOptions, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return &iteminfo.ExtendedFileInfo{
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
Path: opts.Path,
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "a.txt",
|
||||
Type: "text/plain",
|
||||
},
|
||||
},
|
||||
RealPath: filePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
newOwner := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "checksum_owner",
|
||||
Permissions: users.Permissions{Admin: false},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{{Path: "/srv", Scope: "/"}},
|
||||
}
|
||||
if err := state.CreateUser(newOwner, ""); err != nil {
|
||||
t.Fatal("failed to save user:", err)
|
||||
}
|
||||
ownerValue, err := state.GetUserByUsername("checksum_owner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner := &ownerValue
|
||||
|
||||
if err := state.CreateShare(&share.Share{
|
||||
ShareSettings: share.ShareSettings{ShareLimits: share.ShareLimits{SourceName: "srv"}},
|
||||
ShareColumns: share.ShareColumns{Hash: "checksum_hash", Path: "/"},
|
||||
SourcePath: "/srv",
|
||||
UserID: owner.ID,
|
||||
}); err != nil {
|
||||
t.Fatal("failed to create share:", err)
|
||||
}
|
||||
|
||||
originalAuthKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
|
||||
|
||||
t.Run("anonymous", func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := newTestRequest(t, "checksum_hash", "", "", nil)
|
||||
q := req.URL.Query()
|
||||
q.Set("checksum", "md5")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
withHashFile(publicGetResourceHandler)(recorder, req)
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected status %d, got %d body=%s", http.StatusUnauthorized, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("authenticated", func(t *testing.T) {
|
||||
token := testSessionToken(t, owner, time.Hour)
|
||||
recorder := httptest.NewRecorder()
|
||||
req := newTestRequest(t, "checksum_hash", "", "", map[string]string{
|
||||
"Authorization": "Bearer " + token,
|
||||
})
|
||||
q := req.URL.Query()
|
||||
q.Set("checksum", "md5")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
withHashFile(publicGetResourceHandler)(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "b1946ac92492d2347c6235b4d2611184") {
|
||||
t.Fatalf("checksum missing from response: %s", recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithTimeoutReturnsJSONTimeoutReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
timeout := 50 * time.Millisecond
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -39,31 +38,6 @@ 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.
|
||||
@@ -81,9 +55,7 @@ func isViewerOriginalPreview(previewSize, fileName, fileType string, documentRen
|
||||
// @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) {
|
||||
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" {
|
||||
if settings.Config.Server.DisablePreviews {
|
||||
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
|
||||
}
|
||||
path := r.URL.Query().Get("path")
|
||||
@@ -113,9 +85,6 @@ 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
|
||||
@@ -140,9 +109,7 @@ 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) {
|
||||
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)) {
|
||||
if settings.Config.Server.DisablePreviews || d.Share.DisableThumbnails {
|
||||
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
|
||||
}
|
||||
if d.Share.ShareType == "upload" {
|
||||
@@ -229,35 +196,13 @@ func getDirectoryPreview(ctx context.Context, r *http.Request, d *Context, frame
|
||||
// force an ffprobe probe and a subtitle scan per frame on the fast path.
|
||||
fileInfo, err := files.FileInfoFaster(
|
||||
utils.FileOptions{
|
||||
Path: path,
|
||||
Source: source,
|
||||
AlbumArt: true,
|
||||
FollowSymlinks: true,
|
||||
Path: path,
|
||||
Source: source,
|
||||
AlbumArt: true,
|
||||
}, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.Share.Hash != "" {
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return nil, fmt.Errorf("source not available")
|
||||
}
|
||||
userScope, scopeErr := d.ShareUser.GetScopeForSourceName(source)
|
||||
if scopeErr != nil {
|
||||
return nil, scopeErr
|
||||
}
|
||||
shareRel := strings.TrimPrefix(d.Share.Path, userScope)
|
||||
bound := utils.JoinScopedIndexPath(userScope, shareRel)
|
||||
childIndexPath := utils.JoinScopedIndexPath(userScope, path)
|
||||
realPath, _, rerr := idx.GetRealPathScoped(bound, childIndexPath)
|
||||
if rerr != nil {
|
||||
if index != 0 {
|
||||
return getDirectoryPreview(ctx, r, d, 0)
|
||||
}
|
||||
return nil, rerr
|
||||
}
|
||||
fileInfo.RealPath = realPath
|
||||
}
|
||||
tempCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
_, previewErr := preview.GetPreviewForFile(tempCtx, *fileInfo, "small", "", 0)
|
||||
cancel()
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func TestGetDirectoryPreview_ShareRejectsSymlinkChildOutsideShare(t *testing.T) {
|
||||
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
|
||||
sharedDir := filepath.Join(sourceRoot, "shared")
|
||||
privateDir := filepath.Join(sourceRoot, "private")
|
||||
for _, dir := range []string{sharedDir, privateDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(privateDir, "secret.jpg"), []byte{0xff, 0xd8, 0xff, 0xd9}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join("..", "private", "secret.jpg"), filepath.Join(sharedDir, "link.jpg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
adminUser.BackendScopes = []users.BackendScope{{Path: sourceRoot, Scope: "/"}}
|
||||
d := &Context{
|
||||
User: adminUser,
|
||||
ShareUser: adminUser,
|
||||
IndexPath: "/shared/",
|
||||
Share: share.Share{
|
||||
ShareColumns: share.ShareColumns{Hash: "h", Path: "/shared"},
|
||||
SourcePath: sourceRoot,
|
||||
},
|
||||
FileInfo: iteminfo.ExtendedFileInfo{
|
||||
Source: "Downloads",
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
Path: "/shared/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "link.jpg", Type: "image/jpeg", HasPreview: true}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
idx := indexing.GetIndex("Downloads")
|
||||
if idx == nil {
|
||||
t.Fatal("missing index")
|
||||
}
|
||||
|
||||
_, err := getDirectoryPreview(ctx, req, d, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected scope error for share child symlink outside share root")
|
||||
}
|
||||
if !errors.Is(err, commonerrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("error=%v want ErrPathEscapesScope", err)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
)
|
||||
|
||||
func TestGetRemoteIPUsesRightmostForwardedFor(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -28,7 +27,6 @@ func TestGetRemoteIPUsesRightmostForwardedFor(t *testing.T) {
|
||||
// proxy hops so multi-hop deployments (CDN -> proxy -> filebrowser) resolve the
|
||||
// real client IP instead of collapsing everyone onto the shared edge address.
|
||||
func TestGetRemoteIPTrustedProxies(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -61,7 +59,6 @@ func TestGetRemoteIPTrustedProxies(t *testing.T) {
|
||||
|
||||
// Bare IPs (not just CIDRs) are accepted in http.trustedProxies.
|
||||
func TestGetRemoteIPTrustedProxiesBareIP(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -77,7 +74,6 @@ func TestGetRemoteIPTrustedProxiesBareIP(t *testing.T) {
|
||||
|
||||
// Without trustProxyHeaders the XFF chain is ignored entirely.
|
||||
func TestGetRemoteIPIgnoresForwardedForWhenUntrusted(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = false
|
||||
@@ -94,7 +90,6 @@ func TestGetRemoteIPIgnoresForwardedForWhenUntrusted(t *testing.T) {
|
||||
// A direct client that is not a trusted proxy cannot spoof its address with
|
||||
// forwarded headers, even when trustProxyHeaders is enabled.
|
||||
func TestGetRemoteIPIgnoresForwardedHeadersFromUntrustedPeer(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -109,26 +104,8 @@ func TestGetRemoteIPIgnoresForwardedHeadersFromUntrustedPeer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRemoteIPUnixSocketHonorsForwardedWithTrustAllLocalPeers(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
settings.Config.Http.Socket = "/var/run/filebrowser.sock"
|
||||
settings.Config.Http.UnixSocketTrustAllLocalPeers = true
|
||||
settings.Config.Http.TrustedProxies = nil
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/", nil)
|
||||
req.RemoteAddr = "@"
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.50")
|
||||
if got := GetRemoteIP(req); got != "203.0.113.50" {
|
||||
t.Fatalf("GetRemoteIP = %q, want 203.0.113.50", got)
|
||||
}
|
||||
}
|
||||
|
||||
// With no trusted proxies configured, forwarded headers are never honored.
|
||||
func TestGetRemoteIPIgnoresForwardedHeadersWithoutTrustedProxies(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -144,7 +121,6 @@ func TestGetRemoteIPIgnoresForwardedHeadersWithoutTrustedProxies(t *testing.T) {
|
||||
|
||||
// An IPv4-mapped IPv6 trusted hop in the XFF chain must still be skipped.
|
||||
func TestGetRemoteIPMappedTrustedHop(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -159,7 +135,6 @@ func TestGetRemoteIPMappedTrustedHop(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetScheme(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
t.Run("trusted proto", func(t *testing.T) {
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
|
||||
@@ -183,7 +158,6 @@ func TestGetScheme(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestSchemeTLSFallback(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
settings.Config.Http.TrustProxyHeaders = false
|
||||
|
||||
req := httptest.NewRequest("GET", "https://example.com/", nil)
|
||||
@@ -194,7 +168,6 @@ func TestRequestSchemeTLSFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestSchemeIgnoresInvalidProto(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
|
||||
|
||||
@@ -206,7 +179,6 @@ func TestRequestSchemeIgnoresInvalidProto(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestHostTrustProxyHeaders(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
|
||||
|
||||
@@ -220,7 +192,6 @@ func TestRequestHostTrustProxyHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestSchemeForPublicURL(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http.TrustProxyHeaders
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = orig })
|
||||
|
||||
@@ -254,7 +225,6 @@ func TestRequestSchemeForPublicURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestPageFullURL(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.ExternalUrl = ""
|
||||
|
||||
@@ -237,7 +237,6 @@ func TestResourcePutHandler_SuccessResponseOmitsRealPath(t *testing.T) {
|
||||
|
||||
func setupPutHTTPTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
downloadsPath := filepath.Join(t.TempDir(), "downloads")
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "quota-http-test.sqlite")
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/quota"
|
||||
@@ -172,10 +171,6 @@ func putResourceWithQuota(w http.ResponseWriter, r *http.Request, d *Context, so
|
||||
if err != nil {
|
||||
return sanitizePutPathError(err)
|
||||
}
|
||||
permUser := accessCheckUsername(d)
|
||||
if !state.AccessPermittedAtRealPath(idx, permUser, realPath, access.IsDirAtRealPath(realPath)) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", fullIndexPath)
|
||||
}
|
||||
if stat, statErr := os.Stat(realPath); statErr == nil && stat.IsDir() {
|
||||
return http.StatusMethodNotAllowed, errPutPathIsDirectory
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
|
||||
func setupQuotaHTTPTest(t *testing.T) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
dbPath := filepath.Join(t.TempDir(), "quota-http-test.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
@@ -209,9 +208,7 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int
|
||||
// @Param path query string false "Path within the share to retrieve information for. Defaults to share root."
|
||||
// @Param content query string false "Include file content if true"
|
||||
// @Param metadata query string false "Extract audio/video metadata if true"
|
||||
// @Param checksum query string false "Optional checksum algorithm (md5, sha1, sha256, sha512); requires a logged-in session"
|
||||
// @Success 200 {object} iteminfo.FileInfo "File or directory metadata"
|
||||
// @Failure 401 {object} map[string]string "Login required for checksum generation"
|
||||
// @Failure 403 {object} map[string]string "Share unavailable or access denied"
|
||||
// @Failure 404 {object} map[string]string "Share not found or file not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
@@ -221,24 +218,6 @@ func publicGetResourceHandler(w http.ResponseWriter, r *http.Request, d *Context
|
||||
if d.Share.ShareType == "upload" {
|
||||
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
|
||||
}
|
||||
if algo := r.URL.Query().Get("checksum"); algo != "" {
|
||||
if d.User == nil || d.User.Username == "" || d.User.Username == "anonymous" {
|
||||
return http.StatusUnauthorized, fmt.Errorf("login required to generate checksums")
|
||||
}
|
||||
if d.FileInfo.Type == "directory" {
|
||||
return http.StatusBadRequest, fmt.Errorf("checksum is only supported for files")
|
||||
}
|
||||
if d.Share.DisableFileViewer {
|
||||
return http.StatusForbidden, fmt.Errorf("file viewing is disabled for this share")
|
||||
}
|
||||
checksum, err := utils.GetChecksum(d.FileInfo.RealPath, algo)
|
||||
if err == errors.ErrInvalidOption {
|
||||
return http.StatusBadRequest, nil
|
||||
} else if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
d.FileInfo.Checksums = map[string]string{algo: checksum}
|
||||
}
|
||||
return RenderJSON(w, r, d.FileInfo)
|
||||
}
|
||||
|
||||
@@ -813,9 +792,6 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
if !state.AccessPermitted(idx.Path, utils.IndexPathFromNormalized(fullIndexPath, true), filePermUser.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idx, filePermUser.Username, realPath, access.IsDirAtRealPath(realPath)) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
|
||||
}
|
||||
|
||||
// Check for file/folder conflicts before creation
|
||||
if stat, statErr := os.Stat(realPath); statErr == nil {
|
||||
@@ -1552,15 +1528,6 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
|
||||
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
|
||||
continue
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(srcIdx, d.User.Username, realSrc, isSrcDir) {
|
||||
item.Message = "access denied to source path"
|
||||
if d.Share.Hash != "" {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{Message: item.Message})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve the destination: copy must reject escaping symlink leaves; move/rename
|
||||
// must replace an existing symlink entry without following it.
|
||||
@@ -1581,28 +1548,10 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
|
||||
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
|
||||
continue
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(dstIdx, d.User.Username, realDest, access.IsDirAtRealPath(realDest)) {
|
||||
item.Message = "access denied to destination path"
|
||||
if d.Share.Hash != "" {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{Message: item.Message})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
|
||||
continue
|
||||
}
|
||||
|
||||
// Auto-rename if requested
|
||||
if req.Rename {
|
||||
realDest = addVersionSuffix(realDest)
|
||||
if !state.AccessPermittedAtRealPath(dstIdx, d.User.Username, realDest, isSrcDir) {
|
||||
item.Message = "access denied to destination path"
|
||||
if d.Share.Hash != "" {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{Message: item.Message})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Validate move/rename operation to prevent circular references
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
)
|
||||
|
||||
func TestResourcePatchHandler_CopyRename_DeniesDeniedVersionSuffix(t *testing.T) {
|
||||
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
destDir := filepath.Join(sourceRoot, "dest")
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := filepath.Join(sourceRoot, "src.txt")
|
||||
if err := os.WriteFile(src, []byte("src"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := filepath.Join(destDir, "report.txt")
|
||||
if err := os.WriteFile(existing, []byte("existing"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
denied := filepath.Join(destDir, "report(1).txt")
|
||||
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/dest/report(1).txt", false), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user, err := state.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"action":"copy","rename":true,"items":[{"fromSource":"` + sourceName +
|
||||
`","fromPath":"/src.txt","toSource":"` + sourceName + `","toPath":"/dest/report.txt"}]}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/resources", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := ResourcePatchHandler(rec, req, &Context{User: &user})
|
||||
if handlerErr != nil {
|
||||
t.Fatalf("err=%v", handlerErr)
|
||||
}
|
||||
if status != http.StatusInternalServerError && status != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", status, rec.Body.String())
|
||||
}
|
||||
var resp MoveCopyResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.Failed) != 1 || !strings.Contains(resp.Failed[0].Message, "access denied") {
|
||||
t.Fatalf("failed=%+v", resp.Failed)
|
||||
}
|
||||
if _, err := os.Stat(denied); err == nil {
|
||||
t.Fatal("denied destination file should not exist")
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
|
||||
func setupPutQuotaTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
downloadsPath := filepath.Join(t.TempDir(), "downloads")
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "quota-http-test.sqlite")
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
)
|
||||
|
||||
func TestResourcePostHandler_RejectsEscapingParentSymlinkMkdir(t *testing.T) {
|
||||
downloadsPath := setupPutHTTPTest(t)
|
||||
outside := filepath.Join(filepath.Dir(downloadsPath), "outside-mkdir")
|
||||
if err := os.MkdirAll(outside, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(downloadsPath, "evil")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
adminUser, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources?source=Downloads&path=/evil/newdir&isDir=true", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
status, handlerErr := ResourcePostHandler(rec, req, &requestContext{User: &adminUser})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status=%d err=%v", status, handlerErr)
|
||||
}
|
||||
if handlerErr == nil {
|
||||
t.Fatal("expected mkdir to be forbidden")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "newdir")); !os.IsNotExist(err) {
|
||||
t.Fatal("mkdir escaped outside the source via symlink parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceDeleteHandler_RejectsEscapingIntermediate(t *testing.T) {
|
||||
downloadsPath := setupPutHTTPTest(t)
|
||||
if preview.GetService() == nil {
|
||||
if err := preview.StartPreviewGenerator(1, t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
outsideDir := filepath.Join(filepath.Dir(downloadsPath), "outside-delete")
|
||||
if err := os.MkdirAll(outsideDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
victim := filepath.Join(outsideDir, "victim.txt")
|
||||
if err := os.WriteFile(victim, []byte("keep"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideDir, filepath.Join(downloadsPath, "evil")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
adminUser, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/resources?source=Downloads&path=/evil/victim.txt", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
status, handlerErr := resourceDeleteHandler(rec, req, &requestContext{User: &adminUser})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status=%d err=%v", status, handlerErr)
|
||||
}
|
||||
if handlerErr == nil {
|
||||
t.Fatal("expected delete to be forbidden")
|
||||
}
|
||||
got, err := os.ReadFile(victim)
|
||||
if err != nil || string(got) != "keep" {
|
||||
t.Fatalf("delete escaped: err=%v content=%q", err, got)
|
||||
}
|
||||
}
|
||||
@@ -87,11 +87,7 @@ func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int
|
||||
// filesystem. It is the comment/secret-marker source for both the YAML export and the settings
|
||||
// JSON redactor.
|
||||
func readEmbeddedConfigYaml() (string, error) {
|
||||
path := "config.generated.yaml"
|
||||
if settings.Env.EmbeddedFs {
|
||||
path = "embed/config.generated.yaml"
|
||||
}
|
||||
content, err := fs.ReadFile(assetFs, path)
|
||||
content, err := fs.ReadFile(assetFs, "config.generated.yaml")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -487,16 +487,10 @@ func ServeSingleFile(w http.ResponseWriter, r *http.Request, d *Context, source
|
||||
bound = userScope
|
||||
}
|
||||
|
||||
realPath, isDir, err := idx.GetRealPathScoped(bound, scopedFilePath)
|
||||
realPath, _, err := idx.GetRealPathScoped(bound, scopedFilePath)
|
||||
if err != nil {
|
||||
return realPathErrStatus(err), err
|
||||
}
|
||||
if st, statErr := os.Stat(realPath); statErr == nil {
|
||||
isDir = st.IsDir()
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idx, permUser, realPath, isDir) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", scopedFilePath)
|
||||
}
|
||||
|
||||
isOnlyOffice := IsOnlyOfficeCompatibleFile(displayFileName) && settings.Config.Integrations.OnlyOffice.Url != "" && !settings.Config.Integrations.OnlyOffice.IsCollabora()
|
||||
var documentId string
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// webGlobalTestMu serializes tests that mutate process-wide settings.Config or state.
|
||||
var webGlobalTestMu sync.Mutex
|
||||
|
||||
func lockWebGlobals(t testing.TB) {
|
||||
t.Helper()
|
||||
webGlobalTestMu.Lock()
|
||||
t.Cleanup(webGlobalTestMu.Unlock)
|
||||
}
|
||||
@@ -176,8 +176,6 @@ 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
|
||||
}
|
||||
@@ -201,7 +199,7 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
return status, err
|
||||
}
|
||||
plaintextPassword := req.User.Password
|
||||
err = state.CreateUserKeepingProfile(&req.User, plaintextPassword)
|
||||
err = state.CreateUser(&req.User, plaintextPassword)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func createPostTestAdmin(t *testing.T) *users.User {
|
||||
t.Helper()
|
||||
admin := &users.User{
|
||||
ID: 1,
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
Permissions: users.Permissions{Admin: true},
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(admin, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return admin
|
||||
}
|
||||
|
||||
// Issue #3322: profile/account values in POST /api/users must not be replaced by userDefaults.
|
||||
func TestUsersPostKeepsRequestedProfileValues(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
defaults := settings.Config.UserDefaults
|
||||
defaults.UI.Locale = "en"
|
||||
defaults.UI.ThemeColor = "var(--blue)"
|
||||
defaults.UI.DarkMode = users.DarkModeDark
|
||||
defaults.Account.LockPassword = false
|
||||
defaults.Account.DisableSettings = false
|
||||
defaults.Account.ShowAdvancedProfile = false
|
||||
defaults.Listing.PromptRightCloseButton = false
|
||||
defaults.Listing.NewFileTemplate = []string{""}
|
||||
defaults.Listing.ShowHidden = true
|
||||
state.SetEffectiveUserDefaultsForTest(t, defaults)
|
||||
|
||||
admin := createPostTestAdmin(t)
|
||||
|
||||
body := []byte(`{"which":[],"data":{
|
||||
"username":"profiletest","password":"secret","loginMethod":"password",
|
||||
"locale":"zhCN","themeColor":"var(--green)","darkMode":false,
|
||||
"lockPassword":true,"disableSettings":true,"showAdvancedProfile":true,
|
||||
"promptRightCloseButton":true,"newFileTemplate":["custom.txt"],
|
||||
"preview":{"image":false},
|
||||
"permissions":{"admin":false,"api":true,"share":true,"realtime":false},
|
||||
"scopes":[{"name":"/","scope":"/","permissions":{"view":true,"download":true,"modify":true,"create":true,"delete":true}}]
|
||||
}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, err := usersPostHandler(rec, req, &Context{User: admin})
|
||||
if err != nil || status != http.StatusCreated {
|
||||
t.Fatalf("usersPostHandler() status=%d err=%v", status, err)
|
||||
}
|
||||
|
||||
got, err := state.GetUserByUsername("profiletest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Locale != "zhCN" {
|
||||
t.Errorf("locale = %q, want zhCN", got.Locale)
|
||||
}
|
||||
if got.ThemeColor != "var(--green)" {
|
||||
t.Errorf("themeColor = %q, want var(--green)", got.ThemeColor)
|
||||
}
|
||||
if got.DarkMode != users.DarkModeLight {
|
||||
t.Errorf("darkMode = %q, want light", got.DarkMode)
|
||||
}
|
||||
if !got.LockPassword {
|
||||
t.Errorf("lockPassword = false, want true")
|
||||
}
|
||||
if !got.DisableSettings {
|
||||
t.Errorf("disableSettings = false, want true")
|
||||
}
|
||||
if !got.ShowAdvancedProfile {
|
||||
t.Errorf("showAdvancedProfile = false, want true")
|
||||
}
|
||||
if !got.PromptRightCloseButton {
|
||||
t.Errorf("promptRightCloseButton = false, want true")
|
||||
}
|
||||
if len(got.NewFileTemplate) != 1 || got.NewFileTemplate[0] != "custom.txt" {
|
||||
t.Errorf("newFileTemplate = %v, want [custom.txt]", got.NewFileTemplate)
|
||||
}
|
||||
if got.Preview.Image {
|
||||
t.Errorf("preview.image = true, want false")
|
||||
}
|
||||
// Omitted fields still come from userDefaults.
|
||||
if !got.ShowHidden {
|
||||
t.Errorf("showHidden = false, want true from userDefaults")
|
||||
}
|
||||
if !got.Preview.Video {
|
||||
t.Errorf("preview.video = false, want true from userDefaults")
|
||||
}
|
||||
wantPerms := users.Permissions{Api: true, Share: true}
|
||||
if got.Permissions.Admin != wantPerms.Admin || got.Permissions.Api != wantPerms.Api ||
|
||||
got.Permissions.Share != wantPerms.Share || got.Permissions.Realtime != wantPerms.Realtime {
|
||||
t.Errorf("permissions = %+v, want %+v", got.Permissions, wantPerms)
|
||||
}
|
||||
}
|
||||
|
||||
// Enforced userDefaults still win over request values on create.
|
||||
func TestUsersPostEnforcedDefaultsWinOverRequest(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
defaults := settings.Config.UserDefaults
|
||||
defaults.UI.Locale = "en"
|
||||
state.SetEffectiveUserDefaultsForTest(t, defaults)
|
||||
if err := state.PatchUserDefaultsEnforced([]byte(`{"ui":{"locale":true}}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
admin := createPostTestAdmin(t)
|
||||
|
||||
body := []byte(`{"which":[],"data":{"username":"enforced","password":"secret","loginMethod":"password","locale":"zhCN","lockPassword":true}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, err := usersPostHandler(rec, req, &Context{User: admin})
|
||||
if err != nil || status != http.StatusCreated {
|
||||
t.Fatalf("usersPostHandler() status=%d err=%v", status, err)
|
||||
}
|
||||
got, err := state.GetUserByUsername("enforced")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Locale != "en" {
|
||||
t.Errorf("locale = %q, want enforced en", got.Locale)
|
||||
}
|
||||
if !got.LockPassword {
|
||||
t.Errorf("lockPassword = false, want true (not enforced)")
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
@@ -238,35 +237,6 @@ func (ffs *filteredFileSystem) checkScopePath(requestPath string, forRemoval boo
|
||||
return err
|
||||
}
|
||||
|
||||
// checkResolvedAccess enforces per-path DENY rules on the symlink-resolved target.
|
||||
func (ffs *filteredFileSystem) checkResolvedAccess(requestPath string, forRemoval bool) error {
|
||||
idx := indexing.GetIndex(ffs.source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("source not found")
|
||||
}
|
||||
userScope, err := ffs.user.GetScopeForSourceName(ffs.source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fullIndexPath := utils.JoinScopedIndexPath(userScope, strings.TrimPrefix(requestPath, "/"))
|
||||
var realPath string
|
||||
if forRemoval {
|
||||
realPath, err = idx.GetRealPathForRemoval(userScope, fullIndexPath)
|
||||
} else {
|
||||
realPath, err = idx.GetRealPathForMutation(userScope, fullIndexPath)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, commonerrors.ErrPathEscapesScope) {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idx, ffs.user.Username, realPath, access.IsDirAtRealPath(realPath)) {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ffs *filteredFileSystem) requireCreatePermissionForNewFile(requestPath string) error {
|
||||
idx := indexing.GetIndex(ffs.source)
|
||||
if idx == nil {
|
||||
@@ -442,9 +412,6 @@ func (ffs *filteredFileSystem) Mkdir(ctx context.Context, name string, perm os.F
|
||||
logger.Debugf("Mkdir: scope check denied for %s: %v", name, err)
|
||||
return err
|
||||
}
|
||||
if err := ffs.checkResolvedAccess(name, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ffs.fs.Mkdir(ctx, name, perm); err != nil {
|
||||
return err
|
||||
@@ -483,9 +450,6 @@ func (ffs *filteredFileSystem) OpenFile(ctx context.Context, requestPath string,
|
||||
logger.Debugf("OpenFile: scope check denied for %s: %v", requestPath, err)
|
||||
return nil, err
|
||||
}
|
||||
if err := ffs.checkResolvedAccess(requestPath, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if (flag&os.O_CREATE) != 0 && !ffs.sourceFilePerms().Create {
|
||||
if err := ffs.requireCreatePermissionForNewFile(requestPath); err != nil {
|
||||
@@ -498,9 +462,6 @@ func (ffs *filteredFileSystem) OpenFile(ctx context.Context, requestPath string,
|
||||
logger.Debugf("OpenFile: read denied by scope for %s", requestPath)
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
if err := ffs.checkResolvedAccess(requestPath, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var desiredMtime time.Time
|
||||
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
|
||||
// setupWebDAVTestEnv sets up the test environment with multiple sources and users
|
||||
func setupWebDAVTestEnv(t *testing.T) (string, string) {
|
||||
lockWebGlobals(t)
|
||||
// Create temp directories for test sources
|
||||
tempDir := t.TempDir()
|
||||
source1Path := filepath.Join(tempDir, "source1")
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
@@ -25,9 +24,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
RealPathCache = cache.NewCache[string](48*time.Hour, 72*time.Hour)
|
||||
IsDirCache = cache.NewCache[bool](48*time.Hour, 72*time.Hour)
|
||||
realPathGeneration atomic.Uint64
|
||||
RealPathCache = cache.NewCache[string](48*time.Hour, 72*time.Hour)
|
||||
IsDirCache = cache.NewCache[bool](48*time.Hour, 72*time.Hour)
|
||||
)
|
||||
|
||||
// getDiskUsage returns the actual disk space used by a file in bytes
|
||||
@@ -1224,7 +1222,6 @@ func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool,
|
||||
if ok && cached != "" {
|
||||
return cached, isDir, nil
|
||||
}
|
||||
generation := realPathGeneration.Load()
|
||||
absolutePath, err := filepath.Abs(joinedPath)
|
||||
if err != nil {
|
||||
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err)
|
||||
@@ -1242,77 +1239,9 @@ func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool,
|
||||
}
|
||||
RealPathCache.Set(cacheKey, realPath)
|
||||
IsDirCache.Set(cacheKey+":isdir", isDir)
|
||||
if realPathGeneration.Load() != generation {
|
||||
RealPathCache.Delete(cacheKey)
|
||||
IsDirCache.Delete(cacheKey + ":isdir")
|
||||
}
|
||||
return realPath, isDir, nil
|
||||
}
|
||||
|
||||
// realPathCacheJoinedPath builds the filesystem join used in RealPathCache keys.
|
||||
func realPathCacheJoinedPath(sourceRoot, indexPath string) string {
|
||||
seg := utils.CleanIndexPathSegment(indexPath)
|
||||
if seg == "" {
|
||||
return sourceRoot
|
||||
}
|
||||
return filepath.Join(sourceRoot, seg)
|
||||
}
|
||||
|
||||
func realPathCacheIndexAncestors(indexPath string) []string {
|
||||
rel := utils.CleanIndexPathSegment(indexPath)
|
||||
if rel == "" {
|
||||
return []string{"/"}
|
||||
}
|
||||
parts := strings.Split(rel, "/")
|
||||
ancestors := []string{"/"}
|
||||
for i := range parts {
|
||||
ancestors = append(ancestors, "/"+strings.Join(parts[:i+1], "/"))
|
||||
}
|
||||
return ancestors
|
||||
}
|
||||
|
||||
// ClearRealPathCaches drops every cached symlink resolution. The cache library has no
|
||||
// prefix delete, so subtree invalidation uses a full clear; entries repopulate lazily.
|
||||
func ClearRealPathCaches() {
|
||||
realPathGeneration.Add(1)
|
||||
RealPathCache.ClearAll()
|
||||
IsDirCache.ClearAll()
|
||||
}
|
||||
|
||||
// InvalidateRealPathCache drops cached symlink resolutions for indexPath and its
|
||||
// ancestors. scopeBounds should include any caller scope prefixes used with
|
||||
// GetRealPathScoped (e.g. user or share path).
|
||||
func (idx *Index) InvalidateRealPathCache(indexPath string, scopeBounds ...string) {
|
||||
realPathGeneration.Add(1)
|
||||
boundSet := map[string]struct{}{"/": {}, "": {}}
|
||||
for _, b := range scopeBounds {
|
||||
b = strings.TrimRight(strings.TrimSpace(b), "/")
|
||||
if b == "" || b == "/" {
|
||||
boundSet["/"] = struct{}{}
|
||||
boundSet[""] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(b, "/") {
|
||||
b = "/" + b
|
||||
}
|
||||
boundSet[b] = struct{}{}
|
||||
}
|
||||
bounds := make([]string, 0, len(boundSet))
|
||||
for b := range boundSet {
|
||||
bounds = append(bounds, b)
|
||||
}
|
||||
for _, ip := range realPathCacheIndexAncestors(indexPath) {
|
||||
joined := realPathCacheJoinedPath(idx.Path, ip)
|
||||
for _, enforceScope := range []bool{false, true} {
|
||||
for _, bound := range bounds {
|
||||
key := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", enforceScope, bound, joined)
|
||||
RealPathCache.Delete(key)
|
||||
IsDirCache.Delete(key + ":isdir")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetRealPathForMutation resolves an index path for a mutating filesystem
|
||||
// operation (create/overwrite/mkdir/move-into). When the leaf exists it is fully
|
||||
// resolved; otherwise the deepest existing ancestor is resolved and the missing
|
||||
@@ -1674,10 +1603,7 @@ func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
|
||||
}
|
||||
switch simpleType {
|
||||
case "image":
|
||||
// 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 {
|
||||
if fileInfo.Size > iteminfo.LargeFileSizeThreshold {
|
||||
fileInfo.HasPreview = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func TestSetFilePreviewFlagsLargeImages(t *testing.T) {
|
||||
large := iteminfo.LargeFileSizeThreshold + 1
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
typ string
|
||||
size int64
|
||||
want bool
|
||||
}{
|
||||
{"normal image under the limit", "a.jpg", "image/jpeg", 5 << 20, true},
|
||||
{"normal image over the limit", "a.jpg", "image/jpeg", int64(large), false},
|
||||
{"RAW under the limit", "a.raf", "image/x-fuji-raf", 26 << 20, true},
|
||||
{"large RAW keeps its preview, the embedded JPEG is read without decoding", "a.RAF", "image/x-fuji-raf", int64(large), true},
|
||||
{"large DNG keeps its preview", "a.dng", "image/x-adobe-dng", int64(large), true},
|
||||
{"empty file has no preview", "a.raf", "image/x-fuji-raf", 0, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
info := &iteminfo.ItemInfo{Name: tt.file, Type: tt.typ, Size: tt.size}
|
||||
setFilePreviewFlags(info, "/nonexistent/"+tt.file)
|
||||
if info.HasPreview != tt.want {
|
||||
t.Fatalf("HasPreview = %v, want %v", info.HasPreview, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package indexing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -164,66 +163,6 @@ func TestGetRealPathScoped_CacheKeyDoesNotCollideWithUnscoped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealPathCacheGeneration_BumpsOnInvalidateAndClear(t *testing.T) {
|
||||
idx := symlinkTestIndex(t.TempDir())
|
||||
gen := realPathGeneration.Load()
|
||||
idx.InvalidateRealPathCache("/any")
|
||||
if realPathGeneration.Load() != gen+1 {
|
||||
t.Fatalf("InvalidateRealPathCache: gen=%d want %d", realPathGeneration.Load(), gen+1)
|
||||
}
|
||||
gen = realPathGeneration.Load()
|
||||
ClearRealPathCaches()
|
||||
if realPathGeneration.Load() != gen+1 {
|
||||
t.Fatalf("ClearRealPathCaches: gen=%d want %d", realPathGeneration.Load(), gen+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearRealPathCaches_ClearsWarmEntries(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
userDir := filepath.Join(sourceRoot, "user1")
|
||||
if err := os.WriteFile(filepath.Join(userDir, "foo"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := idx.GetRealPathScoped(userScope, "/user1/foo"); err != nil {
|
||||
t.Fatalf("warm cache: %v", err)
|
||||
}
|
||||
joined := filepath.Join(sourceRoot, "user1", "foo")
|
||||
key := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", true, userScope, joined)
|
||||
if _, ok := RealPathCache.Get(key); !ok {
|
||||
t.Fatalf("expected cache key %q", key)
|
||||
}
|
||||
ClearRealPathCaches()
|
||||
if _, ok := RealPathCache.Get(key); ok {
|
||||
t.Fatal("ClearRealPathCaches should drop warm entries")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateRealPathCache_ClearsGetRealPathEntry(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
userDir := filepath.Join(sourceRoot, "user1")
|
||||
if err := os.WriteFile(filepath.Join(userDir, "foo"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, _, err := idx.GetRealPath("/user1/foo"); err != nil {
|
||||
t.Fatalf("warm cache: %v", err)
|
||||
}
|
||||
joined := realPathCacheJoinedPath(sourceRoot, "/user1/foo")
|
||||
key := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", false, "/", joined)
|
||||
if _, ok := RealPathCache.Get(key); !ok {
|
||||
t.Fatalf("expected cache key %q", key)
|
||||
}
|
||||
|
||||
idx.InvalidateRealPathCache("/user1/foo", userScope)
|
||||
if _, ok := RealPathCache.Get(key); ok {
|
||||
t.Fatal("InvalidateRealPathCache should drop unscoped cache entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealPathForMutation_ExistingInScope(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
@@ -120,11 +120,7 @@ func warnHttpProxyConfig() {
|
||||
}
|
||||
if Config.Http.TrustProxyHeaders && Config.Http.Socket != "" && !strings.HasPrefix(Config.Http.Socket, "@") &&
|
||||
len(Config.Http.UnixSocketTrustedUIDs) == 0 && len(Config.Http.UnixSocketTrustedGIDs) == 0 {
|
||||
if Config.Http.UnixSocketTrustAllLocalPeers {
|
||||
logger.Warning("http.unixSocketTrustAllLocalPeers is true: any local process that can connect to the filesystem socket may present X-Forwarded-For and X-Real-IP. Prefer http.unixSocketTrustedUIDs and/or http.unixSocketTrustedGIDs so only peers with allowed Linux credentials can set forwarded headers.")
|
||||
} else {
|
||||
logger.Warning("http.trustProxyHeaders is true on a filesystem Unix socket, but http.unixSocketTrustedUIDs and http.unixSocketTrustedGIDs are empty; forwarded client headers from socket peers are ignored. List allowed peer UIDs and/or GIDs (Linux SO_PEERCRED), or set http.unixSocketTrustAllLocalPeers: true only if you accept legacy trust-all behavior.")
|
||||
}
|
||||
logger.Warning("http.trustProxyHeaders is true and http.socket is a filesystem Unix socket, but http.unixSocketTrustedUIDs and http.unixSocketTrustedGIDs are empty; X-Forwarded-For and X-Real-IP are ignored until you list the reverse proxy's UID or GID (Linux peer credentials).")
|
||||
}
|
||||
if !Env.IsDevMode {
|
||||
if u := Config.Http.ExternalUrl; u != "" && strings.HasPrefix(strings.ToLower(u), "http://") {
|
||||
@@ -1048,10 +1044,9 @@ func SetDefaults(generate bool) Settings {
|
||||
Locale: "en",
|
||||
},
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 10,
|
||||
UploadChunkSize: 10, // 10MB
|
||||
DownloadChunkSize: 0, // 0MB, default to no chunking
|
||||
UploadStallTimeoutSeconds: 10, // pause an upload after 10 seconds without progress
|
||||
MaxConcurrent: 10,
|
||||
UploadChunkSize: 10, // 10MB
|
||||
DownloadChunkSize: 0, // 0MB, default to no chunking
|
||||
},
|
||||
Account: UserDefaultsAccount{
|
||||
Permissions: UserDefaultsAccountPermissions{
|
||||
|
||||
@@ -71,10 +71,9 @@ type UserDefaultsUIEnforcement struct {
|
||||
}
|
||||
|
||||
type UserDefaultsFileLoadingEnforcement struct {
|
||||
MaxConcurrent bool `json:"maxConcurrentUpload,omitempty"`
|
||||
UploadChunkSize bool `json:"uploadChunkSizeMb,omitempty"`
|
||||
DownloadChunkSize bool `json:"downloadChunkSizeMb,omitempty"`
|
||||
UploadStallTimeoutSeconds bool `json:"uploadStallTimeoutSeconds,omitempty"`
|
||||
MaxConcurrent bool `json:"maxConcurrentUpload,omitempty"`
|
||||
UploadChunkSize bool `json:"uploadChunkSizeMb,omitempty"`
|
||||
DownloadChunkSize bool `json:"downloadChunkSizeMb,omitempty"`
|
||||
}
|
||||
|
||||
type UserDefaultsAccountEnforcement struct {
|
||||
|
||||
@@ -37,14 +37,11 @@ type Http struct {
|
||||
TrustProxyHeaders bool `json:"trustProxyHeaders"` // honor X-Forwarded-* and X-Real-IP from a reverse proxy (default: false)
|
||||
TrustedProxies []string `json:"trustedProxies"` // IPs/CIDRs of trusted proxies; forwarded client headers are only honored when the socket peer matches one of these (default: empty = forwarded headers ignored)
|
||||
// UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to
|
||||
// present X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted
|
||||
// unless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts
|
||||
// every peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust
|
||||
// forwarded headers.
|
||||
UnixSocketTrustedUIDs []int `json:"unixSocketTrustedUIDs"`
|
||||
UnixSocketTrustedGIDs []int `json:"unixSocketTrustedGIDs"`
|
||||
UnixSocketTrustAllLocalPeers bool `json:"unixSocketTrustAllLocalPeers"` // default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty
|
||||
SocketFileMode string `json:"socketFileMode"` // octal mode for filesystem http.socket (default "0660"); ignored for abstract sockets
|
||||
// present X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers
|
||||
// are not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.
|
||||
UnixSocketTrustedUIDs []int `json:"unixSocketTrustedUIDs"`
|
||||
UnixSocketTrustedGIDs []int `json:"unixSocketTrustedGIDs"`
|
||||
SocketFileMode string `json:"socketFileMode"` // octal mode for filesystem http.socket (default "0660"); ignored for abstract sockets
|
||||
SocketGroup string `json:"socketGroup"` // optional group name or numeric gid for the socket file (filesystem sockets only)
|
||||
DisableRateLimit bool `json:"disableRateLimit"` // turns off built-in auth route rate limiting and failed-login lockout (default false).
|
||||
}
|
||||
@@ -82,12 +79,11 @@ 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)
|
||||
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
|
||||
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
|
||||
// not exposed to config
|
||||
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
|
||||
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
|
||||
|
||||
@@ -113,11 +113,10 @@ func ipMatchesPrefixes(ip string, prefixes []netip.Prefix) bool {
|
||||
// proxy headers. Forwarded client headers must only be honored when this returns
|
||||
// true; otherwise any client that can reach the listener directly could spoof its address.
|
||||
//
|
||||
// When http.socket is set the server listens only on that Unix socket. Abstract
|
||||
// sockets ("@name") have no filesystem permissions and their peers are never trusted.
|
||||
// On filesystem sockets, when either allowlist is set, only peers whose Linux SO_PEERCRED
|
||||
// UID or GID matches may present forwarded headers. When both allowlists are empty,
|
||||
// peers are trusted only if http.unixSocketTrustAllLocalPeers is true (legacy opt-in).
|
||||
// When http.socket is set the server listens only on that Unix socket. Filesystem
|
||||
// socket peers are trusted only when their UID or GID matches
|
||||
// http.unixSocketTrustedUIDs / http.unixSocketTrustedGIDs (Linux peer credentials).
|
||||
// Abstract sockets ("@name") have no filesystem permissions and their peers are never trusted.
|
||||
func IsTrustedProxyPeer(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
@@ -126,9 +125,6 @@ func IsTrustedProxyPeer(r *http.Request) bool {
|
||||
if strings.HasPrefix(socket, "@") {
|
||||
return false
|
||||
}
|
||||
if len(Config.Http.UnixSocketTrustedUIDs) == 0 && len(Config.Http.UnixSocketTrustedGIDs) == 0 {
|
||||
return Config.Http.UnixSocketTrustAllLocalPeers
|
||||
}
|
||||
cred, ok := UnixPeerFromContext(r.Context())
|
||||
if !ok {
|
||||
return false
|
||||
|
||||
@@ -132,6 +132,7 @@ func TestIsTrustedProxyPeer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// With http.socket set, forwarded headers require matching unixSocketTrustedUIDs/GIDs.
|
||||
func TestIsTrustedProxyPeerUnixSocket(t *testing.T) {
|
||||
resetTrustedProxyCache()
|
||||
Config.Http.TrustedProxies = nil
|
||||
@@ -140,23 +141,15 @@ func TestIsTrustedProxyPeerUnixSocket(t *testing.T) {
|
||||
Config.Http.Socket = ""
|
||||
Config.Http.UnixSocketTrustedUIDs = nil
|
||||
Config.Http.UnixSocketTrustedGIDs = nil
|
||||
Config.Http.UnixSocketTrustAllLocalPeers = false
|
||||
resetTrustedProxyCache()
|
||||
})
|
||||
if IsTrustedProxyPeer(&http.Request{RemoteAddr: "@"}) {
|
||||
t.Fatal("filesystem unix socket with empty allowlists must not trust forwarded headers without opt-in")
|
||||
for _, peer := range []string{"", "@"} {
|
||||
if IsTrustedProxyPeer(&http.Request{RemoteAddr: peer}) {
|
||||
t.Fatalf("unix socket peer %q must not be trusted without peer credentials", peer)
|
||||
}
|
||||
}
|
||||
Config.Http.UnixSocketTrustAllLocalPeers = true
|
||||
if !IsTrustedProxyPeer(&http.Request{RemoteAddr: "@"}) {
|
||||
t.Fatal("unixSocketTrustAllLocalPeers should restore legacy trust-all behavior")
|
||||
}
|
||||
Config.Http.UnixSocketTrustAllLocalPeers = false
|
||||
|
||||
Config.Http.UnixSocketTrustedUIDs = []int{33}
|
||||
reqNoCred := &http.Request{RemoteAddr: "@"}
|
||||
if IsTrustedProxyPeer(reqNoCred) {
|
||||
t.Fatal("with allowlists set, peer without credentials must not be trusted")
|
||||
}
|
||||
req := (&http.Request{}).WithContext(ContextWithUnixPeerForTest(context.Background(), 33, 1000))
|
||||
if !IsTrustedProxyPeer(req) {
|
||||
t.Fatal("peer with allowed UID should be trusted")
|
||||
|
||||
@@ -7,10 +7,6 @@ import (
|
||||
"net"
|
||||
)
|
||||
|
||||
func peerCredentialFromConn(net.Conn) (UnixPeerCredential, bool) {
|
||||
return UnixPeerCredential{}, false
|
||||
}
|
||||
|
||||
func attachUnixPeerContext(ctx context.Context, _ net.Conn) context.Context {
|
||||
return ctx
|
||||
}
|
||||
@@ -8,11 +8,8 @@ import (
|
||||
"os/user"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var unixListenUmaskMu sync.Mutex
|
||||
|
||||
type unixPeerCredKey struct{}
|
||||
|
||||
// UnixPeerCredential holds the peer process identity for a Unix domain connection.
|
||||
@@ -72,15 +69,14 @@ func ListenUnixFilesystem(socketPath string) (net.Listener, error) {
|
||||
}
|
||||
setGroup = true
|
||||
}
|
||||
unixListenUmaskMu.Lock()
|
||||
oldMask := unixUmask(0o077)
|
||||
defer unixUmask(oldMask)
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
unixUmask(oldMask)
|
||||
unixListenUmaskMu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closeListener := func() { _ = listener.Close() }
|
||||
// Assign group ownership while the socket is still owner-only (umask 077), then grant mode bits.
|
||||
if setGroup {
|
||||
if err := os.Chown(socketPath, -1, gid); err != nil {
|
||||
closeListener()
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestParseSocketFileModeDefaults(t *testing.T) {
|
||||
mode, err := parseSocketFileMode("")
|
||||
@@ -56,29 +51,3 @@ func TestUnixPeerAllowed(t *testing.T) {
|
||||
t.Fatal("empty allowlists must deny")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenUnixFilesystemSetsModeWithRestrictiveUmask(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("filesystem unix sockets are not used on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
socketPath := filepath.Join(dir, "fb.sock")
|
||||
orig := Config.Http
|
||||
Config.Http.SocketFileMode = ""
|
||||
t.Cleanup(func() {
|
||||
Config.Http = orig
|
||||
_ = os.Remove(socketPath)
|
||||
})
|
||||
ln, err := ListenUnixFilesystem(socketPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
st, err := os.Stat(socketPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Mode().Perm() != 0o660 {
|
||||
t.Fatalf("socket perm = %o, want 0660", st.Mode().Perm())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//go:build !unix
|
||||
|
||||
package settings
|
||||
|
||||
func unixUmask(mask int) int {
|
||||
return 0
|
||||
}
|
||||
@@ -61,10 +61,9 @@ var previewJSONSubfields = map[string]string{
|
||||
}
|
||||
|
||||
var fileLoadingJSONSubfields = map[string]string{
|
||||
"maxConcurrentUpload": "fileLoading.maxConcurrentUpload",
|
||||
"uploadChunkSizeMb": "fileLoading.uploadChunkSizeMb",
|
||||
"downloadChunkSizeMb": "fileLoading.downloadChunkSizeMb",
|
||||
"uploadStallTimeoutSeconds": "fileLoading.uploadStallTimeoutSeconds",
|
||||
"maxConcurrentUpload": "fileLoading.maxConcurrentUpload",
|
||||
"uploadChunkSizeMb": "fileLoading.uploadChunkSizeMb",
|
||||
"downloadChunkSizeMb": "fileLoading.downloadChunkSizeMb",
|
||||
}
|
||||
|
||||
// EnforcedPathsTouchedByUserJSONField returns enforcement paths a user JSON field name may affect.
|
||||
|
||||
@@ -4552,12 +4552,6 @@ const docTemplate = `{
|
||||
"description": "Extract audio/video metadata if true",
|
||||
"name": "metadata",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Optional checksum algorithm (md5, sha1, sha256, sha512); requires a logged-in session",
|
||||
"name": "checksum",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -4567,15 +4561,6 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/iteminfo.FileInfo"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Login required for checksum generation",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Share unavailable or access denied",
|
||||
"schema": {
|
||||
@@ -6446,10 +6431,6 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"unixSocketTrustAllLocalPeers": {
|
||||
"description": "default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty",
|
||||
"type": "boolean"
|
||||
},
|
||||
"unixSocketTrustedGIDs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -6457,7 +6438,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"unixSocketTrustedUIDs": {
|
||||
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted\nunless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts\nevery peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust\nforwarded headers.",
|
||||
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers\nare not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
@@ -6984,10 +6965,6 @@ 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": [
|
||||
@@ -8169,10 +8146,6 @@ const docTemplate = `{
|
||||
},
|
||||
"uploadChunkSizeMb": {
|
||||
"type": "integer"
|
||||
},
|
||||
"uploadStallTimeoutSeconds": {
|
||||
"description": "seconds without progress before an upload is paused, 0 uses the default (10)",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4541,12 +4541,6 @@
|
||||
"description": "Extract audio/video metadata if true",
|
||||
"name": "metadata",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Optional checksum algorithm (md5, sha1, sha256, sha512); requires a logged-in session",
|
||||
"name": "checksum",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -4556,15 +4550,6 @@
|
||||
"$ref": "#/definitions/iteminfo.FileInfo"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Login required for checksum generation",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Share unavailable or access denied",
|
||||
"schema": {
|
||||
@@ -6435,10 +6420,6 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"unixSocketTrustAllLocalPeers": {
|
||||
"description": "default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty",
|
||||
"type": "boolean"
|
||||
},
|
||||
"unixSocketTrustedGIDs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -6446,7 +6427,7 @@
|
||||
}
|
||||
},
|
||||
"unixSocketTrustedUIDs": {
|
||||
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted\nunless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts\nevery peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust\nforwarded headers.",
|
||||
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers\nare not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
@@ -6973,10 +6954,6 @@
|
||||
"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": [
|
||||
@@ -8158,10 +8135,6 @@
|
||||
},
|
||||
"uploadChunkSizeMb": {
|
||||
"type": "integer"
|
||||
},
|
||||
"uploadStallTimeoutSeconds": {
|
||||
"description": "seconds without progress before an upload is paused, 0 uses the default (10)",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -574,10 +574,6 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
unixSocketTrustAllLocalPeers:
|
||||
description: 'default false: set true to trust forwarded headers from any
|
||||
local filesystem socket peer when allowlists are empty'
|
||||
type: boolean
|
||||
unixSocketTrustedGIDs:
|
||||
items:
|
||||
type: integer
|
||||
@@ -585,10 +581,8 @@ definitions:
|
||||
unixSocketTrustedUIDs:
|
||||
description: |-
|
||||
UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to
|
||||
present X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted
|
||||
unless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts
|
||||
every peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust
|
||||
forwarded headers.
|
||||
present X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers
|
||||
are not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
@@ -1012,11 +1006,6 @@ 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'
|
||||
@@ -1843,10 +1832,6 @@ definitions:
|
||||
type: integer
|
||||
uploadChunkSizeMb:
|
||||
type: integer
|
||||
uploadStallTimeoutSeconds:
|
||||
description: seconds without progress before an upload is paused, 0 uses the
|
||||
default (10)
|
||||
type: integer
|
||||
type: object
|
||||
users.FrontendScope:
|
||||
properties:
|
||||
@@ -5714,11 +5699,6 @@ paths:
|
||||
in: query
|
||||
name: metadata
|
||||
type: string
|
||||
- description: Optional checksum algorithm (md5, sha1, sha256, sha512); requires
|
||||
a logged-in session
|
||||
in: query
|
||||
name: checksum
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -5726,12 +5706,6 @@ paths:
|
||||
description: File or directory metadata
|
||||
schema:
|
||||
$ref: '#/definitions/iteminfo.FileInfo'
|
||||
"401":
|
||||
description: Login required for checksum generation
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Share unavailable or access denied
|
||||
schema:
|
||||
|
||||
+258
-134
@@ -1,140 +1,264 @@
|
||||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": [
|
||||
"**",
|
||||
"!dist",
|
||||
"!public/config.generated.yaml",
|
||||
"!public/index.html",
|
||||
"!node_modules",
|
||||
"!test-results"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"root": "../"
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": [
|
||||
"**",
|
||||
"!dist",
|
||||
"!public/config.generated.yaml",
|
||||
"!public/index.html",
|
||||
"!node_modules",
|
||||
"!test-results"
|
||||
]
|
||||
},
|
||||
"formatter": { "enabled": false },
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"a11y": {
|
||||
"noAutofocus": "off",
|
||||
"noNoninteractiveElementToInteractiveRole": "off",
|
||||
"noStaticElementInteractions": "off",
|
||||
"noSvgWithoutTitle": "off",
|
||||
"useAriaPropsSupportedByRole": "off",
|
||||
"useGenericFontNames": "off",
|
||||
"useKeyWithClickEvents": "off",
|
||||
"useMediaCaption": "off",
|
||||
"useSemanticElements": "off",
|
||||
"useValidAnchor": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "on",
|
||||
"noImportantStyles": "off",
|
||||
"noRedundantDefaultExport": "warn",
|
||||
"noUnnecessaryTemplateExpression": "error",
|
||||
"noUselessStringConcat": "warn",
|
||||
"useMathMinMax": "warn"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnknownPseudoClass": "off",
|
||||
"noVueImportCompilerMacros": "error",
|
||||
"noVueRefAsOperand": "error",
|
||||
"noVueVOnNumberValues": "error",
|
||||
"useVueValidVFor": "error",
|
||||
"noVueVIfWithVFor": "error"
|
||||
},
|
||||
"nursery": {
|
||||
"noFloatingPromises": "error",
|
||||
"noImpliedEval": "error",
|
||||
"noJsonUnsafeValues": "error",
|
||||
"noMisusedPromises": "error",
|
||||
"noVueDeprecatedScopedSlots": "error",
|
||||
"noVueUndeclaredDirectives": "off",
|
||||
"useConsistentObjectKeys": "error",
|
||||
"useVueBaseImport": "warn",
|
||||
"noDoubleComparison": "on",
|
||||
"noNestedSwitch": "on",
|
||||
"noVueLifecycleAfterAwait": "error",
|
||||
"noVueShadowNativeEvents": "warn",
|
||||
"noVueVHtml": "error",
|
||||
"noVueVText": "warn",
|
||||
"noVueVTextVHtmlOnComponent": "error",
|
||||
"useSimplifiedBooleanReturn": "warn",
|
||||
"useVueConsistentEventHyphenation": "warn",
|
||||
"noPromiseExecutorReturn": "warn",
|
||||
"noArgumentsCallerOrCallee": "warn",
|
||||
"noVueRequiredPropWithDefault": "warn",
|
||||
"noBooleanSortComparator": "error",
|
||||
"noObsoleteAttributes": "error",
|
||||
"noUnsafeReturn": "warn",
|
||||
"noUselessSplice": "warn",
|
||||
"noVueWatchAfterAwait": "error",
|
||||
"useMathAbs": "warn",
|
||||
"useMathTrunc": "warn",
|
||||
"useVueComponentIs": "error",
|
||||
"useVueTypedRef": "error",
|
||||
"noReturnArrayPush": "warn",
|
||||
"noUselessRecursion": "error",
|
||||
"noVueComputedPropertiesInData": "error",
|
||||
"noVueDeprecatedDeleteSet": "error",
|
||||
"noVueDeprecatedRouterLinkTagProp": "error",
|
||||
"noVueMutatingProps": "error",
|
||||
"noVueMultipleSlotArgs": "warn",
|
||||
"noVueSideEffectsInComputed": "error",
|
||||
"noVueUnusedRefs": "error",
|
||||
"useVueTemplateRef": "warn",
|
||||
"useVueTypedProps": "warn",
|
||||
"useVueVModel": "warn",
|
||||
"useVueValidDefineEmits": "error",
|
||||
"useVueValidDefineProps": "error",
|
||||
"useVueValidVIs": "error",
|
||||
"useVueValidVModel": "error",
|
||||
"useVueValidVShow": "error",
|
||||
"useVueValidVSlot": "error",
|
||||
"noVueDeprecatedListeners": "error",
|
||||
"noArrayForEach": "error",
|
||||
"noChainedComparison": "error",
|
||||
"noDynamicDelete": "warn",
|
||||
"noLateEventControl": "error",
|
||||
"noObjectLiteralComparison": "error",
|
||||
"noPlaywrightUnsafeReferences": "error",
|
||||
"noUnnecessaryStringTrim": "warn",
|
||||
"noUselessLengthCheck": "info",
|
||||
"noUselessTypeAssertion": "warn",
|
||||
"noVueChildContent": "error",
|
||||
"noVueDeprecatedConfigKeyCodes": "info",
|
||||
"noVueDeprecatedEventsApi": "error",
|
||||
"noVueDeprecatedHtmlElementIs": "error",
|
||||
"noVueDeprecatedModelDefinition": "error",
|
||||
"noVueDeprecatedPropsDefaultThis": "error",
|
||||
"noVueDeprecatedVBindSync": "error",
|
||||
"noVueLoneTemplate": "error",
|
||||
"noVueMixin": "error",
|
||||
"noVueMultipleObjectsInClass": "warn",
|
||||
"noVueRefObjectReactivityLoss": "error",
|
||||
"noVueRestrictedCallAfterAwait": "error",
|
||||
"noVueRestrictedVOn": "warn",
|
||||
"noVueTemplateKey": "error",
|
||||
"noVueTemplateShadow": "warn",
|
||||
"noVueThisInTemplate": "warn",
|
||||
"noVueUndeclaredEmits": "warn",
|
||||
"noVueUndeclaredProperties": "error",
|
||||
"noVueUndeclaredSlots": "warn",
|
||||
"noVueUselessVBind": "warn",
|
||||
"noVueUselessTemplateAttributes": "error",
|
||||
"noVueVForTemplateKeyOnChild": "error",
|
||||
"useObjectFromEntries": "warn",
|
||||
"useVueBlockOrder": {
|
||||
"level": "on",
|
||||
"options": {
|
||||
"order": [
|
||||
"template",
|
||||
"script",
|
||||
"style"
|
||||
]
|
||||
}
|
||||
},
|
||||
"useVueBooleanPropTypeFirst": "warn",
|
||||
"useVueComputedPropertyReturn": "error",
|
||||
"useVueEmitValidator": "warn",
|
||||
"useVueEmitsValidatorReturn": "error",
|
||||
"useVuePropTypeConstructor": "error",
|
||||
"useVueRenderReturn": "error",
|
||||
"useVueSingleEventPayload": "warn",
|
||||
"useVueTypedObjectProps": "warn",
|
||||
"useVueValidDefaultProp": "error",
|
||||
"useVueValidNextTick": "error",
|
||||
"useVueValidDefineOptions": "error",
|
||||
"noRedundantTypeConstituents": "warn",
|
||||
"noUselessSpread": "warn",
|
||||
"noVueExposeAfterAwait": "error"
|
||||
},
|
||||
"performance": { "useVueVapor": "off" },
|
||||
"security": {
|
||||
"preset": "recommended",
|
||||
"useIframeSandbox": "off"
|
||||
},
|
||||
"style": {
|
||||
"noDescendingSpecificity": "off",
|
||||
"noTopLevelLiterals": "error",
|
||||
"noVueOptionsApi": "off",
|
||||
"useVueConsistentDefinePropsDeclaration": "on",
|
||||
"useVueConsistentVBindStyle": "off",
|
||||
"useVueHyphenatedAttributes": "off",
|
||||
"useVueMultiWordComponentNames": "off",
|
||||
"useVueNextTickPromise": "warn",
|
||||
"useImportsFirst": "off",
|
||||
"useVarsOnTop": "on",
|
||||
"useDomQuerySelector": "warn",
|
||||
"useVueDefineMacrosOrder": "warn"
|
||||
},
|
||||
"suspicious": {
|
||||
"noEmptyObjectKeys": "error",
|
||||
"noExplicitAny": "warn",
|
||||
"noImportCycles": "off",
|
||||
"noNestedPromises": "error",
|
||||
"noParametersOnlyUsedInRecursion": "error",
|
||||
"noShadow": "off",
|
||||
"noUnnecessaryConditions": "off",
|
||||
"useArraySortCompare": "error",
|
||||
"useScopedStyles": "off",
|
||||
"noConfusingVoidType": "warn"
|
||||
}
|
||||
},
|
||||
"domains": { "vue": "all", "project": "all", "types": "all" }
|
||||
},
|
||||
"css": {
|
||||
"formatter": { "enabled": false },
|
||||
"linter": { "enabled": false },
|
||||
"assist": { "enabled": false }
|
||||
},
|
||||
"html": {
|
||||
"experimentalFullSupportEnabled": true,
|
||||
"parser": { "interpolation": true },
|
||||
"linter": { "enabled": true }
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["tests/playwright/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"complexity": { "noPlaywrightUselessAwait": "error" },
|
||||
"correctness": {
|
||||
"noEmptyPattern": "off",
|
||||
"noPlaywrightMissingAwait": "error",
|
||||
"noUnresolvedImports": "off",
|
||||
"usePlaywrightValidDescribeCallback": "error"
|
||||
},
|
||||
"style": {
|
||||
"noPlaywrightElementHandle": "error",
|
||||
"noPlaywrightEval": "error",
|
||||
"noPlaywrightWaitForSelector": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noPlaywrightForceOption": "error",
|
||||
"noPlaywrightNetworkidle": "error",
|
||||
"noPlaywrightPagePause": "error",
|
||||
"noPlaywrightWaitForNavigation": "error",
|
||||
"noPlaywrightWaitForTimeout": "warn",
|
||||
"noEmptyObjectKeys": "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.js"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"nursery": { "noUnsafeReturn": "off" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"scripts/postcss-strip-legacy.ts",
|
||||
"playwright.config.ts",
|
||||
"playwright.performance.config.ts",
|
||||
"**/*.test.js",
|
||||
"**/*.test.ts"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noUndeclaredDependencies": "off",
|
||||
"noUnresolvedImports": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": { "source": { "organizeImports": "off" } }
|
||||
},
|
||||
"css": {
|
||||
"linter": { "enabled": false },
|
||||
"formatter": { "enabled": false },
|
||||
"assist": { "enabled": false }
|
||||
},
|
||||
"html": {
|
||||
"experimentalFullSupportEnabled": true,
|
||||
"parser": { "interpolation": true },
|
||||
"linter": { "enabled": true }
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"domains": { "vue": "all", "project": "all", "types": "all" },
|
||||
"rules": {
|
||||
"performance": {
|
||||
"useVueVapor": "off"
|
||||
},
|
||||
"security": {
|
||||
"preset": "recommended"
|
||||
},
|
||||
"a11y": {
|
||||
"noAutofocus": "off",
|
||||
"noNoninteractiveElementToInteractiveRole": "off",
|
||||
"noStaticElementInteractions": "off",
|
||||
"noSvgWithoutTitle": "off",
|
||||
"useAriaPropsSupportedByRole": "off",
|
||||
"useGenericFontNames": "off",
|
||||
"useKeyWithClickEvents": "off",
|
||||
"useMediaCaption": "off",
|
||||
"useSemanticElements": "off",
|
||||
"useValidAnchor": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "on",
|
||||
"noImportantStyles": "off",
|
||||
"noRedundantDefaultExport": "warn"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnknownPseudoClass": "off"
|
||||
},
|
||||
"nursery": {
|
||||
"noEmptyObjectKeys": "error",
|
||||
"noFloatingPromises": "error",
|
||||
"noImpliedEval": "error",
|
||||
"noJsonUnsafeValues": "error",
|
||||
"noMisusedPromises": "error",
|
||||
"noTopLevelLiterals": "error",
|
||||
"noVueDeprecatedScopedSlots": "error",
|
||||
"noVueImportCompilerMacros": "error",
|
||||
"noVueRefAsOperand": "error",
|
||||
"noVueUndeclaredDirectives": "off",
|
||||
"noVueVOnNumberValues": "error",
|
||||
"useConsistentObjectKeys": "error",
|
||||
"useVueBaseImport": "warn",
|
||||
"useVueConsistentDefinePropsDeclaration": "off",
|
||||
"useVueNextTickPromise": "warn",
|
||||
"useVueValidVFor": "error"
|
||||
},
|
||||
"style": {
|
||||
"noDescendingSpecificity": "off",
|
||||
"noVueOptionsApi": "off",
|
||||
"useVueConsistentVBindStyle": "off",
|
||||
"useVueHyphenatedAttributes": "off",
|
||||
"useVueMultiWordComponentNames": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn",
|
||||
"noImportCycles": "off",
|
||||
"noNestedPromises": "error",
|
||||
"noParametersOnlyUsedInRecursion": "error",
|
||||
"noShadow": "off",
|
||||
"noUnnecessaryConditions": "off",
|
||||
"useArraySortCompare": "error"
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "off",
|
||||
"useGraphqlSortedDefinitions": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["tests/playwright/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noEmptyPattern": "off",
|
||||
"noUnresolvedImports": "off"
|
||||
},
|
||||
"nursery": {
|
||||
"noPlaywrightElementHandle": "error",
|
||||
"noPlaywrightEval": "error",
|
||||
"noPlaywrightForceOption": "error",
|
||||
"noPlaywrightMissingAwait": "error",
|
||||
"noPlaywrightNetworkidle": "error",
|
||||
"noPlaywrightPagePause": "error",
|
||||
"noPlaywrightUselessAwait": "error",
|
||||
"noPlaywrightWaitForNavigation": "error",
|
||||
"noPlaywrightWaitForSelector": "error",
|
||||
"noPlaywrightWaitForTimeout": "warn",
|
||||
"usePlaywrightValidDescribeCallback": "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"scripts/postcss-strip-legacy.ts",
|
||||
"playwright.config.ts",
|
||||
"playwright.performance.config.ts",
|
||||
"**/*.test.js",
|
||||
"**/*.test.ts"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noUnresolvedImports": "off",
|
||||
"noUndeclaredDependencies": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { defineConfig } from "eslint/config";
|
||||
import * as tsParser from "@typescript-eslint/parser";
|
||||
import pluginVue from "eslint-plugin-vue";
|
||||
import vueI18n from "@intlify/eslint-plugin-vue-i18n";
|
||||
|
||||
export default defineConfig(
|
||||
{ ignores: ["**/dist/**", "tests/playwright-files/**"] },
|
||||
|
||||
...pluginVue.configs["flat/essential"],
|
||||
...vueI18n.configs.recommended,
|
||||
|
||||
// js/ts is parsed for vue-i18n to check t() calls
|
||||
{ files: ["**/*.js", "**/*.ts"], languageOptions: { parser: tsParser } },
|
||||
{ files: ["**/*.vue"], languageOptions: { parserOptions: { parser: tsParser } } },
|
||||
{
|
||||
settings: {
|
||||
"vue-i18n": {
|
||||
localeDir: "src/i18n/en.json",
|
||||
messageSyntaxVersion: "^11.0.0",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@intlify/vue-i18n/no-missing-keys": "error",
|
||||
"@intlify/vue-i18n/no-unused-keys": ["error", {
|
||||
src: "./src",
|
||||
extensions: [".js", ".vue", ".ts"],
|
||||
ignores: ["/^languages\\./"],
|
||||
}],
|
||||
"@intlify/vue-i18n/no-raw-text": ["error", {
|
||||
ignoreNodes: ["i", "v-icon"],
|
||||
}],
|
||||
"@intlify/vue-i18n/no-missing-keys-in-other-locales": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.vue"],
|
||||
rules: {
|
||||
// this are the most common ones that likely biome also has
|
||||
// we can add more to the list or if some if buggy in biome better enable it here.
|
||||
"vue/no-duplicate-attributes": "off",
|
||||
"vue/no-use-v-if-with-v-for": "off",
|
||||
"vue/require-v-for-key": "off",
|
||||
"vue/valid-template-root": "off",
|
||||
"vue/valid-v-bind": "off",
|
||||
"vue/valid-v-cloak": "off",
|
||||
"vue/valid-v-else": "off",
|
||||
"vue/valid-v-else-if": "off",
|
||||
"vue/valid-v-for": "off",
|
||||
"vue/valid-v-html": "off",
|
||||
"vue/valid-v-if": "off",
|
||||
"vue/valid-v-on": "off",
|
||||
"vue/valid-v-once": "off",
|
||||
"vue/valid-v-pre": "off",
|
||||
"vue/valid-v-text": "off",
|
||||
"vue/no-deprecated-v-on-number-modifiers": "off",
|
||||
"vue/no-dupe-keys": "off",
|
||||
"vue/no-reserved-keys": "off",
|
||||
"vue/no-reserved-props": "off",
|
||||
"vue/no-deprecated-data-object-declaration": "off",
|
||||
"vue/no-arrow-functions-in-watch": "off",
|
||||
"vue/no-ref-as-operand": "off",
|
||||
"vue/prefer-import-from-vue": "off",
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/no-reserved-component-names": "off",
|
||||
"vue/no-parsing-error": "off",
|
||||
"vue/no-unused-components": "warn",
|
||||
"vue/no-v-text-v-html-on-component": "warn",
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+795
-2123
File diff suppressed because it is too large.
Load diff
+23
-31
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"npm": ">=9.0.0",
|
||||
"npm": ">=12.0.0",
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -13,18 +13,18 @@
|
||||
"dev": "vite",
|
||||
"watch": "vite build --watch",
|
||||
"typecheck": "vue-tsc --build",
|
||||
"lint": "biome check && eslint src/",
|
||||
"lint:fix": "biome check --write && eslint --fix src/",
|
||||
"lint": "biome check && npm run i18n:check",
|
||||
"lint:fix": "biome check --write",
|
||||
"lint:biome": "biome check",
|
||||
"lint:biome:fix": "biome check --write",
|
||||
"lint:eslint": "eslint src/",
|
||||
"format": "biome format --write",
|
||||
"i18n:check": "node scripts/i18n/index.ts check",
|
||||
"i18n:check:strict": "node scripts/i18n/index.ts check --strict",
|
||||
"lint:css": "stylelint \"**/*.{css,vue}\"",
|
||||
"lint:css:fix": "stylelint --fix \"**/*.{css,vue}\"",
|
||||
"i18n:sync": "node ./scripts/sync-translations.js",
|
||||
"i18n:check": "node ./scripts/sync-translations.js --check",
|
||||
"i18n:enforce-order": "node scripts/sync-translations.js --enforce-order",
|
||||
"i18n:cleanup": "node scripts/sync-translations.js --cleanup",
|
||||
"i18n:types": "node scripts/i18n/index.ts types",
|
||||
"i18n:sync": "node scripts/i18n/index.ts sync",
|
||||
"i18n:cleanup": "node scripts/i18n/index.ts sync --cleanup",
|
||||
"i18n:enforce-order": "node scripts/i18n/index.ts sync --enforce-order",
|
||||
"icons:sync": "node ./scripts/build-icon-subset.js",
|
||||
"icons:check": "node ./scripts/build-icon-subset.js --check",
|
||||
"test": "vitest run "
|
||||
@@ -36,7 +36,7 @@
|
||||
"chart.js": "^4.6.1",
|
||||
"dompurify": "^3.4.17",
|
||||
"highlight.js": "^11.12.0",
|
||||
"hls.js": "^1.7.2",
|
||||
"hls.js": "^1.7.4",
|
||||
"js-yaml": "^5.5.0",
|
||||
"mammoth": "^1.13.0",
|
||||
"marked": "^18.2.0",
|
||||
@@ -49,7 +49,6 @@
|
||||
"vue-router": "^5.5.0"
|
||||
},
|
||||
"overrides": {
|
||||
"stylelint-config-recommended": "^19.0.0",
|
||||
"minimatch": "^11.0.8",
|
||||
"@xmldom/xmldom": "^0.8.15",
|
||||
"mammoth": {
|
||||
@@ -58,34 +57,27 @@
|
||||
"@intlify/vue-i18n-extensions": {
|
||||
"vue-i18n": "$vue-i18n"
|
||||
},
|
||||
"@intlify/eslint-plugin-vue-i18n": {
|
||||
"glob": "^14.0.6"
|
||||
}
|
||||
"glob": "^14.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.15",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^4.5.1",
|
||||
"@intlify/unplugin-vue-i18n": "^11.2.7",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@types/node": "^26.6.4",
|
||||
"@typescript-eslint/parser": "^8.72.0",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"deepl-node": "^1.28.1",
|
||||
"@biomejs/biome": "2.6.7",
|
||||
"@intlify/unplugin-vue-i18n": "^11.2.8",
|
||||
"@playwright/test": "^1.65.0",
|
||||
"@types/node": "^26.6.6",
|
||||
"@vitejs/plugin-vue": "^6.0.11",
|
||||
"deepl-node": "^1.29.0",
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-plugin-vue": "^10.11.1",
|
||||
"fs-extra": "^11.4.1",
|
||||
"jsdom": "^30.1.3",
|
||||
"jsdom": "^31.0.1",
|
||||
"stylelint": "^18.0.0",
|
||||
"stylelint-config-standard": "^41.0.0",
|
||||
"stylelint-config-standard-vue": "^2.0.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.4",
|
||||
"vite-plugin-checker": "^0.14.5",
|
||||
"vite-plugin-compression2": "^2.5.3",
|
||||
"vitest": "^5.0.5",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"vue-tsc": "^3.3.13",
|
||||
"vite": "^8.3.8",
|
||||
"vite-plugin-checker": "^1.0.0",
|
||||
"vite-plugin-compression2": "^2.5.4",
|
||||
"vitest": "^5.1.0",
|
||||
"vue-tsc": "^3.3.14",
|
||||
"wawoff2": "^2.0.1"
|
||||
}
|
||||
}
|
||||
@@ -142,7 +142,7 @@ function scanMimetype(icons) {
|
||||
}
|
||||
|
||||
function scanConstants(icons) {
|
||||
const content = fs.readFileSync(path.join(srcDir, 'utils/constants.js'), 'utf8');
|
||||
const content = fs.readFileSync(path.join(srcDir, 'utils/constants.ts'), 'utf8');
|
||||
const iconField = new RegExp(`\\bicon:\\s*["'](${ICON_NAME})["']`, 'g');
|
||||
for (const match of content.matchAll(iconField)) addIcon(icons, match[1]);
|
||||
}
|
||||
@@ -185,7 +185,7 @@ function collectIcons() {
|
||||
scanSearchFilters(icons);
|
||||
scanMarkdownViewer(icons);
|
||||
icons.delete('close_back');
|
||||
return [...icons].sort();
|
||||
return [...icons].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
}
|
||||
|
||||
function readInventory() {
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/** biome-ignore-all lint/suspicious/noTemplateCurlyInString: the tests need it */
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { maskJs, maskSource } from "../shared.ts";
|
||||
import { syncTranslations } from "../sync.ts";
|
||||
import { dynamicCalls, isPassedAround, literalCalls } from "../types.ts";
|
||||
|
||||
// The sync reads and writes the locales folder: point it at a temporary one.
|
||||
const dir = await vi.hoisted(async () => {
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
return mkdtempSync(join(tmpdir(), "i18n-test-"));
|
||||
});
|
||||
|
||||
vi.mock("../shared.ts", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../shared.ts")>();
|
||||
const { readdirSync } = await import("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
localesDir: dir,
|
||||
translationFiles: () => readdirSync(dir).filter((file) => file.endsWith(".json") && file !== "en.json").sort(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("deepl-node", () => ({
|
||||
DeepLClient: class {
|
||||
async translateText(text: string) {
|
||||
return { text: `[${text}]` };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const write = (file: string, content: unknown) => fs.writeFileSync(path.join(dir, file), JSON.stringify(content));
|
||||
const read = (file: string): unknown => JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
|
||||
|
||||
describe("sync with a different shape in a locale", () => {
|
||||
beforeEach(() => {
|
||||
process.env.DEEPL_API_KEY = "test-key";
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const file of fs.readdirSync(dir)) fs.rmSync(path.join(dir, file));
|
||||
});
|
||||
|
||||
const run = (options: { enforceOrder?: boolean; cleanup?: boolean } = {}) =>
|
||||
syncTranslations({ enforceOrder: false, cleanup: false, ...options });
|
||||
|
||||
describe("an object where en.json has a string", () => {
|
||||
beforeEach(() => {
|
||||
write("en.json", { general: { title: "Title" } });
|
||||
write("de.json", { general: { title: { old: "Alt" } } });
|
||||
});
|
||||
|
||||
it("replaces the subtree with a translation", async () => {
|
||||
expect(await run()).toBe(0);
|
||||
expect(read("de.json")).toEqual({ general: { title: "[Title]" } });
|
||||
});
|
||||
|
||||
it("removes the obsolete subtree on cleanup", async () => {
|
||||
expect(await run({ cleanup: true })).toBe(0);
|
||||
expect(read("de.json")).toEqual({ general: {} });
|
||||
});
|
||||
|
||||
it("removes the obsolete subtree when enforcing the order", async () => {
|
||||
expect(await run({ enforceOrder: true })).toBe(0);
|
||||
expect(read("de.json")).toEqual({ general: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe("a string where en.json has an object", () => {
|
||||
beforeEach(() => {
|
||||
write("en.json", { group: { a: "A" } });
|
||||
write("de.json", { group: "legacy" });
|
||||
});
|
||||
|
||||
it("replaces the string with the translated keys", async () => {
|
||||
await run();
|
||||
expect(read("de.json")).toEqual({ group: { a: "[A]" } });
|
||||
});
|
||||
|
||||
it("removes the obsolete string on cleanup", async () => {
|
||||
await run({ cleanup: true });
|
||||
expect(read("de.json")).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskJs", () => {
|
||||
it("blanks comments and keeps every offset and newline", () => {
|
||||
const code = `a(); // t("x")\n/* t("y")\n */ b();`;
|
||||
const masked = maskJs(code);
|
||||
expect(masked).toHaveLength(code.length);
|
||||
expect(masked).not.toContain("t(");
|
||||
expect(masked.split("\n")).toHaveLength(3);
|
||||
expect(masked).toContain("a();");
|
||||
expect(masked).toContain("b();");
|
||||
});
|
||||
|
||||
it("keeps `//` inside strings", () => {
|
||||
expect(maskJs(`const u = "https://example.com"; t("k");`)).toContain("https://example.com");
|
||||
});
|
||||
|
||||
it("blanks the content of quoted strings only on request, and keeps template literals", () => {
|
||||
const code = `x("a 't(' b", 'c'); y(\`\${t("k")}\`);`;
|
||||
const bare = maskJs(code, true);
|
||||
expect(bare).toHaveLength(code.length);
|
||||
expect(bare).not.toContain("a 't(' b");
|
||||
expect(bare).toContain('t(" ")');
|
||||
expect(maskJs(code)).toContain("a 't(' b");
|
||||
});
|
||||
|
||||
it("does not take an apostrophe left open on a line for a string", () => {
|
||||
expect(maskJs(`don't\nt("k")`, true)).toContain('t("');
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskJs with template literals", () => {
|
||||
const masked = (code: string) => maskJs(code, true);
|
||||
|
||||
it("template literal keeps its ${} expressions", () => {
|
||||
const code = 'const example = `t("example.only") ${t("real.key")} end`;';
|
||||
const bare = masked(code);
|
||||
expect(bare).toHaveLength(code.length);
|
||||
expect(bare).not.toContain("example.only");
|
||||
expect(bare).toContain('t(" ")');
|
||||
expect(bare.startsWith("const example = `")).toBe(true);
|
||||
expect(bare.endsWith("`;")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the text of a template literal when the strings are not masked", () => {
|
||||
const code = 'const example = `t("example.only") ${t("real.key")}`; // t("c.d")';
|
||||
const plain = maskJs(code);
|
||||
expect(plain).toContain('`t("example.only") ${t("real.key")}`');
|
||||
expect(plain).not.toContain("c.d");
|
||||
});
|
||||
|
||||
it("keeps the offsets and the newlines of a template literal that spans lines", () => {
|
||||
const code = "const a = `first t(\"x.y\")\nsecond ${\n t(\"real.key\")\n} third`;\nb();";
|
||||
const bare = masked(code);
|
||||
expect(bare).toHaveLength(code.length);
|
||||
expect(bare.split("\n")).toHaveLength(code.split("\n").length);
|
||||
expect(bare.indexOf('t(" ")')).toBe(code.indexOf('t("real.key")'));
|
||||
expect(bare).not.toContain("x.y");
|
||||
expect(bare).toContain("b();");
|
||||
});
|
||||
|
||||
it("does not take `//` or `/*` in the text of a template for a comment", () => {
|
||||
const code = "const u = `https://example.com/*path*/` + t('k');";
|
||||
expect(maskJs(code)).toBe(code);
|
||||
expect(masked(code)).toMatch(/` \+ t\(' '\);$/);
|
||||
});
|
||||
|
||||
it("masks a comment inside an expression and the text of a template nested in it", () => {
|
||||
const code = "`${ /* t('c.d') */ cond ? `t(\"nested.text\")` : t('real.key') }`";
|
||||
const bare = masked(code);
|
||||
expect(bare).toHaveLength(code.length);
|
||||
expect(bare).not.toContain("c.d");
|
||||
expect(bare).not.toContain("nested.text");
|
||||
expect(bare).toContain("t(' ')");
|
||||
});
|
||||
|
||||
it("closes an expression at its own brace, not at the brace of an object inside it", () => {
|
||||
const code = "`a ${ fn({ x: 1 }) } t(\"after.only\") ${ t('real.key') }`";
|
||||
const bare = masked(code);
|
||||
expect(bare).not.toContain("after.only");
|
||||
expect(bare).toContain("t(' ')");
|
||||
});
|
||||
|
||||
it("treats an escaped `${` and an escaped backtick as text", () => {
|
||||
const code = "`\\${t('escaped.key')} \\` t('after.escape')` + t('real.key')";
|
||||
const bare = masked(code);
|
||||
expect(bare).toHaveLength(code.length);
|
||||
expect(bare).not.toContain("escaped.key");
|
||||
expect(bare).not.toContain("after.escape");
|
||||
expect(bare).toMatch(/` \+ t\(' +'\)$/);
|
||||
});
|
||||
|
||||
it("copes with a template or an expression that is never closed", () => {
|
||||
for (const code of ["const a = `t('x.y')", "const a = `${t('x.y')", "const a = `trailing \\"]) {
|
||||
expect(masked(code)).toHaveLength(code.length);
|
||||
expect(maskJs(code)).toHaveLength(code.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskSource for .vue files", () => {
|
||||
const vue = [
|
||||
"<template>",
|
||||
" <!-- {{ t('a.b') }} -->",
|
||||
" <p>{{ t('c.d') }}</p>",
|
||||
"</template>",
|
||||
'<script setup lang="ts">',
|
||||
"// t('e.f')",
|
||||
"const x = t('g.h');",
|
||||
"</script>",
|
||||
"<style>",
|
||||
".a::after { content: 't(\"i.j\")'; }",
|
||||
"</style>",
|
||||
].join("\n");
|
||||
|
||||
it("drops comments and styles but keeps the template and script code at the same offsets", () => {
|
||||
const masked = maskSource("A.vue", vue);
|
||||
expect(masked).toHaveLength(vue.length);
|
||||
for (const dropped of ["a.b", "e.f", "i.j"]) expect(masked).not.toContain(dropped);
|
||||
for (const kept of ["c.d", "g.h"]) expect(masked.indexOf(kept)).toBe(vue.indexOf(kept));
|
||||
});
|
||||
});
|
||||
|
||||
describe("literalCalls", () => {
|
||||
const calls = (source: string) => [...literalCalls(maskJs(source), maskJs(source, true))];
|
||||
|
||||
it("reads a parameter that holds an escaped quote", () => {
|
||||
const [call] = calls(`t("prompts.uploadSkipped", { count: "a\\"b".length })`);
|
||||
expect(call?.params).toEqual({ names: new Set(["count"]), dynamic: false });
|
||||
});
|
||||
|
||||
it("skips a key that is concatenated with an expression", () => {
|
||||
expect(calls(`t('prompts.' + name)`)).toEqual([]);
|
||||
expect(calls(`t("a" + b, { x: 1 })`)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores calls in comments and strings, and finds those in template literals", () => {
|
||||
const found = calls(`// t('example.key')\nconst s = "t('example.str')";\nconst u = \`\${t("real.key")}\`;`);
|
||||
expect(found.map((call) => call.key)).toEqual(["real.key"]);
|
||||
});
|
||||
|
||||
it("does not check the text of a template literal as a call", () => {
|
||||
expect(calls('const example = `t("example.only")`;')).toEqual([]);
|
||||
expect(calls('const example = `see t("example.only") and ${t("real.key")}`;').map((call) => call.key)).toEqual(["real.key"]);
|
||||
});
|
||||
|
||||
it("finds a call in an expression nested in a template, not in the template nested in that", () => {
|
||||
const found = calls("const a = `${ cond ? `t('nested.text')` : t('real.key', { count: 1 }) }`;");
|
||||
expect(found.map((call) => call.key)).toEqual(["real.key"]);
|
||||
});
|
||||
|
||||
it("is not confused by a comment in the params", () => {
|
||||
const [call] = calls(`t("k", { count, /* it's */ other })`);
|
||||
expect(call?.params.names).toEqual(new Set(["count", "other"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("dynamicCalls", () => {
|
||||
const dynamic = (source: string) => [...dynamicCalls(maskJs(source), maskJs(source, true))].map((call) => call.index);
|
||||
|
||||
it("flags a key held in a variable or a property", () => {
|
||||
expect(dynamic("t(key)")).toEqual([0]);
|
||||
expect(dynamic("this.$t(tool.name)")).toEqual([0]);
|
||||
expect(dynamic("i18n.global.t(i18nKey || 'a.b')")).toEqual([0]);
|
||||
});
|
||||
|
||||
it("flags a key built from a template literal or a concatenation", () => {
|
||||
expect(dynamic("t(`tools.${view}View`)")).toEqual([0]);
|
||||
expect(dynamic("$t('prompts.' + name)")).toEqual([0]);
|
||||
expect(dynamic("t(`a.b` + suffix)")).toEqual([0]);
|
||||
});
|
||||
|
||||
it("flags a dynamic key whatever its params are", () => {
|
||||
expect(dynamic('$t(section.label, { suffix: "" })')).toEqual([0]);
|
||||
});
|
||||
|
||||
it("accepts string literals, with or without params", () => {
|
||||
expect(dynamic("this.$t (\"a.b\")")).toEqual([]);
|
||||
expect(dynamic("t('a.b')")).toEqual([]);
|
||||
expect(dynamic('$t("a.b", { count: n })')).toEqual([]);
|
||||
expect(dynamic("t(`a.b`)")).toEqual([]);
|
||||
expect(dynamic("this.$t( 'a.b' )")).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores calls in comments and strings, and names that only end in t", () => {
|
||||
expect(dynamic('// t(key)\nconst s = "t(key)";')).toEqual([]);
|
||||
expect(dynamic("list.insert(key); sort(key); a.t(key)")).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds a dynamic call inside a template expression and reports each call", () => {
|
||||
expect(dynamic("const a = `${t(key)}`;")).toEqual([13]);
|
||||
expect(dynamic("t(a); t('ok.key'); $t(b)")).toEqual([0, 19]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPassedAround", () => {
|
||||
it("flags $t used as a value", () => {
|
||||
expect(isPassedAround("return buildRows(this.info, this.$t, locale);")).toBe(true);
|
||||
expect(isPassedAround("this.$t.bind(this)")).toBe(true);
|
||||
expect(isPassedAround("export function label(value, $t) {")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts calls and unrelated names", () => {
|
||||
expect(isPassedAround('return this.$t("a.b");')).toBe(false);
|
||||
expect(isPassedAround("$t ('a.b')")).toBe(false);
|
||||
expect(isPassedAround("const $total = 1; const a$t = 2;")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import fs from "node:fs";
|
||||
import { parse } from "vue/compiler-sfc";
|
||||
import { locales } from "../../src/i18n/locales.ts";
|
||||
import { isTestFile, maskSource, readLocale, rel, sourceFiles, translationFiles } from "./shared.ts";
|
||||
|
||||
const IGNORED_ELEMENTS = new Set(["i", "v-icon", "kbd"]);
|
||||
const IGNORE_COMMENT = "i18n-ignore";
|
||||
const TEXT_ATTRIBUTES = new Set(["placeholder", "title", "alt"]);
|
||||
|
||||
// Vue's NodeTypes values
|
||||
const ELEMENT = 1;
|
||||
const TEXT = 2;
|
||||
const COMMENT = 3;
|
||||
const INTERPOLATION = 5;
|
||||
const ATTRIBUTE = 6;
|
||||
const DIRECTIVE = 7;
|
||||
|
||||
interface TemplateNode {
|
||||
type: number;
|
||||
tag?: string;
|
||||
content?: unknown;
|
||||
props?: {
|
||||
type?: number;
|
||||
name?: string;
|
||||
value?: { content: string };
|
||||
arg?: { content?: string; isStatic?: boolean };
|
||||
exp?: { content?: string };
|
||||
loc?: { start: { line: number } };
|
||||
}[];
|
||||
children?: TemplateNode[];
|
||||
loc: { start: { line: number } };
|
||||
}
|
||||
|
||||
/** The code of a source file that can use a key -- comments and <style> blocks are dropped */
|
||||
const codeOf = (file: string) => maskSource(file, fs.readFileSync(file, "utf8"));
|
||||
|
||||
/** Every key in en.json must appear as a string literal in src/ */
|
||||
export function checkUnusedKeys(): string[] {
|
||||
const code = [...sourceFiles()].filter((file) => !isTestFile(file)).map(codeOf).join("\n");
|
||||
const isUsed = (key: string) => ["'", '"', "`"].some((quote) => code.includes(`${quote}${key}${quote}`));
|
||||
return Object.keys(readLocale("en.json"))
|
||||
.filter((key) => !isUsed(key))
|
||||
.map((key) => `en.json: unused key ${key}`);
|
||||
}
|
||||
|
||||
|
||||
export function checkLocaleRegistry(): string[] {
|
||||
const files = new Set(["en.json", ...translationFiles()]);
|
||||
const listed = new Set(Object.values(locales).map(({ file }) => `${file}.json`));
|
||||
return [
|
||||
...[...files].filter((file) => !listed.has(file)).map((file) => `${file}: not listed in src/i18n/locales.ts`),
|
||||
...Object.entries(locales)
|
||||
.filter(([, { file }]) => !files.has(`${file}.json`))
|
||||
.map(([id, { file }]) => `locales.ts: ${id} has no file ${file}.json`),
|
||||
];
|
||||
}
|
||||
|
||||
const isIgnored = (node: TemplateNode) =>
|
||||
IGNORED_ELEMENTS.has(node.tag ?? "") ||
|
||||
(node.props ?? []).some((prop) => prop.name === "class" && prop.value?.content.includes("material-symbols"));
|
||||
|
||||
const TRANSLATION_CALL = /^\$?t\(.*\)$/s;
|
||||
|
||||
function rawAttributeMessage(name: string, value: string): string {
|
||||
const text = value.trim();
|
||||
if (TRANSLATION_CALL.test(text)) {
|
||||
return `${name}="${text}" is a plain string, not a translation, add a colon: :${name}="${text}"`;
|
||||
}
|
||||
return `raw ${name} "${text}", use t()`;
|
||||
}
|
||||
|
||||
function stringLiteralOf(expression: string): string | null {
|
||||
const match = /^\s*(?:'((?:[^'\\\n]|\\['\\])*)'|"((?:[^"\\\n]|\\["\\])*)"|`((?:[^`$\\]|\\[`$\\])*)`)\s*$/.exec(expression);
|
||||
const text = match ? (match[1] ?? match[2] ?? match[3] ?? "") : null;
|
||||
return text === null ? null : text.replace(/\\(['"`$\\])/g, "$1");
|
||||
}
|
||||
|
||||
const hasLetters = (text: string) => /\p{L}/u.test(text);
|
||||
|
||||
/** Templates shouldn't have plain text, to ignore them we can use <!-- i18n-ignore --> */
|
||||
export function checkRawText(): string[] {
|
||||
const problems: string[] = [];
|
||||
for (const file of sourceFiles()) {
|
||||
if (!file.endsWith(".vue")) continue;
|
||||
const ast = parse(fs.readFileSync(file, "utf8"), { filename: file }).descriptor.template?.ast;
|
||||
const walk = (node: TemplateNode) => {
|
||||
for (const prop of node.type === ELEMENT ? (node.props ?? []) : []) {
|
||||
const line = prop.loc?.start.line ?? node.loc.start.line;
|
||||
if (prop.type === ATTRIBUTE && TEXT_ATTRIBUTES.has(prop.name ?? "") && hasLetters(prop.value?.content ?? "")) {
|
||||
problems.push(`${rel(file)}:${line}: ${rawAttributeMessage(prop.name ?? "", prop.value?.content ?? "")}`);
|
||||
} else if (prop.type === DIRECTIVE && prop.name === "bind" && prop.arg?.isStatic && TEXT_ATTRIBUTES.has(prop.arg.content ?? "")) {
|
||||
const literal = stringLiteralOf(prop.exp?.content ?? "");
|
||||
if (literal !== null && hasLetters(literal)) {
|
||||
problems.push(`${rel(file)}:${line}: raw ${prop.arg.content} "${literal.trim()}" bound as a string, use t()`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node.type === ELEMENT && isIgnored(node)) return;
|
||||
if (node.type === INTERPOLATION) {
|
||||
const literal = stringLiteralOf((node.content as { content?: string } | undefined)?.content ?? "");
|
||||
if (literal !== null && hasLetters(literal)) problems.push(`${rel(file)}:${node.loc.start.line}: raw text "${literal.trim()}" in {{ }}, use t()`);
|
||||
}
|
||||
if (node.type === TEXT && typeof node.content === "string" && /\p{L}/u.test(node.content)) {
|
||||
problems.push(`${rel(file)}:${node.loc.start.line}: raw text "${node.content.trim()}", use t()`);
|
||||
}
|
||||
let skip = false;
|
||||
for (const child of node.children ?? []) {
|
||||
if (!skip) walk(child);
|
||||
if (child.type === COMMENT) skip = typeof child.content === "string" && child.content.trim() === IGNORE_COMMENT;
|
||||
else if (child.type === ELEMENT || child.type === INTERPOLATION || (child.type === TEXT && String(child.content).trim())) skip = false;
|
||||
}
|
||||
};
|
||||
if (ast) walk(ast as unknown as TemplateNode);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* npm run i18n:types regenerate messages.generated.ts
|
||||
* npm run i18n:sync regenerate the types, translate new keys (DEEPL_API_KEY)
|
||||
* npm run i18n:enforce-order regenerate the types, reorder locales like en.json
|
||||
* npm run i18n:cleanup regenerate the types, remove obsolete keys
|
||||
* npm run i18n:check locale list, translations, types, dynamic keys, unused keys and raw text
|
||||
* npm run i18n:check:strict checks for raw `$t` and 'vue-i18n' imports, including JS files
|
||||
*/
|
||||
|
||||
import { checkLocaleRegistry, checkRawText, checkUnusedKeys } from "./check.ts";
|
||||
import { report } from "./shared.ts";
|
||||
import { checkTranslations, syncTranslations } from "./sync.ts";
|
||||
import { checkTypes, writeTypes } from "./types.ts";
|
||||
|
||||
const [command, ...flags] = process.argv.slice(2);
|
||||
const has = (flag: string) => flags.includes(flag);
|
||||
|
||||
try {
|
||||
if (command === "types") {
|
||||
process.exitCode = writeTypes();
|
||||
} else if (command === "sync") {
|
||||
process.exitCode = writeTypes() || (await syncTranslations({ enforceOrder: has("--enforce-order"), cleanup: has("--cleanup") }));
|
||||
} else if (command === "check") {
|
||||
const translations = checkTranslations();
|
||||
const problems = [...checkLocaleRegistry(), ...translations, ...checkTypes(has("--strict")), ...checkUnusedKeys(), ...checkRawText()];
|
||||
if (problems.length) {
|
||||
report(problems);
|
||||
if (translations.length) console.error("Run: npm run i18n:sync (or i18n:cleanup)");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("✅ i18n checks passed");
|
||||
}
|
||||
} else {
|
||||
console.error("Usage: node scripts/i18n/index.ts <types | sync [--enforce-order | --cleanup] | check [--strict]>");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("\n❌ i18n script failed:", error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse } from "vue/compiler-sfc";
|
||||
|
||||
export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
export const srcDir = path.join(root, "src");
|
||||
export const i18nDir = path.join(srcDir, "i18n");
|
||||
export const localesDir = path.join(i18nDir, "locales");
|
||||
export const generatedFile = path.join(i18nDir, "messages.generated.ts");
|
||||
|
||||
export type Messages = Record<string, string>;
|
||||
|
||||
/** Tests t/$t with throwaway keys */
|
||||
export const isTestFile = (file: string) => /\.test\.[jt]s$/.test(file);
|
||||
export const rel = (file: string) => path.relative(root, file);
|
||||
export const readJson = (file: string): unknown => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
/** Flat nested messages into `{ "a.b.c": "message" }`; null counts as empty message. */
|
||||
export function flatten(tree: unknown, prefix = ""): Messages {
|
||||
const result: Messages = {};
|
||||
for (const [key, value] of Object.entries(tree as Record<string, unknown>)) {
|
||||
const fullKey = prefix + key;
|
||||
if (typeof value === "string") {
|
||||
result[fullKey] = value;
|
||||
} else if (value === null) {
|
||||
result[fullKey] = "";
|
||||
} else if (typeof value === "object") {
|
||||
Object.assign(result, flatten(value, `${fullKey}.`));
|
||||
} else {
|
||||
throw new Error(`${fullKey}: messages must be strings or objects`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const readLocale = (file: string): Messages => flatten(readJson(path.join(localesDir, file)));
|
||||
|
||||
/** File names of every locale except en.json, sorted */
|
||||
export const translationFiles = (): string[] =>
|
||||
fs
|
||||
.readdirSync(localesDir)
|
||||
.filter((file) => file.endsWith(".json") && file !== "en.json")
|
||||
.sort();
|
||||
|
||||
/** .vue, .ts and .js in src/ without the locales and the generated types */
|
||||
export function* sourceFiles(skipI18nDir = false, dir = srcDir): Generator<string> {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (full !== localesDir && !(skipI18nDir && full === i18nDir)) yield* sourceFiles(skipI18nDir, full);
|
||||
} else if (/\.(vue|ts|js)$/.test(entry.name) && full !== generatedFile) {
|
||||
yield full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function report(problems: string[]): void {
|
||||
for (const problem of problems) console.error(`✗ ${problem}`);
|
||||
console.error(`\n${problems.length} problem(s) found.`);
|
||||
}
|
||||
|
||||
const blank = (text: string) => text.replace(/[^\n]/g, " ");
|
||||
|
||||
/** Replaces the comments of JS/TS code by spaces and, with `strings`, the content of '...' and "..." literals too */
|
||||
export function maskJs(text: string, strings = false): string {
|
||||
const scan = (start: number, inExpression: boolean): [string, number] => {
|
||||
let out = "";
|
||||
let i = start;
|
||||
let depth = 0;
|
||||
while (i < text.length) {
|
||||
const ch = text.charAt(i);
|
||||
const next = text.charAt(i + 1);
|
||||
if (ch === "/" && (next === "/" || next === "*")) {
|
||||
const close = next === "/" ? text.indexOf("\n", i) : text.indexOf("*/", i + 2);
|
||||
const end = close === -1 ? text.length : next === "/" ? close : close + 2;
|
||||
out += blank(text.slice(i, end));
|
||||
i = end;
|
||||
} else if (ch === "`") {
|
||||
const [masked, end] = maskTemplate(i);
|
||||
out += masked;
|
||||
i = end;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
let end = i + 1;
|
||||
while (end < text.length && text.charAt(end) !== ch) {
|
||||
if (text.charAt(end) === "\\") end++;
|
||||
else if (text.charAt(end) === "\n") break; // it's not a string
|
||||
end++;
|
||||
}
|
||||
const closed = text.charAt(end) === ch;
|
||||
const literal = text.slice(i, closed ? end + 1 : end);
|
||||
out += strings && closed ? ch + blank(literal.slice(1, -1)) + ch : literal;
|
||||
i += literal.length;
|
||||
} else {
|
||||
if (inExpression && ch === "{") depth++;
|
||||
else if (inExpression && ch === "}") {
|
||||
if (depth === 0) return [out, i];
|
||||
depth--;
|
||||
}
|
||||
out += ch;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return [out, i];
|
||||
};
|
||||
|
||||
// A template literal from its opening backtick -- its `${...}` expressions are scanned
|
||||
const maskTemplate = (start: number): [string, number] => {
|
||||
const keep = (chunk: string) => (strings ? blank(chunk) : chunk);
|
||||
let out = "`";
|
||||
let i = start + 1;
|
||||
while (i < text.length) {
|
||||
const ch = text.charAt(i);
|
||||
if (ch === "\\") {
|
||||
out += keep(text.slice(i, i + 2));
|
||||
i += 2;
|
||||
} else if (ch === "`") {
|
||||
return [`${out}\``, i + 1];
|
||||
} else if (ch === "$" && text.charAt(i + 1) === "{") {
|
||||
const [code, end] = scan(i + 2, true);
|
||||
out += `\${${code}`;
|
||||
i = end;
|
||||
if (text.charAt(i) === "}") {
|
||||
out += "}";
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
out += keep(ch);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return [out, i];
|
||||
};
|
||||
|
||||
return scan(0, false)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The code of a source file without comments at the same offsets as the file. In .vue files only the template and the scripts
|
||||
* are kept while styles no
|
||||
*/
|
||||
export function maskSource(file: string, text: string, strings = false): string {
|
||||
if (!file.endsWith(".vue")) return maskJs(text, strings);
|
||||
const { descriptor } = parse(text, { filename: file });
|
||||
let out = text;
|
||||
// each replacement keeps the length of what it replaces, so the blocks can be masked one after the other
|
||||
const mask = ({ start, end }: { start: { offset: number }; end: { offset: number } }, apply: (code: string) => string) => {
|
||||
out = out.slice(0, start.offset) + apply(text.slice(start.offset, end.offset)) + out.slice(end.offset);
|
||||
};
|
||||
for (const style of descriptor.styles) mask(style.loc, blank);
|
||||
for (const script of [descriptor.script, descriptor.scriptSetup]) {
|
||||
if (script) mask(script.loc, (code) => maskJs(code, strings));
|
||||
}
|
||||
if (descriptor.template) mask(descriptor.template.loc, (code) => code.replace(/<!--[\s\S]*?-->/g, blank));
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DeepLClient, type TargetLanguageCode, type TranslateTextOptions } from "deepl-node";
|
||||
import { localesDir, readJson, readLocale, translationFiles } from "./shared.ts";
|
||||
|
||||
type Tree = { [key: string]: unknown };
|
||||
|
||||
const MASTER_LANGUAGE = "en";
|
||||
|
||||
const DEEPL_LANGUAGES: Record<string, string> = {
|
||||
"zh-cn": "ZH-HANS",
|
||||
"zh-tw": "ZH-HANT",
|
||||
pt: "PT-PT",
|
||||
"pt-br": "PT-BR",
|
||||
en: "EN",
|
||||
"en-us": "EN-US",
|
||||
"en-gb": "EN-GB",
|
||||
"sv-se": "SV",
|
||||
ua: "UK",
|
||||
"nl-be": "NL",
|
||||
cz: "CS",
|
||||
bg: "BG",
|
||||
};
|
||||
|
||||
let translator: DeepLClient | null = null;
|
||||
|
||||
const isTree = (value: unknown): value is Tree => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
/** Reports what syncTranslations would change */
|
||||
export function checkTranslations(): string[] {
|
||||
const master = Object.keys(readLocale("en.json"));
|
||||
const problems: string[] = [];
|
||||
for (const file of translationFiles()) {
|
||||
let target: Record<string, string>;
|
||||
try {
|
||||
target = readLocale(file);
|
||||
} catch (error) {
|
||||
problems.push(`${file}: cannot be read (${error instanceof Error ? error.message : error})`);
|
||||
continue;
|
||||
}
|
||||
for (const key of master) {
|
||||
if (!target[key]) problems.push(`${file}: missing "${key}"`);
|
||||
}
|
||||
for (const key of Object.keys(target)) {
|
||||
if (!master.includes(key)) problems.push(`${file}: obsolete key "${key}"`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
async function translateText(text: string, targetLanguage: string, keyPath: string): Promise<string | null> {
|
||||
if (!text.trim()) {
|
||||
console.warn(`Skipping translation for empty text: "${text}"`);
|
||||
return text;
|
||||
}
|
||||
|
||||
const hasPlaceholders = /\{[^}]+\}/.test(text);
|
||||
let textToTranslate = text;
|
||||
const options: TranslateTextOptions = {};
|
||||
if (hasPlaceholders) {
|
||||
console.log(`Found placeholder in: "${text}". Wrapping for translation.`);
|
||||
textToTranslate = text.replace(/(\{[^}]+\})/g, "<ph>$1</ph>");
|
||||
options.tagHandling = "xml";
|
||||
options.ignoreTags = ["ph"];
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Translating "${text}" from '${MASTER_LANGUAGE}' to '${targetLanguage}'...`);
|
||||
const deeplTarget = (DEEPL_LANGUAGES[targetLanguage.toLowerCase()] ?? targetLanguage.toUpperCase()) as TargetLanguageCode;
|
||||
const result = await translator?.translateText(textToTranslate, MASTER_LANGUAGE, deeplTarget, options);
|
||||
await new Promise((resolve) => {setTimeout(resolve, 100)}); // avoid rate-limiting
|
||||
let translated = result?.text ?? "";
|
||||
if (hasPlaceholders) {
|
||||
// the translator might add spaces around the tags
|
||||
translated = translated.replace(/<ph>\s*(\{[^}]+\})\s*<\/ph>/g, "$1");
|
||||
}
|
||||
if (!translated.trim()) {
|
||||
console.error(`⚠️ Translation failed for "${text}" (${keyPath}): empty result`);
|
||||
return null;
|
||||
}
|
||||
return translated;
|
||||
} catch (error) {
|
||||
console.error(`⚠️ Translation failed for "${text}" (${keyPath}):`, error instanceof Error ? error.message : error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reorders `value` to match the key order of `master`, dropping keys `master` lacks */
|
||||
function reorderObject(value: unknown, master: unknown): unknown {
|
||||
if (!isTree(value) || !isTree(master)) return value;
|
||||
const ordered: Tree = {};
|
||||
for (const key of Object.keys(master)) {
|
||||
if (Object.hasOwn(value, key)) ordered[key] = reorderObject(value[key], master[key]);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/** Add missing keys, remove obsolete ones */
|
||||
async function processKeys(
|
||||
master: Tree,
|
||||
target: Tree,
|
||||
lang: string,
|
||||
parents: string[],
|
||||
enforceOrder: boolean,
|
||||
cleanup: boolean,
|
||||
): Promise<boolean | "UNSUPPORTED"> {
|
||||
let changed = false;
|
||||
|
||||
for (const key of Object.keys(master)) {
|
||||
const masterValue = master[key];
|
||||
const keyPath = [...parents, key].join(".");
|
||||
|
||||
if (Object.hasOwn(target, key) && isTree(masterValue) !== isTree(target[key])) {
|
||||
console.log(`🗑️ Removing "${keyPath}" from ${lang}.json: its structure differs from en.json`);
|
||||
Reflect.deleteProperty(target, key);
|
||||
changed = true;
|
||||
}
|
||||
if (isTree(masterValue)) {
|
||||
if (!cleanup && !isTree(target[key])) {
|
||||
console.log(`Creating missing object structure for "${keyPath}" in ${lang}.json`);
|
||||
target[key] = {};
|
||||
changed = true;
|
||||
}
|
||||
const child = target[key];
|
||||
if (isTree(child)) {
|
||||
const result = await processKeys(masterValue, child, lang, [...parents, key], enforceOrder, cleanup);
|
||||
if (result === "UNSUPPORTED") {
|
||||
console.log(`Skipping translation for "${lang}" due to unsupported structure.`);
|
||||
return result;
|
||||
}
|
||||
if (result) changed = true;
|
||||
}
|
||||
} else if (!cleanup) {
|
||||
if (typeof masterValue === "string") {
|
||||
const existing = target[key];
|
||||
if (!Object.hasOwn(target, key) || existing === "" || existing === null) {
|
||||
if (enforceOrder) {
|
||||
console.log(`Skipping new key "${keyPath}" for ${lang}.json`);
|
||||
} else {
|
||||
const translated = await translateText(masterValue, lang, keyPath);
|
||||
if (translated === null) return "UNSUPPORTED";
|
||||
if (target[key] !== translated) {
|
||||
target[key] = translated;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!Object.hasOwn(target, key)) {
|
||||
console.log(`Key "${keyPath}" (non-string) missing in ${lang}.json. Copying from English.`);
|
||||
target[key] = masterValue;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(target)) {
|
||||
if (!Object.hasOwn(master, key)) {
|
||||
console.log(`🗑️ Removing obsolete key "${[...parents, key].join(".")}" from ${lang}.json`);
|
||||
Reflect.deleteProperty(target, key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export async function syncTranslations({ enforceOrder, cleanup }: { enforceOrder: boolean; cleanup: boolean }): Promise<number> {
|
||||
const reorder = enforceOrder && !cleanup;
|
||||
|
||||
if (!enforceOrder && !cleanup) {
|
||||
const apiKey = process.env.DEEPL_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("❌ Missing DEEPL_API_KEY in environment.");
|
||||
return 1;
|
||||
}
|
||||
translator = new DeepLClient(apiKey);
|
||||
console.warn("--- Using DeepL API for translation ---");
|
||||
} else if (cleanup) {
|
||||
console.warn("--- Only obsolete keys will be removed - No translations, or reordering will happen ---");
|
||||
} else {
|
||||
console.warn("--- Enforcing order of all files to match master language (no translations will be performed) ---");
|
||||
}
|
||||
|
||||
const master = readJson(path.join(localesDir, "en.json")) as Tree;
|
||||
const failed: string[] = [];
|
||||
|
||||
for (const file of translationFiles()) {
|
||||
const targetFile = path.join(localesDir, file);
|
||||
const lang = path.basename(file, ".json");
|
||||
let original: Tree;
|
||||
try {
|
||||
const parsed = readJson(targetFile);
|
||||
if (!isTree(parsed)) throw new Error("the root must be an object");
|
||||
original = parsed;
|
||||
} catch (error) {
|
||||
console.error(`❌ Could not read ${targetFile}, leaving it unchanged: ${error instanceof Error ? error.message : error}`);
|
||||
failed.push(file);
|
||||
continue;
|
||||
}
|
||||
let target = structuredClone(original);
|
||||
|
||||
const result = await processKeys(master, target, lang, [], reorder, cleanup);
|
||||
if (result === "UNSUPPORTED") {
|
||||
console.error(`❌ ${targetFile} was left unchanged: a translation failed.`);
|
||||
failed.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
let orderChanged = false;
|
||||
if (reorder) {
|
||||
target = reorderObject(target, master) as Tree;
|
||||
orderChanged = JSON.stringify(original) !== JSON.stringify(target);
|
||||
if (orderChanged) console.log(`- Reordered keys in ${lang}.json`);
|
||||
}
|
||||
|
||||
if (result || orderChanged) {
|
||||
fs.writeFileSync(targetFile, `${JSON.stringify(target, null, 2)}\n`);
|
||||
if (!cleanup && !reorder) console.log(`Successfully updated ${targetFile}`);
|
||||
} else {
|
||||
console.log(`No changes needed for ${targetFile}`);
|
||||
}
|
||||
}
|
||||
if (failed.length) {
|
||||
console.error(`\n❌ Synchronization incomplete, ${failed.length} locale(s) were not changed: ${failed.join(", ")}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (cleanup) console.log("\n✅ Cleanup complete (obsolete keys removed).");
|
||||
else if (reorder) console.log("\n✅ Enforce order complete.");
|
||||
else console.log("\n✅ Translation synchronization complete (via DeepL).");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import fs from "node:fs";
|
||||
import { generatedFile, isTestFile, maskSource, type Messages, readLocale, rel, sourceFiles, translationFiles } from "./shared.ts";
|
||||
|
||||
const OPTIONAL_PLACEHOLDERS = new Set(["suffix", "prefix"]);
|
||||
const NAMED_PLACEHOLDER = /\{([A-Za-z_]\w*)\}/g;
|
||||
/** vue-i18n literal interpolation, like {'|'} to print a pipe or @ */
|
||||
const LITERAL_INTERPOLATION = /\{'[^']*'\}/g;
|
||||
const REGENERATE = "Run: npm run i18n:types";
|
||||
|
||||
/** Named placeholders of a message in order */
|
||||
function placeholders(message: string): string[] {
|
||||
const names: string[] = [];
|
||||
for (const match of message.matchAll(NAMED_PLACEHOLDER)) {
|
||||
const name = match[1] ?? "";
|
||||
if (!names.includes(name)) names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Returns a description of unsupported vue-i18n syntax, or null. */
|
||||
function unsupportedSyntax(message: string): string | null {
|
||||
const rest = message.replace(LITERAL_INTERPOLATION, "").replace(NAMED_PLACEHOLDER, "");
|
||||
if (/[{}]/.test(rest)) return "braces other than {name} placeholders";
|
||||
if (rest.includes("|")) return "plural separator '|'";
|
||||
if (/@[.:]/.test(rest)) return "linked message '@:' / '@.'";
|
||||
return null;
|
||||
}
|
||||
|
||||
function generate() {
|
||||
const messages = readLocale("en.json");
|
||||
const problems: string[] = [];
|
||||
const lines: string[] = [];
|
||||
for (const [key, message] of Object.entries(messages)) {
|
||||
const unsupported = unsupportedSyntax(message);
|
||||
if (unsupported) problems.push(`en.json: ${key} uses ${unsupported}, which the types do not support`);
|
||||
const names = placeholders(message);
|
||||
const params = names.length ? names.map((name) => JSON.stringify(name)).join(" | ") : "never";
|
||||
lines.push(` ${JSON.stringify(key)}: ${params};`);
|
||||
}
|
||||
const content = `// This file is generated by scripts/i18n from src/i18n/locales/en.json.
|
||||
// Run \`make generate-translation-types\` \`npm run i18n:types\` after changing en.json. Do not edit manually.
|
||||
|
||||
/** Every message in en.json mapped to the named placeholders. Used to type-check \`t()\` calls */
|
||||
export interface MessageParams {
|
||||
${lines.join("\n")}
|
||||
}
|
||||
|
||||
/** Placeholders that callers may omit */
|
||||
export type OptionalParam = ${[...OPTIONAL_PLACEHOLDERS].map((name) => JSON.stringify(name)).join(" | ")};
|
||||
`;
|
||||
return { content, messages, problems };
|
||||
}
|
||||
|
||||
/** Regenerates messages.generated.ts; returns the exit code. */
|
||||
export function writeTypes(): number {
|
||||
const { content, messages, problems } = generate();
|
||||
if (problems.length) {
|
||||
for (const problem of problems) console.error(`✗ ${problem}`);
|
||||
return 1;
|
||||
}
|
||||
fs.writeFileSync(generatedFile, content);
|
||||
console.log(`Wrote ${rel(generatedFile)} (${Object.keys(messages).length} messages).`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function checkGeneratedFile(expected: string): string[] {
|
||||
if (!fs.existsSync(generatedFile)) return [`${rel(generatedFile)} is missing. ${REGENERATE}`];
|
||||
if (fs.readFileSync(generatedFile, "utf8") !== expected) return [`${rel(generatedFile)} is out of date. ${REGENERATE}`];
|
||||
return [];
|
||||
}
|
||||
|
||||
function checkLocalePlaceholders(enMessages: Messages): string[] {
|
||||
const problems: string[] = [];
|
||||
for (const file of translationFiles()) {
|
||||
for (const [key, message] of Object.entries(readLocale(file))) {
|
||||
const english = enMessages[key];
|
||||
if (english === undefined) continue;
|
||||
const expected = new Set(placeholders(english));
|
||||
const actual = new Set(placeholders(message));
|
||||
for (const name of actual) {
|
||||
if (!expected.has(name)) problems.push(`${file}: ${key} uses {${name}}, which en.json does not provide`);
|
||||
}
|
||||
for (const name of expected) {
|
||||
if (!actual.has(name) && !OPTIONAL_PLACEHOLDERS.has(name)) problems.push(`${file}: ${key} is missing {${name}}`);
|
||||
}
|
||||
const unsupported = unsupportedSyntax(message);
|
||||
if (unsupported) problems.push(`${file}: ${key} uses ${unsupported}`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** .ts files and .vue files with <script lang="ts"> are checked by vue-tsc */
|
||||
function isTypeScript(file: string, text: string): boolean {
|
||||
if (file.endsWith(".ts")) return true;
|
||||
return file.endsWith(".vue") && /<script[^>]*lang=["']ts["']/.test(text);
|
||||
}
|
||||
|
||||
const BYPASS_RULES = [
|
||||
{
|
||||
pattern: /from\s+["']vue-i18n["']/,
|
||||
message: "import useI18n from '@/i18n/index.ts' instead of 'vue-i18n'",
|
||||
inStrings: true,
|
||||
},
|
||||
{
|
||||
pattern: /(^|[^\w$])\$t\b/,
|
||||
message: "use t(...) from useI18n() in '@/i18n/index.ts' instead of $t(...), so the key and params are type-checked",
|
||||
inStrings: false,
|
||||
},
|
||||
];
|
||||
|
||||
/** `$t` that isn't called: `helper(this.$t)`, `this.$t.bind(this)` or a `$t` parameter. The helper keys can't be type-checked */
|
||||
const PASSED_AROUND = /(^|[^\w$])\$t\b(?!\s*\()/;
|
||||
export const isPassedAround = (line: string) => PASSED_AROUND.test(line);
|
||||
|
||||
function* passedAroundIndexes(bare: string): Generator<number> {
|
||||
for (const match of bare.matchAll(new RegExp(PASSED_AROUND.source, "g"))) yield match.index + (match[1] ?? "").length;
|
||||
}
|
||||
|
||||
// The start of a t(...) call: t(, $t(, this.$t( or i18n.global.t(
|
||||
const CALL_START = /(?:(?<![\w$.])(?:\$t|t|i18n\.global\.t)|\bthis\.\$t)\s*\(/g;
|
||||
|
||||
interface Params {
|
||||
names: Set<string>;
|
||||
dynamic: boolean;
|
||||
}
|
||||
|
||||
const lineOf = (text: string, index: number) => text.slice(0, index).split("\n").length;
|
||||
|
||||
function skipSpaces(text: string, index: number): number {
|
||||
let i = index;
|
||||
while (i < text.length && /\s/.test(text.charAt(i))) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Reads a string literal starting at `index`; returns null for anything else or a dynamic template */
|
||||
function readStringLiteral(text: string, index: number): { value: string; end: number } | null {
|
||||
const quote = text.charAt(index);
|
||||
if (quote !== '"' && quote !== "'" && quote !== "`") return null;
|
||||
let i = index + 1;
|
||||
let value = "";
|
||||
while (i < text.length && text.charAt(i) !== quote) {
|
||||
if (text.charAt(i) === "\\") {
|
||||
value += text.charAt(i + 1);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (quote === "`" && text.charAt(i) === "$" && text.charAt(i + 1) === "{") return null;
|
||||
value += text.charAt(i);
|
||||
i++;
|
||||
}
|
||||
return i < text.length ? { value, end: i + 1 } : null;
|
||||
}
|
||||
|
||||
const isQuote = (ch: string) => ch === '"' || ch === "'" || ch === "`";
|
||||
|
||||
function skipString(text: string, index: number): number {
|
||||
const quote = text.charAt(index);
|
||||
let i = index + 1;
|
||||
while (i < text.length) {
|
||||
const ch = text.charAt(i);
|
||||
if (ch === "\\") i++;
|
||||
else if (ch === quote) return i + 1;
|
||||
i++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
const CLOSERS: Record<string, string> = { "{": "}", "[": "]", "(": ")" };
|
||||
|
||||
/** Finds the index of the bracket closing the one at `index`, skipping strings. */
|
||||
function findClosing(text: string, index: number): number {
|
||||
const stack = [CLOSERS[text.charAt(index)]];
|
||||
let i = index + 1;
|
||||
while (i < text.length && stack.length) {
|
||||
const ch = text.charAt(i);
|
||||
if (isQuote(ch)) {
|
||||
const end = skipString(text, i);
|
||||
if (end === -1) return -1;
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
const closer = CLOSERS[ch];
|
||||
if (closer) stack.push(closer);
|
||||
else if (ch === stack.at(-1)) stack.pop();
|
||||
i++;
|
||||
}
|
||||
return stack.length ? -1 : i - 1;
|
||||
}
|
||||
|
||||
/** Property names of the object literal at `index`; `dynamic` when a spread or computed key hides some */
|
||||
function readObjectKeys(text: string, index: number): Params | null {
|
||||
const close = findClosing(text, index);
|
||||
if (close === -1) return null;
|
||||
const names = new Set<string>();
|
||||
let dynamic = false;
|
||||
let segmentStart = index + 1;
|
||||
let i = index + 1;
|
||||
const flush = (end: number) => {
|
||||
const segment = text.slice(segmentStart, end).trim();
|
||||
if (!segment) return;
|
||||
if (segment.startsWith("...") || segment.startsWith("[")) {
|
||||
dynamic = true;
|
||||
return;
|
||||
}
|
||||
const match = segment.match(/^(?:([A-Za-z_$][\w$]*)|'([^']*)'|"([^"]*)")\s*(?::|$)/);
|
||||
const name = match?.[1] ?? match?.[2] ?? match?.[3];
|
||||
if (name === undefined) dynamic = true;
|
||||
else names.add(name);
|
||||
};
|
||||
while (i < close) {
|
||||
const ch = text.charAt(i);
|
||||
if (isQuote(ch)) {
|
||||
i = skipString(text, i);
|
||||
continue;
|
||||
}
|
||||
if (ch === "{" || ch === "[" || ch === "(") {
|
||||
i = findClosing(text, i) + 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === ",") {
|
||||
flush(i);
|
||||
segmentStart = i + 1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
flush(close);
|
||||
return { names, dynamic };
|
||||
}
|
||||
|
||||
/**
|
||||
* Yields every t(...) call whose first argument is a literal key. `text` is the source without
|
||||
* comments, `bare` the same without the content of its strings
|
||||
*/
|
||||
export function* literalCalls(text: string, bare: string): Generator<{ key: string; params: Params; index: number }> {
|
||||
for (const match of bare.matchAll(CALL_START)) {
|
||||
const keyStart = skipSpaces(text, match.index + match[0].length);
|
||||
const key = readStringLiteral(text, keyStart);
|
||||
if (!key) continue; // only vue-tsc can check it
|
||||
let i = skipSpaces(text, key.end);
|
||||
if (text.charAt(i) !== "," && text.charAt(i) !== ")") continue; // 'prefix.' + name if the key isn't literal
|
||||
let params: Params = { names: new Set(), dynamic: false };
|
||||
if (text.charAt(i) === ",") {
|
||||
i = skipSpaces(text, i + 1);
|
||||
if (text.charAt(i) === "{") params = readObjectKeys(text, i) ?? params;
|
||||
else if (text.charAt(i) !== ")") params = { names: new Set(), dynamic: true };
|
||||
}
|
||||
yield { key: key.value, params, index: match.index };
|
||||
}
|
||||
}
|
||||
|
||||
export function* dynamicCalls(text: string, bare: string): Generator<{ index: number }> {
|
||||
const literal = new Set<number>();
|
||||
for (const call of literalCalls(text, bare)) literal.add(call.index);
|
||||
for (const match of bare.matchAll(CALL_START)) {
|
||||
if (literal.has(match.index)) continue;
|
||||
if (text.charAt(skipSpaces(text, match.index + match[0].length)) === ")") continue; // t() without a key, vue-tsc reports it
|
||||
yield { index: match.index };
|
||||
}
|
||||
}
|
||||
|
||||
function checkCall(key: string, params: Params, messages: Messages): string[] {
|
||||
const expected = messages[key];
|
||||
if (expected === undefined) return [`unknown translation key "${key}"`];
|
||||
if (params.dynamic) return []; // spread or variable: names unknown
|
||||
const known = new Set(placeholders(expected));
|
||||
const problems: string[] = [];
|
||||
for (const name of params.names) {
|
||||
if (!known.has(name)) problems.push(`"${key}" does not take a {${name}} param`);
|
||||
}
|
||||
for (const name of known) {
|
||||
if (!params.names.has(name) && !OPTIONAL_PLACEHOLDERS.has(name)) problems.push(`"${key}" is missing its {${name}} param`);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
function checkTypedUsage(messages: Messages, strict: boolean): string[] {
|
||||
const problems: string[] = [];
|
||||
for (const file of sourceFiles(true)) {
|
||||
if (isTestFile(file)) continue; // tests t/$t with throwaway keys
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
const code = maskSource(file, text);
|
||||
const bare = maskSource(file, text, true);
|
||||
if (strict || isTypeScript(file, text)) {
|
||||
const codeLines = code.split("\n");
|
||||
const bareLines = bare.split("\n");
|
||||
for (const rule of BYPASS_RULES) {
|
||||
const lines = rule.inStrings ? codeLines : bareLines;
|
||||
for (const [index, line] of lines.entries()) {
|
||||
if (rule.pattern.test(line)) problems.push(`${rel(file)}:${index + 1}: ${rule.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!strict && !isTypeScript(file, text)) {
|
||||
const lines = new Set([...passedAroundIndexes(bare)].map((index) => lineOf(text, index)));
|
||||
for (const line of lines) {
|
||||
problems.push(`${rel(file)}:${line}: $t is passed around as a value, import i18n from '@/i18n/index.ts' and call t() with a literal key in the helper instead`);
|
||||
}
|
||||
}
|
||||
for (const call of literalCalls(code, bare)) {
|
||||
for (const problem of checkCall(call.key, call.params, messages)) {
|
||||
problems.push(`${rel(file)}:${lineOf(text, call.index)}: ${problem}`);
|
||||
}
|
||||
}
|
||||
for (const call of dynamicCalls(code, bare)) {
|
||||
problems.push(`${rel(file)}:${lineOf(text, call.index)}: dynamic translation key, call t() with a string literal so the key is type-checked and found by the unused keys check`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** Checks the generated types, the placeholders of every locale and the t() calls in src/. With `strict`, raw `$t` and 'vue-i18n' */
|
||||
export function checkTypes(strict: boolean): string[] {
|
||||
const { content, messages, problems } = generate();
|
||||
return [...problems, ...checkGeneratedFile(content), ...checkLocalePlaceholders(messages), ...checkTypedUsage(messages, strict)];
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
// scripts/sync-translations.js
|
||||
import path from 'node:path';
|
||||
import { globSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { DeepLClient } from 'deepl-node';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const checkOnly = args.includes('--check') || args.includes('-c');
|
||||
const enforceOrder = args.includes('--enforce-order'); // When using this will force the order of eng, will not perform new translations. If we move keys to a different block will count as new translations and will delete it from all the languages
|
||||
const cleanupOnly = args.includes('--cleanup'); // Will only delete keys that not longer exist in eng, no translation or reorder, just deletion.
|
||||
|
||||
// --- Configuration ---
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const localesDir = path.resolve(__dirname, '../src/i18n');
|
||||
const masterLocaleFile = path.join(localesDir, 'en.json');
|
||||
const masterLanguageCode = 'en';
|
||||
const targetLocaleFiles = globSync(path.join(localesDir, '*.json'))
|
||||
.filter(file => path.basename(file) !== `${masterLanguageCode}.json`)
|
||||
|
||||
const requireApiKey = !checkOnly && !enforceOrder && !cleanupOnly;
|
||||
const DEEPL_API_KEY = process.env.DEEPL_API_KEY;
|
||||
if (requireApiKey && !DEEPL_API_KEY) {
|
||||
console.error("❌ Missing DEEPL_API_KEY in environment.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const translator = requireApiKey ? new DeepLClient(DEEPL_API_KEY) : null;
|
||||
|
||||
const deeplLangMap = {
|
||||
'zh-cn': 'ZH-HANS',
|
||||
'zh-tw': 'ZH-HANT',
|
||||
'pt': 'PT-PT', // or 'PT-BR' if you want Brazilian Portuguese
|
||||
'pt-br': 'PT-BR',
|
||||
'en': 'EN',
|
||||
'en-us': 'EN-US',
|
||||
'en-gb': 'EN-GB',
|
||||
'sv-se': 'SV',
|
||||
'ua': 'UK',
|
||||
'nl-be': 'NL',
|
||||
'cz': 'CS',
|
||||
'bg': 'BG',
|
||||
// Add more as needed
|
||||
};
|
||||
|
||||
// --- Translation Function using DeepL ---
|
||||
async function translateText(text, targetLanguage, keyPath = '') {
|
||||
if (checkOnly) {
|
||||
// In check mode, just return the original text to detect changes
|
||||
return text;
|
||||
}
|
||||
|
||||
if (!text || typeof text !== 'string' || text.trim() === '') {
|
||||
console.warn(`Skipping translation for empty or non-string text: "${text}"`);
|
||||
return text;
|
||||
}
|
||||
|
||||
if (keyPath === 'languages' || keyPath.startsWith('languages.')) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const hasPlaceholders = /\{[^}]+\}/.test(text);
|
||||
let textToTranslate = text;
|
||||
const options = {};
|
||||
|
||||
if (hasPlaceholders) {
|
||||
console.log(`Found placeholder in: "${text}". Wrapping for translation.`);
|
||||
textToTranslate = text.replace(/(\{[^}]+\})/g, '<ph>$1</ph>');
|
||||
options.tagHandling = 'xml';
|
||||
options.ignoreTags = ['ph'];
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Translating "${text}" from '${masterLanguageCode}' to '${targetLanguage}'...`);
|
||||
|
||||
const deeplTargetLang = deeplLangMap[targetLanguage.toLowerCase()] || targetLanguage.toUpperCase();
|
||||
|
||||
const result = await translator.translateText(textToTranslate, masterLanguageCode, deeplTargetLang, options);
|
||||
|
||||
// Delay to avoid rate-limiting
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
let translatedText = result.text;
|
||||
|
||||
if (hasPlaceholders) {
|
||||
// Unwrap the placeholders. The translator might add spaces around tags.
|
||||
translatedText = translatedText.replace(/<ph>\s*(\{[^}]+\})\s*<\/ph>/g, '$1');
|
||||
}
|
||||
|
||||
return translatedText;
|
||||
|
||||
} catch (err) {
|
||||
console.error(`⚠️ Translation failed for "${text}" (${keyPath}):`, err.message || err);
|
||||
return ``;
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder objects to match eng (master lang) key order
|
||||
function reorderObject(obj, masterObj) {
|
||||
// If obj is not an object, return as is
|
||||
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return obj;
|
||||
const newObj = {};
|
||||
for (const key in masterObj) {
|
||||
if (Object.hasOwn(masterObj, key)) {
|
||||
if (Object.hasOwn(obj, key)) {
|
||||
newObj[key] = reorderObject(obj[key], masterObj[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newObj;
|
||||
}
|
||||
|
||||
// --- Recursive key processor ---
|
||||
async function processKeys(masterObj, targetObj, targetLangCode, currentPathParts = [], doEnforceOrder, doCleanupOnly = false) {
|
||||
let changesMade = false;
|
||||
let meaningfulChanges = 0; // Only count meaningful changes
|
||||
|
||||
// First pass: Add/update keys from master to target
|
||||
for (const key in masterObj) {
|
||||
if (Object.hasOwn(masterObj, key)) {
|
||||
const currentPathPartsNext = [...currentPathParts, key];
|
||||
const currentKeyPath = currentPathPartsNext.join('.');
|
||||
|
||||
const masterValue = masterObj[key];
|
||||
|
||||
// Special handling for "languages" key - always copy the entire object from master
|
||||
if (key === 'languages' && currentPathParts.length === 0 && !doCleanupOnly) {
|
||||
if (!checkOnly && !doEnforceOrder) {
|
||||
console.log(`Copying entire "languages" object from master to ${targetLangCode}.json`);
|
||||
}
|
||||
targetObj[key] = JSON.parse(JSON.stringify(masterValue)); // Deep copy
|
||||
changesMade = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof masterValue === 'object' && masterValue !== null && !Array.isArray(masterValue)) {
|
||||
if (!doCleanupOnly && (!targetObj[key] || typeof targetObj[key] !== 'object')) {
|
||||
if (checkOnly) {
|
||||
console.log(`Would create missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Creating new object structures is meaningful
|
||||
// In check mode, create a temporary object for processing
|
||||
targetObj[key] = {};
|
||||
} else {
|
||||
console.log(`Creating missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
|
||||
targetObj[key] = {};
|
||||
}
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
if (targetObj[key] && typeof targetObj[key] === 'object') {
|
||||
const result = await processKeys( masterValue, targetObj[key], targetLangCode, currentPathPartsNext,
|
||||
doEnforceOrder, doCleanupOnly);
|
||||
if (result === "UNSUPPORTED") {
|
||||
console.log(`Skipping translation for "${targetLangCode}" due to unsupported structure.`);
|
||||
return "UNSUPPORTED";
|
||||
}
|
||||
if (typeof result === 'number') {
|
||||
meaningfulChanges += result;
|
||||
changesMade = true;
|
||||
} else if (result) {
|
||||
changesMade = true;
|
||||
}
|
||||
}
|
||||
} else if (!doCleanupOnly) {
|
||||
// Only handle non‑object values
|
||||
if (typeof masterValue === 'string') {
|
||||
const existingTargetValue = targetObj[key];
|
||||
const isMissing = !Object.hasOwn(targetObj, key) || existingTargetValue === '' || existingTargetValue === null;
|
||||
|
||||
if (isMissing) {
|
||||
if (checkOnly) {
|
||||
console.log(`Would translate "${currentKeyPath}" for ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Translation is meaningful
|
||||
} else if (doEnforceOrder) {
|
||||
// Skip translations for new keys
|
||||
console.log(`Skipping new key "${currentKeyPath}" for ${targetLangCode}.json`);
|
||||
} else {
|
||||
// Sync translation
|
||||
const result = await translateText(masterValue, targetLangCode, currentKeyPath);
|
||||
if (result === "") {
|
||||
return "UNSUPPORTED";
|
||||
}
|
||||
targetObj[key] = result;
|
||||
changesMade = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!Object.hasOwn(targetObj, key)) {
|
||||
if (checkOnly) {
|
||||
console.log(`Would copy key "${currentKeyPath}" (non-string) from English to ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Copying non-string values is meaningful
|
||||
} else {
|
||||
console.log(`Key "${currentKeyPath}" (non-string) missing in ${targetLangCode}.json. Copying from English.`);
|
||||
targetObj[key] = masterValue;
|
||||
}
|
||||
changesMade = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: Remove obsolete keys that exist in target but not in master
|
||||
const keysToRemove = [];
|
||||
for (const key in targetObj) {
|
||||
if (Object.hasOwn(targetObj, key)) {
|
||||
if (!Object.hasOwn(masterObj, key)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keysToRemove) {
|
||||
const currentKeyPath = [...currentPathParts, key].join('.');
|
||||
if (checkOnly) {
|
||||
console.log(`Would remove obsolete key "${currentKeyPath}" from ${targetLangCode}.json`);
|
||||
meaningfulChanges++; // Removing obsolete keys is meaningful
|
||||
} else {
|
||||
console.log(`🗑️ Removing obsolete key "${currentKeyPath}" from ${targetLangCode}.json`);
|
||||
delete targetObj[key];
|
||||
}
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
return checkOnly ? meaningfulChanges : changesMade;
|
||||
}
|
||||
|
||||
// --- Main synchronization ---
|
||||
async function syncAllTranslations() {
|
||||
const isSync = !checkOnly && !enforceOrder && !cleanupOnly;
|
||||
const isEnforceOrder = enforceOrder && !cleanupOnly;
|
||||
const isCleanup = cleanupOnly;
|
||||
|
||||
if (checkOnly) {
|
||||
console.log("--- Checking for translation changes (no translations will be performed) ---");
|
||||
} else if (isCleanup) {
|
||||
console.warn("--- Only obsolete keys will be removed - No translations, or reordering will happen ---");
|
||||
} else if (isEnforceOrder) {
|
||||
console.warn("--- Enforcing order of all files to match master language (no translations will be performed) ---");
|
||||
} else if (isSync) {
|
||||
console.warn("--- Using DeepL API for translation ---");
|
||||
}
|
||||
|
||||
if (!await fs.pathExists(masterLocaleFile)) {
|
||||
console.error(`Master locale file not found: ${masterLocaleFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const masterContent = await fs.readJson(masterLocaleFile);
|
||||
console.log(`Loaded master translations from ${masterLocaleFile}`);
|
||||
|
||||
let meaningfulChanges = 0;
|
||||
let hasMeaningfulChanges = false;
|
||||
|
||||
for (const targetFile of targetLocaleFiles) {
|
||||
const targetLangCode = path.basename(targetFile, '.json');
|
||||
let targetContent = {};
|
||||
let originalTargetContent = null;
|
||||
const fileExisted = await fs.pathExists(targetFile);
|
||||
|
||||
if (fileExisted) {
|
||||
try {
|
||||
originalTargetContent = await fs.readJson(targetFile);
|
||||
targetContent = JSON.parse(JSON.stringify(originalTargetContent)); // start from original
|
||||
} catch (e) {
|
||||
console.warn(`Warning: Could not parse ${targetFile}. Starting fresh. Error: ${e.message}`);
|
||||
targetContent = {};
|
||||
originalTargetContent = null;
|
||||
}
|
||||
} else {
|
||||
if (isCleanup) continue;
|
||||
console.log(`\nTarget file ${targetFile} not found. Will create for language: ${targetLangCode}.`);
|
||||
}
|
||||
|
||||
const result = await processKeys(masterContent, targetContent, targetLangCode, [], isEnforceOrder, isCleanup);
|
||||
|
||||
let reorderedContent = targetContent;
|
||||
let orderChanged = false;
|
||||
if (isEnforceOrder && originalTargetContent && !checkOnly) {
|
||||
reorderedContent = reorderObject(targetContent, masterContent);
|
||||
// Compare the reordered content with the original to see if order changed
|
||||
const origStr = JSON.stringify(originalTargetContent);
|
||||
const reorderedStr = JSON.stringify(reorderedContent);
|
||||
if (origStr !== reorderedStr) {
|
||||
orderChanged = true;
|
||||
console.log(`- Reordered keys in ${targetLangCode}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
if (checkOnly) {
|
||||
let wouldChange = false;
|
||||
if (typeof result === 'number' && result > 0) {
|
||||
meaningfulChanges += result;
|
||||
wouldChange = true;
|
||||
console.log(`Found ${result} meaningful changes needed for ${targetLangCode}.json`);
|
||||
}
|
||||
if (!fileExisted && !isCleanup) {
|
||||
wouldChange = true;
|
||||
console.log(`Would create new file for ${targetLangCode}.json`);
|
||||
}
|
||||
if (wouldChange) {
|
||||
hasMeaningfulChanges = true;
|
||||
}
|
||||
} else {
|
||||
const needsWrite = result || orderChanged || (!isCleanup && !fileExisted);
|
||||
if (needsWrite) {
|
||||
const finalContent = (isEnforceOrder && orderChanged) ? reorderedContent : targetContent;
|
||||
try {
|
||||
await fs.writeJson(targetFile, finalContent, { spaces: 2 });
|
||||
if (!isCleanup && !isEnforceOrder) {
|
||||
console.log(`Successfully ${result ? 'updated' : 'created'} ${targetFile}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error writing to ${targetFile}:`, error);
|
||||
}
|
||||
} else {
|
||||
console.log(`No changes needed for ${targetFile}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (checkOnly) {
|
||||
if (hasMeaningfulChanges) {
|
||||
console.log(`\n⚠️ Found ${meaningfulChanges} meaningful translation changes needed. -- update via "make sync-translations"`);
|
||||
return 1; // Exit code 1 for meaningful changes needed
|
||||
} else {
|
||||
console.log('\n✅ No meaningful translation changes needed.');
|
||||
return 0; // Exit code 0 for no meaningful changes
|
||||
}
|
||||
} else {
|
||||
if (isCleanup) {
|
||||
console.log('\n✅ Cleanup complete (obsolete keys removed).');
|
||||
} else if (isEnforceOrder) {
|
||||
console.log('\n✅ Enforce order complete.');
|
||||
} else {
|
||||
console.log('\n✅ Translation synchronization complete (via DeepL).');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
syncAllTranslations()
|
||||
.then(exitCode => {
|
||||
process.exit(exitCode);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("\n❌ An error occurred during translation synchronization:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
<script>
|
||||
import { onMounted } from "vue";
|
||||
import { mutations } from "@/store"; // Import your store's mutations
|
||||
import { scheduleFullIcons } from "@/utils/loadFullIcons";
|
||||
import { mutations } from "@/store/index.ts"; // Import your store's mutations
|
||||
import { scheduleFullIcons } from "@/utils/loadFullIcons.js";
|
||||
|
||||
mutations.setLoading("main-app", true);
|
||||
export default {
|
||||
|
||||
@@ -366,7 +366,7 @@ function passkeyActorPasswordCancelledError() {
|
||||
return err
|
||||
}
|
||||
|
||||
function promptPasskeyActorPassword(i18nKey) {
|
||||
function promptPasskeyActorPassword() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
const finish = (fn, value) => {
|
||||
@@ -382,7 +382,7 @@ function promptPasskeyActorPassword(i18nKey) {
|
||||
finish(reject, passkeyActorPasswordCancelledError())
|
||||
},
|
||||
props: {
|
||||
infoText: i18n.global.t(i18nKey || 'prompts.confirmPasswordToSaveUser'),
|
||||
infoText: i18n.global.t('prompts.confirmPasswordToSaveUser'),
|
||||
submitLabel: i18n.global.t('general.confirm'),
|
||||
submitCallback: async (actorPassword) => {
|
||||
finish(resolve, actorPassword)
|
||||
@@ -442,7 +442,7 @@ export async function beginPasskeyRegistration(options = {}) {
|
||||
if (!canRetry) {
|
||||
throw err
|
||||
}
|
||||
const actorPassword = await promptPasskeyActorPassword(options.actorPasswordPromptI18nKey)
|
||||
const actorPassword = await promptPasskeyActorPassword()
|
||||
return beginPasskeyRegistration({
|
||||
...options,
|
||||
actorPassword,
|
||||
@@ -479,7 +479,7 @@ export async function deletePasskeyCredential(credentialId, options = {}) {
|
||||
if (!canRetry) {
|
||||
throw err
|
||||
}
|
||||
const actorPassword = await promptPasskeyActorPassword(options.actorPasswordPromptI18nKey)
|
||||
const actorPassword = await promptPasskeyActorPassword()
|
||||
return deletePasskeyCredential(credentialId, {
|
||||
...options,
|
||||
actorPassword,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notify } from "@/notify/index.ts"
|
||||
import { getters, mutations, state } from "@/store/index.ts"
|
||||
import { globalVars } from "@/utils/constants.js"
|
||||
import { globalVars } from "@/utils/constants.ts"
|
||||
import { downloadManager } from "@/utils/downloadManager.ts"
|
||||
import {
|
||||
notifyDownloadComplete,
|
||||
@@ -34,7 +34,7 @@ function parseMockDataCount(value, defaultCount) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return defaultCount;
|
||||
}
|
||||
const n = parseInt(String(value), 10);
|
||||
const n = Math.trunc(Number(value));
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
return defaultCount;
|
||||
}
|
||||
@@ -804,7 +804,6 @@ async function downloadChunked(file, shareHash = "") {
|
||||
* @param {((percent: number) => void) | undefined} [onupload]
|
||||
* @param {Record<string, string | number>} [headers]
|
||||
* @param {boolean} [isDir]
|
||||
* @param {(() => void) | undefined} [onsent]
|
||||
* @returns {Promise<any> & { xhr?: XMLHttpRequest | null, abort?: () => void }}
|
||||
*/
|
||||
export function post(
|
||||
@@ -814,8 +813,7 @@ export function post(
|
||||
overwrite = false,
|
||||
onupload,
|
||||
headers = {},
|
||||
isDir = false,
|
||||
onsent
|
||||
isDir = false
|
||||
) {
|
||||
if (!source || source === undefined || source === null) {
|
||||
throw new Error('no source provided')
|
||||
@@ -865,10 +863,6 @@ export function post(
|
||||
}
|
||||
};
|
||||
}
|
||||
if (typeof onsent === "function") {
|
||||
// The whole body has been sent; from here the server answers when it is done saving it.
|
||||
request.upload.onload = () => onsent();
|
||||
}
|
||||
|
||||
request.onload = () => {
|
||||
if (request.status >= 200 && request.status < 300) {
|
||||
@@ -1072,21 +1066,6 @@ export async function checksum(source, path, algo) {
|
||||
throw new Error('no source provided')
|
||||
}
|
||||
try {
|
||||
if (getters.isShare()) {
|
||||
if (!getters.isLoggedIn()) {
|
||||
throw new Error('login required to generate checksums')
|
||||
}
|
||||
const hash = state.shareInfo?.hash
|
||||
if (!hash) {
|
||||
throw new Error('no share hash provided')
|
||||
}
|
||||
const params = { path, hash, checksum: algo }
|
||||
const res = await fetchURL(getPublicApiPath('resources', params), {
|
||||
headers: sharePublicAuthHeaders(hash),
|
||||
})
|
||||
const data = await res.json()
|
||||
return getObjectProperty(data.checksums, algo)
|
||||
}
|
||||
const params = {
|
||||
path: path,
|
||||
source: source,
|
||||
@@ -1432,7 +1411,6 @@ export function getTextPreviewURLPublic(path, viewToken = state.req?.viewToken)
|
||||
* @param {((percent: number) => void) | undefined} [onupload]
|
||||
* @param {Record<string, string | number>} [headers]
|
||||
* @param {boolean} [isDir]
|
||||
* @param {(() => void) | undefined} [onsent]
|
||||
* @returns {Promise<any> & { xhr?: XMLHttpRequest | null, abort?: () => void }}
|
||||
*/
|
||||
export function postPublic(
|
||||
@@ -1442,8 +1420,7 @@ export function postPublic(
|
||||
overwrite = false,
|
||||
onupload,
|
||||
headers = {},
|
||||
isDir = false,
|
||||
onsent
|
||||
isDir = false
|
||||
) {
|
||||
if (!hash || hash === undefined || hash === null) {
|
||||
throw new Error('no hash provided')
|
||||
@@ -1477,10 +1454,6 @@ export function postPublic(
|
||||
}
|
||||
};
|
||||
}
|
||||
if (typeof onsent === "function") {
|
||||
// The whole body has been sent; from here the server answers when it is done saving it.
|
||||
request.upload.onload = () => onsent();
|
||||
}
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
request.onload = () => {
|
||||
|
||||
@@ -33,7 +33,6 @@ export async function get(username, options = {}) {
|
||||
// POST /api/users (create user)
|
||||
// Password-login: tries without X-Password first; on 401 requiring X-Password, opens the prompt and retries.
|
||||
// options.skipActorPasswordConfirm / pre-set X-Password skip that flow.
|
||||
// options.actorPasswordPromptI18nKey — optional vue-i18n key (default: confirmPasswordToSaveUser).
|
||||
export async function create(user, options = {}) {
|
||||
const mergedHeaders = { ...(options.headers || {}) }
|
||||
const apiPath = getApiPath('users')
|
||||
@@ -64,13 +63,11 @@ export async function create(user, options = {}) {
|
||||
if (!needsActorPasswordRetry(e)) {
|
||||
throw e
|
||||
}
|
||||
const promptKey =
|
||||
options.actorPasswordPromptI18nKey || 'prompts.confirmPasswordToSaveUser'
|
||||
return new Promise((resolve, reject) => {
|
||||
mutations.showPrompt({
|
||||
name: 'password',
|
||||
props: {
|
||||
infoText: i18n.global.t(promptKey),
|
||||
infoText: i18n.global.t('prompts.confirmPasswordToSaveUser'),
|
||||
submitLabel: i18n.global.t('general.confirm'),
|
||||
submitCallback: async (actorPassword) => {
|
||||
try {
|
||||
@@ -96,7 +93,6 @@ export async function create(user, options = {}) {
|
||||
// PATCH /api/users (update user)
|
||||
// Password-login: tries without X-Password first; on 401 requiring X-Password, opens the prompt and retries.
|
||||
// options.skipActorPasswordConfirm / pre-set X-Password skip that flow.
|
||||
// options.actorPasswordPromptI18nKey — optional vue-i18n key (default: confirmPasswordToSaveUser).
|
||||
export async function update(user, which, options = {}) {
|
||||
if (!Array.isArray(which) || which.length === 0) {
|
||||
throw new Error('users.update requires a non-empty which array of field names')
|
||||
@@ -145,13 +141,11 @@ export async function update(user, which, options = {}) {
|
||||
if (!needsActorPasswordRetry(e)) {
|
||||
throw e
|
||||
}
|
||||
const promptKey =
|
||||
options.actorPasswordPromptI18nKey || 'prompts.confirmPasswordToSaveUser'
|
||||
return new Promise((resolve, reject) => {
|
||||
mutations.showPrompt({
|
||||
name: 'password',
|
||||
props: {
|
||||
infoText: i18n.global.t(promptKey),
|
||||
infoText: i18n.global.t('prompts.confirmPasswordToSaveUser'),
|
||||
submitLabel: i18n.global.t('general.confirm'),
|
||||
submitCallback: async (actorPassword) => {
|
||||
try {
|
||||
@@ -187,7 +181,6 @@ export async function patchPinnedItem({ source, path, name, action = 'add' }) {
|
||||
// DELETE /api/users (remove user)
|
||||
// Password-login: tries without X-Password first; on 401 requiring X-Password, opens the prompt and retries.
|
||||
// options.skipActorPasswordConfirm / pre-set X-Password skip that flow.
|
||||
// options.actorPasswordPromptI18nKey — optional vue-i18n key (default: confirmPasswordToSaveUser).
|
||||
export async function deleteUser(username, options = {}) {
|
||||
const mergedHeaders = { ...(options.headers || {}) }
|
||||
const apiPath = getApiPath('users', { username: username })
|
||||
@@ -209,13 +202,11 @@ export async function deleteUser(username, options = {}) {
|
||||
if (!needsActorPasswordRetry(e)) {
|
||||
throw e
|
||||
}
|
||||
const promptKey =
|
||||
options.actorPasswordPromptI18nKey || 'prompts.confirmPasswordToSaveUser'
|
||||
return new Promise((resolve, reject) => {
|
||||
mutations.showPrompt({
|
||||
name: 'password',
|
||||
props: {
|
||||
infoText: i18n.global.t(promptKey),
|
||||
infoText: i18n.global.t('prompts.confirmPasswordToSaveUser'),
|
||||
submitLabel: i18n.global.t('general.confirm'),
|
||||
submitCallback: async (actorPassword) => {
|
||||
try {
|
||||
|
||||
@@ -51,7 +51,7 @@ function readCacheForScope(scope) {
|
||||
if (!parsed?.viewToken || !parsed?.expiresAt) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
return { viewToken: parsed.viewToken, expiresAt: parsed.expiresAt };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
close: multistate === 'close',
|
||||
}"
|
||||
>
|
||||
<title id="toggle-navbar-title">{{ $t('general.close') }}</title>
|
||||
<title id="toggle-navbar-title">{{ t('general.close') }}</title>
|
||||
<path class="line top" d="M 30,33 H 70" />
|
||||
<path class="line middle" d="M 30,50 H 70" />
|
||||
<path class="line bottom" d="M 30,67 H 70" />
|
||||
@@ -56,8 +56,9 @@ export function actionIconClass(icon: string, customIconClass?: string): string
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { getters, mutations } from "@/store";
|
||||
import type { Prompt } from "@/store/types";
|
||||
import { getters, mutations } from "@/store/index.ts";
|
||||
import type { Prompt } from "@/store/types.ts";
|
||||
import { useI18n } from "@/i18n/index.ts";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -82,6 +83,8 @@ const emit = defineEmits<{
|
||||
action: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const multistate = computed(() => getters.multibuttonState());
|
||||
const iconClass = computed(() => actionIconClass(props.icon, props.customIconClass));
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
"button-clicked": (_value) => true,
|
||||
"remove-button-clicked": (_value) => true,
|
||||
disableAll: () => true,
|
||||
enableAll: () => true,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeButton: null, // Initially no button is active
|
||||
@@ -77,12 +83,11 @@ export default {
|
||||
this.$emit("remove-button-clicked", value);
|
||||
} else {
|
||||
// Emit remove-button-clicked for all other indexes
|
||||
this.buttons.forEach((button, idx) => {
|
||||
for (const [idx, button] of this.buttons.entries()) {
|
||||
if (idx !== index) {
|
||||
this.$emit("remove-button-clicked", button.value);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
this.activeButton = index;
|
||||
this.$emit("button-clicked", value);
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { store } = vi.hoisted(() => ({ store: { tool: null, showSelectMultiple: false, mobile: false } }));
|
||||
vi.mock("@/store", () => ({
|
||||
state: {
|
||||
get user() {
|
||||
return { showSelectMultiple: store.showSelectMultiple };
|
||||
},
|
||||
},
|
||||
mutations: {},
|
||||
getters: {
|
||||
currentTool: () => store.tool,
|
||||
isMobile: () => store.mobile,
|
||||
},
|
||||
}));
|
||||
vi.mock("@/api", () => ({ resourcesApi: {}, shareApi: {}, usersApi: {} }));
|
||||
vi.mock("@/components/Action.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/notify", () => ({ notify: {} }));
|
||||
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
|
||||
vi.mock("@/utils", () => ({ url: {} }));
|
||||
vi.mock("@/utils/clipboard", () => ({ copyToClipboard: vi.fn() }));
|
||||
vi.mock("@/utils/constants.js", () => ({ globalVars: {} }));
|
||||
vi.mock("@/utils/download", () => ({ default: vi.fn() }));
|
||||
vi.mock("@/utils/nativeShare", () => ({ canNativeShare: vi.fn(), nativeShareFile: vi.fn() }));
|
||||
vi.mock("@/utils/mimetype", () => ({ isRichTextPreviewMimeType: vi.fn(), getTypeInfoFromExt: vi.fn() }));
|
||||
vi.mock("@/utils/mediaFile", () => ({ isMediaRequest: vi.fn() }));
|
||||
vi.mock("@/utils/expandTransition", () => ({ expandBeforeEnter: vi.fn(), expandEnter: vi.fn(), expandLeave: vi.fn() }));
|
||||
import ContextMenu from "./ContextMenu.vue";
|
||||
|
||||
const { computed } = ContextMenu;
|
||||
const inAdvancedSearch = () => computed.isAdvancedSearch.call({});
|
||||
|
||||
describe("ContextMenu in advanced search", () => {
|
||||
beforeEach(() => {
|
||||
store.tool = null;
|
||||
store.showSelectMultiple = false;
|
||||
store.mobile = false;
|
||||
});
|
||||
|
||||
it("detects the advanced search tool", () => {
|
||||
expect(inAdvancedSearch()).toBe(false);
|
||||
store.tool = { component: "DuplicateFinder" };
|
||||
expect(inAdvancedSearch()).toBe(false);
|
||||
store.tool = { component: "AdvancedSearch" };
|
||||
expect(inAdvancedSearch()).toBe(true);
|
||||
});
|
||||
|
||||
it("offers Select multiple on desktop even when the user preference is off", () => {
|
||||
const menu = (isAdvancedSearch) => ({ showLimitedOptions: false, isMultiple: false, isSearchActive: false, isAdvancedSearch });
|
||||
expect(computed.showSelectMultiple.call(menu(false))).toBe(false);
|
||||
expect(computed.showSelectMultiple.call(menu(true))).toBe(true);
|
||||
});
|
||||
|
||||
it("doesn't offer Select multiple once multiple mode is on", () => {
|
||||
expect(
|
||||
computed.showSelectMultiple.call({ showLimitedOptions: false, isMultiple: true, isSearchActive: false, isAdvancedSearch: true }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the create (+) toggle, since results have no single folder to create in", () => {
|
||||
const toggle = (isAdvancedSearch) =>
|
||||
computed.showCreateToggle.call({ showLimitedOptions: false, createOnly: false, isSearchActive: false, isAdvancedSearch });
|
||||
expect(toggle(false)).toBe(true);
|
||||
expect(toggle(true)).toBe(false);
|
||||
});
|
||||
|
||||
it("hides Share when nothing is selected, since the results listing has no folder to share", () => {
|
||||
const share = (isAdvancedSearch, selectedCount) =>
|
||||
computed.showShareAction.call({ showLimitedOptions: false, isAdvancedSearch, selectedCount, showShare: true });
|
||||
expect(share(true, 0)).toBe(false);
|
||||
expect(share(true, 1)).toBe(true);
|
||||
expect(share(true, 2)).toBe(false);
|
||||
expect(share(false, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { store } = vi.hoisted(() => ({
|
||||
store: { share: false, creatable: [], user: { scopes: [{ name: "data" }, { name: "inbox" }] } },
|
||||
}));
|
||||
vi.mock("@/store", () => ({
|
||||
state: { get user() { return store.user; } },
|
||||
mutations: {},
|
||||
getters: {
|
||||
isShare: () => store.share,
|
||||
canCreateInSource: (source) => store.creatable.includes(source),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/api", () => ({ resourcesApi: {}, shareApi: {}, usersApi: {} }));
|
||||
vi.mock("@/components/Action.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/notify", () => ({ notify: {} }));
|
||||
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
|
||||
vi.mock("@/utils", () => ({ url: {} }));
|
||||
vi.mock("@/utils/clipboard", () => ({ copyToClipboard: vi.fn() }));
|
||||
vi.mock("@/utils/constants.js", () => ({ globalVars: {} }));
|
||||
vi.mock("@/utils/download", () => ({ default: vi.fn() }));
|
||||
vi.mock("@/utils/nativeShare", () => ({ canNativeShare: vi.fn(), nativeShareFile: vi.fn() }));
|
||||
vi.mock("@/utils/mimetype", () => ({ isRichTextPreviewMimeType: vi.fn(), getTypeInfoFromExt: vi.fn() }));
|
||||
vi.mock("@/utils/mediaFile", () => ({ isMediaRequest: vi.fn() }));
|
||||
vi.mock("@/utils/expandTransition", () => ({ expandBeforeEnter: vi.fn(), expandEnter: vi.fn(), expandLeave: vi.fn() }));
|
||||
import ContextMenu from "./ContextMenu.vue";
|
||||
|
||||
const showCopy = (permissions) =>
|
||||
ContextMenu.computed.showCopy.call({ showLimitedOptions: false, showCreate: false, selectedCount: 1, permissions });
|
||||
|
||||
describe("ContextMenu Copy", () => {
|
||||
beforeEach(() => {
|
||||
store.share = false;
|
||||
store.creatable = ["inbox"];
|
||||
});
|
||||
|
||||
it("is offered in a read-only source, since copying only reads it", () => {
|
||||
expect(showCopy({ download: true, modify: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("is hidden when no source accepts the copy", () => {
|
||||
store.creatable = [];
|
||||
expect(showCopy({ download: true, modify: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("is hidden without download", () => {
|
||||
expect(showCopy({ download: false, modify: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("in a share, still requires modify, which the server checks for every change", () => {
|
||||
store.share = true;
|
||||
expect(showCopy({ download: true, modify: false })).toBe(false);
|
||||
expect(showCopy({ download: true, modify: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,10 @@ vi.mock("@/store", () => ({
|
||||
isSplitViewActive: () => false,
|
||||
},
|
||||
}));
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("@/i18n/index.ts", () => ({
|
||||
default: { global: { t: (key: string) => key } },
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("vue-router", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }));
|
||||
vi.mock("@/api", () => ({ resourcesApi: {}, shareApi: {}, usersApi: {} }));
|
||||
vi.mock("@/notify", () => ({ notify: {} }));
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
<hr class="divider">
|
||||
<p v-if="newFileTemplates.length === 0 && isLoggedIn" class="context-menu-empty-message">
|
||||
{{ $t('prompts.noTemplate') }}
|
||||
{{ t('prompts.noTemplate') }}
|
||||
</p>
|
||||
<button
|
||||
v-for="(item, index) in newFileTemplateItems"
|
||||
@@ -39,12 +39,12 @@
|
||||
<button
|
||||
type="button"
|
||||
class="action no-select"
|
||||
:aria-label="$t('files.emptyFile')"
|
||||
:title="$t('files.emptyFile')"
|
||||
:aria-label="t('files.emptyFile')"
|
||||
:title="t('files.emptyFile')"
|
||||
@click="createEmptyFile"
|
||||
>
|
||||
<i :class="actionIconClass('insert_drive_file')">insert_drive_file</i>
|
||||
<span>{{ $t('files.emptyFile') }}</span>
|
||||
<span>{{ t('files.emptyFile') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -66,7 +66,7 @@
|
||||
<div
|
||||
v-if="selectedCount > 0"
|
||||
@mouseleave="hideInteractiveTooltip()"
|
||||
@mouseenter="showHoverTooltip($t('buttons.selectedCount'), $event)"
|
||||
@mouseenter="showHoverTooltip(t('buttons.selectedCount'), $event)"
|
||||
class="button selected-count-header"
|
||||
:class="{ 'selected-count-header--circle': selectedCount <= 99 }"
|
||||
>
|
||||
@@ -112,21 +112,21 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, onBeforeUnmount, onMounted, ref, toRaw, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useI18n } from "@/i18n/index.ts";
|
||||
import { useRouter } from "vue-router";
|
||||
import { resourcesApi, shareApi, usersApi } from "@/api";
|
||||
import { notify } from "@/notify";
|
||||
import { getters, mutations, state } from "@/store/index.js";
|
||||
import { resourcesApi, shareApi, usersApi } from "@/api/index.js";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import type { FileListItem, ReqObject } from "@/store/types.ts";
|
||||
import { hideInteractiveTooltip, showHoverTooltip } from "@/utils/tooltipHelp.js";
|
||||
import { goToItem, getParentDir, removeLastDir } from "@/utils/url.js";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
import downloadFiles from "@/utils/download";
|
||||
import { canNativeShare, nativeShareFile } from "@/utils/nativeShare";
|
||||
import { isRichTextPreviewMimeType, getTypeInfoFromExt } from "@/utils/mimetype";
|
||||
import { isMediaRequest } from "@/utils/mediaFile";
|
||||
import { copyToClipboard } from "@/utils/clipboard.js";
|
||||
import downloadFiles from "@/utils/download.js";
|
||||
import { canNativeShare, nativeShareFile } from "@/utils/nativeShare.js";
|
||||
import { isRichTextPreviewMimeType, getTypeInfoFromExt } from "@/utils/mimetype.js";
|
||||
import { isMediaRequest } from "@/utils/mediaFile.js";
|
||||
import { actionIconClass } from "@/components/Action.vue";
|
||||
import { popEnter, popLeave } from "@/utils/transitions";
|
||||
import { popEnter, popLeave } from "@/utils/transitions.ts";
|
||||
|
||||
type MenuItem = FileListItem & { isDir?: boolean; from?: string };
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
createLightningStrike,
|
||||
prefersReducedMotion,
|
||||
type LightningStrike,
|
||||
} from "@/utils/lightning-bolt";
|
||||
} from "@/utils/lightning-bolt.ts";
|
||||
|
||||
type Bolt = LightningStrike & { id: number };
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { notify } from "@/notify";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
|
||||
export default {
|
||||
name: "load-failed",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
@keydown.esc.prevent="$emit('close')"
|
||||
/>
|
||||
</div>
|
||||
<div ref="scroll" class="menu-option-list-scroll" :style="scrollStyle">
|
||||
<div class="menu-option-list-scroll" :style="scrollStyle">
|
||||
<button
|
||||
v-for="option in filteredOptions"
|
||||
:key="optionKey(option.value)"
|
||||
@@ -24,7 +24,7 @@
|
||||
}"
|
||||
:aria-selected="isSelected(option.value) ? 'true' : 'false'"
|
||||
:disabled="option.disabled"
|
||||
@click="$emit('select', option)"
|
||||
@click="$emit('option-select', option)"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
</button>
|
||||
@@ -69,9 +69,11 @@ export default {
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ["select", "update:searchQuery", "close"],
|
||||
|
||||
emits: {
|
||||
"option-select": (_option) => true,
|
||||
"update:searchQuery": (_query) => true,
|
||||
close: () => true,
|
||||
},
|
||||
computed: {
|
||||
resolvedSearchPlaceholder() {
|
||||
return this.searchPlaceholder || this.$t("general.search");
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { notify } from "@/notify";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
|
||||
export default {
|
||||
name: "notifications",
|
||||
|
||||
@@ -47,12 +47,15 @@ export default {
|
||||
default: 0,
|
||||
},
|
||||
max: {
|
||||
type: [Number, String],
|
||||
default: 100,
|
||||
},
|
||||
"val-background": {
|
||||
valBackground: {
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
"val-text": {
|
||||
valText: {
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
unit: {
|
||||
@@ -60,23 +63,24 @@ export default {
|
||||
default: "",
|
||||
},
|
||||
size: {
|
||||
type: [Number, String],
|
||||
// either a number (pixel width/height) or 'tiny', 'small',
|
||||
// 'medium', 'large', 'huge', 'massive' for common sizes
|
||||
default: "big",
|
||||
},
|
||||
"bg-color": {
|
||||
bgColor: {
|
||||
type: String,
|
||||
default: "#eee",
|
||||
},
|
||||
"bar-color": {
|
||||
barColor: {
|
||||
type: String,
|
||||
default: "var(--primaryColor)", // match .blue color to Material Design's 'Blue 500' color
|
||||
},
|
||||
"bar-transition": {
|
||||
barTransition: {
|
||||
type: String,
|
||||
default: "all 0.5s ease",
|
||||
},
|
||||
"bar-border-radius": {
|
||||
barBorderRadius: {
|
||||
type: Number,
|
||||
default: 8, // ~0.5em at default font size
|
||||
},
|
||||
@@ -88,19 +92,19 @@ export default {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
"text-align": {
|
||||
textAlign: {
|
||||
type: String,
|
||||
default: "center", // 'left', 'right'
|
||||
},
|
||||
"text-position": {
|
||||
textPosition: {
|
||||
type: String,
|
||||
default: "inside", // 'bottom', 'top', 'middle', 'inside'
|
||||
},
|
||||
"font-size": {
|
||||
fontSize: {
|
||||
type: Number,
|
||||
default: 13,
|
||||
},
|
||||
"text-fg-color": {
|
||||
textFgColor: {
|
||||
type: String,
|
||||
default: "#000",
|
||||
},
|
||||
@@ -108,7 +112,7 @@ export default {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
"help-text": {
|
||||
helpText: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
@@ -133,7 +137,7 @@ export default {
|
||||
displayed_text() {
|
||||
// Use valText if provided (for hybrid mode text override), otherwise use val
|
||||
const displayVal = this.valText !== null ? this.valText : this.val;
|
||||
|
||||
|
||||
if (!isNumber(displayVal)) return displayVal;
|
||||
|
||||
// Cap % like the bar fill so nested-mount indexed size > partition total
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<!-- Search input section -->
|
||||
<div class="search-input-container">
|
||||
<!-- Close button visible when search is active -->
|
||||
<button v-if="active" type="button" class="action" @click="close" :aria-label="$t('general.close')"
|
||||
:title="$t('general.close')">
|
||||
<button v-if="active" type="button" class="action" @click="close" :aria-label="t('general.close')"
|
||||
:title="t('general.close')">
|
||||
<i class="material-symbols">close</i>
|
||||
</button>
|
||||
<!-- Search icon when search is not active -->
|
||||
@@ -12,13 +12,13 @@
|
||||
<!-- Input field for search -->
|
||||
<input id="search-input" type="text"
|
||||
@keyup.exact="keyup" @input="onInput" ref="input" :autofocus="active" v-model.trim="value"
|
||||
aria-label="search input" :placeholder="$t('general.search', { suffix: '...' })" />
|
||||
aria-label="search input" :placeholder="t('general.search', { suffix: '...' })" />
|
||||
<div v-if="eventTheme === 'halloween'" class="halloween-eyes" aria-hidden="true">
|
||||
<div class="eye"><div class="pupil"></div></div>
|
||||
<div class="eye right"><div class="pupil"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="active" id="results" ref="result">
|
||||
<div v-show="active" id="results">
|
||||
<div class="inputWrapper">
|
||||
<ExpandDropdown
|
||||
v-if="multipleSources"
|
||||
@@ -27,13 +27,13 @@
|
||||
transparent
|
||||
:options="searchSourceOptions"
|
||||
all-value="__all__"
|
||||
:all-selected-label="$t('general.all')"
|
||||
:all-selected-label="t('general.all')"
|
||||
aria-label="search sources dropdown"
|
||||
@update:model-value="updateSource"
|
||||
/>
|
||||
|
||||
<!-- Formatted display of selected value -->
|
||||
<div class="searchContext">{{ $t("search.searchContext", { context: getContext }) }}</div>
|
||||
<div class="searchContext">{{ t("search.searchContext", { context: getContext }) }}</div>
|
||||
</div>
|
||||
|
||||
<div id="result-list" ref="resultListEl">
|
||||
@@ -48,8 +48,8 @@
|
||||
<ToggleSwitch
|
||||
class="item"
|
||||
v-model="showPreviewImages"
|
||||
:name="$t('search.showPreviewImages')"
|
||||
:description="$t('search.showPreviewImagesDescription')"
|
||||
:name="t('search.showPreviewImages')"
|
||||
:description="t('search.showPreviewImagesDescription')"
|
||||
/>
|
||||
</template>
|
||||
</SearchFilters>
|
||||
@@ -90,7 +90,6 @@
|
||||
:thumbnail-url="showPreviewImages ? getThumbnailUrl(s) : ''"
|
||||
/>
|
||||
<span class="text-container">
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
{{ basePath(s.path, s.type === "directory") }}{{ baseName(s.path) }}
|
||||
</span>
|
||||
<div class="filesize">{{ humanSize(s.size) }}</div>
|
||||
@@ -105,7 +104,7 @@
|
||||
class="button open-advanced-search-button"
|
||||
@click.stop.prevent="openInAdvancedSearch"
|
||||
>
|
||||
{{ $t("search.openInAdvancedSearch") }}
|
||||
{{ t("search.openInAdvancedSearch") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,26 +112,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch, type Ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useListingContextMenu } from "@/composables/useListingContextMenu";
|
||||
import { useSearchFilters } from "@/composables/useSearchFilters";
|
||||
import { useSearchRequest } from "@/composables/useSearchRequest";
|
||||
import { resourcesApi, toolsApi } from "@/api";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, useTemplateRef, watch, type Ref } from "vue";
|
||||
import { useI18n } from "@/i18n/index.ts";
|
||||
import { useListingContextMenu } from "@/composables/useListingContextMenu.ts";
|
||||
import { useSearchFilters } from "@/composables/useSearchFilters.ts";
|
||||
import { useSearchRequest } from "@/composables/useSearchRequest.ts";
|
||||
import { resourcesApi, toolsApi } from "@/api/index.js";
|
||||
import HelpTooltipIcon from "@/components/HelpTooltipIcon.vue";
|
||||
import Icon from "@/components/files/Icon.vue";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import SearchFilters from "@/components/tools/SearchFilters.vue";
|
||||
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import { router } from "@/router";
|
||||
import { getters, mutations, state } from "@/store";
|
||||
import type { FileListItem } from "@/store/types";
|
||||
import { url } from "@/utils/";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { router } from "@/router/index.ts";
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import type { FileListItem } from "@/store/types.ts";
|
||||
import { url } from "@/utils/index.ts";
|
||||
import { globalVars } from "@/utils/constants.ts";
|
||||
import { debounce } from "@/utils/debounce.js";
|
||||
import { sortedItems } from "@/utils/sort.ts";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
|
||||
import { isTypeAheadSessionActive } from "@/utils/listingTypeAhead.js";
|
||||
import { shouldIgnoreOutsideTap } from "@/utils/tooltipHelp.js";
|
||||
|
||||
@@ -150,8 +149,8 @@ interface SearchResult {
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const resultListEl = ref<HTMLElement | null>(null);
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
const resultListEl = useTemplateRef<HTMLElement>("resultListEl");
|
||||
const input = useTemplateRef<HTMLInputElement>("input");
|
||||
const noneMessage = ref(t("search.typeToSearch", { minSearchLength: globalVars.minSearchLength }));
|
||||
const showPreviewImages = ref(false);
|
||||
const advancedOptionsExpanded = ref(false);
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
import Breadcrumbs from "@/components/files/Breadcrumbs.vue";
|
||||
import ListingHeader from "@/components/files/ListingHeader.vue";
|
||||
import DuplicateFinderActions from "@/components/tools/DuplicateFinderActions.vue";
|
||||
import { getters, state } from "@/store";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
import { getters, state } from "@/store/index.ts";
|
||||
import { eventBus } from "@/store/eventBus.ts";
|
||||
|
||||
export default {
|
||||
name: "Shelf",
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
max="24"
|
||||
step="1"
|
||||
/>
|
||||
<span class="size-label">{{ editorFontSize }}px</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<!-- i18n-ignore -->
|
||||
<span class="size-label">{{ editorFontSize }}px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,8 +45,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getters, mutations, state } from "@/store";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
|
||||
|
||||
export default {
|
||||
name: "StatusBar",
|
||||
|
||||
@@ -12,14 +12,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getIconClass } from "@/utils/material-symbols";
|
||||
import { getIconClass } from "@/utils/material-symbols.js";
|
||||
|
||||
export default {
|
||||
name: "Toast",
|
||||
props: {
|
||||
toasts: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,16 +19,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from "vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { state } from "@/store";
|
||||
import { state } from "@/store/index.ts";
|
||||
import { hideInteractiveTooltip } from "@/utils/tooltipHelp.js";
|
||||
|
||||
const margin = 15;
|
||||
const adjustedX = ref(0);
|
||||
const adjustedY = ref(0);
|
||||
const tooltipRef = ref<HTMLElement | null>(null);
|
||||
const tooltipRef = useTemplateRef<HTMLElement>("tooltipRef");
|
||||
const route = useRoute();
|
||||
|
||||
const tooltip = computed(() => state.tooltip);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
position-mode="absolute"
|
||||
:edge-offset="{ top: '0.5em', right: '0.5em' }"
|
||||
:auto-hide="false"
|
||||
@click="lyricsScrollLocked = !lyricsScrollLocked"
|
||||
@press="lyricsScrollLocked = !lyricsScrollLocked"
|
||||
:label="lyricsLockToggleLabel"
|
||||
/>
|
||||
<!-- Scrollable area -->
|
||||
@@ -61,7 +61,7 @@
|
||||
position-mode="absolute"
|
||||
:edge-offset="{ top: '0.5em', right: '0.5em' }"
|
||||
:auto-hide="false"
|
||||
@click="showVisualizerSettings"
|
||||
@press="showVisualizerSettings"
|
||||
:label="visualizerSettingsLabel"
|
||||
/>
|
||||
<canvas ref="visualizerCanvas" class="visualizer-canvas"></canvas>
|
||||
@@ -74,7 +74,7 @@
|
||||
import SlidingTabs from "@/components/settings/SlidingTabs.vue";
|
||||
import PlaybackQueue from "@/components/prompts/PlaybackQueue.vue";
|
||||
import FloatingActionButton from "@/components/settings/FloatingActionButton.vue";
|
||||
import { mutations, state } from "@/store";
|
||||
import { mutations, state } from "@/store/index.ts";
|
||||
import { visualizerConfig } from "@/utils/visualizerConfig.js";
|
||||
|
||||
const LAST_TAB_KEY = 'plyrSidePanelActiveTab';
|
||||
@@ -111,7 +111,10 @@ export default {
|
||||
audioContext: { type: Object, default: null },
|
||||
audioSource: { type: Object, default: null }, // MediaElementAudioSourceNode, see https://developer.mozilla.org/en-US/docs/Web/API/MediaElementAudioSourceNode
|
||||
},
|
||||
emits: ["seek", "needs-audio-graph"],
|
||||
emits: {
|
||||
seek: (timestamp) => typeof timestamp === "number",
|
||||
"needs-audio-graph": () => true,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeTab: (() => {
|
||||
@@ -119,6 +122,7 @@ export default {
|
||||
return stored && ['queue', 'lyrics', 'visualizer'].includes(stored) ? stored : 'queue';
|
||||
})(),
|
||||
lyricsScrollLocked: false,
|
||||
resizeObserver: null,
|
||||
// Visualizer state
|
||||
visualizerAnalyserLeft: null,
|
||||
visualizerAnalyserRight: null,
|
||||
@@ -244,19 +248,8 @@ export default {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
if (this.activeTab === 'visualizer' && this.visualizerAnalyserLeft) this.resizeVisualizer();
|
||||
});
|
||||
this.windowResizeHandler = () => {
|
||||
if (this.activeTab === 'visualizer' && this.visualizerAnalyserLeft) this.resizeVisualizer();
|
||||
};
|
||||
window.addEventListener('resize', this.windowResizeHandler);
|
||||
this.visibilityChangeHandler = () => {
|
||||
if (this.activeTab !== 'visualizer') return;
|
||||
if (document.hidden) {
|
||||
this.stopVisualizer();
|
||||
} else {
|
||||
this.startVisualizer();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', this.visibilityChangeHandler);
|
||||
window.addEventListener('resize', this.onWindowResize);
|
||||
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||
await this.$nextTick();
|
||||
const container = this.$el?.querySelector('.tab-visualizer');
|
||||
if (container) this.resizeObserver.observe(container);
|
||||
@@ -264,12 +257,23 @@ export default {
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('keydown', this.onKeyDown);
|
||||
this.resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', this.windowResizeHandler);
|
||||
document.removeEventListener('visibilitychange', this.visibilityChangeHandler);
|
||||
window.removeEventListener('resize', this.onWindowResize);
|
||||
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
this.stopVisualizer();
|
||||
this.fullCleanup();
|
||||
},
|
||||
methods: {
|
||||
onWindowResize() {
|
||||
if (this.activeTab === 'visualizer' && this.visualizerAnalyserLeft) this.resizeVisualizer();
|
||||
},
|
||||
onVisibilityChange() {
|
||||
if (this.activeTab !== 'visualizer') return;
|
||||
if (document.hidden) {
|
||||
this.stopVisualizer();
|
||||
} else {
|
||||
this.startVisualizer();
|
||||
}
|
||||
},
|
||||
scrollToActiveLine() {
|
||||
if (this.lyricsScrollLocked) return;
|
||||
const el = this.$refs.lyricsScrollable;
|
||||
|
||||
@@ -32,13 +32,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { resourcesApi } from "@/api";
|
||||
import { notify } from "@/notify";
|
||||
import { getters, mutations, state } from "@/store";
|
||||
import { url } from "@/utils";
|
||||
import { resourcesApi } from "@/api/index.js";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import { url } from "@/utils/index.ts";
|
||||
import { getObjectProperty } from '@/utils/object.js';
|
||||
import { goToItemNotificationButton } from "@/utils/notificationActions";
|
||||
import { notifyMoveCopyFailure } from "@/utils/appNotifications";
|
||||
import { goToItemNotificationButton } from "@/utils/notificationActions.js";
|
||||
import { notifyMoveCopyFailure } from "@/utils/appNotifications.js";
|
||||
|
||||
export default {
|
||||
name: "breadcrumbs",
|
||||
@@ -48,7 +48,12 @@ export default {
|
||||
dragOverItem: null, // Track which breadcrumb is being dragged over
|
||||
};
|
||||
},
|
||||
props: ["noLink"],
|
||||
props: {
|
||||
noLink: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.updatePaths();
|
||||
window.addEventListener('dragend', this.clearDragState);
|
||||
|
||||
@@ -104,8 +104,8 @@
|
||||
ref="extraMenuTrigger"
|
||||
type="button"
|
||||
class="editor-toolbar-btn"
|
||||
:title="$t('editor.md.moreOptions')"
|
||||
:aria-label="$t('editor.md.moreOptions')"
|
||||
:title="t('editor.md.moreOptions')"
|
||||
:aria-label="t('editor.md.moreOptions')"
|
||||
@mousedown.prevent
|
||||
@click="toggleMenu('extra')"
|
||||
>
|
||||
@@ -134,16 +134,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, useTemplateRef, watch } from "vue";
|
||||
import { useI18n } from "@/i18n/index.ts";
|
||||
import ace from "ace-builds";
|
||||
import type { Ace } from "ace-builds";
|
||||
import { mutations, state, getters } from "@/store";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
import { mutations, state, getters } from "@/store/index.ts";
|
||||
import { eventBus } from "@/store/eventBus.ts";
|
||||
import { removeLastDir } from "@/utils/url.js";
|
||||
import { popEnter, popLeave } from "@/utils/transitions.ts";
|
||||
import { copyToClipboard } from "@/utils/clipboard.js";
|
||||
import { notify } from "@/notify";
|
||||
import { notify } from "@/notify/index.ts";
|
||||
|
||||
interface AnchorRange {
|
||||
start: Ace.Anchor;
|
||||
@@ -221,8 +221,8 @@ const lastColors = reactive(new Map<string, string>([
|
||||
const colorInputRefs = new Map<string, HTMLInputElement>();
|
||||
const iconMenuTriggerEls = new Map<"align" | "clipboard", HTMLElement>();
|
||||
const iconMenuEls = new Map<"align" | "clipboard", HTMLElement>();
|
||||
const extraMenuTrigger = ref<HTMLElement | null>(null);
|
||||
const extraMenu = ref<HTMLElement | null>(null);
|
||||
const extraMenuTrigger = useTemplateRef<HTMLElement>("extraMenuTrigger");
|
||||
const extraMenu = useTemplateRef<HTMLElement>("extraMenu");
|
||||
|
||||
const toolbarButtons = computed((): ToolbarButton[] => {
|
||||
// alwaysAvailable are for buttons that will be available for all filetypes, not just markdown.
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
v-if="thumbnailSrc && !fullImageLoaded && !isTiff"
|
||||
:src="thumbnailSrc"
|
||||
class="image-ex-img"
|
||||
ref="thumbnail"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
@@ -38,13 +37,13 @@
|
||||
|
||||
<script>
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import { notify } from "@/notify";
|
||||
import { getters, mutations, state } from '@/store';
|
||||
import { notify } from "@/notify/index.ts";
|
||||
import { getters, mutations, state } from "@/store/index.ts";
|
||||
import { acquireBestCachedThumbnailBlob, getBestCachedImage, releaseThumbnailBlob } from "@/utils/cache/imageCache.ts";
|
||||
import { isRawImageMimeType } from "@/utils/mimetype";
|
||||
import { resourcesApi } from "@/api";
|
||||
import { isRawImageMimeType } from "@/utils/mimetype.js";
|
||||
import { resourcesApi } from "@/api/index.js";
|
||||
import { refreshViewToken, requestViewIdentity } from "@/api/viewToken.js";
|
||||
import throttle from "@/utils/throttle";
|
||||
import throttle from "@/utils/throttle.js";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -56,8 +55,14 @@ export default {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
"navigate-previous": () => true,
|
||||
"navigate-next": () => true,
|
||||
"close-preview": () => true,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
thumbBlobKey: null, // only read in lifecycle/sync code, never during render
|
||||
moveDisabledTime: 200,
|
||||
classList: [],
|
||||
zoomStep: 0.25,
|
||||
@@ -213,9 +218,6 @@ export default {
|
||||
return this.edgeDy >= this.edgeCommitY && ay >= ax;
|
||||
},
|
||||
},
|
||||
beforeCreate() {
|
||||
this.thumbBlobKey = null;
|
||||
},
|
||||
created() {
|
||||
this.syncThumbnailBlob();
|
||||
},
|
||||
@@ -525,7 +527,7 @@ export default {
|
||||
this.showDismissHint = false;
|
||||
} else {
|
||||
this.dragOffsetX = 0;
|
||||
const downward = this.edgeDy > 0 ? this.edgeDy : 0;
|
||||
const downward = Math.max(this.edgeDy, 0);
|
||||
this.dragOffsetY = this.rubberband(downward, this.edgeRubberMax);
|
||||
this.showDismissHint = this.edgeDy >= this.edgeHintPx;
|
||||
this.showNavHint = false;
|
||||
@@ -549,7 +551,7 @@ export default {
|
||||
this.showDismissHint = false;
|
||||
} else {
|
||||
this.dragOffsetX = 0;
|
||||
const downward = this.edgeDy > 0 ? this.edgeDy : 0;
|
||||
const downward = Math.max(this.edgeDy, 0);
|
||||
this.dragOffsetY = this.rubberband(downward, this.edgeRubberMax);
|
||||
this.showDismissHint = this.edgeDy >= this.edgeHintPx;
|
||||
this.showNavHint = false;
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { createApp, h, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchFiles, state } = vi.hoisted(() => ({
|
||||
fetchFiles: vi.fn(),
|
||||
state: { req: {}, prompts: [], selected: [], sources: { info: { data: {} } } },
|
||||
}));
|
||||
vi.mock("@/api", () => ({ resourcesApi: { fetchFiles } }));
|
||||
vi.mock("@/store", () => ({
|
||||
state,
|
||||
mutations: {},
|
||||
getters: {
|
||||
isShare: () => false,
|
||||
isMobile: () => false,
|
||||
currentPromptName: () => null,
|
||||
pickerSorting: () => ({ by: "name", asc: true }),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/utils", async () => {
|
||||
const { removeLastDir } = await import("@/utils/url.js");
|
||||
return { url: { removeLastDir } };
|
||||
});
|
||||
vi.mock("@/router/index.ts", () => ({ router: {} }));
|
||||
vi.mock("@/components/files/ListingHeader.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/files/ListingItem.vue", () => ({
|
||||
default: {
|
||||
props: ["name"],
|
||||
render() {
|
||||
return h("button", {}, this.name);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import FileList from "./FileList.vue";
|
||||
|
||||
let app, root;
|
||||
const selection = vi.fn();
|
||||
function mount(items = [], path = "/child/") {
|
||||
state.req = { source: "data", path, items };
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(FileList, { hidePathChrome: true, "onUpdate:selected": selection });
|
||||
app.config.globalProperties.$t = (key) => key;
|
||||
app.mount(root);
|
||||
}
|
||||
|
||||
function row(name) {
|
||||
return Array.from(root.querySelectorAll("button")).find((button) => button.textContent === name);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
root?.remove();
|
||||
fetchFiles.mockReset();
|
||||
selection.mockReset();
|
||||
});
|
||||
|
||||
describe("file picker parent navigation", () => {
|
||||
it("loads the parent and shows sibling folders after one click on '..'", async () => {
|
||||
mount();
|
||||
fetchFiles.mockResolvedValue({
|
||||
source: "data",
|
||||
items: [{ name: "sibling", path: "/sibling/", type: "directory" }],
|
||||
});
|
||||
await nextTick();
|
||||
row("..").click();
|
||||
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ path: "/", source: "data" }));
|
||||
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/");
|
||||
await vi.waitFor(() => expect(row("sibling")).toBeDefined());
|
||||
});
|
||||
|
||||
it("keeps single-click selection and double-click navigation for ordinary folders", async () => {
|
||||
mount([{ name: "nested", path: "/child/nested/", type: "directory" }]);
|
||||
fetchFiles.mockResolvedValue({ source: "data", items: [] });
|
||||
await nextTick();
|
||||
row("nested").click();
|
||||
expect(fetchFiles).not.toHaveBeenCalled();
|
||||
row("nested").dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
|
||||
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/child/nested/");
|
||||
});
|
||||
|
||||
it("does not fetch the parent again when the same row receives a double-click", async () => {
|
||||
mount();
|
||||
fetchFiles.mockResolvedValue({ source: "data", items: [] });
|
||||
await nextTick();
|
||||
const parent = row("..");
|
||||
parent.click();
|
||||
parent.click();
|
||||
parent.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
|
||||
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/");
|
||||
});
|
||||
|
||||
it("ignores the rest of a double-click on a replacement parent row", async () => {
|
||||
mount([], "/parent/child/");
|
||||
fetchFiles.mockResolvedValue({ source: "data", items: [] });
|
||||
await nextTick();
|
||||
const firstParent = row("..");
|
||||
firstParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
|
||||
await vi.waitFor(() => {
|
||||
expect(row("..")).toBeDefined();
|
||||
expect(row("..")).not.toBe(firstParent);
|
||||
});
|
||||
const nextParent = row("..");
|
||||
nextParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 2 }));
|
||||
nextParent.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, detail: 2 }));
|
||||
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/parent/");
|
||||
|
||||
nextParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
|
||||
expect(fetchFiles).toHaveBeenLastCalledWith("data", "/");
|
||||
expect(fetchFiles).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -3,21 +3,21 @@
|
||||
<!-- Source Selection Dropdown (hidden when browsing a fixed source, e.g. user scope row) -->
|
||||
<div v-if="!hidePathChrome && showSourceSelector" class="source-selector" style="margin-bottom: 1rem;">
|
||||
<label for="destinationSource" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
{{ $t("prompts.destinationSource") }}
|
||||
{{ t("prompts.destinationSource") }}
|
||||
</label>
|
||||
<ExpandDropdown
|
||||
input-id="destinationSource"
|
||||
v-model="currentSource"
|
||||
:options="sourceOptions"
|
||||
:aria-label="$t('prompts.destinationSource')"
|
||||
:aria-label="t('prompts.destinationSource')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sortable Column Header (opt-in via sortable prop, e.g. destination pickers) -->
|
||||
<div v-if="(!hidePathChrome && !fileList) || sortable || $slots.pinned" class="sticky-header" :class="{ 'header-hidden': headerHidden }">
|
||||
<div v-if="(!hidePathChrome && !fileList) || sortable || $slots.sticky" class="sticky-header" :class="{ 'header-hidden': headerHidden }">
|
||||
<!-- Current Path Display -->
|
||||
<div v-if="!hidePathChrome && !fileList" aria-label="filelist-path" class="searchContext button clickable">
|
||||
{{ $t('general.path', { suffix: ':' }) }} {{ sourcePath.path }}
|
||||
{{ t('general.path', { suffix: ':' }) }} {{ sourcePath.path }}
|
||||
</div>
|
||||
<slot name="sticky" />
|
||||
<ListingHeader v-if="sortable && !loading" :use-picker-sorting="!fileList" :use-navigation-sorting="!!fileList" />
|
||||
@@ -68,24 +68,25 @@
|
||||
</div>
|
||||
<h2 v-if="filterQuery && !visibleItems.some((item) => item.name !== '..')" class="no-results">
|
||||
<i class="material-symbols-outlined">search_off</i>
|
||||
<span>{{ $t("tools.advancedSearch.noResults") }}</span>
|
||||
<span>{{ t("tools.advancedSearch.noResults") }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { state, mutations, getters } from "@/store";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from "vue";
|
||||
import { state, mutations, getters } from "@/store/index.ts";
|
||||
import type { FileListItem, MediaMetadata } from "@/store/types.ts";
|
||||
import { url } from "@/utils";
|
||||
import { resourcesApi } from "@/api";
|
||||
import { url } from "@/utils/index.ts";
|
||||
import { resourcesApi } from "@/api/index.js";
|
||||
import ListingHeader from "@/components/files/ListingHeader.vue";
|
||||
import ListingItem from "@/components/files/ListingItem.vue";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import ExpandDropdown from "@/components/settings/ExpandDropdown.vue";
|
||||
import { sortedItems } from "@/utils/sort.ts";
|
||||
import { findScrollParents, findScroller, useListingVirtualization } from "@/composables/useListingVirtualization.ts";
|
||||
import { useI18n } from "@/i18n/index.ts";
|
||||
|
||||
type ItemInfo = FileListItem & { isDir?: boolean; hasDuration?: boolean };
|
||||
|
||||
@@ -161,8 +162,14 @@ const emit = defineEmits<{
|
||||
"update:filterQuery": [value: string];
|
||||
}>();
|
||||
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const list = ref<HTMLElement | null>(null);
|
||||
defineSlots<{
|
||||
sticky?(): unknown;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const root = useTemplateRef<HTMLElement>("root");
|
||||
const list = useTemplateRef<HTMLElement>("list");
|
||||
|
||||
const fallbackModified = new Date().toISOString();
|
||||
const initialSource = props.browseSource || state.req.source;
|
||||
@@ -373,7 +380,7 @@ async function withLoading(operation: () => Promise<unknown>) {
|
||||
} finally {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remaining = Math.max(0, 200 - elapsed);
|
||||
await new Promise((resolve) => setTimeout(resolve, remaining));
|
||||
await new Promise((resolve) => { setTimeout(resolve, remaining); });
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 325 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user