mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-13 20:51:59 -05:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaa34e9bb3 | ||
|
|
844f8f9fdd |
No files matched your search
@@ -674,6 +674,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)
|
||||
@@ -807,6 +810,9 @@ func moveResourceImpl(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst st
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isSrcDir {
|
||||
indexing.ClearRealPathCaches()
|
||||
}
|
||||
|
||||
// Handle SOURCE cleanup (treat as deletion)
|
||||
// Run async to avoid blocking the HTTP response
|
||||
|
||||
@@ -2,6 +2,8 @@ package access
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
@@ -13,7 +15,22 @@ func (s *Storage) PermittedAtRealPath(index *indexing.Index, username, realPath
|
||||
if s == nil || index == nil || username == "" || realPath == "" {
|
||||
return false
|
||||
}
|
||||
indexPath := index.MakeIndexPath(realPath, isDir).String()
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,37 @@ func TestPermittedAtRealPath_DeniesCopySourceResolved(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -465,6 +465,9 @@ func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
|
||||
}
|
||||
|
||||
if orientStr != "" && orientStr != "1" {
|
||||
if !embeddedPreviewWithinLimits(thm) {
|
||||
return nil, wrappedReader, fmt.Errorf("embedded thumbnail exceeds decode limits")
|
||||
}
|
||||
thm = applyOrientationToPreviewBytes(thm, orientStr)
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +288,9 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
|
||||
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)
|
||||
}
|
||||
@@ -1269,6 +1272,7 @@ func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time
|
||||
logger.Debugf("unarchive: symlink: %v", err)
|
||||
return fmt.Errorf("failed to create symlink from archive entry")
|
||||
}
|
||||
indexing.ClearRealPathCaches()
|
||||
return applyArchivedTimesAndPerm(destPath, fs.ModeSymlink, modTime)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestArchiveCreate_DeniesResolvedSymlinkDestination(t *testing.T) {
|
||||
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/denied", true), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowedDir := filepath.Join(sourceRoot, "allowed")
|
||||
deniedDir := filepath.Join(sourceRoot, "denied")
|
||||
for _, dir := range []string{allowedDir, deniedDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
victim := filepath.Join(deniedDir, "victim")
|
||||
if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceRoot, "a.txt"), []byte("payload"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(allowedDir, "report.zip")
|
||||
if err := os.Symlink(filepath.Join("..", "denied", "victim"), link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user, err := state.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Config.Server.SourceMap[sourceRoot].Config.DenyByDefault = false
|
||||
|
||||
body := `{"fromSource":"` + sourceName + `","paths":["/a.txt"],"destination":"/allowed/report.zip","format":"zip"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources/archive", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := archiveCreateHandler(rec, req, &Context{User: &user})
|
||||
if status != http.StatusForbidden || handlerErr == nil {
|
||||
t.Fatalf("status=%d err=%v", status, handlerErr)
|
||||
}
|
||||
got, err := os.ReadFile(victim)
|
||||
if err != nil || string(got) != "keep" {
|
||||
t.Fatalf("victim content=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveCreate_AllowsNonSymlinkDestination(t *testing.T) {
|
||||
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
dest := filepath.Join(sourceRoot, "out.zip")
|
||||
if err := os.WriteFile(filepath.Join(sourceRoot, "a.txt"), []byte("payload"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user, err := state.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := `{"fromSource":"` + sourceName + `","paths":["/a.txt"],"destination":"/out.zip","format":"zip"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources/archive", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := archiveCreateHandler(rec, req, &Context{User: &user})
|
||||
if status != http.StatusOK || handlerErr != nil {
|
||||
t.Fatalf("status=%d err=%v body=%s", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(dest); err != nil {
|
||||
t.Fatalf("expected archive at %s: %v", dest, err)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"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"
|
||||
@@ -125,6 +126,9 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -242,6 +242,54 @@ func TestSubtitlesHandler_ShareSingleFileLoadsSidecar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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/"
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -226,6 +227,27 @@ func getDirectoryPreview(ctx context.Context, r *http.Request, d *Context, frame
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.Share.Hash != "" {
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return nil, fmt.Errorf("source not available")
|
||||
}
|
||||
userScope, scopeErr := d.ShareUser.GetScopeForSourceName(source)
|
||||
if scopeErr != nil {
|
||||
return nil, scopeErr
|
||||
}
|
||||
shareRel := strings.TrimPrefix(d.Share.Path, userScope)
|
||||
bound := utils.JoinScopedIndexPath(userScope, shareRel)
|
||||
childIndexPath := utils.JoinScopedIndexPath(userScope, path)
|
||||
realPath, _, rerr := idx.GetRealPathScoped(bound, childIndexPath)
|
||||
if rerr != nil {
|
||||
if index != 0 {
|
||||
return getDirectoryPreview(ctx, r, d, 0)
|
||||
}
|
||||
return nil, rerr
|
||||
}
|
||||
fileInfo.RealPath = realPath
|
||||
}
|
||||
tempCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
_, previewErr := preview.GetPreviewForFile(tempCtx, *fileInfo, "small", "", 0)
|
||||
cancel()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func TestGetDirectoryPreview_ShareRejectsSymlinkChildOutsideShare(t *testing.T) {
|
||||
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
|
||||
sharedDir := filepath.Join(sourceRoot, "shared")
|
||||
privateDir := filepath.Join(sourceRoot, "private")
|
||||
for _, dir := range []string{sharedDir, privateDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(privateDir, "secret.jpg"), []byte{0xff, 0xd8, 0xff, 0xd9}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join("..", "private", "secret.jpg"), filepath.Join(sharedDir, "link.jpg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
adminUser.BackendScopes = []users.BackendScope{{Path: sourceRoot, Scope: "/"}}
|
||||
d := &Context{
|
||||
User: adminUser,
|
||||
ShareUser: adminUser,
|
||||
IndexPath: "/shared/",
|
||||
Share: share.Share{
|
||||
ShareColumns: share.ShareColumns{Hash: "h", Path: "/shared"},
|
||||
SourcePath: sourceRoot,
|
||||
},
|
||||
FileInfo: iteminfo.ExtendedFileInfo{
|
||||
Source: "Downloads",
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
Path: "/shared/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "link.jpg", Type: "image/jpeg", HasPreview: true}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
idx := indexing.GetIndex("Downloads")
|
||||
if idx == nil {
|
||||
t.Fatal("missing index")
|
||||
}
|
||||
|
||||
_, err := getDirectoryPreview(ctx, req, d, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected scope error for share child symlink outside share root")
|
||||
}
|
||||
if !errors.Is(err, commonerrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("error=%v want ErrPathEscapesScope", err)
|
||||
}
|
||||
}
|
||||
@@ -1595,6 +1595,15 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
|
||||
// Auto-rename if requested
|
||||
if req.Rename {
|
||||
realDest = addVersionSuffix(realDest)
|
||||
if !state.AccessPermittedAtRealPath(dstIdx, d.User.Username, realDest, isSrcDir) {
|
||||
item.Message = "access denied to destination path"
|
||||
if d.Share.Hash != "" {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{Message: item.Message})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Validate move/rename operation to prevent circular references
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
)
|
||||
|
||||
func TestResourcePatchHandler_CopyRename_DeniesDeniedVersionSuffix(t *testing.T) {
|
||||
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
destDir := filepath.Join(sourceRoot, "dest")
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := filepath.Join(sourceRoot, "src.txt")
|
||||
if err := os.WriteFile(src, []byte("src"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := filepath.Join(destDir, "report.txt")
|
||||
if err := os.WriteFile(existing, []byte("existing"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
denied := filepath.Join(destDir, "report(1).txt")
|
||||
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/dest/report(1).txt", false), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user, err := state.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"action":"copy","rename":true,"items":[{"fromSource":"` + sourceName +
|
||||
`","fromPath":"/src.txt","toSource":"` + sourceName + `","toPath":"/dest/report.txt"}]}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/resources", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := ResourcePatchHandler(rec, req, &Context{User: &user})
|
||||
if handlerErr != nil {
|
||||
t.Fatalf("err=%v", handlerErr)
|
||||
}
|
||||
if status != http.StatusInternalServerError && status != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", status, rec.Body.String())
|
||||
}
|
||||
var resp MoveCopyResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.Failed) != 1 || !strings.Contains(resp.Failed[0].Message, "access denied") {
|
||||
t.Fatalf("failed=%+v", resp.Failed)
|
||||
}
|
||||
if _, err := os.Stat(denied); err == nil {
|
||||
t.Fatal("denied destination file should not exist")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
@@ -23,8 +24,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
RealPathCache = cache.NewCache[string](48*time.Hour, 72*time.Hour)
|
||||
IsDirCache = cache.NewCache[bool](48*time.Hour, 72*time.Hour)
|
||||
RealPathCache = cache.NewCache[string](48*time.Hour, 72*time.Hour)
|
||||
IsDirCache = cache.NewCache[bool](48*time.Hour, 72*time.Hour)
|
||||
realPathGeneration atomic.Uint64
|
||||
)
|
||||
|
||||
// getDiskUsage returns the actual disk space used by a file in bytes
|
||||
@@ -1221,6 +1223,7 @@ func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool,
|
||||
if ok && cached != "" {
|
||||
return cached, isDir, nil
|
||||
}
|
||||
generation := realPathGeneration.Load()
|
||||
absolutePath, err := filepath.Abs(joinedPath)
|
||||
if err != nil {
|
||||
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err)
|
||||
@@ -1238,6 +1241,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -1263,10 +1270,19 @@ func realPathCacheIndexAncestors(indexPath string) []string {
|
||||
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), "/")
|
||||
|
||||
@@ -164,6 +164,42 @@ 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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import path from 'node:path';
|
||||
import { globSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Translator } from 'deepl-node';
|
||||
import { DeepLClient } from 'deepl-node';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
// Parse command line arguments
|
||||
@@ -27,7 +27,7 @@ if (requireApiKey && !DEEPL_API_KEY) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const translator = requireApiKey ? new Translator(DEEPL_API_KEY) : null;
|
||||
const translator = requireApiKey ? new DeepLClient(DEEPL_API_KEY) : null;
|
||||
|
||||
const deeplLangMap = {
|
||||
'zh-cn': 'ZH-HANS',
|
||||
|
||||
Reference in new issue
Block a user