Compare commits

..
37 changed files with 1455 additions and 174 deletions

No files matched your search

+15 -1
View File
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. For commit
## v2.1.5
**Security**:
- [High] Server-side archive create (`POST /api/resources/archive`) now resolves the full destination path for writes via symlink-scoped mutation resolution, closing arbitrary host file overwrite through an in-scope symlink leaf (GHSA-jg32-ppcc-ph62) reporter @manus-use
- [Moderate] `GET /api/media/subtitles` no longer follows symlink external subtitle sidecars beside the video file, and public share requests resolve sidecar paths under the share prefix before read (GHSA-hfvf-2639-p2mq) reporter @manus-use
- [Moderate] Directory motion-preview now resolves symlinked children within the caller's scope, closing the incomplete GHSA-g2q5 remediation path through `getDirectoryPreview` (GHSA-2546-vf4j-vh9v) reporter @manus-use
- [Moderate] RealPathCache entries for affected index paths are invalidated after symlink-scoped mutations so stale resolved paths cannot bypass containment on subsequent create, write, delete, or move operations (GHSA-j764-qg3m-rhv7 follow-up, #3381)
- [Moderate] File watcher, download, view, upload, copy, and WebDAV now enforce per-path access rules on symlink-resolved targets, so DENY rules and scope boundaries cannot be bypassed via a link inside an allowed folder (GHSA-823x-gh7f-grmf) reporters @ch4rlesexe @manus-use @zohuyhieuzo03
**New Features**:
- feat(cache): add server.cacheDirMaxSizeMB to cap the preview cache size (#3314)
- Square image previews: avoid the full-resolution crop copy in imaging.Fill (#3340)
@@ -15,6 +22,13 @@ All notable changes to this project will be documented in this file. For commit
- fix(preview): open RAW, HEIC and rendered documents in the viewer when previews are disabled (#3313)
- fix(users): keep requested profile values when creating a user
- fix advanced user toggle duplicate (#3353) (#3324)
- fix(upload): don't pause while the server saves, and make the stall timeout a setting (#3346)
- fix(preview): respect EXIF orientation on embedded JPEG thumbnails (#3358)
- fix(picker): open the parent folder on a single click (#3355)
- fix(users): show password-change requirement in edit preferences (#3354)
- fix(sidebar): keep source selector stationary during horizontal tree scrolling (#3368)
- fix(http): restore Unix socket reverse-proxy behavior after #3309 hardening (#3365)
- fix(share): file checksum generation on public shares uses `GET /public/api/resources?checksum=…` with the share hash and session cookie; anonymous visitors receive 401 and no longer see the checksum section in the file info dialog (#3360)
**Upgrade notes** (Unix socket listeners):
- If you listen on a filesystem `http.socket` with `http.trustProxyHeaders: true`, set `http.unixSocketTrustedUIDs` and/or `http.unixSocketTrustedGIDs` to the UID/GID(s) from Linux peer credentials (`SO_PEERCRED`) that may set `X-Forwarded-*`. A GID allowlist trusts every peer with that GID (for example when the proxy and FileBrowser share a group), not only one process. If you relied on empty allowlists before, set `http.unixSocketTrustAllLocalPeers: true` (a warning is logged).
@@ -64,7 +78,7 @@ All notable changes to this project will be documented in this file. For commit
- [Moderate] Authenticated `GET /api/resources/view` no longer accepts view tokens minted for a public share when the caller lacks view permission on the requested source (GHSA-wxj5-g3qp-g732) reporter @BlueN0r
- [Low] SSE source-update and user-targeted delivery hold the client registry lock through nonblocking sends, preventing concurrent map mutation and send-on-closed-channel panics when Realtime clients connect, disconnect, or shut down (GHSA-p9wm-f5jj-75pj) reporter @tao0845
- [High] Incomplete symlink-containment remediation: an authenticated non-admin user could create a relative symlink via archive extraction, relocate it with WebDAV MOVE, and write through it to arbitrary host paths with the service account's privileges (GHSA-3j46-5w28-f8p2) reporter @Saku0512
- [Moderate] Source-root containment bypass: create, write, delete, and move operations now resolve symlinks uncached and enforce the caller's scope, closing API escapes through pre-existing out-of-source symlinks on upload, PUT, PATCH, delete, and WebDAV endpoints; RealPathCache entries for affected index paths are invalidated after mutations (GHSA-j764-qg3m-rhv7) reporter @chaoming0625
- [Moderate] Source-root containment bypass: create, write, delete, and move operations now resolve symlinks uncached and enforce the caller's scope, closing API escapes through pre-existing out-of-source symlinks on upload, PUT, PATCH, delete, and WebDAV endpoints (GHSA-j764-qg3m-rhv7) reporter @chaoming0625
- [Moderate] Preview reads resolve symlinks within the caller's scope so authenticated preview cannot follow an out-of-scope symlink target (GHSA-g2q5-r6ch-v25f) reporter @tao0845
- [Moderate] Archive downloads and extraction no longer read or write through symlinks that resolve outside the caller's share or user scope; escaping archive-walk entries are skipped (GHSA-qqf3-2wvq-q4w9) reporter @SmokinCaterpillar
- [Moderate] Share directory-archive downloads now apply the share owner's access rules to every entry, closing a bypass where anonymous share visitors could download admin-denied content (GHSA-9fcr-gm58-p839) reporter @Tan-JunWei
+20 -20
View File
@@ -3,7 +3,7 @@ module github.com/gtsteffaniak/filebrowser/backend
go 1.27.0
require (
github.com/alecthomas/kong v1.16.1
github.com/alecthomas/kong v1.17.0
github.com/asdine/storm/v3 v3.2.1
github.com/coreos/go-oidc/v3 v3.21.0
github.com/coreos/go-systemd/v22 v22.7.0
@@ -12,34 +12,34 @@ require (
github.com/evanoberholster/imagemeta v1.1.0
github.com/gen2brain/go-fitz v1.28.2
github.com/go-ldap/ldap/v3 v3.4.15
github.com/go-playground/validator/v10 v10.30.5
github.com/go-playground/validator/v10 v10.30.6
github.com/go-webauthn/webauthn v0.18.2
github.com/goccy/go-yaml v1.19.2
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-ffmpeg v1.4.0
github.com/gtsteffaniak/go-ffmpeg v1.6.2
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.9.0
github.com/mattn/go-sqlite3 v1.14.52
github.com/mattn/go-sqlite3 v1.14.53
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.12.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.58.0
golang.org/x/image v0.47.0
golang.org/x/mod v0.42.0
golang.org/x/net v0.61.0
golang.org/x/oauth2 v0.37.0
golang.org/x/sys v0.49.0
golang.org/x/term v0.47.0
golang.org/x/text v0.43.0
golang.org/x/crypto v0.59.0
golang.org/x/image v0.48.0
golang.org/x/mod v0.43.0
golang.org/x/net v0.62.0
golang.org/x/oauth2 v0.38.0
golang.org/x/sys v0.50.0
golang.org/x/term v0.48.0
golang.org/x/text v0.44.0
golang.org/x/time v0.16.0
golang.org/x/tools v0.52.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.60.1
modernc.org/sqlite v1.62.0
)
require (
@@ -49,12 +49,12 @@ require (
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/ebitengine/purego v0.11.2 // indirect
github.com/fxamacker/cbor/v2 v2.9.6 // indirect
github.com/ebitengine/purego v0.11.3 // indirect
github.com/fxamacker/cbor/v2 v2.10.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.16 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.6 // indirect
github.com/go-jose/go-jose/v4 v4.1.9 // indirect
github.com/go-openapi/jsonpointer v1.0.2 // indirect
github.com/go-openapi/jsonreference v1.0.3 // indirect
github.com/go-openapi/spec v1.0.1 // indirect
@@ -70,7 +70,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.3.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20260928092222-7d12f68cfadb // indirect
github.com/golang/geo v0.0.0-20261106073233-5e8d6967632a // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-tpm v0.9.8 // indirect
@@ -89,13 +89,13 @@ require (
github.com/tinylib/msgp v1.6.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sync v0.24.0 // indirect
go.yaml.in/yaml/v3 v3.0.6 // indirect
golang.org/x/sync v0.25.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.79.0 // indirect
modernc.org/libc v1.81.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+46 -46
View File
@@ -8,8 +8,8 @@ github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwv
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.16.1 h1:ixhCt93XkJ98kGposQ54+bl0IK6XwqB40AsMynU7Z8E=
github.com/alecthomas/kong v1.16.1/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
github.com/alecthomas/kong v1.17.0 h1:f52BVWChYMDkMkCoqkEWRuFOk/K4B5LO7xIijeLJRdk=
github.com/alecthomas/kong v1.17.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
@@ -47,12 +47,12 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/ebitengine/purego v0.11.3 h1:OB+JLpdm7ioymhyA+6s5kbb/uOuYycAgL8NNTFc5MLg=
github.com/ebitengine/purego v0.11.3/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/evanoberholster/imagemeta v1.1.0 h1:0iBtgVxLORZTZAlKxRcNodBmjMfHCSFwUh8Asmi83oI=
github.com/evanoberholster/imagemeta v1.1.0/go.mod h1:nmf6le+qSyoRAXgp2psPbtfkxcEgf/AZGgai9pnN8d0=
github.com/fxamacker/cbor/v2 v2.9.6 h1:NHZPxuUjJXQjX9itA785y/5NKSlI4HQ+S7noZsmI7hs=
github.com/fxamacker/cbor/v2 v2.9.6/go.mod h1:4EaZp6ClVunETjQ4GHmGNzysDK91Lr+r6Yz6tJqzVCw=
github.com/fxamacker/cbor/v2 v2.10.0 h1:HVF3T5vYwLqO81ebPirWEPNv2SMrUMD3HLnKgEr6uWE=
github.com/fxamacker/cbor/v2 v2.10.0/go.mod h1:uSwlToFuT9BZAKRxxVm3qQC8DR4tlWl7D6pf20iRYH4=
github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDmTtiEvjx1E=
github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4=
github.com/gen2brain/go-fitz v1.28.2 h1:845G85N5TUgnq5oDqyYrW0JvehAkeo35UkkK2dJtW1M=
@@ -65,8 +65,8 @@ github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWE
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4=
github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-jose/go-jose/v4 v4.1.9 h1:pKfeQkig1NmgK4hJrZYXLEYURXfoJB61Larw0uIW4gY=
github.com/go-jose/go-jose/v4 v4.1.9/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY=
github.com/go-ldap/ldap/v3 v3.4.15/go.mod h1:jeD0c98EdZQCKKKKe4kTWs5aCf6y+j8KuYsYLznZSQk=
github.com/go-openapi/jsonpointer v1.0.2 h1:NbW7xT2b+l7x+JefiPGDsZWsrLk1ogrz4AHK1TTzMt8=
@@ -102,8 +102,8 @@ github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9
github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU=
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
github.com/go-playground/validator/v10 v10.30.6 h1:E+mymlPNRzAMNz1FShrRvLeGWcprF5w305eLLgWieuo=
github.com/go-playground/validator/v10 v10.30.6/go.mod h1:jPLL8SKCXGe/g87QmEyNw3NO28nmGt5GCnZnufm7rj4=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.18.2 h1:0BeftmEHU7i3Dv0VFwBtidy/ba37Vcdjvqst9EYu8Sk=
@@ -119,8 +119,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20260928092222-7d12f68cfadb h1:z//sB+BhCYNvUrk19E8aWqNx+BBom8p/uD/nh95N/5U=
github.com/golang/geo v0.0.0-20260928092222-7d12f68cfadb/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/geo v0.0.0-20261106073233-5e8d6967632a h1:GLMvsMlp+kWJbqjyIav7/LBks3hMVyANhPVy+tSDA5M=
github.com/golang/geo v0.0.0-20261106073233-5e8d6967632a/go.mod h1:bJozU6ZRa8d29WpXkTQZ42lWCOJ+SC3XA8sXnLqA5IU=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
@@ -137,14 +137,14 @@ github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gtsteffaniak/go-cache v1.1.0 h1:ni/2cJpCxpGmaMZd92iTDIGOy+yUcMcxQuW7bSKhkCE=
github.com/gtsteffaniak/go-cache v1.1.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
github.com/gtsteffaniak/go-ffmpeg v1.4.0 h1:XkNFlx6koCCh8cFXn9HxYEEpvLlbnxmIvWX+5HLuN+8=
github.com/gtsteffaniak/go-ffmpeg v1.4.0/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
github.com/gtsteffaniak/go-ffmpeg v1.6.2 h1:gtvBg3G4PHO1Z/ZIPEK/XP4XT/g+l9S0Rpmmq6v5tnA=
github.com/gtsteffaniak/go-ffmpeg v1.6.2/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
github.com/gtsteffaniak/go-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcdxwMIVKI=
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
@@ -185,8 +185,8 @@ github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-sqlite3 v1.14.53 h1:mnTM0owzUMrKuLo+0w51NUiB0ir2Fo7kFXlPt/l3BGs=
github.com/mattn/go-sqlite3 v1.14.53/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/philhofer/fwd v1.2.1 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ=
@@ -229,17 +229,17 @@ go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go.yaml.in/yaml/v3 v3.0.6 h1:zcDfvjXEqTQmpjniUBLc91xYJMB2c4QOFxEF/LCEckM=
go.yaml.in/yaml/v3 v3.0.6/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
golang.org/x/crypto v0.59.0 h1:xS2jhqYbzNrpvkWP2YTDf4TJwBy3ykR03i8vFDxEc7c=
golang.org/x/crypto v0.59.0/go.mod h1:veDyUwATGs3c+vN3VfTbzViMGXeTBk3WyhBOkn+F/4U=
golang.org/x/image v0.48.0 h1:1AvuPcuoOTJpA7s4VZ5umrb90T3So0rkB/bSGmxkxik=
golang.org/x/image v0.48.0/go.mod h1:mmtg64iqJKGqEgy+r13dfybl85tmxUd2lMnmaiSdWYY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/mod v0.43.0 h1:MFuq5J6s35W9D3IjkwyWc9vHh0nENOrt5K8Iof4ofmg=
golang.org/x/mod v0.43.0/go.mod h1:4ec1JnuyBz5+FIVhg86UsA39YG8BpnrZdJYSNHmGzHY=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -251,14 +251,14 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/oauth2 v0.38.0 h1:uW7XQbcu1eoiQS7uMJvLOjPMl04bTbmY1VeRPxvbspI=
golang.org/x/oauth2 v0.38.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sync v0.25.0 h1:dJnifr0nMV8/SBnBvdo4eSGGnrc3O4FFx6IqvtLcmRs=
golang.org/x/sync v0.25.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -270,21 +270,21 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sys v0.50.0 h1:dkKZEFTEKaLr1sjhs0WiKqoi8Jj5nHbS3Njmx1UoMJs=
golang.org/x/sys v0.50.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/term v0.48.0 h1:G6UU1eiSB8nl3DzeUGt7Mg+2SctI8tPS6Z5/mBt6cOk=
golang.org/x/term v0.48.0/go.mod h1:BRDLHCZr2SUjIeBHHpRyQeC9xzSAVbzY2rvKgafnHf4=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/text v0.44.0 h1:YItdp/mQNGnsmrgpcdA5yQ3bnRxCfo/Tj/iByGW9GGo=
golang.org/x/text v0.44.0/go.mod h1:S4FR1waQ9LbjQTls97SyRjDaUUVkaM+cXwwuG/G+MwY=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -311,10 +311,10 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
modernc.org/cc/v4 v4.34.0 h1:ooKvVkbVJApIi1dVGl9R0cxQz+UKixXd+zjUH6l8af0=
modernc.org/cc/v4 v4.34.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.39.0 h1:Dk2UbgzY/DwlSKNHAJeO1hxGkkjiY3S1sgqSCrlDhzU=
modernc.org/ccgo/v4 v4.39.0/go.mod h1:/0Re1N2uIR+YRP3B57BDD2XW+zUAnxqlT5GZfSP+vBE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -323,8 +323,8 @@ modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
modernc.org/libc v1.81.0 h1:Kx11pb+4ETDPgkN9P6nHNTeVfrS+1w46LoIDbYcZEtM=
modernc.org/libc v1.81.0/go.mod h1:xlOEexcpOVlRiVw/CDpTqkXxG2uXXGF4i8uR7BAM9sY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
@@ -333,8 +333,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/sqlite v1.62.0 h1:gMaCjSM1qpl+iOjiWhR1LYP3zqrYDI+5GYLGt6veP2g=
modernc.org/sqlite v1.62.0/go.mod h1:Jzjh1mt4s0HuQ0KvtB1LIuHiGRmU2D5pgj8dUhW7/SE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
@@ -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
@@ -0,0 +1,44 @@
package access
import (
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
)
// PermittedAtRealPath reports whether username may access realPath after symlink
// resolution. DENY rules apply to the target index path, not only the link path.
func (s *Storage) PermittedAtRealPath(index *indexing.Index, username, realPath string, isDir bool) bool {
if s == nil || index == nil || username == "" || realPath == "" {
return false
}
lexical := realPath
if !strings.HasPrefix(realPath, index.Path) {
root, err := filepath.EvalSymlinks(index.Path)
if err != nil {
return false
}
rel, err := filepath.Rel(root, realPath)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return false
}
lexical = index.Path
if rel != "." {
lexical = filepath.Join(index.Path, rel)
}
}
indexPath := index.MakeIndexPath(lexical, isDir).String()
return s.Permitted(index.Path, utils.IndexPathFromNormalized(indexPath, true), username)
}
// IsDirAtRealPath reports whether an existing path is a directory; missing paths are treated as files.
func IsDirAtRealPath(realPath string) bool {
st, err := os.Stat(realPath)
if err != nil {
return false
}
return st.IsDir()
}
@@ -0,0 +1,140 @@
package access
import (
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func setupSymlinkDenyFixture(t *testing.T) (sourceRoot string, idx *indexing.Index, store *Storage, username string) {
t.Helper()
sourceRoot = filepath.Join(t.TempDir(), "data")
aliceDir := filepath.Join(sourceRoot, "alice")
bobDir := filepath.Join(sourceRoot, "bob")
for _, dir := range []string{aliceDir, bobDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(bobDir, "secret.txt"), []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../bob/secret.txt", filepath.Join(aliceDir, "link.txt")); err != nil {
t.Skipf("symlinks required: %v", err)
}
if err := os.Symlink("../bob", filepath.Join(aliceDir, "bobdir")); err != nil {
t.Skipf("symlinks required: %v", err)
}
settings.Config.Server.SourceMap = map[string]*settings.Source{
sourceRoot: {Path: sourceRoot, Config: settings.SourceConfig{DenyByDefault: false}},
}
store = NewStorage(nil)
username = "scoped-user"
if err := store.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/bob", true), username); err != nil {
t.Fatal(err)
}
idx = &indexing.Index{Source: settings.Source{Path: sourceRoot}}
return sourceRoot, idx, store, username
}
func TestPermittedAtRealPath_AllowsLinkPathDeniesResolvedTarget(t *testing.T) {
_, idx, store, user := setupSymlinkDenyFixture(t)
if !store.Permitted(idx.Path, utils.IndexPathFromNormalized("/alice/link.txt", false), user) {
t.Fatal("index path at the symlink should be allowed")
}
if store.Permitted(idx.Path, utils.IndexPathFromNormalized("/bob/secret.txt", false), user) {
t.Fatal("direct path under /bob should be denied")
}
realPath, _, err := idx.GetRealPathScoped("/", "/alice/link.txt")
if err != nil {
t.Fatal(err)
}
if store.PermittedAtRealPath(idx, user, realPath, false) {
t.Fatal("resolved target under denied /bob must not be permitted")
}
}
func TestPermittedAtRealPath_DeniesWriteThroughDirectorySymlink(t *testing.T) {
_, idx, store, user := setupSymlinkDenyFixture(t)
realPath, err := idx.GetRealPathForMutation("/", "/alice/bobdir/planted.txt")
if err != nil {
t.Fatal(err)
}
if store.PermittedAtRealPath(idx, user, realPath, false) {
t.Fatal("resolved write target under denied /bob must not be permitted")
}
}
func TestPermittedAtRealPath_DeniesCopySourceResolved(t *testing.T) {
_, idx, store, user := setupSymlinkDenyFixture(t)
realSrc, isDir, err := idx.GetRealPathScoped("/", "/alice/link.txt")
if err != nil {
t.Fatal(err)
}
if isDir {
t.Fatal("expected file")
}
if store.PermittedAtRealPath(idx, user, realSrc, isDir) {
t.Fatal("copy source resolved to denied target must not be permitted")
}
}
func TestPermittedAtRealPath_SymlinkedSourceRoot(t *testing.T) {
sourceRoot, _, _, user := setupSymlinkDenyFixture(t)
linkRoot := filepath.Join(filepath.Dir(sourceRoot), "root-link")
if err := os.Symlink(sourceRoot, linkRoot); err != nil {
t.Skipf("symlinks required: %v", err)
}
if _, err := filepath.EvalSymlinks(linkRoot); err != nil {
t.Fatal(err)
}
settings.Config.Server.SourceMap = map[string]*settings.Source{
linkRoot: {Path: linkRoot, Config: settings.SourceConfig{DenyByDefault: false}},
}
store := NewStorage(nil)
if err := store.DenyUser(linkRoot, utils.IndexPathFromNormalized("/bob", true), user); err != nil {
t.Fatal(err)
}
idx := &indexing.Index{Source: settings.Source{Path: linkRoot}}
realPath, _, err := idx.GetRealPathScoped("/", "/alice/link.txt")
if err != nil {
t.Fatal(err)
}
realPath, err = filepath.EvalSymlinks(realPath)
if err != nil {
t.Fatal(err)
}
if store.PermittedAtRealPath(idx, user, realPath, false) {
t.Fatal("resolved target under denied /bob must not be permitted with symlinked source root")
}
}
func TestIsDirAtRealPath(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "nested"), 0o755); err != nil {
t.Fatal(err)
}
file := filepath.Join(dir, "file.txt")
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if !IsDirAtRealPath(dir) {
t.Fatal("expected directory")
}
if IsDirAtRealPath(file) {
t.Fatal("expected file")
}
if IsDirAtRealPath(filepath.Join(dir, "missing")) {
t.Fatal("missing path should not be treated as directory")
}
}
+32 -1
View File
@@ -10,6 +10,7 @@ import (
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
"github.com/gtsteffaniak/go-ffmpeg/capabilities"
"github.com/gtsteffaniak/go-ffmpeg/ops"
"github.com/gtsteffaniak/go-ffmpeg/probe"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -118,7 +119,37 @@ func (s *Service) VideoPreview(ctx context.Context, w io.Writer, opts ops.Previe
if s == nil || s.inner == nil {
return fmt.Errorf("ffmpeg service not available")
}
return s.inner.VideoPreview(ctx, w, opts)
return s.inner.CaptureFrame(ctx, w, previewOptionsToFrame(opts))
}
func previewOptionsToFrame(opts ops.PreviewOptions) ops.FrameOptions {
var at ops.FramePosition
if opts.SeekSec > 0 {
at = ops.AtSec(opts.SeekSec)
} else {
pct := opts.SeekPercent
if pct <= 0 || pct > 100 {
pct = 10
}
at = ops.AtPercent(pct)
}
q := opts.Quality
if q == 0 {
q = ops.LegacyPreviewDefaultQuality
}
return ops.FrameOptions{
Input: ops.InputSource{URL: opts.Input, StreamType: probe.StreamFile},
At: at,
DurationSec: opts.DurationSec,
Width: opts.Width,
Height: opts.Height,
ScaleMode: opts.ScaleMode,
Quality: q,
Timeout: opts.Timeout,
LightProbe: opts.LightProbe,
Accuracy: opts.Accuracy,
Format: ops.FrameJPEG,
}
}
// ExtractSubtitle returns embedded subtitle content as WebVTT.
+3
View File
@@ -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)
}
+2 -2
View File
@@ -13,7 +13,7 @@ type ffmpegPreviewParams struct {
func ffmpegPreviewParamsForSize(previewSize string) (ffmpegPreviewParams, error) {
if previewSize == "original" {
return ffmpegPreviewParams{quality: 10}, nil
return ffmpegPreviewParams{quality: ops.LegacyPreviewDefaultQuality}, nil
}
opts, err := getPreviewOptions(previewSize)
@@ -24,7 +24,7 @@ func ffmpegPreviewParamsForSize(previewSize string) (ffmpegPreviewParams, error)
params := ffmpegPreviewParams{
width: opts.Width,
height: opts.Height,
quality: 10, // fastest MJPEG encode (-q:v 10); visual quality comes from target dimensions
quality: ops.LegacyPreviewDefaultQuality, // 1–100 scale; equivalent to historic MJPEG -q:v 10
}
if opts.ResizeMode == ResizeModeFill {
params.scaleMode = ops.ScaleFill
@@ -25,7 +25,7 @@ func TestFfmpegPreviewParamsForSize(t *testing.T) {
wantW: 256,
wantH: 256,
wantMode: ops.ScaleFill,
wantQual: 10,
wantQual: ops.LegacyPreviewDefaultQuality,
},
{
name: "large fit",
@@ -33,7 +33,7 @@ func TestFfmpegPreviewParamsForSize(t *testing.T) {
wantW: 640,
wantH: 640,
wantMode: ops.ScaleFit,
wantQual: 10,
wantQual: ops.LegacyPreviewDefaultQuality,
},
{
name: "xlarge fit",
@@ -41,12 +41,12 @@ func TestFfmpegPreviewParamsForSize(t *testing.T) {
wantW: 1024,
wantH: 1024,
wantMode: ops.ScaleFit,
wantQual: 10,
wantQual: ops.LegacyPreviewDefaultQuality,
},
{
name: "original no scale",
size: "original",
wantQual: 10,
wantQual: ops.LegacyPreviewDefaultQuality,
},
{
name: "unsupported",
+9
View File
@@ -19,6 +19,15 @@ func AccessPermitted(sourcePath string, indexPath utils.IndexPath, username stri
return accessDb.Permitted(sourcePath, indexPath, username)
}
// AccessPermittedAtRealPath reports whether username may access realPath after symlink
// resolution on the given index. When accessDb is not initialized, access is allowed.
func AccessPermittedAtRealPath(idx *indexing.Index, username, realPath string, isDir bool) bool {
if accessDb == nil {
return true
}
return accessDb.PermittedAtRealPath(idx, username, realPath, isDir)
}
func AllowUser(sourcePath string, indexPath utils.IndexPath, username string) error {
return accessDb.AllowUser(sourcePath, indexPath, username)
}
+8 -2
View File
@@ -15,12 +15,18 @@ type SubtitleTrack struct {
Embedded bool `json:"embedded"` // true for embedded subtitles, false for external files
}
// ErrSubtitleSidecarNotRegular is returned when an external subtitle path is not a regular file (e.g. symlink).
var ErrSubtitleSidecarNotRegular = errors.New("subtitle sidecar is not a regular file")
func GetSubtitleSidecarContent(subtitlePath string) (string, error) {
// validate the size is not too large
info, err := os.Stat(subtitlePath)
// Sidecars sit beside the media file; do not follow symlink leaves (see lyricsSidecar).
info, err := os.Lstat(subtitlePath)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() {
return "", ErrSubtitleSidecarNotRegular
}
if info.Size() > 1024*1024*50 { // 50MB
return "", errors.New("subtitle file is too large")
}
+11 -8
View File
@@ -279,19 +279,21 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
if fullDestParent != fullDestFile && !state.AccessPermitted(idxTo.Path, utils.IndexPathFromNormalized(fullDestParent, true), d.User.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to destination parent of %s", req.Destination)
}
parentReal, err := idxTo.GetRealPathForMutation(userScopeTo, fullDestParent)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("destination parent path invalid: %v", err)
}
if err = os.MkdirAll(parentReal, fileutils.EffectiveDirPerm()); err != nil {
return http.StatusInternalServerError, fmt.Errorf("cannot create destination parent directory: %v", err)
}
destRel := strings.TrimLeft(filepath.ToSlash(req.Destination), "/")
fileName := path.Base(destRel)
if fileName == "." || fileName == "/" {
return http.StatusBadRequest, fmt.Errorf("invalid destination file name")
}
destFileReal := filepath.Join(parentReal, fileName)
destFileReal, err := idxTo.GetRealPathForMutation(userScopeTo, fullDestFile)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("destination path invalid: %v", err)
}
if !state.AccessPermittedAtRealPath(idxTo, d.User.Username, destFileReal, false) {
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
}
if err = os.MkdirAll(filepath.Dir(destFileReal), fileutils.EffectiveDirPerm()); err != nil {
return http.StatusInternalServerError, fmt.Errorf("cannot create destination parent directory: %v", err)
}
format := strings.ToLower(strings.TrimSpace(req.Format))
if format == "" {
@@ -1270,6 +1272,7 @@ func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time
logger.Debugf("unarchive: symlink: %v", err)
return fmt.Errorf("failed to create symlink from archive entry")
}
indexing.ClearRealPathCaches()
return applyArchivedTimesAndPerm(destPath, fs.ModeSymlink, modTime)
}
@@ -0,0 +1,86 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestArchiveCreate_DeniesResolvedSymlinkDestination(t *testing.T) {
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/denied", true), username); err != nil {
t.Fatal(err)
}
allowedDir := filepath.Join(sourceRoot, "allowed")
deniedDir := filepath.Join(sourceRoot, "denied")
for _, dir := range []string{allowedDir, deniedDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
victim := filepath.Join(deniedDir, "victim")
if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourceRoot, "a.txt"), []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(allowedDir, "report.zip")
if err := os.Symlink(filepath.Join("..", "denied", "victim"), link); err != nil {
t.Fatal(err)
}
user, err := state.GetUserByUsername(username)
if err != nil {
t.Fatal(err)
}
settings.Config.Server.SourceMap[sourceRoot].Config.DenyByDefault = false
body := `{"fromSource":"` + sourceName + `","paths":["/a.txt"],"destination":"/allowed/report.zip","format":"zip"}`
req := httptest.NewRequest(http.MethodPost, "/api/resources/archive", strings.NewReader(body))
rec := httptest.NewRecorder()
status, handlerErr := archiveCreateHandler(rec, req, &Context{User: &user})
if status != http.StatusForbidden || handlerErr == nil {
t.Fatalf("status=%d err=%v", status, handlerErr)
}
got, err := os.ReadFile(victim)
if err != nil || string(got) != "keep" {
t.Fatalf("victim content=%q err=%v", got, err)
}
}
func TestArchiveCreate_AllowsNonSymlinkDestination(t *testing.T) {
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
dest := filepath.Join(sourceRoot, "out.zip")
if err := os.WriteFile(filepath.Join(sourceRoot, "a.txt"), []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
user, err := state.GetUserByUsername(username)
if err != nil {
t.Fatal(err)
}
body := `{"fromSource":"` + sourceName + `","paths":["/a.txt"],"destination":"/out.zip","format":"zip"}`
req := httptest.NewRequest(http.MethodPost, "/api/resources/archive", strings.NewReader(body))
rec := httptest.NewRecorder()
status, handlerErr := archiveCreateHandler(rec, req, &Context{User: &user})
if status != http.StatusOK || handlerErr != nil {
t.Fatalf("status=%d err=%v body=%s", status, handlerErr, rec.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(dest); err != nil {
t.Fatalf("expected archive at %s: %v", dest, err)
}
}
+21
View File
@@ -3,11 +3,16 @@ package web
import (
"archive/tar"
"archive/zip"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestNormalizeArchiveEntryName(t *testing.T) {
@@ -557,3 +562,19 @@ func TestExtractZip_symlinkDestination(t *testing.T) {
t.Fatalf("victim.txt should be unchanged: %q, %v", victimData, err)
}
}
func TestArchiveCreateDestination_RejectsEscapingSymlinkLeaf(t *testing.T) {
t.Parallel()
root := t.TempDir()
outside := filepath.Join(filepath.Dir(root), "outside-archive")
if err := os.MkdirAll(outside, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "report.zip")); err != nil {
t.Fatal(err)
}
idx := &indexing.Index{Source: settings.Source{Path: root}}
if _, err := idx.GetRealPathForMutation("", "/report.zip"); !errors.Is(err, commonerrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
}
}
+12 -6
View File
@@ -187,10 +187,10 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
return http.StatusForbidden, fmt.Errorf("access denied to file")
}
// Get real file path
realPath, _, err := idx.GetRealPath(scopePath)
// Get real file path (scoped) and enforce ACL on the resolved target
realPath, _, err := idx.GetRealPathScoped(userScope, scopePath)
if err != nil {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
return realPathErrStatus(err), err
}
// Get file/directory info
@@ -198,6 +198,9 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
if err != nil {
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
}
if !state.AccessPermittedAtRealPath(idx, d.User.Username, realPath, info.IsDir()) {
return http.StatusForbidden, fmt.Errorf("access denied to file")
}
// Get MIME type from the index if available
mimeType := "application/octet-stream"
@@ -335,10 +338,10 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
return http.StatusForbidden, fmt.Errorf("access denied to file")
}
// Get real file path
realPath, _, err := idx.GetRealPath(scopePath)
// Get real file path (scoped) and enforce ACL on the resolved target
realPath, _, err := idx.GetRealPathScoped(userScope, scopePath)
if err != nil {
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
return realPathErrStatus(err), err
}
// Get file/directory info
@@ -346,6 +349,9 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
if err != nil {
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
}
if !state.AccessPermittedAtRealPath(idx, d.User.Username, realPath, info.IsDir()) {
return http.StatusForbidden, fmt.Errorf("access denied to file")
}
// Set up SSE headers
w.Header().Set("Content-Type", "text/event-stream")
+57 -1
View File
@@ -2,6 +2,7 @@ package web
import (
"bytes"
"errors"
"fmt"
"net/http"
"path/filepath"
@@ -10,7 +11,9 @@ 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"
)
@@ -106,9 +109,34 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
var content string
if !embedded {
subtitlePath := filepath.Join(filepath.Dir(fileInfo.RealPath), filepath.Base(track.Name))
var subtitlePath string
if d.Share.Hash != "" {
if d.Share.Path == "" {
return http.StatusForbidden, fmt.Errorf("share has no path scope")
}
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
}
bound, sidecarIndexPath, scopeErr := shareSubtitleSidecarIndexPaths(d, source, path, track.Name)
if scopeErr != nil {
return http.StatusForbidden, scopeErr
}
subtitlePath, _, err = idx.GetRealPathScoped(bound, sidecarIndexPath)
if err != nil {
return realPathErrStatus(err), err
}
if !state.AccessPermittedAtRealPath(idx, d.ShareUser.Username, subtitlePath, false) {
return http.StatusForbidden, fmt.Errorf("access denied")
}
} else {
subtitlePath = filepath.Join(filepath.Dir(fileInfo.RealPath), filepath.Base(track.Name))
}
content, err = utils.GetSubtitleSidecarContent(subtitlePath)
if err != nil {
if errors.Is(err, utils.ErrSubtitleSidecarNotRegular) {
return http.StatusForbidden, err
}
return http.StatusInternalServerError, fmt.Errorf("failed to get subtitle sidecar content: %v", err)
}
} else {
@@ -143,6 +171,34 @@ func findSubtitleTrack(subtitles []utils.SubtitleTrack, name string, embedded bo
return nil
}
// shareSubtitleSidecarIndexPaths maps middleware IndexPath (user-scope-relative) and Share.Path
// to scoped index paths for sidecar containment (see publicDeleteHandler).
func shareSubtitleSidecarIndexPaths(d *Context, sourceName, videoIndexPath, sidecarFileName string) (bound, sidecarIndexPath string, err error) {
userScope, scopeErr := d.ShareUser.GetScopeForSourceName(sourceName)
if scopeErr != nil || userScope == "" {
return "", "", fmt.Errorf("user has no access to source: %s", sourceName)
}
if d.Share.Path == "" {
return "", "", fmt.Errorf("share has no path scope")
}
shareRel := strings.TrimPrefix(d.Share.Path, userScope)
bound = utils.JoinScopedIndexPath(userScope, shareRel)
videoRel := strings.Trim(strings.TrimSpace(videoIndexPath), "/")
fullVideo := utils.JoinScopedIndexPath(userScope, videoRel)
if strings.TrimSuffix(bound, "/") == strings.TrimSuffix(fullVideo, "/") {
parent := utils.GetParentDirectoryPath(strings.TrimSuffix(bound, "/"))
if parent == "" {
parent = "/"
}
bound = parent
}
sidecarIndexPath = utils.JoinPathAsUnix(
utils.GetParentDirectoryPath(strings.TrimSuffix(fullVideo, "/")),
filepath.Base(sidecarFileName),
)
return bound, sidecarIndexPath, nil
}
// metadataHandler returns the same directory resource shape as GET /api/resources with metadata enabled,
// for client-side patching after a fast listing load.
// @Summary Directory with media metadata
@@ -0,0 +1,333 @@
package web
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func setupSubtitlesHTTPTest(t *testing.T) (downloadsPath string, admin *users.User) {
t.Helper()
lockWebGlobals(t)
downloadsPath = filepath.Join(t.TempDir(), "downloads")
dbPath := filepath.Join(t.TempDir(), "subtitles-http-test.sqlite")
if _, err := state.Initialize(dbPath); err != nil {
t.Fatal(err)
}
app.MustWireServices(state.Default())
t.Cleanup(func() {
state.Close()
})
settings.Config.Server.SourceMap = map[string]*settings.Source{
downloadsPath: {
Path: downloadsPath,
Name: "Downloads",
},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
"Downloads": settings.Config.Server.SourceMap[downloadsPath],
}
settings.InitializeUserResolvers()
adminUser := &users.User{
ID: 1,
FrontendUser: users.FrontendUser{
Username: "admin",
Permissions: users.Permissions{Admin: true},
},
BackendScopes: []users.BackendScope{
{Path: downloadsPath, Scope: "/"},
},
BackendSourcePermissions: map[string]users.SourceFilePermissions{
downloadsPath: {
View: true,
Download: true,
Modify: true,
Create: true,
Delete: true,
},
},
Version: users.SourcePermissionsMigrationVersion,
}
if err := state.CreateUser(adminUser, ""); err != nil {
t.Fatal(err)
}
adminUser.Permissions = users.Permissions{Admin: true}
if err := state.UpdateUser(adminUser, "", "permissions"); err != nil {
t.Fatal(err)
}
if err := state.UpdateUser(adminUser, "", "backendScopes"); err != nil {
t.Fatal(err)
}
if err := state.UpdateUser(adminUser, "", "backendSourcePermissions"); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(downloadsPath, 0755); err != nil {
t.Fatal(err)
}
indexing.SetTestIndex("Downloads", downloadsPath)
t.Cleanup(func() {
indexing.ClearTestIndices()
})
loaded, err := state.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
return downloadsPath, &loaded
}
func mockVideoWithExternalSubtitle(t *testing.T, trackName, videoRealPath string) {
t.Helper()
prev := files.FileInfoFasterFunc
t.Cleanup(func() { files.FileInfoFasterFunc = prev })
files.FileInfoFasterFunc = func(opts utils.FileOptions, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
return &iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
Path: opts.Path,
ItemInfo: iteminfo.ItemInfo{
Type: "video/mp4",
},
},
Subtitles: []utils.SubtitleTrack{
{Name: trackName, Embedded: false},
},
RealPath: videoRealPath,
}, nil
}
}
func TestSubtitlesHandler_RejectsEscapingSidecarSymlink(t *testing.T) {
downloadsPath, adminUser := setupSubtitlesHTTPTest(t)
videoPath := filepath.Join(downloadsPath, "clip.mov")
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
outside := filepath.Join(filepath.Dir(downloadsPath), "outside-secret")
if err := os.MkdirAll(outside, 0755); err != nil {
t.Fatal(err)
}
secret := filepath.Join(outside, "secret.srt")
if err := os.WriteFile(secret, []byte("SECRET_OUTSIDE_SUBTITLE"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(secret, filepath.Join(downloadsPath, "clip.srt")); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/media/subtitles?source=Downloads&path=/clip.mov&name=clip.srt&embedded=false", nil)
rec := httptest.NewRecorder()
status, handlerErr := subtitlesHandler(rec, req, &Context{User: adminUser})
if status != http.StatusForbidden {
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "SECRET_OUTSIDE_SUBTITLE") {
t.Fatalf("response leaked outside subtitle content")
}
}
func TestSubtitlesHandler_ReturnsInScopeSidecar(t *testing.T) {
downloadsPath, adminUser := setupSubtitlesHTTPTest(t)
videoPath := filepath.Join(downloadsPath, "clip.mov")
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
srtBody := "1\n00:00:01,000 --> 00:00:02,000\nHello in scope"
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(downloadsPath, "clip.srt"), []byte(srtBody), 0644); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/media/subtitles?source=Downloads&path=/clip.mov&name=clip.srt&embedded=false", nil)
rec := httptest.NewRecorder()
status, handlerErr := subtitlesHandler(rec, req, &Context{User: adminUser})
if status != http.StatusOK || handlerErr != nil {
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Hello in scope") {
t.Fatalf("body=%q want in-scope subtitle text", rec.Body.String())
}
}
func TestSubtitlesHandler_ShareScopedOwnerLoadsSidecar(t *testing.T) {
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
aliceRoot := filepath.Join(sourceRoot, "users", "alice", "shared")
if err := os.MkdirAll(aliceRoot, 0755); err != nil {
t.Fatal(err)
}
srtBody := "1\n00:00:01,000 --> 00:00:02,000\nScoped share OK"
videoPath := filepath.Join(aliceRoot, "clip.mov")
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(aliceRoot, "clip.srt"), []byte(srtBody), 0644); err != nil {
t.Fatal(err)
}
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
adminUser.BackendScopes = []users.BackendScope{{Path: sourceRoot, Scope: "/users/alice"}}
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=h&path=clip.mov&name=clip.srt&embedded=false", nil)
rec := httptest.NewRecorder()
status, handlerErr := subtitlesHandler(rec, req, &Context{
User: adminUser,
ShareUser: adminUser,
IndexPath: "/shared/clip.mov/",
Share: share.Share{
ShareColumns: share.ShareColumns{
Hash: "h",
Path: "/users/alice/shared",
},
SourcePath: sourceRoot,
},
})
if status != http.StatusOK || handlerErr != nil {
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Scoped share OK") {
t.Fatalf("body=%q", rec.Body.String())
}
}
func TestSubtitlesHandler_ShareSingleFileLoadsSidecar(t *testing.T) {
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
srtBody := "single-file share subtitle"
videoPath := filepath.Join(sourceRoot, "clip.mov")
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourceRoot, "clip.srt"), []byte(srtBody), 0644); err != nil {
t.Fatal(err)
}
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=h&path=/&name=clip.srt&embedded=false", nil)
rec := httptest.NewRecorder()
status, handlerErr := subtitlesHandler(rec, req, &Context{
User: adminUser,
ShareUser: adminUser,
IndexPath: "/clip.mov/",
Share: share.Share{
ShareColumns: share.ShareColumns{
Hash: "h",
Path: "/clip.mov",
},
SourcePath: sourceRoot,
},
})
if status != http.StatusOK || handlerErr != nil {
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "single-file share subtitle") {
t.Fatalf("body=%q", rec.Body.String())
}
}
func TestSubtitlesHandler_ShareDeniesSidecarSymlinkToDeniedTarget(t *testing.T) {
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
sharedDir := filepath.Join(sourceRoot, "shared")
privateDir := filepath.Join(sourceRoot, "private")
for _, dir := range []string{sharedDir, privateDir} {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
}
secret := filepath.Join(privateDir, "clip.srt")
if err := os.WriteFile(secret, []byte("SECRET_DENIED_SUBTITLE"), 0644); err != nil {
t.Fatal(err)
}
videoPath := filepath.Join(sharedDir, "clip.mov")
if err := os.WriteFile(videoPath, []byte("video"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join("..", "private", "clip.srt"), filepath.Join(sharedDir, "clip.srt")); err != nil {
t.Fatal(err)
}
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/private", true), "admin"); err != nil {
t.Fatal(err)
}
mockVideoWithExternalSubtitle(t, "clip.srt", videoPath)
adminUser.BackendScopes = []users.BackendScope{{Path: sourceRoot, Scope: "/"}}
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=h&path=clip.mov&name=clip.srt&embedded=false", nil)
rec := httptest.NewRecorder()
status, handlerErr := subtitlesHandler(rec, req, &Context{
User: adminUser,
ShareUser: adminUser,
IndexPath: "/shared/clip.mov/",
Share: share.Share{
ShareColumns: share.ShareColumns{
Hash: "h",
Path: "/shared",
},
SourcePath: sourceRoot,
},
})
if status != http.StatusForbidden {
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "SECRET_DENIED_SUBTITLE") {
t.Fatalf("response leaked denied subtitle")
}
}
func TestSubtitlesHandler_ShareRejectsSidecarPastEscapingDirSymlink(t *testing.T) {
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
videoIndexPath := "/shared/link/clip.mov/"
mockVideoWithExternalSubtitle(t, "clip.srt", filepath.Join(sourceRoot, "shared", "link", "clip.mov"))
sharedDir := filepath.Join(sourceRoot, "shared")
if err := os.MkdirAll(sharedDir, 0755); err != nil {
t.Fatal(err)
}
outside := filepath.Join(filepath.Dir(sourceRoot), "outside-share")
if err := os.MkdirAll(outside, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "clip.srt"), []byte("SECRET_PAST_DIR_SYMLINK"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(sharedDir, "link")); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/public/api/media/subtitles?hash=h&path=link/clip.mov&name=clip.srt&embedded=false", nil)
rec := httptest.NewRecorder()
status, handlerErr := subtitlesHandler(rec, req, &Context{
User: adminUser,
ShareUser: adminUser,
IndexPath: videoIndexPath,
Share: share.Share{
ShareColumns: share.ShareColumns{
Hash: "h",
Path: "/shared",
},
SourcePath: sourceRoot,
},
})
if status != http.StatusForbidden {
t.Fatalf("status=%d err=%v body=%q", status, handlerErr, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "SECRET_PAST_DIR_SYMLINK") {
t.Fatalf("response leaked subtitle past directory symlink")
}
}
+4 -1
View File
@@ -122,7 +122,10 @@ func withHashFileHelper(fn handleFunc) handleFunc {
pathWithoutUserScope = pathWithoutUserScope + "/"
}
data.IndexPath = pathWithoutUserScope
// skip file fetch for certain apis
// Skip FileInfoFaster prefetch for performance; handlers must apply equivalent
// checks (access rules + GetRealPathScoped with d.Share.Path where bytes are read).
// TODO: align all skipped public-share routes on one bound (Share.Path) or stop
// skipping FileInfoFaster here for media/metadata/items.
if (r.Method == "POST" && strings.Contains(r.URL.Path, "/resources")) ||
(r.Method == "POST" && strings.Contains(r.URL.Path, "/resources/view-token")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items")) ||
+84
View File
@@ -5,7 +5,9 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -535,6 +537,88 @@ func newHTTPRequest(t *testing.T, hash string, requestModifiers ...func(*http.Re
return r
}
func TestPublicGetResourceChecksum(t *testing.T) {
setupTestEnv(t)
filePath := filepath.Join(t.TempDir(), "a.txt")
if err := os.WriteFile(filePath, []byte("hello\n"), 0o600); err != nil {
t.Fatal(err)
}
originalFileInfoFaster := FileInfoFasterFunc
t.Cleanup(func() { FileInfoFasterFunc = originalFileInfoFaster })
FileInfoFasterFunc = func(opts utils.FileOptions, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
return &iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
Path: opts.Path,
ItemInfo: iteminfo.ItemInfo{
Name: "a.txt",
Type: "text/plain",
},
},
RealPath: filePath,
}, nil
}
newOwner := &users.User{
FrontendUser: users.FrontendUser{
Username: "checksum_owner",
Permissions: users.Permissions{Admin: false},
},
BackendScopes: []users.BackendScope{{Path: "/srv", Scope: "/"}},
}
if err := state.CreateUser(newOwner, ""); err != nil {
t.Fatal("failed to save user:", err)
}
ownerValue, err := state.GetUserByUsername("checksum_owner")
if err != nil {
t.Fatal(err)
}
owner := &ownerValue
if err := state.CreateShare(&share.Share{
ShareSettings: share.ShareSettings{ShareLimits: share.ShareLimits{SourceName: "srv"}},
ShareColumns: share.ShareColumns{Hash: "checksum_hash", Path: "/"},
SourcePath: "/srv",
UserID: owner.ID,
}); err != nil {
t.Fatal("failed to create share:", err)
}
originalAuthKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "key"
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
t.Run("anonymous", func(t *testing.T) {
recorder := httptest.NewRecorder()
req := newTestRequest(t, "checksum_hash", "", "", nil)
q := req.URL.Query()
q.Set("checksum", "md5")
req.URL.RawQuery = q.Encode()
withHashFile(publicGetResourceHandler)(recorder, req)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d body=%s", http.StatusUnauthorized, recorder.Code, recorder.Body.String())
}
})
t.Run("authenticated", func(t *testing.T) {
token := testSessionToken(t, owner, time.Hour)
recorder := httptest.NewRecorder()
req := newTestRequest(t, "checksum_hash", "", "", map[string]string{
"Authorization": "Bearer " + token,
})
q := req.URL.Query()
q.Set("checksum", "md5")
req.URL.RawQuery = q.Encode()
withHashFile(publicGetResourceHandler)(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "b1946ac92492d2347c6235b4d2611184") {
t.Fatalf("checksum missing from response: %s", recorder.Body.String())
}
})
}
func TestWithTimeoutReturnsJSONTimeoutReason(t *testing.T) {
t.Parallel()
timeout := 50 * time.Millisecond
+27 -4
View File
@@ -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"
@@ -217,14 +218,36 @@ func getDirectoryPreview(ctx context.Context, r *http.Request, d *Context, frame
}
fileInfo, err := files.FileInfoFaster(
utils.FileOptions{
Path: path,
Source: source,
AlbumArt: true,
Metadata: true,
Path: path,
Source: source,
AlbumArt: true,
Metadata: true,
FollowSymlinks: true,
}, user)
if err != nil {
return nil, err
}
if d.Share.Hash != "" {
idx := indexing.GetIndex(source)
if idx == nil {
return nil, fmt.Errorf("source not available")
}
userScope, scopeErr := d.ShareUser.GetScopeForSourceName(source)
if scopeErr != nil {
return nil, scopeErr
}
shareRel := strings.TrimPrefix(d.Share.Path, userScope)
bound := utils.JoinScopedIndexPath(userScope, shareRel)
childIndexPath := utils.JoinScopedIndexPath(userScope, path)
realPath, _, rerr := idx.GetRealPathScoped(bound, childIndexPath)
if rerr != nil {
if index != 0 {
return getDirectoryPreview(ctx, r, d, 0)
}
return nil, rerr
}
fileInfo.RealPath = realPath
}
tempCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
_, previewErr := preview.GetPreviewForFile(tempCtx, *fileInfo, "small", "", 0)
cancel()
@@ -0,0 +1,70 @@
package web
import (
"context"
"errors"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
)
func TestGetDirectoryPreview_ShareRejectsSymlinkChildOutsideShare(t *testing.T) {
sourceRoot, adminUser := setupSubtitlesHTTPTest(t)
sharedDir := filepath.Join(sourceRoot, "shared")
privateDir := filepath.Join(sourceRoot, "private")
for _, dir := range []string{sharedDir, privateDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(privateDir, "secret.jpg"), []byte{0xff, 0xd8, 0xff, 0xd9}, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join("..", "private", "secret.jpg"), filepath.Join(sharedDir, "link.jpg")); err != nil {
t.Fatal(err)
}
ctx := context.Background()
req := httptest.NewRequest("GET", "/", nil)
adminUser.BackendScopes = []users.BackendScope{{Path: sourceRoot, Scope: "/"}}
d := &Context{
User: adminUser,
ShareUser: adminUser,
IndexPath: "/shared/",
Share: share.Share{
ShareColumns: share.ShareColumns{Hash: "h", Path: "/shared"},
SourcePath: sourceRoot,
},
FileInfo: iteminfo.ExtendedFileInfo{
Source: "Downloads",
FileInfo: iteminfo.FileInfo{
Path: "/shared/",
ItemInfo: iteminfo.ItemInfo{
Type: "directory",
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "link.jpg", Type: "image/jpeg", HasPreview: true}},
},
},
},
}
idx := indexing.GetIndex("Downloads")
if idx == nil {
t.Fatal("missing index")
}
_, err := getDirectoryPreview(ctx, req, d, 0)
if err == nil {
t.Fatal("expected scope error for share child symlink outside share root")
}
if !errors.Is(err, commonerrors.ErrPathEscapesScope) {
t.Fatalf("error=%v want ErrPathEscapesScope", err)
}
}
+5
View File
@@ -12,6 +12,7 @@ import (
"github.com/google/uuid"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/quota"
@@ -171,6 +172,10 @@ func putResourceWithQuota(w http.ResponseWriter, r *http.Request, d *Context, so
if err != nil {
return sanitizePutPathError(err)
}
permUser := accessCheckUsername(d)
if !state.AccessPermittedAtRealPath(idx, permUser, realPath, access.IsDirAtRealPath(realPath)) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", fullIndexPath)
}
if stat, statErr := os.Stat(realPath); statErr == nil && stat.IsDir() {
return http.StatusMethodNotAllowed, errPutPathIsDirectory
}
+51
View File
@@ -16,6 +16,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
@@ -209,7 +210,9 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int
// @Param path query string false "Path within the share to retrieve information for. Defaults to share root."
// @Param content query string false "Include file content if true"
// @Param metadata query string false "Extract audio/video metadata if true"
// @Param checksum query string false "Optional checksum algorithm (md5, sha1, sha256, sha512); requires a logged-in session"
// @Success 200 {object} iteminfo.FileInfo "File or directory metadata"
// @Failure 401 {object} map[string]string "Login required for checksum generation"
// @Failure 403 {object} map[string]string "Share unavailable or access denied"
// @Failure 404 {object} map[string]string "Share not found or file not found"
// @Failure 500 {object} map[string]string "Internal server error"
@@ -219,6 +222,24 @@ func publicGetResourceHandler(w http.ResponseWriter, r *http.Request, d *Context
if d.Share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
}
if algo := r.URL.Query().Get("checksum"); algo != "" {
if d.User == nil || d.User.Username == "" || d.User.Username == "anonymous" {
return http.StatusUnauthorized, fmt.Errorf("login required to generate checksums")
}
if d.FileInfo.Type == "directory" {
return http.StatusBadRequest, fmt.Errorf("checksum is only supported for files")
}
if d.Share.DisableFileViewer {
return http.StatusForbidden, fmt.Errorf("file viewing is disabled for this share")
}
checksum, err := utils.GetChecksum(d.FileInfo.RealPath, algo)
if err == errors.ErrInvalidOption {
return http.StatusBadRequest, nil
} else if err != nil {
return http.StatusInternalServerError, err
}
d.FileInfo.Checksums = map[string]string{algo: checksum}
}
return RenderJSON(w, r, d.FileInfo)
}
@@ -793,6 +814,9 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
if !state.AccessPermitted(idx.Path, utils.IndexPathFromNormalized(fullIndexPath, true), filePermUser.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
if !state.AccessPermittedAtRealPath(idx, filePermUser.Username, realPath, access.IsDirAtRealPath(realPath)) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
// Check for file/folder conflicts before creation
if stat, statErr := os.Stat(realPath); statErr == nil {
@@ -1529,6 +1553,15 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
if !state.AccessPermittedAtRealPath(srcIdx, d.User.Username, realSrc, isSrcDir) {
item.Message = "access denied to source path"
if d.Share.Hash != "" {
response.Failed = append(response.Failed, MoveCopyItem{Message: item.Message})
continue
}
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
// Resolve the destination: copy must reject escaping symlink leaves; move/rename
// must replace an existing symlink entry without following it.
@@ -1549,10 +1582,28 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
if !state.AccessPermittedAtRealPath(dstIdx, d.User.Username, realDest, access.IsDirAtRealPath(realDest)) {
item.Message = "access denied to destination path"
if d.Share.Hash != "" {
response.Failed = append(response.Failed, MoveCopyItem{Message: item.Message})
continue
}
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
// Auto-rename if requested
if req.Rename {
realDest = addVersionSuffix(realDest)
if !state.AccessPermittedAtRealPath(dstIdx, d.User.Username, realDest, isSrcDir) {
item.Message = "access denied to destination path"
if d.Share.Hash != "" {
response.Failed = append(response.Failed, MoveCopyItem{Message: item.Message})
continue
}
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
}
// Validate move/rename operation to prevent circular references
@@ -0,0 +1,60 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
)
func TestResourcePatchHandler_CopyRename_DeniesDeniedVersionSuffix(t *testing.T) {
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
destDir := filepath.Join(sourceRoot, "dest")
if err := os.MkdirAll(destDir, 0o755); err != nil {
t.Fatal(err)
}
src := filepath.Join(sourceRoot, "src.txt")
if err := os.WriteFile(src, []byte("src"), 0o644); err != nil {
t.Fatal(err)
}
existing := filepath.Join(destDir, "report.txt")
if err := os.WriteFile(existing, []byte("existing"), 0o644); err != nil {
t.Fatal(err)
}
denied := filepath.Join(destDir, "report(1).txt")
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/dest/report(1).txt", false), username); err != nil {
t.Fatal(err)
}
user, err := state.GetUserByUsername(username)
if err != nil {
t.Fatal(err)
}
body := `{"action":"copy","rename":true,"items":[{"fromSource":"` + sourceName +
`","fromPath":"/src.txt","toSource":"` + sourceName + `","toPath":"/dest/report.txt"}]}`
req := httptest.NewRequest(http.MethodPatch, "/api/resources", strings.NewReader(body))
rec := httptest.NewRecorder()
status, handlerErr := ResourcePatchHandler(rec, req, &Context{User: &user})
if handlerErr != nil {
t.Fatalf("err=%v", handlerErr)
}
if status != http.StatusInternalServerError && status != http.StatusOK {
t.Fatalf("status=%d body=%s", status, rec.Body.String())
}
var resp MoveCopyResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Failed) != 1 || !strings.Contains(resp.Failed[0].Message, "access denied") {
t.Fatalf("failed=%+v", resp.Failed)
}
if _, err := os.Stat(denied); err == nil {
t.Fatal("denied destination file should not exist")
}
}
+7 -1
View File
@@ -487,10 +487,16 @@ func ServeSingleFile(w http.ResponseWriter, r *http.Request, d *Context, source
bound = userScope
}
realPath, _, err := idx.GetRealPathScoped(bound, scopedFilePath)
realPath, isDir, err := idx.GetRealPathScoped(bound, scopedFilePath)
if err != nil {
return realPathErrStatus(err), err
}
if st, statErr := os.Stat(realPath); statErr == nil {
isDir = st.IsDir()
}
if !state.AccessPermittedAtRealPath(idx, permUser, realPath, isDir) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", scopedFilePath)
}
isOnlyOffice := IsOnlyOfficeCompatibleFile(displayFileName) && settings.Config.Integrations.OnlyOffice.Url != ""
var documentId string
+39
View File
@@ -19,6 +19,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
@@ -237,6 +238,35 @@ func (ffs *filteredFileSystem) checkScopePath(requestPath string, forRemoval boo
return err
}
// checkResolvedAccess enforces per-path DENY rules on the symlink-resolved target.
func (ffs *filteredFileSystem) checkResolvedAccess(requestPath string, forRemoval bool) error {
idx := indexing.GetIndex(ffs.source)
if idx == nil {
return fmt.Errorf("source not found")
}
userScope, err := ffs.user.GetScopeForSourceName(ffs.source)
if err != nil {
return err
}
fullIndexPath := utils.JoinScopedIndexPath(userScope, strings.TrimPrefix(requestPath, "/"))
var realPath string
if forRemoval {
realPath, err = idx.GetRealPathForRemoval(userScope, fullIndexPath)
} else {
realPath, err = idx.GetRealPathForMutation(userScope, fullIndexPath)
}
if err != nil {
if errors.Is(err, commonerrors.ErrPathEscapesScope) {
return os.ErrPermission
}
return err
}
if !state.AccessPermittedAtRealPath(idx, ffs.user.Username, realPath, access.IsDirAtRealPath(realPath)) {
return os.ErrPermission
}
return nil
}
func (ffs *filteredFileSystem) requireCreatePermissionForNewFile(requestPath string) error {
idx := indexing.GetIndex(ffs.source)
if idx == nil {
@@ -412,6 +442,9 @@ func (ffs *filteredFileSystem) Mkdir(ctx context.Context, name string, perm os.F
logger.Debugf("Mkdir: scope check denied for %s: %v", name, err)
return err
}
if err := ffs.checkResolvedAccess(name, false); err != nil {
return err
}
if err := ffs.fs.Mkdir(ctx, name, perm); err != nil {
return err
@@ -450,6 +483,9 @@ func (ffs *filteredFileSystem) OpenFile(ctx context.Context, requestPath string,
logger.Debugf("OpenFile: scope check denied for %s: %v", requestPath, err)
return nil, err
}
if err := ffs.checkResolvedAccess(requestPath, false); err != nil {
return nil, err
}
if (flag&os.O_CREATE) != 0 && !ffs.sourceFilePerms().Create {
if err := ffs.requireCreatePermissionForNewFile(requestPath); err != nil {
@@ -462,6 +498,9 @@ func (ffs *filteredFileSystem) OpenFile(ctx context.Context, requestPath string,
logger.Debugf("OpenFile: read denied by scope for %s", requestPath)
return nil, os.ErrPermission
}
if err := ffs.checkResolvedAccess(requestPath, false); err != nil {
return nil, err
}
}
var desiredMtime time.Time
+18 -2
View File
@@ -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)
+15
View File
@@ -4344,6 +4344,12 @@ const docTemplate = `{
"description": "Extract audio/video metadata if true",
"name": "metadata",
"in": "query"
},
{
"type": "string",
"description": "Optional checksum algorithm (md5, sha1, sha256, sha512); requires a logged-in session",
"name": "checksum",
"in": "query"
}
],
"responses": {
@@ -4353,6 +4359,15 @@ const docTemplate = `{
"$ref": "#/definitions/iteminfo.FileInfo"
}
},
"401": {
"description": "Login required for checksum generation",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Share unavailable or access denied",
"schema": {
+15
View File
@@ -4333,6 +4333,12 @@
"description": "Extract audio/video metadata if true",
"name": "metadata",
"in": "query"
},
{
"type": "string",
"description": "Optional checksum algorithm (md5, sha1, sha256, sha512); requires a logged-in session",
"name": "checksum",
"in": "query"
}
],
"responses": {
@@ -4342,6 +4348,15 @@
"$ref": "#/definitions/iteminfo.FileInfo"
}
},
"401": {
"description": "Login required for checksum generation",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Share unavailable or access denied",
"schema": {
+11
View File
@@ -5589,6 +5589,11 @@ paths:
in: query
name: metadata
type: string
- description: Optional checksum algorithm (md5, sha1, sha256, sha512); requires
a logged-in session
in: query
name: checksum
type: string
produces:
- application/json
responses:
@@ -5596,6 +5601,12 @@ paths:
description: File or directory metadata
schema:
$ref: '#/definitions/iteminfo.FileInfo'
"401":
description: Login required for checksum generation
schema:
additionalProperties:
type: string
type: object
"403":
description: Share unavailable or access denied
schema:
+139 -72
View File
@@ -236,9 +236,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -256,9 +253,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -276,9 +270,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -296,9 +287,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
@@ -593,6 +581,7 @@
"version": "2.0.0-beta.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-beta.2.tgz",
"integrity": "sha512-MzCVoWtcg4Pm2B3je5mMvYMvj3MmBKw+fU7vbsJc9/iT8ip6/eYIHo/Dsm/mr+wsHB6SEyJDuxB09Yy6r/8+4A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -604,6 +593,7 @@
"version": "2.0.0-beta.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-beta.2.tgz",
"integrity": "sha512-JcrSPzPvZuNZvJqPTF9Fw25LbOJ8avzFVlaf2grmH4Cku0oGP47tRgqPZadcfj3YoZMHkFVnCYFbQM8I+i5QEw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -614,6 +604,7 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.2.0.tgz",
"integrity": "sha512-JP7OYH38Q7+pyTAe14raZHLQIK7BDKbdwji+Xi3pcd/6QkJYQH3SX4wu6EplGrGJWRepoNzedfm0G3B9Z4e9Rw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -627,6 +618,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -643,6 +635,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -659,6 +652,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -675,6 +669,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -691,6 +686,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -707,6 +703,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -723,6 +720,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -739,6 +737,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -755,6 +754,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -771,6 +771,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -787,6 +788,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -803,6 +805,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -819,6 +822,7 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -835,6 +839,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -851,6 +856,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -867,6 +873,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -883,6 +890,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -899,6 +907,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -915,6 +924,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -931,6 +941,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -947,6 +958,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -963,6 +975,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -979,6 +992,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -995,6 +1009,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1011,6 +1026,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1027,6 +1043,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1079,15 +1096,15 @@
}
},
"node_modules/@eslint/config-array": {
"version": "0.23.5",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
"integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
"version": "0.23.6",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.6.tgz",
"integrity": "sha512-WrTH7nioI9QPY4NZGnsywmecswJdgeq4BCM76qok9VgjTO29ZgUzUvcuJLVPYVQM4uDbaPBU0M33R9DIxI8X3w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^3.0.5",
"debug": "^4.3.1",
"minimatch": "^10.2.4"
"minimatch": "^11.0.5"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
@@ -2181,9 +2198,9 @@
"license": "MIT"
},
"node_modules/@likecoin/epub-ts": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@likecoin/epub-ts/-/epub-ts-0.7.3.tgz",
"integrity": "sha512-uaIQZhTGjJMDZnJyPh5vBAfjjdGqPnGk80kKqwVYd+QdFxo/ukKna9FkMoqPg0shh0ltoLpSEc3A7olMpmlDWw==",
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@likecoin/epub-ts/-/epub-ts-0.7.4.tgz",
"integrity": "sha512-CYSTx9O1NKAe6iJqcULaCSbWQwgai3wmakl0/EreSwCh8QzRRHOYBjMuifqSjbami+uKDONJvXQg1Mhv3WP9/Q==",
"license": "BSD-2-Clause",
"dependencies": {
"jszip": "^3.10.1"
@@ -2201,6 +2218,7 @@
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.6.tgz",
"integrity": "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -2284,6 +2302,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2300,6 +2319,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2316,6 +2336,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2332,6 +2353,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2348,6 +2370,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2364,6 +2387,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2380,9 +2404,7 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2399,9 +2421,7 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2418,9 +2438,7 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2437,9 +2455,7 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2456,9 +2472,7 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2475,9 +2489,7 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2494,6 +2506,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2510,6 +2523,7 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -2528,6 +2542,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2544,6 +2559,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2586,6 +2602,7 @@
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
"integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -2634,7 +2651,7 @@
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
@@ -2940,6 +2957,39 @@
}
}
},
"node_modules/@vue-macros/common/node_modules/local-pkg": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
"integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
"license": "MIT",
"dependencies": {
"mlly": "^1.7.4",
"pkg-types": "^2.3.0",
"quansync": "^0.2.11"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vue-macros/common/node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/antfu"
},
{
"type": "individual",
"url": "https://github.com/sponsors/sxzz"
}
],
"license": "MIT"
},
"node_modules/@vue/compiler-core": {
"version": "3.5.45",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.45.tgz",
@@ -3472,6 +3522,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
@@ -4312,6 +4363,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -4920,6 +4972,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4940,6 +4993,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4960,6 +5014,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4980,6 +5035,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5000,6 +5056,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5020,9 +5077,7 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5043,9 +5098,7 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5066,9 +5119,7 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5089,9 +5140,7 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5112,6 +5161,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5132,6 +5182,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5152,17 +5203,17 @@
"license": "MIT"
},
"node_modules/local-pkg": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
"integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-2.0.0.tgz",
"integrity": "sha512-xvTNwM27EpU+fkLSpo33Pq/nm8m5zNf0aU/hF4W4ZM9RHQjVZa5KTmmXIzEzVsdI09NOUqj0djHhTv96sUowSQ==",
"license": "MIT",
"dependencies": {
"mlly": "^1.7.4",
"pkg-types": "^2.3.0",
"quansync": "^0.2.11"
"mlly": "^1.8.2",
"pkg-types": "^2.3.1",
"quansync": "^1.0.0"
},
"engines": {
"node": ">=14"
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
@@ -5893,9 +5944,9 @@
}
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz",
"integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==",
"funding": [
{
"type": "individual",
@@ -6429,6 +6480,7 @@
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz",
"integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==",
"dev": true,
"license": "0BSD",
"optional": true
},
@@ -6485,7 +6537,7 @@
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
@@ -6916,18 +6968,18 @@
}
},
"node_modules/vue-router": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.5.0.tgz",
"integrity": "sha512-nYhG+YzLcrO5Z7KbH1H/xvBeBzhCWhQG0z5DYNLuaJULhavZBmEgoQ5xVHGxwe65c5hnJQHZpBxWdQhnOBzHmw==",
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.5.1.tgz",
"integrity": "sha512-lUENd/QIhKd2HWACnXID3us0GWBnuOi3BwkTUzce2+4nfM/b3seV5saNURM9+wt48/qUxviotpcm7yFxC0U4qA==",
"license": "MIT",
"dependencies": {
"@vue-macros/common": "^3.1.3",
"@vue/devtools-api": "^8.1.5",
"@vue-macros/common": "^3.1.4",
"@vue/devtools-api": "^8.2.1",
"ast-walker-scope": "^0.10.0",
"chokidar": "^5.0.0",
"confbox": "^0.3.1",
"local-pkg": "^1.2.1",
"magic-string": "^1.4.3",
"chokidar": "^6.0.0",
"confbox": "^0.3.2",
"local-pkg": "^2.0.0",
"magic-string": "^1.4.5",
"mlly": "^1.8.2",
"muggle-string": "^0.4.1",
"nostics": "^1.3.0",
@@ -6935,7 +6987,7 @@
"picomatch": "^4.0.7",
"scule": "^1.3.0",
"tinyglobby": "^0.2.17",
"unplugin": "^3.4.0",
"unplugin": "^3.7.0",
"unplugin-utils": "^0.3.2"
},
"funding": {
@@ -6972,6 +7024,21 @@
"@vue/devtools-kit": "^8.2.1"
}
},
"node_modules/vue-router/node_modules/chokidar": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-6.0.0.tgz",
"integrity": "sha512-Ec2Z7wpcKBiMpR7DxteoqS/sq5G5d2A4fpxzPv6+fD7xNNdrTghn7L11YwV5EN1B3i72Erc3XVT5FkWIDx90+A==",
"license": "MIT",
"dependencies": {
"readdirp": "^5.1.1"
},
"engines": {
"node": ">= 22.22"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/vue-router/node_modules/unplugin": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.7.0.tgz",
@@ -7218,7 +7285,7 @@
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"devOptional": true,
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
+1
View File
@@ -48,6 +48,7 @@
"vue-router": "^5.5.0"
},
"overrides": {
"stylelint-config-recommended": "^19.0.0",
"minimatch": "^11.0.8",
"@xmldom/xmldom": "^0.8.15",
"mammoth": {
+2 -2
View File
@@ -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',
+15
View File
@@ -1037,6 +1037,21 @@ export async function checksum(source, path, algo) {
throw new Error('no source provided')
}
try {
if (getters.isShare()) {
if (!getters.isLoggedIn()) {
throw new Error('login required to generate checksums')
}
const hash = state.shareInfo?.hash
if (!hash) {
throw new Error('no share hash provided')
}
const params = { path, hash, checksum: algo }
const res = await fetchURL(getPublicApiPath('resources', params), {
headers: sharePublicAuthHeaders(hash),
})
const data = await res.json()
return getObjectProperty(data.checksums, algo)
}
const params = {
path: path,
source: source,
+7 -1
View File
@@ -109,7 +109,7 @@
</div>
</div>
<!-- Hash Generator Section -->
<div class="info-section" v-if="!dir">
<div class="info-section" v-if="showChecksumSection">
<h3 class="section-title">{{ $t("prompts.checksums") }}</h3>
<div class="hash-generator">
<div class="hash-select">
@@ -245,6 +245,12 @@ export default {
dir() {
return this.item?.type === "directory";
},
showChecksumSection() {
return !this.dir && (!this.isShare || this.isLoggedIn);
},
isLoggedIn() {
return getters.isLoggedIn();
},
source() {
return this.item?.source || "";
},