mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-13 20:51:59 -05:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
321be524a7 | ||
|
|
109af309cd | ||
|
|
9aba7b55ca | ||
|
|
7964b30e5d | ||
|
|
618de3fbcc | ||
|
|
a68fa0ea15 | ||
|
|
3bf3d2ce92 | ||
|
|
f6cc16d1c8 | ||
|
|
8a1b735e0d | ||
|
|
10a26832dd | ||
|
|
0dfab77d6d | ||
|
|
7bd9c67eb7 | ||
|
|
009be1a06d | ||
|
|
272a0fab9d | ||
|
|
63a517de1f | ||
|
|
e39b534531 | ||
|
|
5d25220e94 | ||
|
|
8355682773 | ||
|
|
48c1ce709b | ||
|
|
43682dc809 | ||
|
|
80fba42076 | ||
|
|
a01ab6901c | ||
|
|
e652538ea8 | ||
|
|
c974da7002 | ||
|
|
eaee271b96 | ||
|
|
0d6b592046 | ||
|
|
1791bb7a36 | ||
|
|
86d4994152 | ||
|
|
8415d5f1ff | ||
|
|
85f934208c | ||
|
|
1022ecafef | ||
|
|
062ac9fb2d |
No files matched your search
+33
-1
@@ -2,6 +2,38 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## 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)
|
||||
|
||||
**Bug Fixes**:
|
||||
- fix(http): Unix socket hardening — default `http.socketFileMode` is `0660`; the socket is created under a restrictive umask and then `chmod`/`chown` apply your configured mode; on filesystem sockets, forwarded headers require `http.unixSocketTrustedUIDs` and/or `http.unixSocketTrustedGIDs`, with `http.unixSocketTrustAllLocalPeers: true` as an explicit opt-in for pre-#3309 trust-all behavior (startup warning) (#3309)
|
||||
- fix(users): save the prompt close button and new file templates when editing a user (#3342) (#3323)
|
||||
- fix(upload): serialize directory creation before uploading files (#3321)
|
||||
- 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).
|
||||
- Default `http.socketFileMode` is `0660`; use optional `http.socketGroup` when the proxy runs as another user in the same group.
|
||||
|
||||
## v2.1.4
|
||||
|
||||
**Security**:
|
||||
@@ -46,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 escapes through pre-existing out-of-source symlinks on upload, PUT, PATCH, delete, and WebDAV endpoints (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
@@ -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
@@ -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=
|
||||
|
||||
@@ -9,7 +9,10 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -21,9 +24,28 @@ type Cache interface {
|
||||
Set(key string, value []byte) error
|
||||
}
|
||||
|
||||
const (
|
||||
// evictTargetPercent is the size, as a percentage of the limit, that eviction shrinks the cache to.
|
||||
// Stopping below the limit avoids running an eviction on every write once the cache is full.
|
||||
evictTargetPercent = 90
|
||||
// touchInterval is how old a file's modification time has to be before a read refreshes it. Reads
|
||||
// refresh it so the files in use are the last ones evicted, but not on every read.
|
||||
touchInterval = time.Hour
|
||||
)
|
||||
|
||||
// FileCache struct for file-based caching
|
||||
type FileCache struct {
|
||||
dir string
|
||||
// maxBytes is the size limit of the cache in bytes. 0 means no limit.
|
||||
maxBytes int64
|
||||
// size is the total size of the cached files in bytes. Only tracked when maxBytes > 0.
|
||||
size atomic.Int64
|
||||
// sizeMu keeps size consistent with the files on disk: stores and deletes hold it for reading while
|
||||
// they change a file and its size, and eviction holds it for writing while it measures the directory,
|
||||
// so a store can never fall between a measurement and the size being set from it.
|
||||
sizeMu sync.RWMutex
|
||||
// evicting is set while an eviction is running so only one runs at a time.
|
||||
evicting atomic.Bool
|
||||
// granular locks
|
||||
scopedLocks struct {
|
||||
sync.Mutex
|
||||
@@ -33,6 +55,12 @@ type FileCache struct {
|
||||
}
|
||||
|
||||
func NewFileCache(dir string) (*FileCache, error) {
|
||||
return NewFileCacheWithLimit(dir, 0)
|
||||
}
|
||||
|
||||
// NewFileCacheWithLimit is like NewFileCache but keeps the cache under maxBytes, deleting the least
|
||||
// recently used files when it grows past it. 0 means no limit.
|
||||
func NewFileCacheWithLimit(dir string, maxBytes int64) (*FileCache, error) {
|
||||
cacheDir := filepath.Join(dir, "diskcache")
|
||||
|
||||
// Migrate existing cache files from old structure (if any)
|
||||
@@ -44,7 +72,13 @@ func NewFileCache(dir string) (*FileCache, error) {
|
||||
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
return &FileCache{dir: cacheDir}, nil
|
||||
cache := &FileCache{dir: cacheDir}
|
||||
if maxBytes > 0 {
|
||||
cache.maxBytes = maxBytes
|
||||
// measures the cache and trims it right away if it is already over the limit
|
||||
cache.evictPass()
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
// migrateOldCacheStructure moves cache files from old structure (dir/a/...) to new structure (dir/diskcache/a/...)
|
||||
@@ -99,19 +133,50 @@ func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if f.maxBytes > 0 {
|
||||
f.sizeMu.RLock()
|
||||
defer f.sizeMu.RUnlock()
|
||||
}
|
||||
previousSize := f.fileSize(fileName)
|
||||
|
||||
if err := os.WriteFile(fileName, value, fileutils.PermFile); err != nil {
|
||||
// a failed write can leave a truncated or partial file, so count what is actually there
|
||||
f.sizeChanged(f.fileSize(fileName) - previousSize)
|
||||
return err
|
||||
}
|
||||
|
||||
f.sizeChanged(int64(len(value)) - previousSize)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileSize returns the size of a cached file, or 0 if it doesn't exist or the size isn't tracked.
|
||||
func (f *FileCache) fileSize(fileName string) int64 {
|
||||
if f.maxBytes <= 0 {
|
||||
return 0
|
||||
}
|
||||
info, err := os.Stat(fileName)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
// sizeChanged adds delta to the tracked size and starts an eviction if that put the cache over its limit.
|
||||
func (f *FileCache) sizeChanged(delta int64) {
|
||||
if f.maxBytes <= 0 {
|
||||
return
|
||||
}
|
||||
if f.size.Add(delta) > f.maxBytes {
|
||||
f.evictAsync()
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FileCache) Load(ctx context.Context, key string) (value []byte, exist bool, err error) {
|
||||
r, ok, err := f.open(key)
|
||||
if err != nil || !ok {
|
||||
return nil, ok, err
|
||||
}
|
||||
defer r.Close()
|
||||
f.touch(r)
|
||||
|
||||
value, err = io.ReadAll(r)
|
||||
if err != nil {
|
||||
@@ -125,13 +190,119 @@ func (f *FileCache) Delete(ctx context.Context, key string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if f.maxBytes > 0 {
|
||||
f.sizeMu.RLock()
|
||||
defer f.sizeMu.RUnlock()
|
||||
}
|
||||
fileName := f.getFileName(key)
|
||||
removedSize := f.fileSize(fileName)
|
||||
if err := os.Remove(fileName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if removedSize > 0 {
|
||||
f.size.Add(-removedSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// touch refreshes the modification time of a file that was just read, which is what eviction orders by.
|
||||
func (f *FileCache) touch(file *os.File) {
|
||||
if f.maxBytes <= 0 {
|
||||
return
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil || time.Since(info.ModTime()) < touchInterval {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
_ = os.Chtimes(file.Name(), now, now)
|
||||
}
|
||||
|
||||
type cachedFile struct {
|
||||
path string
|
||||
size int64
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
// scan lists the cached files and their total size.
|
||||
func (f *FileCache) scan() ([]cachedFile, int64) {
|
||||
var files []cachedFile
|
||||
var total int64
|
||||
_ = filepath.WalkDir(f.dir, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil || entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
files = append(files, cachedFile{path: path, size: info.Size(), modTime: info.ModTime()})
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
return files, total
|
||||
}
|
||||
|
||||
// evictAsync starts an eviction in the background unless one is already running, so the request that
|
||||
// stored the file doesn't wait for it.
|
||||
func (f *FileCache) evictAsync() {
|
||||
if !f.evicting.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
removed := f.evictPass()
|
||||
f.evicting.Store(false)
|
||||
if removed == 0 {
|
||||
return // under the limit, or nothing could be removed: don't spin
|
||||
}
|
||||
// Files stored while the pass was running were counted but not necessarily seen by it, and a
|
||||
// store that came in just before the flag was cleared saw it set and didn't start another
|
||||
// pass, so check again before stopping.
|
||||
if f.size.Load() <= f.maxBytes || !f.evicting.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// evictPass measures the cache and, if it is over the limit, deletes the least recently used files until
|
||||
// it is under evictTargetPercent of the limit. Deleting a file that is being read or written is harmless:
|
||||
// readers keep their open file and a removed entry is simply rebuilt the next time it is requested.
|
||||
func (f *FileCache) evictPass() (removed int) {
|
||||
f.sizeMu.Lock()
|
||||
files, total := f.scan()
|
||||
f.size.Store(total)
|
||||
f.sizeMu.Unlock()
|
||||
|
||||
if total <= f.maxBytes {
|
||||
return 0
|
||||
}
|
||||
target := f.maxBytes / 100 * evictTargetPercent
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].modTime.Before(files[j].modTime) })
|
||||
var removedBytes int64
|
||||
for _, file := range files {
|
||||
if f.size.Load() <= target {
|
||||
break
|
||||
}
|
||||
// Hold the lock so no store or delete changes this file between measuring it and removing it, and
|
||||
// subtract the size it has now: the listing is from the scan and the file may have been rewritten since.
|
||||
f.sizeMu.Lock()
|
||||
info, err := os.Stat(file.path)
|
||||
if err == nil {
|
||||
err = os.Remove(file.path)
|
||||
}
|
||||
if err == nil {
|
||||
f.size.Add(-info.Size())
|
||||
removed++
|
||||
removedBytes += info.Size()
|
||||
}
|
||||
f.sizeMu.Unlock()
|
||||
}
|
||||
logger.Debugf("preview cache limit: removed %d files (%d MB), now %d MB of %d MB", removed, removedBytes>>20, f.size.Load()>>20, f.maxBytes>>20)
|
||||
return removed
|
||||
}
|
||||
|
||||
func (f *FileCache) open(key string) (*os.File, bool, error) {
|
||||
fileName := f.getFileName(key)
|
||||
file, err := os.Open(fileName)
|
||||
|
||||
@@ -2,9 +2,12 @@ package diskcache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -70,3 +73,168 @@ func fileExists(filename string) bool {
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
|
||||
// storeAged stores a value and sets its modification time, so the eviction order is deterministic.
|
||||
func storeAged(t *testing.T, cache *FileCache, key string, size int, age time.Duration) {
|
||||
t.Helper()
|
||||
require.NoError(t, cache.Store(context.Background(), key, make([]byte, size)))
|
||||
when := time.Now().Add(-age)
|
||||
require.NoError(t, os.Chtimes(cache.getFileName(key), when, when))
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, condition func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !condition() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("condition not met in time")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func newLimitedCache(t *testing.T, maxBytes int64) *FileCache {
|
||||
t.Helper()
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
cache, err := NewFileCacheWithLimit(t.TempDir(), maxBytes)
|
||||
require.NoError(t, err)
|
||||
return cache
|
||||
}
|
||||
|
||||
func TestFileCacheEvictsLeastRecentlyUsedOverLimit(t *testing.T) {
|
||||
const kb = 1024
|
||||
cache := newLimitedCache(t, 10*kb)
|
||||
// 3 files of 3KB are under the 10KB limit
|
||||
storeAged(t, cache, "oldest", 3*kb, 3*time.Hour)
|
||||
storeAged(t, cache, "old", 3*kb, 2*time.Hour)
|
||||
storeAged(t, cache, "recent", 3*kb, time.Hour)
|
||||
require.True(t, fileExists(cache.getFileName("oldest")), "nothing is removed under the limit")
|
||||
|
||||
// the 4th file goes over the limit: the oldest ones are removed until it is under 90% of it
|
||||
require.NoError(t, cache.Store(context.Background(), "newest", make([]byte, 3*kb)))
|
||||
waitFor(t, func() bool { return !fileExists(cache.getFileName("oldest")) })
|
||||
waitFor(t, func() bool { return !cache.evicting.Load() })
|
||||
|
||||
require.False(t, fileExists(cache.getFileName("oldest")))
|
||||
require.False(t, fileExists(cache.getFileName("old")))
|
||||
require.True(t, fileExists(cache.getFileName("recent")))
|
||||
require.True(t, fileExists(cache.getFileName("newest")))
|
||||
require.LessOrEqual(t, cache.size.Load(), int64(9*kb))
|
||||
}
|
||||
|
||||
func TestFileCacheTrimsAnOversizedCacheAtStartup(t *testing.T) {
|
||||
const kb = 1024
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
dir := t.TempDir()
|
||||
unlimited, err := NewFileCache(dir)
|
||||
require.NoError(t, err)
|
||||
storeAged(t, &FileCache{dir: unlimited.dir}, "a", 4*kb, 4*time.Hour)
|
||||
storeAged(t, &FileCache{dir: unlimited.dir}, "b", 4*kb, 3*time.Hour)
|
||||
storeAged(t, &FileCache{dir: unlimited.dir}, "c", 4*kb, 2*time.Hour)
|
||||
|
||||
cache, err := NewFileCacheWithLimit(dir, 10*kb)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 12KB over a 10KB limit: only the oldest file is removed, which already brings it under 90% (9KB)
|
||||
require.False(t, fileExists(cache.getFileName("a")))
|
||||
require.True(t, fileExists(cache.getFileName("b")))
|
||||
require.True(t, fileExists(cache.getFileName("c")))
|
||||
require.Equal(t, int64(8*kb), cache.size.Load())
|
||||
}
|
||||
|
||||
func TestFileCacheWithoutLimitKeepsEverything(t *testing.T) {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
cache, err := NewFileCache(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
for i := 0; i < 5; i++ {
|
||||
storeAged(t, cache, fmt.Sprintf("key-%d", i), 1<<20, time.Duration(i)*time.Hour)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
require.True(t, fileExists(cache.getFileName(fmt.Sprintf("key-%d", i))))
|
||||
}
|
||||
require.Zero(t, cache.size.Load(), "size is only tracked when there is a limit")
|
||||
}
|
||||
|
||||
func TestFileCacheReadRefreshesModTime(t *testing.T) {
|
||||
cache := newLimitedCache(t, 1<<20)
|
||||
storeAged(t, cache, "used", 100, 3*time.Hour)
|
||||
|
||||
_, ok, err := cache.Load(context.Background(), "used")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
info, err := os.Stat(cache.getFileName("used"))
|
||||
require.NoError(t, err)
|
||||
require.WithinDuration(t, time.Now(), info.ModTime(), time.Minute, "a file that is read is the last to be evicted")
|
||||
}
|
||||
|
||||
func TestFileCacheDeleteAndOverwriteKeepTheSizeAccurate(t *testing.T) {
|
||||
const kb = 1024
|
||||
cache := newLimitedCache(t, 1<<20)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, cache.Store(ctx, "k", make([]byte, 5*kb)))
|
||||
require.Equal(t, int64(5*kb), cache.size.Load())
|
||||
require.NoError(t, cache.Store(ctx, "k", make([]byte, 2*kb)))
|
||||
require.Equal(t, int64(2*kb), cache.size.Load(), "overwriting replaces the old size")
|
||||
require.NoError(t, cache.Delete(ctx, "k"))
|
||||
require.Zero(t, cache.size.Load())
|
||||
}
|
||||
|
||||
// Stores that land while an eviction is measuring or deleting must not be lost from the tracked size,
|
||||
// and the cache must end up under its limit even though more files kept arriving during the eviction.
|
||||
func TestFileCacheStaysUnderTheLimitWithConcurrentStores(t *testing.T) {
|
||||
const (
|
||||
kb = 1024
|
||||
limit = 100 * kb
|
||||
workers = 16
|
||||
stores = 40
|
||||
)
|
||||
cache := newLimitedCache(t, limit)
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < stores; i++ {
|
||||
_ = cache.Store(context.Background(), fmt.Sprintf("worker-%d-key-%d", w, i), make([]byte, 4*kb))
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
waitFor(t, func() bool { return !cache.evicting.Load() })
|
||||
|
||||
_, onDisk := cache.scan()
|
||||
require.LessOrEqual(t, onDisk, int64(limit), "the files left on disk are within the limit")
|
||||
require.Equal(t, onDisk, cache.size.Load(), "the tracked size matches what is on disk")
|
||||
}
|
||||
|
||||
// Files that are rewritten while an eviction is running must be subtracted at the size they have when
|
||||
// they are removed, not at the size the scan saw, or the tracked size drifts from the disk.
|
||||
func TestFileCacheKeepsTheSizeAccurateWhenFilesAreRewrittenDuringEviction(t *testing.T) {
|
||||
const (
|
||||
kb = 1024
|
||||
limit = 60 * kb
|
||||
workers = 8
|
||||
rounds = 300
|
||||
keys = 40
|
||||
)
|
||||
cache := newLimitedCache(t, limit)
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < rounds; i++ {
|
||||
// the same keys again and again, with a different size each time
|
||||
key := fmt.Sprintf("key-%d", (w+i)%keys)
|
||||
_ = cache.Store(context.Background(), key, make([]byte, (1+(w*7+i)%8)*kb))
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
waitFor(t, func() bool { return !cache.evicting.Load() })
|
||||
|
||||
_, onDisk := cache.scan()
|
||||
require.Equal(t, onDisk, cache.size.Load(), "the tracked size matches what is on disk")
|
||||
require.LessOrEqual(t, onDisk, int64(limit))
|
||||
}
|
||||
@@ -655,6 +655,7 @@ func DeleteFiles(source, boundIndexPath, indexPath string, isDir bool) error {
|
||||
if utils.CleanIndexPathSegment(indexPath) == "" {
|
||||
return fmt.Errorf("refusing to delete source root directory: %s", indexPath)
|
||||
}
|
||||
deleteIndexPath := indexPath
|
||||
realPath, err := index.GetRealPathForRemoval(boundIndexPath, indexPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -673,9 +674,13 @@ func DeleteFiles(source, boundIndexPath, indexPath string, isDir bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear cache entries
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.IsDirCache.Delete(realPath + ":isdir")
|
||||
if isDir {
|
||||
indexing.ClearRealPathCaches()
|
||||
}
|
||||
index.InvalidateRealPathCache(deleteIndexPath, boundIndexPath)
|
||||
if parent := utils.GetParentDirectoryPath(deleteIndexPath); parent != "" && parent != "/" {
|
||||
index.InvalidateRealPathCache(parent, boundIndexPath)
|
||||
}
|
||||
|
||||
// Remove metadata from index
|
||||
deleteSuccess := index.DeleteMetadata(indexPath, isDir, isDir)
|
||||
@@ -727,8 +732,7 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
|
||||
if !Exists(realPath) {
|
||||
// Directory no longer exists, remove it from the index
|
||||
// This clears both Directories and DirectoriesLedger maps
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.IsDirCache.Delete(realPath + ":isdir")
|
||||
idx.InvalidateRealPathCache(path)
|
||||
idx.DeleteMetadata(path, true, false)
|
||||
return nil
|
||||
}
|
||||
@@ -806,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
|
||||
@@ -966,6 +973,11 @@ func WriteDirectory(boundIndexPath string, opts utils.FileOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
idx.InvalidateRealPathCache(opts.Path, boundIndexPath)
|
||||
if parent := utils.GetParentDirectoryPath(opts.Path); parent != "" && parent != "/" {
|
||||
idx.InvalidateRealPathCache(parent, boundIndexPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1041,6 +1053,11 @@ func writeFile(source, boundIndexPath, path string, in io.Reader) error {
|
||||
}
|
||||
}
|
||||
|
||||
idx.InvalidateRealPathCache(path, boundIndexPath)
|
||||
if parent := utils.GetParentDirectoryPath(path); parent != "" && parent != "/" {
|
||||
idx.InvalidateRealPathCache(parent, boundIndexPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
)
|
||||
|
||||
// PermittedAtRealPath reports whether username may access realPath after symlink
|
||||
// resolution. DENY rules apply to the target index path, not only the link path.
|
||||
func (s *Storage) PermittedAtRealPath(index *indexing.Index, username, realPath string, isDir bool) bool {
|
||||
if s == nil || index == nil || username == "" || realPath == "" {
|
||||
return false
|
||||
}
|
||||
lexical := realPath
|
||||
if !strings.HasPrefix(realPath, index.Path) {
|
||||
root, err := filepath.EvalSymlinks(index.Path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(root, realPath)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return false
|
||||
}
|
||||
lexical = index.Path
|
||||
if rel != "." {
|
||||
lexical = filepath.Join(index.Path, rel)
|
||||
}
|
||||
}
|
||||
indexPath := index.MakeIndexPath(lexical, isDir).String()
|
||||
return s.Permitted(index.Path, utils.IndexPathFromNormalized(indexPath, true), username)
|
||||
}
|
||||
|
||||
// IsDirAtRealPath reports whether an existing path is a directory; missing paths are treated as files.
|
||||
func IsDirAtRealPath(realPath string) bool {
|
||||
st, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return st.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func setupSymlinkDenyFixture(t *testing.T) (sourceRoot string, idx *indexing.Index, store *Storage, username string) {
|
||||
t.Helper()
|
||||
sourceRoot = filepath.Join(t.TempDir(), "data")
|
||||
aliceDir := filepath.Join(sourceRoot, "alice")
|
||||
bobDir := filepath.Join(sourceRoot, "bob")
|
||||
for _, dir := range []string{aliceDir, bobDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(bobDir, "secret.txt"), []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("../bob/secret.txt", filepath.Join(aliceDir, "link.txt")); err != nil {
|
||||
t.Skipf("symlinks required: %v", err)
|
||||
}
|
||||
if err := os.Symlink("../bob", filepath.Join(aliceDir, "bobdir")); err != nil {
|
||||
t.Skipf("symlinks required: %v", err)
|
||||
}
|
||||
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
sourceRoot: {Path: sourceRoot, Config: settings.SourceConfig{DenyByDefault: false}},
|
||||
}
|
||||
store = NewStorage(nil)
|
||||
username = "scoped-user"
|
||||
if err := store.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/bob", true), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx = &indexing.Index{Source: settings.Source{Path: sourceRoot}}
|
||||
return sourceRoot, idx, store, username
|
||||
}
|
||||
|
||||
func TestPermittedAtRealPath_AllowsLinkPathDeniesResolvedTarget(t *testing.T) {
|
||||
_, idx, store, user := setupSymlinkDenyFixture(t)
|
||||
|
||||
if !store.Permitted(idx.Path, utils.IndexPathFromNormalized("/alice/link.txt", false), user) {
|
||||
t.Fatal("index path at the symlink should be allowed")
|
||||
}
|
||||
if store.Permitted(idx.Path, utils.IndexPathFromNormalized("/bob/secret.txt", false), user) {
|
||||
t.Fatal("direct path under /bob should be denied")
|
||||
}
|
||||
|
||||
realPath, _, err := idx.GetRealPathScoped("/", "/alice/link.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.PermittedAtRealPath(idx, user, realPath, false) {
|
||||
t.Fatal("resolved target under denied /bob must not be permitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermittedAtRealPath_DeniesWriteThroughDirectorySymlink(t *testing.T) {
|
||||
_, idx, store, user := setupSymlinkDenyFixture(t)
|
||||
|
||||
realPath, err := idx.GetRealPathForMutation("/", "/alice/bobdir/planted.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.PermittedAtRealPath(idx, user, realPath, false) {
|
||||
t.Fatal("resolved write target under denied /bob must not be permitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermittedAtRealPath_DeniesCopySourceResolved(t *testing.T) {
|
||||
_, idx, store, user := setupSymlinkDenyFixture(t)
|
||||
|
||||
realSrc, isDir, err := idx.GetRealPathScoped("/", "/alice/link.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isDir {
|
||||
t.Fatal("expected file")
|
||||
}
|
||||
if store.PermittedAtRealPath(idx, user, realSrc, isDir) {
|
||||
t.Fatal("copy source resolved to denied target must not be permitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermittedAtRealPath_SymlinkedSourceRoot(t *testing.T) {
|
||||
sourceRoot, _, _, user := setupSymlinkDenyFixture(t)
|
||||
linkRoot := filepath.Join(filepath.Dir(sourceRoot), "root-link")
|
||||
if err := os.Symlink(sourceRoot, linkRoot); err != nil {
|
||||
t.Skipf("symlinks required: %v", err)
|
||||
}
|
||||
if _, err := filepath.EvalSymlinks(linkRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
linkRoot: {Path: linkRoot, Config: settings.SourceConfig{DenyByDefault: false}},
|
||||
}
|
||||
store := NewStorage(nil)
|
||||
if err := store.DenyUser(linkRoot, utils.IndexPathFromNormalized("/bob", true), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx := &indexing.Index{Source: settings.Source{Path: linkRoot}}
|
||||
|
||||
realPath, _, err := idx.GetRealPathScoped("/", "/alice/link.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realPath, err = filepath.EvalSymlinks(realPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.PermittedAtRealPath(idx, user, realPath, false) {
|
||||
t.Fatal("resolved target under denied /bob must not be permitted with symlinked source root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDirAtRealPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "nested"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(dir, "file.txt")
|
||||
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !IsDirAtRealPath(dir) {
|
||||
t.Fatal("expected directory")
|
||||
}
|
||||
if IsDirAtRealPath(file) {
|
||||
t.Fatal("expected file")
|
||||
}
|
||||
if IsDirAtRealPath(filepath.Join(dir, "missing")) {
|
||||
t.Fatal("missing path should not be treated as directory")
|
||||
}
|
||||
}
|
||||
@@ -343,10 +343,11 @@ type NonAdminEditable struct {
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
MaxConcurrent int `json:"maxConcurrentUpload"`
|
||||
UploadChunkSize int `json:"uploadChunkSizeMb"`
|
||||
ClearAll bool `json:"clearAll"`
|
||||
DownloadChunkSize int `json:"downloadChunkSizeMb"`
|
||||
MaxConcurrent int `json:"maxConcurrentUpload"`
|
||||
UploadChunkSize int `json:"uploadChunkSizeMb"`
|
||||
ClearAll bool `json:"clearAll"`
|
||||
DownloadChunkSize int `json:"downloadChunkSizeMb"`
|
||||
UploadStallTimeoutSeconds int `json:"uploadStallTimeoutSeconds"` // seconds without progress before an upload is paused, 0 uses the default (10)
|
||||
}
|
||||
|
||||
// SidebarLink represents a customizable link in the sidebar.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -18,3 +20,19 @@ func hasEmbeddedPreview(fileType, fileName string) bool {
|
||||
func isJPEG(data []byte) bool {
|
||||
return len(data) >= 2 && data[0] == 0xff && data[1] == 0xd8
|
||||
}
|
||||
|
||||
// maxEmbeddedPreviewPixels bounds the embedded JPEG that is decoded for orientation and resizing, so a
|
||||
// crafted or corrupt file can't make the decode allocate memory proportional to a huge declared size.
|
||||
// Camera previews are at most full sensor resolution (a 100MP medium format JPEG is about 102M pixels).
|
||||
const maxEmbeddedPreviewPixels = 150_000_000
|
||||
|
||||
// embeddedPreviewWithinLimits reports whether a JPEG's declared dimensions are small enough to decode.
|
||||
// Only the header is read. A JPEG whose header can't be parsed is rejected too: its size can't be
|
||||
// checked, and the decode steps that follow would run on it unbounded.
|
||||
func embeddedPreviewWithinLimits(data []byte) bool {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return int64(cfg.Width)*int64(cfg.Height) <= maxEmbeddedPreviewPixels
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package preview
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHasEmbeddedPreview(t *testing.T) {
|
||||
if !hasEmbeddedPreview("image/x-canon-cr2", "photo.cr2") {
|
||||
@@ -19,3 +24,48 @@ func TestIsJPEGMarker(t *testing.T) {
|
||||
t.Fatal("expected JPEG SOI marker")
|
||||
}
|
||||
}
|
||||
|
||||
// jpegWithDeclaredSize returns a valid small JPEG whose SOF header declares the given size.
|
||||
// Only the header is rewritten, so building it costs nothing even for a huge declared size.
|
||||
func jpegWithDeclaredSize(t *testing.T, width, height int) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, image.NewGray(image.Rect(0, 0, 8, 8)), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := buf.Bytes()
|
||||
sof := bytes.Index(data, []byte{0xff, 0xc0})
|
||||
if sof < 0 {
|
||||
t.Fatal("no SOF0 marker in test JPEG")
|
||||
}
|
||||
// marker(2) length(2) precision(1) height(2) width(2)
|
||||
data[sof+5], data[sof+6] = byte(height>>8), byte(height)
|
||||
data[sof+7], data[sof+8] = byte(width>>8), byte(width)
|
||||
return data
|
||||
}
|
||||
|
||||
func TestEmbeddedPreviewWithinLimits(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
width, height int
|
||||
want bool
|
||||
}{
|
||||
{"normal camera preview", 6000, 4000, true},
|
||||
{"100MP medium format preview", 11648, 8736, true},
|
||||
{"declared size far beyond any camera", 60000, 60000, false},
|
||||
{"just over the limit", 12248, 12248, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := embeddedPreviewWithinLimits(jpegWithDeclaredSize(t, tt.width, tt.height)); got != tt.want {
|
||||
t.Fatalf("embeddedPreviewWithinLimits(%dx%d) = %v, want %v", tt.width, tt.height, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedPreviewWithinLimitsRejectsAnUnreadableHeader(t *testing.T) {
|
||||
if embeddedPreviewWithinLimits([]byte{0xff, 0xd8, 0xff, 0xe0, 0x00}) {
|
||||
t.Fatal("a header that can't be read can't be size-checked, so it is rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/kovidgoyal/imaging"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// patternYCbCr is a 4:2:0 image (what a camera JPEG decodes to) with a pattern that differs in
|
||||
// every row and column, so a crop off by one pixel shows up in the resized output.
|
||||
func patternYCbCr(w, h int) *image.YCbCr {
|
||||
return patternYCbCrRatio(w, h, image.YCbCrSubsampleRatio420)
|
||||
}
|
||||
|
||||
// patternYCbCrRatio is patternYCbCr with the given chroma subsampling.
|
||||
func patternYCbCrRatio(w, h int, r image.YCbCrSubsampleRatio) *image.YCbCr {
|
||||
img := image.NewYCbCr(image.Rect(0, 0, w, h), r)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Y[img.YOffset(x, y)] = uint8((x*7 + y*13) % 251)
|
||||
}
|
||||
}
|
||||
for i := range img.Cb {
|
||||
img.Cb[i] = uint8((i*3 + i/img.CStride) % 256)
|
||||
img.Cr[i] = uint8((i + 5*(i/img.CStride)) % 256)
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// patternNRGBA is an NRGBA image whose pixels differ in every row and column.
|
||||
func patternNRGBA(w, h int) *image.NRGBA {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.SetNRGBA(x, y, color.NRGBA{uint8(x % 256), uint8(y % 256), uint8((x + y) % 256), 255})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// requireSamePixels fails unless want and got are the same size and every pixel is equal,
|
||||
// comparing from each image's own origin.
|
||||
func requireSamePixels(t *testing.T, want, got image.Image) {
|
||||
t.Helper()
|
||||
require.Equal(t, want.Bounds().Size(), got.Bounds().Size())
|
||||
wb, gb := want.Bounds().Min, got.Bounds().Min
|
||||
for y := 0; y < want.Bounds().Dy(); y++ {
|
||||
for x := 0; x < want.Bounds().Dx(); x++ {
|
||||
wr, wg, wbl, wa := want.At(wb.X+x, wb.Y+y).RGBA()
|
||||
gr, gg, gbl, ga := got.At(gb.X+x, gb.Y+y).RGBA()
|
||||
if wr != gr || wg != gg || wbl != gbl || wa != ga {
|
||||
t.Fatalf("pixel (%d,%d) differs: imaging.Fill %v, fillCenter %v", x, y,
|
||||
[4]uint32{wr, wg, wbl, wa}, [4]uint32{gr, gg, gbl, ga})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fillCenter must produce exactly what imaging.Fill(…, imaging.Center, …) produces: the same
|
||||
// pixels and the same image type, so the encoded preview is the same bytes.
|
||||
func TestFillCenter_MatchesImagingFill(t *testing.T) {
|
||||
sources := map[string]image.Image{
|
||||
"ycbcr landscape": patternYCbCr(1200, 801),
|
||||
"ycbcr portrait": patternYCbCr(601, 1000),
|
||||
"nrgba square": patternNRGBA(700, 700),
|
||||
"nrgba wide": patternNRGBA(1500, 300),
|
||||
"small (falls back to imaging.Fill)": patternNRGBA(90, 60),
|
||||
"exact target size": patternNRGBA(256, 256),
|
||||
"offset origin": patternNRGBA(900, 700).SubImage(image.Rect(37, 11, 837, 611)),
|
||||
}
|
||||
// Every subsampling ratio, in shapes whose centered crop has an x (or y) offset and reaches
|
||||
// the source's last row (or column) -- where a SubImage's buffer ends short of a full stride.
|
||||
for _, r := range []image.YCbCrSubsampleRatio{
|
||||
image.YCbCrSubsampleRatio444, image.YCbCrSubsampleRatio422, image.YCbCrSubsampleRatio420,
|
||||
image.YCbCrSubsampleRatio440, image.YCbCrSubsampleRatio411, image.YCbCrSubsampleRatio410,
|
||||
} {
|
||||
for _, sz := range [][2]int{{600, 264}, {264, 600}, {601, 263}} {
|
||||
sources[fmt.Sprintf("ycbcr %v %dx%d", r, sz[0], sz[1])] = patternYCbCrRatio(sz[0], sz[1], r)
|
||||
}
|
||||
}
|
||||
sources["nycbcra 444 600x264"] = &image.NYCbCrA{YCbCr: *patternYCbCrRatio(600, 264, image.YCbCrSubsampleRatio444), A: make([]uint8, 600*264), AStride: 600}
|
||||
// 264x264 is the crop of the 600x264 and 264x600 sources itself: nothing to scale.
|
||||
targets := [][2]int{{256, 256}, {320, 180}, {180, 320}, {264, 264}}
|
||||
filters := map[string]imaging.ResampleFilter{
|
||||
"nearest": imaging.NearestNeighbor, "box": imaging.Box, "catmullrom": imaging.CatmullRom,
|
||||
}
|
||||
for name, src := range sources {
|
||||
for _, tg := range targets {
|
||||
for fname, f := range filters {
|
||||
t.Run(fmt.Sprintf("%s/%dx%d/%s", name, tg[0], tg[1], fname), func(t *testing.T) {
|
||||
want := imaging.Fill(src, tg[0], tg[1], imaging.Center, f)
|
||||
got := fillCenter(src, tg[0], tg[1], f)
|
||||
require.Equal(t, fmt.Sprintf("%T", want), fmt.Sprintf("%T", got), "image type")
|
||||
requireSamePixels(t, want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A square thumbnail of a large photo must not allocate a full-resolution copy of it.
|
||||
func TestFillCenter_NoFullSizeCopy(t *testing.T) {
|
||||
const w, h = 4000, 3000
|
||||
src := patternYCbCr(w, h)
|
||||
fullCopy := uint64(h * h * 4) // the square crop imaging.Fill materializes as NRGBA
|
||||
|
||||
var before, after runtime.MemStats
|
||||
runtime.ReadMemStats(&before)
|
||||
out := fillCenter(src, 256, 256, imaging.Box)
|
||||
runtime.ReadMemStats(&after)
|
||||
|
||||
require.Equal(t, image.Pt(256, 256), out.Bounds().Size())
|
||||
allocated := after.TotalAlloc - before.TotalAlloc
|
||||
require.Less(t, allocated, fullCopy/4,
|
||||
"fillCenter allocated %d bytes; a copy of the crop would be %d", allocated, fullCopy)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"math"
|
||||
|
||||
exif "github.com/dsoprea/go-exif/v3"
|
||||
exifcommon "github.com/dsoprea/go-exif/v3/common"
|
||||
@@ -198,6 +199,50 @@ func (s *Service) ResizeWithSize(ctx context.Context, in io.Reader, out io.Write
|
||||
return err
|
||||
}
|
||||
|
||||
// fillCenter is imaging.Fill(img, width, height, imaging.Center, filter) without a full-size copy
|
||||
// of the source. For sources of 100px or more, imaging.Fill first crops into a new NRGBA image
|
||||
// (4 bytes per pixel of the cropped area, about 144 MB for a 48 MP photo) and only then resizes.
|
||||
// Here the crop is a SubImage view into the decoded pixels, so only the resize allocates. The
|
||||
// crop rectangle is computed as imaging's cropAndResize computes it, so the output is the same.
|
||||
//
|
||||
// Two cases still go through imaging.Fill: a crop that is already the target size (nothing to
|
||||
// scale, and Fill's small copy is what keeps the output type the same), and 4:4:4 YCbCr sources,
|
||||
// whose scanner in imaging v1.9.0 advances one full parent stride past the end of a SubImage's
|
||||
// last row and panics when the crop has an x offset and reaches the bottom row.
|
||||
func fillCenter(img image.Image, width, height int, filter imaging.ResampleFilter) image.Image {
|
||||
b := img.Bounds()
|
||||
sub, ok := img.(interface {
|
||||
SubImage(image.Rectangle) image.Image
|
||||
})
|
||||
if !ok || is444(img) || width <= 0 || height <= 0 || b.Dx() < 100 || b.Dy() < 100 || (b.Dx() == width && b.Dy() == height) {
|
||||
return imaging.Fill(img, width, height, imaging.Center, filter)
|
||||
}
|
||||
cropW, cropH := b.Dx(), b.Dy()
|
||||
if float64(b.Dx())/float64(b.Dy()) < float64(width)/float64(height) {
|
||||
cropH = int(math.Max(1, float64(b.Dx())*float64(height)/float64(width)) + 0.5)
|
||||
} else {
|
||||
cropW = int(math.Max(1, float64(b.Dy())*float64(width)/float64(height)) + 0.5)
|
||||
}
|
||||
if cropW == width && cropH == height {
|
||||
return imaging.Fill(img, width, height, imaging.Center, filter)
|
||||
}
|
||||
x0 := b.Min.X + (b.Dx()-cropW)/2
|
||||
y0 := b.Min.Y + (b.Dy()-cropH)/2
|
||||
crop := image.Rect(x0, y0, x0+cropW, y0+cropH).Intersect(b)
|
||||
return imaging.Resize(sub.SubImage(crop), width, height, filter)
|
||||
}
|
||||
|
||||
// is444 reports whether img is a YCbCr image without chroma subsampling.
|
||||
func is444(img image.Image) bool {
|
||||
switch p := img.(type) {
|
||||
case *image.YCbCr:
|
||||
return p.SubsampleRatio == image.YCbCrSubsampleRatio444
|
||||
case *image.NYCbCrA:
|
||||
return p.SubsampleRatio == image.YCbCrSubsampleRatio444
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) resizeWithSize(in io.Reader, out io.Writer, fileSize int64, opts ResizeOptions) error {
|
||||
// Set defaults
|
||||
if opts.ResizeMode == 0 {
|
||||
@@ -275,7 +320,7 @@ func (s *Service) resizeWithSize(in io.Reader, out io.Writer, fileSize int64, op
|
||||
resampleFilter := opts.Quality.resampleFilter()
|
||||
switch opts.ResizeMode {
|
||||
case ResizeModeFill:
|
||||
img = imaging.Fill(img, opts.Width, opts.Height, imaging.Center, resampleFilter)
|
||||
img = fillCenter(img, opts.Width, opts.Height, resampleFilter)
|
||||
case ResizeModeFit:
|
||||
fallthrough
|
||||
default:
|
||||
@@ -401,8 +446,32 @@ func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
|
||||
return nil, wrappedReader, exif.ErrNoThumbnail
|
||||
}
|
||||
|
||||
// Read IFD1's Orientation first; fall back to IFD0's Orientation if not specified (per MWG guidelines)
|
||||
orientStr := ""
|
||||
tags1, err1 := ifd.FindTagWithName("Orientation")
|
||||
if err1 == nil && len(tags1) > 0 {
|
||||
orientStr, _ = tags1[0].FormatFirst()
|
||||
}
|
||||
if orientStr == "" {
|
||||
tags0, err0 := index.RootIfd.FindTagWithName("Orientation")
|
||||
if err0 == nil && len(tags0) > 0 {
|
||||
orientStr, _ = tags0[0].FormatFirst()
|
||||
}
|
||||
}
|
||||
|
||||
thm, err := ifd.Thumbnail()
|
||||
return thm, wrappedReader, err
|
||||
if err != nil {
|
||||
return nil, wrappedReader, err
|
||||
}
|
||||
|
||||
if orientStr != "" && orientStr != "1" {
|
||||
if !embeddedPreviewWithinLimits(thm) {
|
||||
return nil, wrappedReader, fmt.Errorf("embedded thumbnail exceeds decode limits")
|
||||
}
|
||||
thm = applyOrientationToPreviewBytes(thm, orientStr)
|
||||
}
|
||||
|
||||
return thm, wrappedReader, nil
|
||||
}
|
||||
|
||||
// CreateThumbnail decodes an image and creates a fixed-size thumbnail.
|
||||
|
||||
@@ -549,3 +549,41 @@ func TestReadImageFileDimensions(t *testing.T) {
|
||||
require.Greater(t, width, 0)
|
||||
require.Greater(t, height, 0)
|
||||
}
|
||||
|
||||
func TestApplyOrientationToPreviewBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Generate a 100x60 test JPEG image in memory (landscape)
|
||||
img := image.NewRGBA(image.Rect(0, 0, 100, 60))
|
||||
var buf bytes.Buffer
|
||||
err := jpeg.Encode(&buf, img, nil)
|
||||
require.NoError(t, err)
|
||||
rawBytes := buf.Bytes()
|
||||
|
||||
tests := []struct {
|
||||
orientation string
|
||||
wantWidth int
|
||||
wantHeight int
|
||||
}{
|
||||
{orientation: "1", wantWidth: 100, wantHeight: 60},
|
||||
{orientation: "Horizontal (normal)", wantWidth: 100, wantHeight: 60},
|
||||
{orientation: "8", wantWidth: 60, wantHeight: 100},
|
||||
{orientation: "Rotate 270 CW", wantWidth: 60, wantHeight: 100},
|
||||
{orientation: "6", wantWidth: 60, wantHeight: 100},
|
||||
{orientation: "Rotate 90 CW", wantWidth: 60, wantHeight: 100},
|
||||
{orientation: "3", wantWidth: 100, wantHeight: 60},
|
||||
{orientation: "Rotate 180", wantWidth: 100, wantHeight: 60},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.orientation, func(t *testing.T) {
|
||||
rotated := applyOrientationToPreviewBytes(rawBytes, tt.orientation)
|
||||
require.NotEmpty(t, rotated)
|
||||
|
||||
cfg, _, decodeErr := image.DecodeConfig(bytes.NewReader(rotated))
|
||||
require.NoError(t, decodeErr)
|
||||
require.Equal(t, tt.wantWidth, cfg.Width)
|
||||
require.Equal(t, tt.wantHeight, cfg.Height)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func NewPreviewGenerator(concurrencyLimit int, cacheDir string) *Service {
|
||||
var fileCache diskcache.Interface
|
||||
// Use file cache if cacheDir is specified
|
||||
var err error
|
||||
fileCache, err = diskcache.NewFileCache(actualCacheDir)
|
||||
fileCache, err = diskcache.NewFileCacheWithLimit(actualCacheDir, max(settings.Config.Server.CacheDirMaxSizeMB, 0)*1024*1024)
|
||||
if err != nil {
|
||||
logger.Error("The cache dir could not be created. Make sure the user that you executed the program with has access to create directories in the local path. See ")
|
||||
logger.Fatalf("failed to create file cache path, which is now require to run the server: %v", err)
|
||||
@@ -449,7 +449,9 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
|
||||
}
|
||||
|
||||
// Enforce file size limit for image preview generation to prevent memory exhaustion
|
||||
if strings.HasPrefix(file.Type, "image") && file.Size > iteminfo.LargeFileSizeThreshold {
|
||||
// Camera RAW is exempt: its preview is the embedded JPEG and the RAW data is never decoded.
|
||||
if strings.HasPrefix(file.Type, "image") && file.Size > iteminfo.LargeFileSizeThreshold &&
|
||||
!iteminfo.IsRawImage(strings.ToLower(filepath.Ext(file.Name))) {
|
||||
message := fmt.Sprintf("Image file too large for preview: %s (size: %d bytes, limit: %d bytes)",
|
||||
file.Name, file.Size, iteminfo.LargeFileSizeThreshold)
|
||||
logger.Warning(message)
|
||||
@@ -467,7 +469,7 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
|
||||
var imageBytes []byte
|
||||
fromEmbeddedPreview := false
|
||||
if hasEmbeddedPreview(file.Type, file.Name) {
|
||||
if previewBytes, _ := imagemeta.ExtractEmbeddedPreview(ctx, file.RealPath); len(previewBytes) >= minPreviewSize && isJPEG(previewBytes) {
|
||||
if previewBytes, _ := imagemeta.ExtractEmbeddedPreview(ctx, file.RealPath); len(previewBytes) >= minPreviewSize && isJPEG(previewBytes) && embeddedPreviewWithinLimits(previewBytes) {
|
||||
imageBytes = previewBytes
|
||||
fromEmbeddedPreview = true
|
||||
if orient := imagemeta.GetOrientation(ctx, file.RealPath); orient != "" {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -19,6 +19,15 @@ func AccessPermitted(sourcePath string, indexPath utils.IndexPath, username stri
|
||||
return accessDb.Permitted(sourcePath, indexPath, username)
|
||||
}
|
||||
|
||||
// AccessPermittedAtRealPath reports whether username may access realPath after symlink
|
||||
// resolution on the given index. When accessDb is not initialized, access is allowed.
|
||||
func AccessPermittedAtRealPath(idx *indexing.Index, username, realPath string, isDir bool) bool {
|
||||
if accessDb == nil {
|
||||
return true
|
||||
}
|
||||
return accessDb.PermittedAtRealPath(idx, username, realPath, isDir)
|
||||
}
|
||||
|
||||
func AllowUser(sourcePath string, indexPath utils.IndexPath, username string) error {
|
||||
return accessDb.AllowUser(sourcePath, indexPath, username)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -297,6 +298,17 @@ func ApplyUserDefaults(u *users.User) {
|
||||
applyToolAccessDefaults(u)
|
||||
}
|
||||
|
||||
// PrefillUserProfileDefaults seeds u with the effective profile defaults so a JSON decode
|
||||
// overlays only the keys the request sent (encoding/json leaves absent keys untouched).
|
||||
// Permissions are left zero so omitted permissions stay distinguishable; CreateUser merges them.
|
||||
func PrefillUserProfileDefaults(u *users.User) {
|
||||
settings.ApplyFullProfileFromDefaults(u, EffectiveUserDefaults())
|
||||
// json.Unmarshal reuses an existing slice's backing array, so clone it or a request
|
||||
// would overwrite the shared in-memory defaults.
|
||||
u.NewFileTemplate = slices.Clone(u.NewFileTemplate)
|
||||
u.Permissions = users.Permissions{}
|
||||
}
|
||||
|
||||
func applyUserSettingsDefaults(u *users.User) {
|
||||
settings.ApplyUserDefaultsFrom(u, EffectiveUserDefaults())
|
||||
}
|
||||
|
||||
@@ -202,6 +202,18 @@ func UserForShareOwner(link share.Share) (users.User, error) {
|
||||
// may apply default-enabled sources. In-memory/cache users always keep FrontendScopes nil; GET handlers use
|
||||
// PrepForFrontend to repopulate FrontendScopes from BackendScopes for responses.
|
||||
func CreateUser(user *users.User, plaintextPassword string) error {
|
||||
return createUser(user, plaintextPassword, false)
|
||||
}
|
||||
|
||||
// CreateUserKeepingProfile is CreateUser for callers whose user was seeded with
|
||||
// PrefillUserProfileDefaults before decoding the request: the profile fields already hold
|
||||
// defaults overlaid with the request, so they are not replaced by userDefaults again.
|
||||
// Enforced defaults still win.
|
||||
func CreateUserKeepingProfile(user *users.User, plaintextPassword string) error {
|
||||
return createUser(user, plaintextPassword, true)
|
||||
}
|
||||
|
||||
func createUser(user *users.User, plaintextPassword string, keepProfile bool) error {
|
||||
if err := users.ValidateUsername(user.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -256,12 +268,19 @@ func CreateUser(user *users.User, plaintextPassword string) error {
|
||||
requestedScopePermissions[backendScopeKeyFor(scope)] = scope.Permissions
|
||||
}
|
||||
}
|
||||
var requestedProfile settings.UserProfile
|
||||
if keepProfile {
|
||||
requestedProfile = settings.ProfileFromUser(user)
|
||||
}
|
||||
// Apply profile/scope defaults using the final account permissions (admin flag drives scope defaults).
|
||||
if permissionsRequested {
|
||||
settings.ApplyUserDefaultsFromKeepingAccountPermissions(user, EffectiveUserDefaults())
|
||||
} else {
|
||||
applyUserSettingsDefaults(user)
|
||||
}
|
||||
if keepProfile {
|
||||
settings.ExpandProfileIntoUser(user, requestedProfile)
|
||||
}
|
||||
if preserveRequirePasswordChange {
|
||||
user.RequirePasswordChange = true
|
||||
}
|
||||
|
||||
@@ -15,12 +15,18 @@ type SubtitleTrack struct {
|
||||
Embedded bool `json:"embedded"` // true for embedded subtitles, false for external files
|
||||
}
|
||||
|
||||
// ErrSubtitleSidecarNotRegular is returned when an external subtitle path is not a regular file (e.g. symlink).
|
||||
var ErrSubtitleSidecarNotRegular = errors.New("subtitle sidecar is not a regular file")
|
||||
|
||||
func GetSubtitleSidecarContent(subtitlePath string) (string, error) {
|
||||
// validate the size is not too large
|
||||
info, err := os.Stat(subtitlePath)
|
||||
// Sidecars sit beside the media file; do not follow symlink leaves (see lyricsSidecar).
|
||||
info, err := os.Lstat(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", ErrSubtitleSidecarNotRegular
|
||||
}
|
||||
if info.Size() > 1024*1024*50 { // 50MB
|
||||
return "", errors.New("subtitle file is too large")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
func setupAccessHTTPTest(t *testing.T) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
dbPath := filepath.Join(t.TempDir(), "access-http-test.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -279,19 +279,21 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
|
||||
if fullDestParent != fullDestFile && !state.AccessPermitted(idxTo.Path, utils.IndexPathFromNormalized(fullDestParent, true), d.User.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination parent of %s", req.Destination)
|
||||
}
|
||||
parentReal, err := idxTo.GetRealPathForMutation(userScopeTo, fullDestParent)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination parent path invalid: %v", err)
|
||||
}
|
||||
if err = os.MkdirAll(parentReal, fileutils.EffectiveDirPerm()); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination parent directory: %v", err)
|
||||
}
|
||||
destRel := strings.TrimLeft(filepath.ToSlash(req.Destination), "/")
|
||||
fileName := path.Base(destRel)
|
||||
if fileName == "." || fileName == "/" {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid destination file name")
|
||||
}
|
||||
destFileReal := filepath.Join(parentReal, fileName)
|
||||
destFileReal, err := idxTo.GetRealPathForMutation(userScopeTo, fullDestFile)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination path invalid: %v", err)
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idxTo, d.User.Username, destFileReal, false) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(destFileReal), fileutils.EffectiveDirPerm()); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination parent directory: %v", err)
|
||||
}
|
||||
|
||||
format := strings.ToLower(strings.TrimSpace(req.Format))
|
||||
if format == "" {
|
||||
@@ -1270,6 +1272,7 @@ func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time
|
||||
logger.Debugf("unarchive: symlink: %v", err)
|
||||
return fmt.Errorf("failed to create symlink from archive entry")
|
||||
}
|
||||
indexing.ClearRealPathCaches()
|
||||
return applyArchivedTimesAndPerm(destPath, fs.ModeSymlink, modTime)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestArchiveCreate_DeniesResolvedSymlinkDestination(t *testing.T) {
|
||||
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/denied", true), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowedDir := filepath.Join(sourceRoot, "allowed")
|
||||
deniedDir := filepath.Join(sourceRoot, "denied")
|
||||
for _, dir := range []string{allowedDir, deniedDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
victim := filepath.Join(deniedDir, "victim")
|
||||
if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceRoot, "a.txt"), []byte("payload"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(allowedDir, "report.zip")
|
||||
if err := os.Symlink(filepath.Join("..", "denied", "victim"), link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user, err := state.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Config.Server.SourceMap[sourceRoot].Config.DenyByDefault = false
|
||||
|
||||
body := `{"fromSource":"` + sourceName + `","paths":["/a.txt"],"destination":"/allowed/report.zip","format":"zip"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources/archive", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := archiveCreateHandler(rec, req, &Context{User: &user})
|
||||
if status != http.StatusForbidden || handlerErr == nil {
|
||||
t.Fatalf("status=%d err=%v", status, handlerErr)
|
||||
}
|
||||
got, err := os.ReadFile(victim)
|
||||
if err != nil || string(got) != "keep" {
|
||||
t.Fatalf("victim content=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveCreate_AllowsNonSymlinkDestination(t *testing.T) {
|
||||
_, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
dest := filepath.Join(sourceRoot, "out.zip")
|
||||
if err := os.WriteFile(filepath.Join(sourceRoot, "a.txt"), []byte("payload"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user, err := state.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := `{"fromSource":"` + sourceName + `","paths":["/a.txt"],"destination":"/out.zip","format":"zip"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources/archive", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, handlerErr := archiveCreateHandler(rec, req, &Context{User: &user})
|
||||
if status != http.StatusOK || handlerErr != nil {
|
||||
t.Fatalf("status=%d err=%v body=%s", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(dest); err != nil {
|
||||
t.Fatalf("expected archive at %s: %v", dest, err)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
func setupExtractAccessPolicyTest(t *testing.T) (destDir, sourceRoot, sourceName, username string) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "access.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
|
||||
@@ -3,11 +3,16 @@ package web
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestNormalizeArchiveEntryName(t *testing.T) {
|
||||
@@ -557,3 +562,19 @@ func TestExtractZip_symlinkDestination(t *testing.T) {
|
||||
t.Fatalf("victim.txt should be unchanged: %q, %v", victimData, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveCreateDestination_RejectsEscapingSymlinkLeaf(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(filepath.Dir(root), "outside-archive")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(root, "report.zip")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx := &indexing.Index{Source: settings.Source{Path: root}}
|
||||
if _, err := idx.GetRealPathForMutation("", "/report.zip"); !errors.Is(err, commonerrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
|
||||
func setupShareArchiveDownloadTest(t *testing.T) (sourceRoot string, sourceName string, owner *users.User) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
dbPath := filepath.Join(t.TempDir(), "share-dl-test.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -187,10 +187,10 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to file")
|
||||
}
|
||||
|
||||
// Get real file path
|
||||
realPath, _, err := idx.GetRealPath(scopePath)
|
||||
// Get real file path (scoped) and enforce ACL on the resolved target
|
||||
realPath, _, err := idx.GetRealPathScoped(userScope, scopePath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
return realPathErrStatus(err), err
|
||||
}
|
||||
|
||||
// Get file/directory info
|
||||
@@ -198,6 +198,9 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idx, d.User.Username, realPath, info.IsDir()) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to file")
|
||||
}
|
||||
|
||||
// Get MIME type from the index if available
|
||||
mimeType := "application/octet-stream"
|
||||
@@ -335,10 +338,10 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to file")
|
||||
}
|
||||
|
||||
// Get real file path
|
||||
realPath, _, err := idx.GetRealPath(scopePath)
|
||||
// Get real file path (scoped) and enforce ACL on the resolved target
|
||||
realPath, _, err := idx.GetRealPathScoped(userScope, scopePath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
|
||||
return realPathErrStatus(err), err
|
||||
}
|
||||
|
||||
// Get file/directory info
|
||||
@@ -346,6 +349,9 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idx, d.User.Username, realPath, info.IsDir()) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to file")
|
||||
}
|
||||
|
||||
// Set up SSE headers
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
|
||||
@@ -22,3 +22,37 @@ func TestErrToStatusWrapsUnsupportedPreviewFormat(t *testing.T) {
|
||||
t.Fatalf("ErrToStatus() = %d, want %d", status, http.StatusUnsupportedMediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsViewerOriginalPreview(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
size string
|
||||
file string
|
||||
fileType string
|
||||
docs bool // the server can render documents to an image
|
||||
want bool
|
||||
}{
|
||||
{"Fuji RAW viewer", "original", "DSCF0308.RAF", "image/x-fuji-raf", false, true},
|
||||
{"Canon RAW viewer", "original", "IMG_0001.CR3", "image/x-canon-cr3", false, true},
|
||||
{"Adobe DNG viewer", "original", "a.dng", "image/x-adobe-dng", false, true},
|
||||
{"HEIC viewer", "original", "a.heic", "image/heic", false, true},
|
||||
{"HEIF viewer", "original", "a.heif", "image/heif", false, true},
|
||||
{"rendered document viewer", "original", "a.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", true, true},
|
||||
{"documents need the server to be able to render them", "original", "a.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", false, false},
|
||||
{"RAW thumbnail stays disabled", "small", "DSCF0308.RAF", "image/x-fuji-raf", true, false},
|
||||
{"RAW large stays disabled", "large", "DSCF0308.RAF", "image/x-fuji-raf", true, false},
|
||||
{"no size stays disabled", "", "DSCF0308.RAF", "image/x-fuji-raf", true, false},
|
||||
{"video poster stays disabled", "original", "a.mov", "video/quicktime", true, false},
|
||||
{"audio cover stays disabled", "original", "a.mp3", "audio/mpeg", true, false},
|
||||
{"JPEG is opened directly, not through previews", "original", "a.jpg", "image/jpeg", true, false},
|
||||
{"PDF is opened directly, not through previews", "original", "a.pdf", "application/pdf", true, false},
|
||||
{"text stays disabled", "original", "a.txt", "text/plain", true, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isViewerOriginalPreview(tt.size, tt.file, tt.fileType, tt.docs); got != tt.want {
|
||||
t.Fatalf("isViewerOriginalPreview(%q, %q, %q, %v) = %v, want %v", tt.size, tt.file, tt.fileType, tt.docs, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,10 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
pathWithoutUserScope = pathWithoutUserScope + "/"
|
||||
}
|
||||
data.IndexPath = pathWithoutUserScope
|
||||
// skip file fetch for certain apis
|
||||
// Skip FileInfoFaster prefetch for performance; handlers must apply equivalent
|
||||
// checks (access rules + GetRealPathScoped with d.Share.Path where bytes are read).
|
||||
// TODO: align all skipped public-share routes on one bound (Share.Path) or stop
|
||||
// skipping FileInfoFaster here for media/metadata/items.
|
||||
if (r.Method == "POST" && strings.Contains(r.URL.Path, "/resources")) ||
|
||||
(r.Method == "POST" && strings.Contains(r.URL.Path, "/resources/view-token")) ||
|
||||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items")) ||
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -22,6 +24,7 @@ import (
|
||||
)
|
||||
|
||||
func setupTestEnv(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
utils.BcryptCost = bcrypt.MinCost
|
||||
dbPath := filepath.Join(t.TempDir(), "test.sqlite")
|
||||
|
||||
@@ -534,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
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -38,6 +39,31 @@ func isClientCancellation(ctx context.Context, err error) bool {
|
||||
return errors.Is(err, context.Canceled)
|
||||
}
|
||||
|
||||
// viewerConvertedDocExtensions are the documents the viewer opens as a rendered image through the
|
||||
// "original" preview (keep in sync with pdfConvertable in frontend Preview.vue).
|
||||
var viewerConvertedDocExtensions = map[string]bool{
|
||||
".xps": true, ".mobi": true, ".fb2": true, ".cbz": true, ".svg": true,
|
||||
".docx": true, ".pptx": true, ".xlsx": true, ".hwp": true, ".hwpx": true,
|
||||
}
|
||||
|
||||
// isViewerOriginalPreview reports whether a preview request is the viewer opening a file the browser can't
|
||||
// display by itself (camera RAW, HEIC/HEIF, rendered documents). Unlike a thumbnail, without it the file can't
|
||||
// be opened at all, so it is served even when thumbnails are disabled. documentRenderable tells whether the
|
||||
// server can render documents of this kind to an image (see iteminfo.HasDocConvertableExtension).
|
||||
func isViewerOriginalPreview(previewSize, fileName, fileType string, documentRenderable bool) bool {
|
||||
if previewSize != "original" {
|
||||
return false
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
if iteminfo.IsRawImage(ext) {
|
||||
return true
|
||||
}
|
||||
if fileType == "image/heic" || fileType == "image/heif" {
|
||||
return true
|
||||
}
|
||||
return documentRenderable && viewerConvertedDocExtensions[ext]
|
||||
}
|
||||
|
||||
// previewHandler handles the preview request for images.
|
||||
// @Summary Get image preview
|
||||
// @Description Returns a preview image based on the requested path and size.
|
||||
@@ -55,7 +81,9 @@ func isClientCancellation(ctx context.Context, err error) bool {
|
||||
// @Failure 501 {object} map[string]string "Preview generation not implemented"
|
||||
// @Router /api/resources/preview [get]
|
||||
func previewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
|
||||
if settings.Config.Server.DisablePreviews {
|
||||
previewsDisabled := settings.Config.Server.DisablePreviews
|
||||
// Only the viewer's "original" requests can pass when previews are disabled (checked below once the file is known).
|
||||
if previewsDisabled && r.URL.Query().Get("size") != "original" {
|
||||
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
|
||||
}
|
||||
path := r.URL.Query().Get("path")
|
||||
@@ -85,6 +113,9 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, er
|
||||
return ErrToStatus(err), err
|
||||
}
|
||||
d.FileInfo = *fileInfo
|
||||
if previewsDisabled && !isViewerOriginalPreview(r.URL.Query().Get("size"), fileInfo.Name, fileInfo.Type, iteminfo.HasDocConvertableExtension(fileInfo.Name, fileInfo.Type)) {
|
||||
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
|
||||
}
|
||||
status, err := PreviewHelperFunc(w, r, d)
|
||||
if err != nil {
|
||||
// Error already logged in PreviewHelperFunc or its callees
|
||||
@@ -109,7 +140,9 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, er
|
||||
// @Failure 501 {object} map[string]string "Previews disabled globally, for this share, or for upload shares"
|
||||
// @Router /public/api/resources/preview [get]
|
||||
func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
|
||||
if settings.Config.Server.DisablePreviews || d.Share.DisableThumbnails {
|
||||
previewSize := r.URL.Query().Get("size")
|
||||
if (settings.Config.Server.DisablePreviews || d.Share.DisableThumbnails) &&
|
||||
!isViewerOriginalPreview(previewSize, d.FileInfo.Name, d.FileInfo.Type, iteminfo.HasDocConvertableExtension(d.FileInfo.Name, d.FileInfo.Type)) {
|
||||
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
|
||||
}
|
||||
if d.Share.ShareType == "upload" {
|
||||
@@ -185,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)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func TestGetRemoteIPUsesRightmostForwardedFor(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -27,6 +28,7 @@ func TestGetRemoteIPUsesRightmostForwardedFor(t *testing.T) {
|
||||
// proxy hops so multi-hop deployments (CDN -> proxy -> filebrowser) resolve the
|
||||
// real client IP instead of collapsing everyone onto the shared edge address.
|
||||
func TestGetRemoteIPTrustedProxies(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -59,6 +61,7 @@ func TestGetRemoteIPTrustedProxies(t *testing.T) {
|
||||
|
||||
// Bare IPs (not just CIDRs) are accepted in http.trustedProxies.
|
||||
func TestGetRemoteIPTrustedProxiesBareIP(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -74,6 +77,7 @@ func TestGetRemoteIPTrustedProxiesBareIP(t *testing.T) {
|
||||
|
||||
// Without trustProxyHeaders the XFF chain is ignored entirely.
|
||||
func TestGetRemoteIPIgnoresForwardedForWhenUntrusted(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = false
|
||||
@@ -90,6 +94,7 @@ func TestGetRemoteIPIgnoresForwardedForWhenUntrusted(t *testing.T) {
|
||||
// A direct client that is not a trusted proxy cannot spoof its address with
|
||||
// forwarded headers, even when trustProxyHeaders is enabled.
|
||||
func TestGetRemoteIPIgnoresForwardedHeadersFromUntrustedPeer(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -104,8 +109,26 @@ func TestGetRemoteIPIgnoresForwardedHeadersFromUntrustedPeer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRemoteIPUnixSocketHonorsForwardedWithTrustAllLocalPeers(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
settings.Config.Http.Socket = "/var/run/filebrowser.sock"
|
||||
settings.Config.Http.UnixSocketTrustAllLocalPeers = true
|
||||
settings.Config.Http.TrustedProxies = nil
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/", nil)
|
||||
req.RemoteAddr = "@"
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.50")
|
||||
if got := GetRemoteIP(req); got != "203.0.113.50" {
|
||||
t.Fatalf("GetRemoteIP = %q, want 203.0.113.50", got)
|
||||
}
|
||||
}
|
||||
|
||||
// With no trusted proxies configured, forwarded headers are never honored.
|
||||
func TestGetRemoteIPIgnoresForwardedHeadersWithoutTrustedProxies(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -121,6 +144,7 @@ func TestGetRemoteIPIgnoresForwardedHeadersWithoutTrustedProxies(t *testing.T) {
|
||||
|
||||
// An IPv4-mapped IPv6 trusted hop in the XFF chain must still be skipped.
|
||||
func TestGetRemoteIPMappedTrustedHop(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
@@ -135,6 +159,7 @@ func TestGetRemoteIPMappedTrustedHop(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetScheme(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
t.Run("trusted proto", func(t *testing.T) {
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
|
||||
@@ -158,6 +183,7 @@ func TestGetScheme(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestSchemeTLSFallback(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
settings.Config.Http.TrustProxyHeaders = false
|
||||
|
||||
req := httptest.NewRequest("GET", "https://example.com/", nil)
|
||||
@@ -168,6 +194,7 @@ func TestRequestSchemeTLSFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestSchemeIgnoresInvalidProto(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
|
||||
|
||||
@@ -179,6 +206,7 @@ func TestRequestSchemeIgnoresInvalidProto(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestHostTrustProxyHeaders(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
|
||||
|
||||
@@ -192,6 +220,7 @@ func TestRequestHostTrustProxyHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestSchemeForPublicURL(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http.TrustProxyHeaders
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = orig })
|
||||
|
||||
@@ -225,6 +254,7 @@ func TestRequestSchemeForPublicURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequestPageFullURL(t *testing.T) {
|
||||
lockWebGlobals(t)
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.ExternalUrl = ""
|
||||
|
||||
@@ -237,6 +237,7 @@ func TestResourcePutHandler_SuccessResponseOmitsRealPath(t *testing.T) {
|
||||
|
||||
func setupPutHTTPTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
downloadsPath := filepath.Join(t.TempDir(), "downloads")
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "quota-http-test.sqlite")
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/quota"
|
||||
@@ -171,6 +172,10 @@ func putResourceWithQuota(w http.ResponseWriter, r *http.Request, d *Context, so
|
||||
if err != nil {
|
||||
return sanitizePutPathError(err)
|
||||
}
|
||||
permUser := accessCheckUsername(d)
|
||||
if !state.AccessPermittedAtRealPath(idx, permUser, realPath, access.IsDirAtRealPath(realPath)) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", fullIndexPath)
|
||||
}
|
||||
if stat, statErr := os.Stat(realPath); statErr == nil && stat.IsDir() {
|
||||
return http.StatusMethodNotAllowed, errPutPathIsDirectory
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
func setupQuotaHTTPTest(t *testing.T) {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
dbPath := filepath.Join(t.TempDir(), "quota-http-test.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
func setupPutQuotaTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
lockWebGlobals(t)
|
||||
downloadsPath := filepath.Join(t.TempDir(), "downloads")
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "quota-http-test.sqlite")
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
)
|
||||
|
||||
func TestResourcePostHandler_RejectsEscapingParentSymlinkMkdir(t *testing.T) {
|
||||
downloadsPath := setupPutHTTPTest(t)
|
||||
outside := filepath.Join(filepath.Dir(downloadsPath), "outside-mkdir")
|
||||
if err := os.MkdirAll(outside, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(downloadsPath, "evil")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
adminUser, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources?source=Downloads&path=/evil/newdir&isDir=true", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
status, handlerErr := ResourcePostHandler(rec, req, &requestContext{User: &adminUser})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status=%d err=%v", status, handlerErr)
|
||||
}
|
||||
if handlerErr == nil {
|
||||
t.Fatal("expected mkdir to be forbidden")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "newdir")); !os.IsNotExist(err) {
|
||||
t.Fatal("mkdir escaped outside the source via symlink parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceDeleteHandler_RejectsEscapingIntermediate(t *testing.T) {
|
||||
downloadsPath := setupPutHTTPTest(t)
|
||||
if preview.GetService() == nil {
|
||||
if err := preview.StartPreviewGenerator(1, t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
outsideDir := filepath.Join(filepath.Dir(downloadsPath), "outside-delete")
|
||||
if err := os.MkdirAll(outsideDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
victim := filepath.Join(outsideDir, "victim.txt")
|
||||
if err := os.WriteFile(victim, []byte("keep"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideDir, filepath.Join(downloadsPath, "evil")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
adminUser, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/resources?source=Downloads&path=/evil/victim.txt", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
status, handlerErr := resourceDeleteHandler(rec, req, &requestContext{User: &adminUser})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status=%d err=%v", status, handlerErr)
|
||||
}
|
||||
if handlerErr == nil {
|
||||
t.Fatal("expected delete to be forbidden")
|
||||
}
|
||||
got, err := os.ReadFile(victim)
|
||||
if err != nil || string(got) != "keep" {
|
||||
t.Fatalf("delete escaped: err=%v content=%q", err, got)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// webGlobalTestMu serializes tests that mutate process-wide settings.Config or state.
|
||||
var webGlobalTestMu sync.Mutex
|
||||
|
||||
func lockWebGlobals(t testing.TB) {
|
||||
t.Helper()
|
||||
webGlobalTestMu.Lock()
|
||||
t.Cleanup(webGlobalTestMu.Unlock)
|
||||
}
|
||||
@@ -176,6 +176,8 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
}
|
||||
// Parse the JSON into the UserRequest struct
|
||||
var req UserRequest
|
||||
// Seed with defaults so the decode below only overrides fields the request sent.
|
||||
state.PrefillUserProfileDefaults(&req.User)
|
||||
if err = json.Unmarshal(body, &req); err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
@@ -199,7 +201,7 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
return status, err
|
||||
}
|
||||
plaintextPassword := req.User.Password
|
||||
err = state.CreateUser(&req.User, plaintextPassword)
|
||||
err = state.CreateUserKeepingProfile(&req.User, plaintextPassword)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
// Omitted permissions and omitted profile keys come from userDefaults; a partially
|
||||
// specified fileLoading object only overrides the keys it contains.
|
||||
func TestUsersPostOmittedFieldsFallBackToDefaults(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
defaults := settings.Config.UserDefaults
|
||||
defaults.UI.Locale = "de"
|
||||
defaults.Account.Permissions.Share = true
|
||||
defaults.FileLoading.MaxConcurrent = 7
|
||||
defaults.FileLoading.UploadChunkSize = 9
|
||||
state.SetEffectiveUserDefaultsForTest(t, defaults)
|
||||
|
||||
admin := createPostTestAdmin(t)
|
||||
|
||||
body := []byte(`{"which":[],"data":{"username":"partial","password":"secret","loginMethod":"password",
|
||||
"fileLoading":{"maxConcurrentUpload":3}}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
|
||||
status, err := usersPostHandler(httptest.NewRecorder(), req, &Context{User: admin})
|
||||
if err != nil || status != http.StatusCreated {
|
||||
t.Fatalf("usersPostHandler() status=%d err=%v", status, err)
|
||||
}
|
||||
got, err := state.GetUserByUsername("partial")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Locale != "de" {
|
||||
t.Errorf("locale = %q, want default de", got.Locale)
|
||||
}
|
||||
if !got.Permissions.Share {
|
||||
t.Errorf("share = false, want true from default permissions")
|
||||
}
|
||||
if got.FileLoading.MaxConcurrent != 3 {
|
||||
t.Errorf("maxConcurrentUpload = %d, want requested 3", got.FileLoading.MaxConcurrent)
|
||||
}
|
||||
if got.FileLoading.UploadChunkSize != 9 {
|
||||
t.Errorf("uploadChunkSizeMb = %d, want default 9", got.FileLoading.UploadChunkSize)
|
||||
}
|
||||
}
|
||||
|
||||
// A create request must not mutate the shared in-memory userDefaults (json.Unmarshal
|
||||
// reuses slice backing arrays).
|
||||
func TestUsersPostDoesNotMutateUserDefaults(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
defaults := settings.Config.UserDefaults
|
||||
defaults.Listing.NewFileTemplate = []string{"default.txt"}
|
||||
state.SetEffectiveUserDefaultsForTest(t, defaults)
|
||||
|
||||
admin := createPostTestAdmin(t)
|
||||
|
||||
body := []byte(`{"which":[],"data":{"username":"nomutate","password":"secret","loginMethod":"password",
|
||||
"newFileTemplate":["custom.txt"]}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
|
||||
status, err := usersPostHandler(httptest.NewRecorder(), req, &Context{User: admin})
|
||||
if err != nil || status != http.StatusCreated {
|
||||
t.Fatalf("usersPostHandler() status=%d err=%v", status, err)
|
||||
}
|
||||
if got := state.EffectiveUserDefaults().Listing.NewFileTemplate; len(got) != 1 || got[0] != "default.txt" {
|
||||
t.Errorf("userDefaults newFileTemplate = %v, want [default.txt]", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func createPostTestAdmin(t *testing.T) *users.User {
|
||||
t.Helper()
|
||||
admin := &users.User{
|
||||
ID: 1,
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
Permissions: users.Permissions{Admin: true},
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(admin, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return admin
|
||||
}
|
||||
|
||||
// Issue #3322: profile/account values in POST /api/users must not be replaced by userDefaults.
|
||||
func TestUsersPostKeepsRequestedProfileValues(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
defaults := settings.Config.UserDefaults
|
||||
defaults.UI.Locale = "en"
|
||||
defaults.UI.ThemeColor = "var(--blue)"
|
||||
darkTrue := true
|
||||
defaults.UI.DarkMode = &darkTrue
|
||||
defaults.Account.LockPassword = false
|
||||
defaults.Account.DisableSettings = false
|
||||
defaults.Account.ShowAdvancedProfile = false
|
||||
defaults.Listing.PromptRightCloseButton = false
|
||||
defaults.Listing.NewFileTemplate = []string{""}
|
||||
defaults.Listing.ShowHidden = true
|
||||
state.SetEffectiveUserDefaultsForTest(t, defaults)
|
||||
|
||||
admin := createPostTestAdmin(t)
|
||||
|
||||
body := []byte(`{"which":[],"data":{
|
||||
"username":"profiletest","password":"secret","loginMethod":"password",
|
||||
"locale":"zhCN","themeColor":"var(--green)","darkMode":false,
|
||||
"lockPassword":true,"disableSettings":true,"showAdvancedProfile":true,
|
||||
"promptRightCloseButton":true,"newFileTemplate":["custom.txt"],
|
||||
"preview":{"image":false},
|
||||
"permissions":{"admin":false,"api":true,"share":true,"realtime":false},
|
||||
"scopes":[{"name":"/","scope":"/","permissions":{"view":true,"download":true,"modify":true,"create":true,"delete":true}}]
|
||||
}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, err := usersPostHandler(rec, req, &Context{User: admin})
|
||||
if err != nil || status != http.StatusCreated {
|
||||
t.Fatalf("usersPostHandler() status=%d err=%v", status, err)
|
||||
}
|
||||
|
||||
got, err := state.GetUserByUsername("profiletest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Locale != "zhCN" {
|
||||
t.Errorf("locale = %q, want zhCN", got.Locale)
|
||||
}
|
||||
if got.ThemeColor != "var(--green)" {
|
||||
t.Errorf("themeColor = %q, want var(--green)", got.ThemeColor)
|
||||
}
|
||||
if got.DarkMode {
|
||||
t.Errorf("darkMode = true, want false")
|
||||
}
|
||||
if !got.LockPassword {
|
||||
t.Errorf("lockPassword = false, want true")
|
||||
}
|
||||
if !got.DisableSettings {
|
||||
t.Errorf("disableSettings = false, want true")
|
||||
}
|
||||
if !got.ShowAdvancedProfile {
|
||||
t.Errorf("showAdvancedProfile = false, want true")
|
||||
}
|
||||
if !got.PromptRightCloseButton {
|
||||
t.Errorf("promptRightCloseButton = false, want true")
|
||||
}
|
||||
if len(got.NewFileTemplate) != 1 || got.NewFileTemplate[0] != "custom.txt" {
|
||||
t.Errorf("newFileTemplate = %v, want [custom.txt]", got.NewFileTemplate)
|
||||
}
|
||||
if got.Preview.Image {
|
||||
t.Errorf("preview.image = true, want false")
|
||||
}
|
||||
// Omitted fields still come from userDefaults.
|
||||
if !got.ShowHidden {
|
||||
t.Errorf("showHidden = false, want true from userDefaults")
|
||||
}
|
||||
if !got.Preview.Video {
|
||||
t.Errorf("preview.video = false, want true from userDefaults")
|
||||
}
|
||||
wantPerms := users.Permissions{Api: true, Share: true}
|
||||
if got.Permissions.Admin != wantPerms.Admin || got.Permissions.Api != wantPerms.Api ||
|
||||
got.Permissions.Share != wantPerms.Share || got.Permissions.Realtime != wantPerms.Realtime {
|
||||
t.Errorf("permissions = %+v, want %+v", got.Permissions, wantPerms)
|
||||
}
|
||||
}
|
||||
|
||||
// Enforced userDefaults still win over request values on create.
|
||||
func TestUsersPostEnforcedDefaultsWinOverRequest(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
defaults := settings.Config.UserDefaults
|
||||
defaults.UI.Locale = "en"
|
||||
state.SetEffectiveUserDefaultsForTest(t, defaults)
|
||||
if err := state.PatchUserDefaultsEnforced([]byte(`{"ui":{"locale":true}}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
admin := createPostTestAdmin(t)
|
||||
|
||||
body := []byte(`{"which":[],"data":{"username":"enforced","password":"secret","loginMethod":"password","locale":"zhCN","lockPassword":true}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, err := usersPostHandler(rec, req, &Context{User: admin})
|
||||
if err != nil || status != http.StatusCreated {
|
||||
t.Fatalf("usersPostHandler() status=%d err=%v", status, err)
|
||||
}
|
||||
got, err := state.GetUserByUsername("enforced")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Locale != "en" {
|
||||
t.Errorf("locale = %q, want enforced en", got.Locale)
|
||||
}
|
||||
if !got.LockPassword {
|
||||
t.Errorf("lockPassword = false, want true (not enforced)")
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
@@ -237,6 +238,35 @@ func (ffs *filteredFileSystem) checkScopePath(requestPath string, forRemoval boo
|
||||
return err
|
||||
}
|
||||
|
||||
// checkResolvedAccess enforces per-path DENY rules on the symlink-resolved target.
|
||||
func (ffs *filteredFileSystem) checkResolvedAccess(requestPath string, forRemoval bool) error {
|
||||
idx := indexing.GetIndex(ffs.source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("source not found")
|
||||
}
|
||||
userScope, err := ffs.user.GetScopeForSourceName(ffs.source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fullIndexPath := utils.JoinScopedIndexPath(userScope, strings.TrimPrefix(requestPath, "/"))
|
||||
var realPath string
|
||||
if forRemoval {
|
||||
realPath, err = idx.GetRealPathForRemoval(userScope, fullIndexPath)
|
||||
} else {
|
||||
realPath, err = idx.GetRealPathForMutation(userScope, fullIndexPath)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, commonerrors.ErrPathEscapesScope) {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !state.AccessPermittedAtRealPath(idx, ffs.user.Username, realPath, access.IsDirAtRealPath(realPath)) {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ffs *filteredFileSystem) requireCreatePermissionForNewFile(requestPath string) error {
|
||||
idx := indexing.GetIndex(ffs.source)
|
||||
if idx == nil {
|
||||
@@ -412,6 +442,9 @@ func (ffs *filteredFileSystem) Mkdir(ctx context.Context, name string, perm os.F
|
||||
logger.Debugf("Mkdir: scope check denied for %s: %v", name, err)
|
||||
return err
|
||||
}
|
||||
if err := ffs.checkResolvedAccess(name, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ffs.fs.Mkdir(ctx, name, perm); err != nil {
|
||||
return err
|
||||
@@ -450,6 +483,9 @@ func (ffs *filteredFileSystem) OpenFile(ctx context.Context, requestPath string,
|
||||
logger.Debugf("OpenFile: scope check denied for %s: %v", requestPath, err)
|
||||
return nil, err
|
||||
}
|
||||
if err := ffs.checkResolvedAccess(requestPath, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if (flag&os.O_CREATE) != 0 && !ffs.sourceFilePerms().Create {
|
||||
if err := ffs.requireCreatePermissionForNewFile(requestPath); err != nil {
|
||||
@@ -462,6 +498,9 @@ func (ffs *filteredFileSystem) OpenFile(ctx context.Context, requestPath string,
|
||||
logger.Debugf("OpenFile: read denied by scope for %s", requestPath)
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
if err := ffs.checkResolvedAccess(requestPath, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var desiredMtime time.Time
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
|
||||
// setupWebDAVTestEnv sets up the test environment with multiple sources and users
|
||||
func setupWebDAVTestEnv(t *testing.T) (string, string) {
|
||||
lockWebGlobals(t)
|
||||
// Create temp directories for test sources
|
||||
tempDir := t.TempDir()
|
||||
source1Path := filepath.Join(tempDir, "source1")
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
@@ -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,9 +1241,77 @@ func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool,
|
||||
}
|
||||
RealPathCache.Set(cacheKey, realPath)
|
||||
IsDirCache.Set(cacheKey+":isdir", isDir)
|
||||
if realPathGeneration.Load() != generation {
|
||||
RealPathCache.Delete(cacheKey)
|
||||
IsDirCache.Delete(cacheKey + ":isdir")
|
||||
}
|
||||
return realPath, isDir, nil
|
||||
}
|
||||
|
||||
// realPathCacheJoinedPath builds the filesystem join used in RealPathCache keys.
|
||||
func realPathCacheJoinedPath(sourceRoot, indexPath string) string {
|
||||
seg := utils.CleanIndexPathSegment(indexPath)
|
||||
if seg == "" {
|
||||
return sourceRoot
|
||||
}
|
||||
return filepath.Join(sourceRoot, seg)
|
||||
}
|
||||
|
||||
func realPathCacheIndexAncestors(indexPath string) []string {
|
||||
rel := utils.CleanIndexPathSegment(indexPath)
|
||||
if rel == "" {
|
||||
return []string{"/"}
|
||||
}
|
||||
parts := strings.Split(rel, "/")
|
||||
ancestors := []string{"/"}
|
||||
for i := range parts {
|
||||
ancestors = append(ancestors, "/"+strings.Join(parts[:i+1], "/"))
|
||||
}
|
||||
return ancestors
|
||||
}
|
||||
|
||||
// ClearRealPathCaches drops every cached symlink resolution. The cache library has no
|
||||
// prefix delete, so subtree invalidation uses a full clear; entries repopulate lazily.
|
||||
func ClearRealPathCaches() {
|
||||
realPathGeneration.Add(1)
|
||||
RealPathCache.ClearAll()
|
||||
IsDirCache.ClearAll()
|
||||
}
|
||||
|
||||
// InvalidateRealPathCache drops cached symlink resolutions for indexPath and its
|
||||
// ancestors. scopeBounds should include any caller scope prefixes used with
|
||||
// GetRealPathScoped (e.g. user or share path).
|
||||
func (idx *Index) InvalidateRealPathCache(indexPath string, scopeBounds ...string) {
|
||||
realPathGeneration.Add(1)
|
||||
boundSet := map[string]struct{}{"/": {}, "": {}}
|
||||
for _, b := range scopeBounds {
|
||||
b = strings.TrimRight(strings.TrimSpace(b), "/")
|
||||
if b == "" || b == "/" {
|
||||
boundSet["/"] = struct{}{}
|
||||
boundSet[""] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(b, "/") {
|
||||
b = "/" + b
|
||||
}
|
||||
boundSet[b] = struct{}{}
|
||||
}
|
||||
bounds := make([]string, 0, len(boundSet))
|
||||
for b := range boundSet {
|
||||
bounds = append(bounds, b)
|
||||
}
|
||||
for _, ip := range realPathCacheIndexAncestors(indexPath) {
|
||||
joined := realPathCacheJoinedPath(idx.Path, ip)
|
||||
for _, enforceScope := range []bool{false, true} {
|
||||
for _, bound := range bounds {
|
||||
key := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", enforceScope, bound, joined)
|
||||
RealPathCache.Delete(key)
|
||||
IsDirCache.Delete(key + ":isdir")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetRealPathForMutation resolves an index path for a mutating filesystem
|
||||
// operation (create/overwrite/mkdir/move-into). When the leaf exists it is fully
|
||||
// resolved; otherwise the deepest existing ancestor is resolved and the missing
|
||||
@@ -1589,7 +1660,10 @@ func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
|
||||
}
|
||||
switch simpleType {
|
||||
case "image":
|
||||
if fileInfo.Size > iteminfo.LargeFileSizeThreshold {
|
||||
// Camera RAW previews are the embedded JPEG, which is read without decoding the RAW data,
|
||||
// so large RAW files (medium format, uncompressed) don't need the size limit.
|
||||
isRaw := iteminfo.IsRawImage(strings.ToLower(filepath.Ext(fileInfo.Name)))
|
||||
if fileInfo.Size > iteminfo.LargeFileSizeThreshold && !isRaw {
|
||||
fileInfo.HasPreview = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func TestSetFilePreviewFlagsLargeImages(t *testing.T) {
|
||||
large := iteminfo.LargeFileSizeThreshold + 1
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
typ string
|
||||
size int64
|
||||
want bool
|
||||
}{
|
||||
{"normal image under the limit", "a.jpg", "image/jpeg", 5 << 20, true},
|
||||
{"normal image over the limit", "a.jpg", "image/jpeg", int64(large), false},
|
||||
{"RAW under the limit", "a.raf", "image/x-fuji-raf", 26 << 20, true},
|
||||
{"large RAW keeps its preview, the embedded JPEG is read without decoding", "a.RAF", "image/x-fuji-raf", int64(large), true},
|
||||
{"large DNG keeps its preview", "a.dng", "image/x-adobe-dng", int64(large), true},
|
||||
{"empty file has no preview", "a.raf", "image/x-fuji-raf", 0, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
info := &iteminfo.ItemInfo{Name: tt.file, Type: tt.typ, Size: tt.size}
|
||||
setFilePreviewFlags(info, "/nonexistent/"+tt.file)
|
||||
if info.HasPreview != tt.want {
|
||||
t.Fatalf("HasPreview = %v, want %v", info.HasPreview, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package indexing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -163,6 +164,66 @@ func TestGetRealPathScoped_CacheKeyDoesNotCollideWithUnscoped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealPathCacheGeneration_BumpsOnInvalidateAndClear(t *testing.T) {
|
||||
idx := symlinkTestIndex(t.TempDir())
|
||||
gen := realPathGeneration.Load()
|
||||
idx.InvalidateRealPathCache("/any")
|
||||
if realPathGeneration.Load() != gen+1 {
|
||||
t.Fatalf("InvalidateRealPathCache: gen=%d want %d", realPathGeneration.Load(), gen+1)
|
||||
}
|
||||
gen = realPathGeneration.Load()
|
||||
ClearRealPathCaches()
|
||||
if realPathGeneration.Load() != gen+1 {
|
||||
t.Fatalf("ClearRealPathCaches: gen=%d want %d", realPathGeneration.Load(), gen+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearRealPathCaches_ClearsWarmEntries(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
userDir := filepath.Join(sourceRoot, "user1")
|
||||
if err := os.WriteFile(filepath.Join(userDir, "foo"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := idx.GetRealPathScoped(userScope, "/user1/foo"); err != nil {
|
||||
t.Fatalf("warm cache: %v", err)
|
||||
}
|
||||
joined := filepath.Join(sourceRoot, "user1", "foo")
|
||||
key := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", true, userScope, joined)
|
||||
if _, ok := RealPathCache.Get(key); !ok {
|
||||
t.Fatalf("expected cache key %q", key)
|
||||
}
|
||||
ClearRealPathCaches()
|
||||
if _, ok := RealPathCache.Get(key); ok {
|
||||
t.Fatal("ClearRealPathCaches should drop warm entries")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateRealPathCache_ClearsGetRealPathEntry(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
userDir := filepath.Join(sourceRoot, "user1")
|
||||
if err := os.WriteFile(filepath.Join(userDir, "foo"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, _, err := idx.GetRealPath("/user1/foo"); err != nil {
|
||||
t.Fatalf("warm cache: %v", err)
|
||||
}
|
||||
joined := realPathCacheJoinedPath(sourceRoot, "/user1/foo")
|
||||
key := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", false, "/", joined)
|
||||
if _, ok := RealPathCache.Get(key); !ok {
|
||||
t.Fatalf("expected cache key %q", key)
|
||||
}
|
||||
|
||||
idx.InvalidateRealPathCache("/user1/foo", userScope)
|
||||
if _, ok := RealPathCache.Get(key); ok {
|
||||
t.Fatal("InvalidateRealPathCache should drop unscoped cache entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealPathForMutation_ExistingInScope(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
@@ -124,7 +124,11 @@ func warnHttpProxyConfig() {
|
||||
}
|
||||
if Config.Http.TrustProxyHeaders && Config.Http.Socket != "" && !strings.HasPrefix(Config.Http.Socket, "@") &&
|
||||
len(Config.Http.UnixSocketTrustedUIDs) == 0 && len(Config.Http.UnixSocketTrustedGIDs) == 0 {
|
||||
logger.Warning("http.trustProxyHeaders is true and http.socket is a filesystem Unix socket, but http.unixSocketTrustedUIDs and http.unixSocketTrustedGIDs are empty; X-Forwarded-For and X-Real-IP are ignored until you list the reverse proxy's UID or GID (Linux peer credentials).")
|
||||
if Config.Http.UnixSocketTrustAllLocalPeers {
|
||||
logger.Warning("http.unixSocketTrustAllLocalPeers is true: any local process that can connect to the filesystem socket may present X-Forwarded-For and X-Real-IP. Prefer http.unixSocketTrustedUIDs and/or http.unixSocketTrustedGIDs so only peers with allowed Linux credentials can set forwarded headers.")
|
||||
} else {
|
||||
logger.Warning("http.trustProxyHeaders is true on a filesystem Unix socket, but http.unixSocketTrustedUIDs and http.unixSocketTrustedGIDs are empty; forwarded client headers from socket peers are ignored. List allowed peer UIDs and/or GIDs (Linux SO_PEERCRED), or set http.unixSocketTrustAllLocalPeers: true only if you accept legacy trust-all behavior.")
|
||||
}
|
||||
}
|
||||
if !Env.IsDevMode {
|
||||
if u := Config.Http.ExternalUrl; u != "" && strings.HasPrefix(strings.ToLower(u), "http://") {
|
||||
@@ -990,9 +994,10 @@ func SetDefaults(generate bool) Settings {
|
||||
Locale: "en",
|
||||
},
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 10,
|
||||
UploadChunkSize: 10, // 10MB
|
||||
DownloadChunkSize: 0, // 0MB, default to no chunking
|
||||
MaxConcurrent: 10,
|
||||
UploadChunkSize: 10, // 10MB
|
||||
DownloadChunkSize: 0, // 0MB, default to no chunking
|
||||
UploadStallTimeoutSeconds: 10, // pause an upload after 10 seconds without progress
|
||||
},
|
||||
Account: UserDefaultsAccount{
|
||||
Permissions: UserDefaultsAccountPermissions{
|
||||
|
||||
@@ -71,9 +71,10 @@ type UserDefaultsUIEnforcement struct {
|
||||
}
|
||||
|
||||
type UserDefaultsFileLoadingEnforcement struct {
|
||||
MaxConcurrent bool `json:"maxConcurrentUpload,omitempty"`
|
||||
UploadChunkSize bool `json:"uploadChunkSizeMb,omitempty"`
|
||||
DownloadChunkSize bool `json:"downloadChunkSizeMb,omitempty"`
|
||||
MaxConcurrent bool `json:"maxConcurrentUpload,omitempty"`
|
||||
UploadChunkSize bool `json:"uploadChunkSizeMb,omitempty"`
|
||||
DownloadChunkSize bool `json:"downloadChunkSizeMb,omitempty"`
|
||||
UploadStallTimeoutSeconds bool `json:"uploadStallTimeoutSeconds,omitempty"`
|
||||
}
|
||||
|
||||
type UserDefaultsAccountEnforcement struct {
|
||||
|
||||
@@ -37,11 +37,14 @@ type Http struct {
|
||||
TrustProxyHeaders bool `json:"trustProxyHeaders"` // honor X-Forwarded-* and X-Real-IP from a reverse proxy (default: false)
|
||||
TrustedProxies []string `json:"trustedProxies"` // IPs/CIDRs of trusted proxies; forwarded client headers are only honored when the socket peer matches one of these (default: empty = forwarded headers ignored)
|
||||
// UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to
|
||||
// present X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers
|
||||
// are not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.
|
||||
UnixSocketTrustedUIDs []int `json:"unixSocketTrustedUIDs"`
|
||||
UnixSocketTrustedGIDs []int `json:"unixSocketTrustedGIDs"`
|
||||
SocketFileMode string `json:"socketFileMode"` // octal mode for filesystem http.socket (default "0660"); ignored for abstract sockets
|
||||
// present X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted
|
||||
// unless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts
|
||||
// every peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust
|
||||
// forwarded headers.
|
||||
UnixSocketTrustedUIDs []int `json:"unixSocketTrustedUIDs"`
|
||||
UnixSocketTrustedGIDs []int `json:"unixSocketTrustedGIDs"`
|
||||
UnixSocketTrustAllLocalPeers bool `json:"unixSocketTrustAllLocalPeers"` // default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty
|
||||
SocketFileMode string `json:"socketFileMode"` // octal mode for filesystem http.socket (default "0660"); ignored for abstract sockets
|
||||
SocketGroup string `json:"socketGroup"` // optional group name or numeric gid for the socket file (filesystem sockets only)
|
||||
DisableRateLimit bool `json:"disableRateLimit"` // turns off built-in auth route rate limiting and failed-login lockout (default false).
|
||||
}
|
||||
@@ -78,11 +81,12 @@ type Server struct {
|
||||
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
|
||||
Logging []LogConfig `json:"logging" yaml:"logging"`
|
||||
Sources []*Source `json:"sources" validate:"required,dive"`
|
||||
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
|
||||
CacheDirCleanup bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
|
||||
Filesystem Filesystem `json:"filesystem"` // filesystem settings
|
||||
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
|
||||
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
|
||||
CacheDirCleanup bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
|
||||
CacheDirMaxSizeMB int64 `json:"cacheDirMaxSizeMB"` // maximum size in MB of the preview cache (cacheDir/diskcache). Past it, the least recently used files are deleted. 0 means no limit (default: 0)
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
|
||||
Filesystem Filesystem `json:"filesystem"` // filesystem settings
|
||||
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
|
||||
// not exposed to config
|
||||
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
|
||||
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
|
||||
|
||||
@@ -113,10 +113,11 @@ func ipMatchesPrefixes(ip string, prefixes []netip.Prefix) bool {
|
||||
// proxy headers. Forwarded client headers must only be honored when this returns
|
||||
// true; otherwise any client that can reach the listener directly could spoof its address.
|
||||
//
|
||||
// When http.socket is set the server listens only on that Unix socket. Filesystem
|
||||
// socket peers are trusted only when their UID or GID matches
|
||||
// http.unixSocketTrustedUIDs / http.unixSocketTrustedGIDs (Linux peer credentials).
|
||||
// Abstract sockets ("@name") have no filesystem permissions and their peers are never trusted.
|
||||
// When http.socket is set the server listens only on that Unix socket. Abstract
|
||||
// sockets ("@name") have no filesystem permissions and their peers are never trusted.
|
||||
// On filesystem sockets, when either allowlist is set, only peers whose Linux SO_PEERCRED
|
||||
// UID or GID matches may present forwarded headers. When both allowlists are empty,
|
||||
// peers are trusted only if http.unixSocketTrustAllLocalPeers is true (legacy opt-in).
|
||||
func IsTrustedProxyPeer(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
@@ -125,6 +126,9 @@ func IsTrustedProxyPeer(r *http.Request) bool {
|
||||
if strings.HasPrefix(socket, "@") {
|
||||
return false
|
||||
}
|
||||
if len(Config.Http.UnixSocketTrustedUIDs) == 0 && len(Config.Http.UnixSocketTrustedGIDs) == 0 {
|
||||
return Config.Http.UnixSocketTrustAllLocalPeers
|
||||
}
|
||||
cred, ok := UnixPeerFromContext(r.Context())
|
||||
if !ok {
|
||||
return false
|
||||
|
||||
@@ -132,7 +132,6 @@ func TestIsTrustedProxyPeer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// With http.socket set, forwarded headers require matching unixSocketTrustedUIDs/GIDs.
|
||||
func TestIsTrustedProxyPeerUnixSocket(t *testing.T) {
|
||||
resetTrustedProxyCache()
|
||||
Config.Http.TrustedProxies = nil
|
||||
@@ -141,15 +140,23 @@ func TestIsTrustedProxyPeerUnixSocket(t *testing.T) {
|
||||
Config.Http.Socket = ""
|
||||
Config.Http.UnixSocketTrustedUIDs = nil
|
||||
Config.Http.UnixSocketTrustedGIDs = nil
|
||||
Config.Http.UnixSocketTrustAllLocalPeers = false
|
||||
resetTrustedProxyCache()
|
||||
})
|
||||
for _, peer := range []string{"", "@"} {
|
||||
if IsTrustedProxyPeer(&http.Request{RemoteAddr: peer}) {
|
||||
t.Fatalf("unix socket peer %q must not be trusted without peer credentials", peer)
|
||||
}
|
||||
if IsTrustedProxyPeer(&http.Request{RemoteAddr: "@"}) {
|
||||
t.Fatal("filesystem unix socket with empty allowlists must not trust forwarded headers without opt-in")
|
||||
}
|
||||
Config.Http.UnixSocketTrustAllLocalPeers = true
|
||||
if !IsTrustedProxyPeer(&http.Request{RemoteAddr: "@"}) {
|
||||
t.Fatal("unixSocketTrustAllLocalPeers should restore legacy trust-all behavior")
|
||||
}
|
||||
Config.Http.UnixSocketTrustAllLocalPeers = false
|
||||
|
||||
Config.Http.UnixSocketTrustedUIDs = []int{33}
|
||||
reqNoCred := &http.Request{RemoteAddr: "@"}
|
||||
if IsTrustedProxyPeer(reqNoCred) {
|
||||
t.Fatal("with allowlists set, peer without credentials must not be trusted")
|
||||
}
|
||||
req := (&http.Request{}).WithContext(ContextWithUnixPeerForTest(context.Background(), 33, 1000))
|
||||
if !IsTrustedProxyPeer(req) {
|
||||
t.Fatal("peer with allowed UID should be trusted")
|
||||
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
"os/user"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var unixListenUmaskMu sync.Mutex
|
||||
|
||||
type unixPeerCredKey struct{}
|
||||
|
||||
// UnixPeerCredential holds the peer process identity for a Unix domain connection.
|
||||
@@ -69,14 +72,15 @@ func ListenUnixFilesystem(socketPath string) (net.Listener, error) {
|
||||
}
|
||||
setGroup = true
|
||||
}
|
||||
unixListenUmaskMu.Lock()
|
||||
oldMask := unixUmask(0o077)
|
||||
defer unixUmask(oldMask)
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
unixUmask(oldMask)
|
||||
unixListenUmaskMu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closeListener := func() { _ = listener.Close() }
|
||||
// Assign group ownership while the socket is still owner-only (umask 077), then grant mode bits.
|
||||
if setGroup {
|
||||
if err := os.Chown(socketPath, -1, gid); err != nil {
|
||||
closeListener()
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package settings
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSocketFileModeDefaults(t *testing.T) {
|
||||
mode, err := parseSocketFileMode("")
|
||||
@@ -51,3 +56,29 @@ func TestUnixPeerAllowed(t *testing.T) {
|
||||
t.Fatal("empty allowlists must deny")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenUnixFilesystemSetsModeWithRestrictiveUmask(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("filesystem unix sockets are not used on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
socketPath := filepath.Join(dir, "fb.sock")
|
||||
orig := Config.Http
|
||||
Config.Http.SocketFileMode = ""
|
||||
t.Cleanup(func() {
|
||||
Config.Http = orig
|
||||
_ = os.Remove(socketPath)
|
||||
})
|
||||
ln, err := ListenUnixFilesystem(socketPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
st, err := os.Stat(socketPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Mode().Perm() != 0o660 {
|
||||
t.Fatalf("socket perm = %o, want 0660", st.Mode().Perm())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//go:build !unix && !windows
|
||||
|
||||
package settings
|
||||
|
||||
func unixUmask(_ int) int {
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !unix
|
||||
|
||||
package settings
|
||||
|
||||
func unixUmask(mask int) int {
|
||||
return 0
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package settings
|
||||
|
||||
func unixUmask(_ int) int {
|
||||
return 0
|
||||
}
|
||||
@@ -60,9 +60,10 @@ var previewJSONSubfields = map[string]string{
|
||||
}
|
||||
|
||||
var fileLoadingJSONSubfields = map[string]string{
|
||||
"maxConcurrentUpload": "fileLoading.maxConcurrentUpload",
|
||||
"uploadChunkSizeMb": "fileLoading.uploadChunkSizeMb",
|
||||
"downloadChunkSizeMb": "fileLoading.downloadChunkSizeMb",
|
||||
"maxConcurrentUpload": "fileLoading.maxConcurrentUpload",
|
||||
"uploadChunkSizeMb": "fileLoading.uploadChunkSizeMb",
|
||||
"downloadChunkSizeMb": "fileLoading.downloadChunkSizeMb",
|
||||
"uploadStallTimeoutSeconds": "fileLoading.uploadStallTimeoutSeconds",
|
||||
}
|
||||
|
||||
// EnforcedPathsTouchedByUserJSONField returns enforcement paths a user JSON field name may affect.
|
||||
|
||||
@@ -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": {
|
||||
@@ -6040,6 +6055,10 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"unixSocketTrustAllLocalPeers": {
|
||||
"description": "default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty",
|
||||
"type": "boolean"
|
||||
},
|
||||
"unixSocketTrustedGIDs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -6047,7 +6066,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"unixSocketTrustedUIDs": {
|
||||
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers\nare not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.",
|
||||
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted\nunless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts\nevery peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust\nforwarded headers.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
@@ -6535,6 +6554,10 @@ const docTemplate = `{
|
||||
"description": "whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"cacheDirMaxSizeMB": {
|
||||
"description": "maximum size in MB of the preview cache (cacheDir/diskcache). Past it, the least recently used files are deleted. 0 means no limit (default: 0)",
|
||||
"type": "integer"
|
||||
},
|
||||
"database": {
|
||||
"description": "SQLite database configuration",
|
||||
"allOf": [
|
||||
@@ -7711,6 +7734,10 @@ const docTemplate = `{
|
||||
},
|
||||
"uploadChunkSizeMb": {
|
||||
"type": "integer"
|
||||
},
|
||||
"uploadStallTimeoutSeconds": {
|
||||
"description": "seconds without progress before an upload is paused, 0 uses the default (10)",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
@@ -6029,6 +6044,10 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"unixSocketTrustAllLocalPeers": {
|
||||
"description": "default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty",
|
||||
"type": "boolean"
|
||||
},
|
||||
"unixSocketTrustedGIDs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -6036,7 +6055,7 @@
|
||||
}
|
||||
},
|
||||
"unixSocketTrustedUIDs": {
|
||||
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers\nare not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.",
|
||||
"description": "UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to\npresent X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted\nunless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts\nevery peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust\nforwarded headers.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
@@ -6524,6 +6543,10 @@
|
||||
"description": "whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"cacheDirMaxSizeMB": {
|
||||
"description": "maximum size in MB of the preview cache (cacheDir/diskcache). Past it, the least recently used files are deleted. 0 means no limit (default: 0)",
|
||||
"type": "integer"
|
||||
},
|
||||
"database": {
|
||||
"description": "SQLite database configuration",
|
||||
"allOf": [
|
||||
@@ -7700,6 +7723,10 @@
|
||||
},
|
||||
"uploadChunkSizeMb": {
|
||||
"type": "integer"
|
||||
},
|
||||
"uploadStallTimeoutSeconds": {
|
||||
"description": "seconds without progress before an upload is paused, 0 uses the default (10)",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -500,6 +500,10 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
unixSocketTrustAllLocalPeers:
|
||||
description: 'default false: set true to trust forwarded headers from any
|
||||
local filesystem socket peer when allowlists are empty'
|
||||
type: boolean
|
||||
unixSocketTrustedGIDs:
|
||||
items:
|
||||
type: integer
|
||||
@@ -507,8 +511,10 @@ definitions:
|
||||
unixSocketTrustedUIDs:
|
||||
description: |-
|
||||
UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to
|
||||
present X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers
|
||||
are not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.
|
||||
present X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted
|
||||
unless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts
|
||||
every peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust
|
||||
forwarded headers.
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
@@ -890,6 +896,11 @@ definitions:
|
||||
docker must also mount a persistent volume to persist the cache (default:
|
||||
false)'
|
||||
type: boolean
|
||||
cacheDirMaxSizeMB:
|
||||
description: 'maximum size in MB of the preview cache (cacheDir/diskcache).
|
||||
Past it, the least recently used files are deleted. 0 means no limit (default:
|
||||
0)'
|
||||
type: integer
|
||||
database:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.Database'
|
||||
@@ -1719,6 +1730,10 @@ definitions:
|
||||
type: integer
|
||||
uploadChunkSizeMb:
|
||||
type: integer
|
||||
uploadStallTimeoutSeconds:
|
||||
description: seconds without progress before an upload is paused, 0 uses the
|
||||
default (10)
|
||||
type: integer
|
||||
type: object
|
||||
users.FrontendScope:
|
||||
properties:
|
||||
@@ -5574,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:
|
||||
@@ -5581,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:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
22
|
||||
22.22.2
|
||||
+2
-1
@@ -47,7 +47,8 @@
|
||||
"useGenericFontNames": "off",
|
||||
"useKeyWithClickEvents": "off",
|
||||
"useMediaCaption": "off",
|
||||
"useSemanticElements": "off"
|
||||
"useSemanticElements": "off",
|
||||
"useValidAnchor": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "on",
|
||||
|
||||
Generated
+715
-1400
File diff suppressed because it is too large.
Load diff
+16
-14
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"npm": ">=9.0.0",
|
||||
"node": "^22.13.0 || ^24.0.0 || >=26.0.0"
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "mkdir -p ../backend/internal/web/embed && rm -rf ../backend/internal/web/embed/* && vite build && cp -r ../backend/internal/web/dist/* ../backend/internal/web/embed",
|
||||
@@ -30,34 +30,35 @@
|
||||
"test": "vitest run "
|
||||
},
|
||||
"dependencies": {
|
||||
"@likecoin/epub-ts": "^0.7.2",
|
||||
"@likecoin/epub-ts": "^0.7.3",
|
||||
"@onlyoffice/document-editor-vue": "^1.7.0",
|
||||
"ace-builds": "^1.45.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"ace-builds": "^1.45.2",
|
||||
"chart.js": "^4.6.1",
|
||||
"dompurify": "^3.4.17",
|
||||
"highlight.js": "^11.12.0",
|
||||
"js-yaml": "^5.5.0",
|
||||
"mammoth": "^1.13.0",
|
||||
"marked": "^18.1.1",
|
||||
"marked": "^18.2.0",
|
||||
"plyr": "^3.8.5",
|
||||
"qrcode.vue": "^3.11.0",
|
||||
"temml": "^0.14.0",
|
||||
"three": "^0.186.1",
|
||||
"vue": "^3.5.44",
|
||||
"vue-i18n": "^11.4.16",
|
||||
"vue-router": "^5.4.0"
|
||||
"three": "^0.187.0",
|
||||
"vue": "^3.5.45",
|
||||
"vue-i18n": "^11.4.19",
|
||||
"vue-router": "^5.5.0"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": "^10.2.6",
|
||||
"stylelint-config-recommended": "^19.0.0",
|
||||
"minimatch": "^11.0.8",
|
||||
"@xmldom/xmldom": "^0.8.15",
|
||||
"mammoth": {
|
||||
"argparse": "^2.0.1"
|
||||
"argparse": "^3.0.4"
|
||||
},
|
||||
"@intlify/vue-i18n-extensions": {
|
||||
"vue-i18n": "$vue-i18n"
|
||||
},
|
||||
"@intlify/eslint-plugin-vue-i18n": {
|
||||
"glob": "^13.0.0"
|
||||
"glob": "^14.0.6"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -69,12 +70,13 @@
|
||||
"@typescript-eslint/parser": "^8.72.0",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"deepl-node": "^1.28.1",
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-plugin-vue": "^10.11.1",
|
||||
"fs-extra": "^11.4.1",
|
||||
"jsdom": "^30.1.3",
|
||||
"stylelint": "^17.16.0",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"stylelint": "^18.0.0",
|
||||
"stylelint-config-standard": "^41.0.0",
|
||||
"stylelint-config-standard-vue": "^2.0.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.4",
|
||||
|
||||
@@ -50,6 +50,7 @@ server:
|
||||
configured: false # true when explicitly set (allows intentional deny-all)
|
||||
cacheDir: "tmp" # path to the cache directory, used for thumbnails and other cached files
|
||||
cacheDirCleanup: false # whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
|
||||
cacheDirMaxSizeMB: 0 # maximum size in MB of the preview cache (cacheDir/diskcache). Past it, the least recently used files are deleted. 0 means no limit (default: 0)
|
||||
maxArchiveSize: 20 # maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
|
||||
filesystem: # filesystem settings
|
||||
createFilePermission: "644" # Unix permissions like 644, 755, 2755 (default: 644) validate:required,file_permission
|
||||
@@ -223,6 +224,7 @@ userDefaults: # optional signup/CLI defaults; per-us
|
||||
uploadChunkSizeMb: 10
|
||||
clearAll: false
|
||||
downloadChunkSizeMb: 0
|
||||
uploadStallTimeoutSeconds: 10 # seconds without progress before an upload is paused, 0 uses the default (10)
|
||||
account:
|
||||
permissions:
|
||||
api: false # allow api access
|
||||
@@ -316,9 +318,12 @@ http:
|
||||
trustProxyHeaders: false # honor X-Forwarded-* and X-Real-IP from a reverse proxy (default: false)
|
||||
trustedProxies: # IPs/CIDRs of trusted proxies; forwarded client headers are only honored when the socket peer matches one of these (default: empty = forwarded headers ignored)
|
||||
unixSocketTrustedUIDs: # UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to
|
||||
# present X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers
|
||||
# are not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.
|
||||
# present X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted
|
||||
# unless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts
|
||||
# every peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust
|
||||
# forwarded headers.
|
||||
unixSocketTrustedGIDs:
|
||||
unixSocketTrustAllLocalPeers: false # default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty
|
||||
socketFileMode: "" # octal mode for filesystem http.socket (default "0660"); ignored for abstract sockets
|
||||
socketGroup: "" # optional group name or numeric gid for the socket file (filesystem sockets only)
|
||||
disableRateLimit: false # turns off built-in auth route rate limiting and failed-login lockout (default false).
|
||||
@@ -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',
|
||||
|
||||
@@ -790,7 +790,8 @@ export function post(
|
||||
overwrite = false,
|
||||
onupload,
|
||||
headers = {},
|
||||
isDir = false
|
||||
isDir = false,
|
||||
onsent
|
||||
) {
|
||||
if (!source || source === undefined || source === null) {
|
||||
throw new Error('no source provided')
|
||||
@@ -840,6 +841,10 @@ export function post(
|
||||
}
|
||||
};
|
||||
}
|
||||
if (typeof onsent === "function") {
|
||||
// The whole body has been sent; from here the server answers when it is done saving it.
|
||||
request.upload.onload = () => onsent();
|
||||
}
|
||||
|
||||
request.onload = () => {
|
||||
if (request.status >= 200 && request.status < 300) {
|
||||
@@ -1032,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,
|
||||
@@ -1330,7 +1350,8 @@ export function postPublic(
|
||||
overwrite = false,
|
||||
onupload,
|
||||
headers = {},
|
||||
isDir = false
|
||||
isDir = false,
|
||||
onsent
|
||||
) {
|
||||
if (!hash || hash === undefined || hash === null) {
|
||||
throw new Error('no hash provided')
|
||||
@@ -1364,6 +1385,10 @@ export function postPublic(
|
||||
}
|
||||
};
|
||||
}
|
||||
if (typeof onsent === "function") {
|
||||
// The whole body has been sent; from here the server answers when it is done saving it.
|
||||
request.upload.onload = () => onsent();
|
||||
}
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
request.onload = () => {
|
||||
|
||||
@@ -63,4 +63,13 @@ describe("ContextMenu in advanced search", () => {
|
||||
expect(toggle(false)).toBe(true);
|
||||
expect(toggle(true)).toBe(false);
|
||||
});
|
||||
|
||||
it("hides Share when nothing is selected, since the results listing has no folder to share", () => {
|
||||
const share = (isAdvancedSearch, selectedCount) =>
|
||||
computed.showShareAction.call({ showLimitedOptions: false, isAdvancedSearch, selectedCount, showShare: true });
|
||||
expect(share(true, 0)).toBe(false);
|
||||
expect(share(true, 1)).toBe(true);
|
||||
expect(share(true, 2)).toBe(false);
|
||||
expect(share(false, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -423,6 +423,8 @@ export default {
|
||||
},
|
||||
showShareAction() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
// Advanced search results have no folder of their own to share, only the selected results do.
|
||||
if (this.isAdvancedSearch && this.selectedCount === 0) return false;
|
||||
return this.selectedCount <= 1 && this.showShare;
|
||||
},
|
||||
showSendToApp() {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createApp, h, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchFiles, state } = vi.hoisted(() => ({
|
||||
fetchFiles: vi.fn(),
|
||||
state: { req: {}, prompts: [], selected: [], sources: { info: { data: {} } } },
|
||||
}));
|
||||
vi.mock("@/api", () => ({ resourcesApi: { fetchFiles } }));
|
||||
vi.mock("@/store", () => ({
|
||||
state,
|
||||
mutations: {},
|
||||
getters: {
|
||||
isShare: () => false,
|
||||
isMobile: () => false,
|
||||
currentPromptName: () => null,
|
||||
pickerSorting: () => ({ by: "name", asc: true }),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/utils", async () => {
|
||||
const { removeLastDir } = await import("@/utils/url.js");
|
||||
return { url: { removeLastDir } };
|
||||
});
|
||||
vi.mock("@/router/index.ts", () => ({ router: {} }));
|
||||
vi.mock("@/components/files/ListingHeader.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/files/ListingItem.vue", () => ({
|
||||
default: {
|
||||
props: ["name"],
|
||||
render() {
|
||||
return h("button", {}, this.name);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import FileList from "./FileList.vue";
|
||||
|
||||
let app, root;
|
||||
const selection = vi.fn();
|
||||
function mount(items = [], path = "/child/") {
|
||||
state.req = { source: "data", path, items };
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(FileList, { hidePathChrome: true, "onUpdate:selected": selection });
|
||||
app.config.globalProperties.$t = (key) => key;
|
||||
app.mount(root);
|
||||
}
|
||||
|
||||
function row(name) {
|
||||
return Array.from(root.querySelectorAll("button")).find((button) => button.textContent === name);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
root?.remove();
|
||||
fetchFiles.mockReset();
|
||||
selection.mockReset();
|
||||
});
|
||||
|
||||
describe("file picker parent navigation", () => {
|
||||
it("loads the parent and shows sibling folders after one click on '..'", async () => {
|
||||
mount();
|
||||
fetchFiles.mockResolvedValue({
|
||||
source: "data",
|
||||
items: [{ name: "sibling", path: "/sibling/", type: "directory" }],
|
||||
});
|
||||
await nextTick();
|
||||
row("..").click();
|
||||
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ path: "/", source: "data" }));
|
||||
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/");
|
||||
await vi.waitFor(() => expect(row("sibling")).toBeDefined());
|
||||
});
|
||||
|
||||
it("keeps single-click selection and double-click navigation for ordinary folders", async () => {
|
||||
mount([{ name: "nested", path: "/child/nested/", type: "directory" }]);
|
||||
fetchFiles.mockResolvedValue({ source: "data", items: [] });
|
||||
await nextTick();
|
||||
row("nested").click();
|
||||
expect(fetchFiles).not.toHaveBeenCalled();
|
||||
row("nested").dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
|
||||
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/child/nested/");
|
||||
});
|
||||
|
||||
it("does not fetch the parent again when the same row receives a double-click", async () => {
|
||||
mount();
|
||||
fetchFiles.mockResolvedValue({ source: "data", items: [] });
|
||||
await nextTick();
|
||||
const parent = row("..");
|
||||
parent.click();
|
||||
parent.click();
|
||||
parent.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
|
||||
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/");
|
||||
});
|
||||
|
||||
it("ignores the rest of a double-click on a replacement parent row", async () => {
|
||||
mount([], "/parent/child/");
|
||||
fetchFiles.mockResolvedValue({ source: "data", items: [] });
|
||||
await nextTick();
|
||||
const firstParent = row("..");
|
||||
firstParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
|
||||
await vi.waitFor(() => {
|
||||
expect(row("..")).toBeDefined();
|
||||
expect(row("..")).not.toBe(firstParent);
|
||||
});
|
||||
const nextParent = row("..");
|
||||
nextParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 2 }));
|
||||
nextParent.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, detail: 2 }));
|
||||
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/parent/");
|
||||
|
||||
nextParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
|
||||
expect(fetchFiles).toHaveBeenLastCalledWith("data", "/");
|
||||
expect(fetchFiles).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -542,6 +542,7 @@ export default {
|
||||
this.next(event);
|
||||
}
|
||||
},
|
||||
/** @param {MouseEvent} event */
|
||||
handleItemClick(item, _index, event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -552,7 +553,6 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Browse mode: single click selects only, emit selected, do not navigate
|
||||
const syntheticEvent = {
|
||||
currentTarget: {
|
||||
dataset: {
|
||||
@@ -562,11 +562,19 @@ export default {
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
};
|
||||
if (item.name === "..") {
|
||||
if (event.detail > 1 || this.path === item.path) return;
|
||||
this.select(syntheticEvent);
|
||||
this.next(syntheticEvent);
|
||||
return;
|
||||
}
|
||||
this.select(syntheticEvent);
|
||||
},
|
||||
/** @param {MouseEvent} event */
|
||||
handleItemDblClick(item, _index, event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (item.name === ".." && (!this.fileList || this.path === item.path)) return;
|
||||
|
||||
const syntheticEvent = {
|
||||
currentTarget: {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, h } from "vue";
|
||||
|
||||
vi.mock("@/store", () => ({ state: { sources: {} }, mutations: { showPrompt: vi.fn() } }));
|
||||
vi.mock("@/store/eventBus", () => ({ eventBus: { on: vi.fn(), off: vi.fn() } }));
|
||||
import PathPickerButton from "./PathPickerButton.vue";
|
||||
|
||||
describe("PathPickerButton keyboard access", () => {
|
||||
let app;
|
||||
afterEach(() => app?.unmount());
|
||||
|
||||
it("can be focused and is exposed as a button, so its Enter and Space handlers can run", () => {
|
||||
const el = document.createElement("div");
|
||||
app = createApp({ render: () => h(PathPickerButton, { path: "/", source: "" }) });
|
||||
app.config.globalProperties.$t = (key) => key;
|
||||
app.mount(el);
|
||||
const picker = el.querySelector(".unified-path-picker");
|
||||
expect(picker.getAttribute("tabindex")).toBe("0");
|
||||
expect(picker.getAttribute("role")).toBe("button");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div
|
||||
:aria-label="resolvedAriaLabel"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="searchContext clickable button unified-path-picker"
|
||||
@click="openPicker"
|
||||
@keydown.enter.prevent="openPicker"
|
||||
@@ -170,4 +172,10 @@ export default {
|
||||
overflow-wrap: break-word;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* .button removes the outline, so keyboard focus needs its own indicator. */
|
||||
.unified-path-picker:focus-visible {
|
||||
outline: 2px solid var(--primaryColor);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -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 || "";
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<UserProfilePreferences
|
||||
v-model="preferenceSections"
|
||||
enforceable
|
||||
hide-account-options
|
||||
:enforced="enforced"
|
||||
:config-locked-paths="lockedFromConfigPaths"
|
||||
show-extension-inputs
|
||||
|
||||
@@ -295,6 +295,8 @@ const PROFILE_SNAPSHOT_FIELDS = [
|
||||
"debugOffice",
|
||||
"viewMode",
|
||||
"gallerySize",
|
||||
"promptRightCloseButton",
|
||||
"newFileTemplate",
|
||||
];
|
||||
|
||||
export default {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createUserEditSession, destroyUserEditSession, getUserEditSession } from "@/utils/userEditSession.js";
|
||||
import { applySectionsToFlatUser, sectionsFromFlatUser } from "@/utils/userProfileSections.js";
|
||||
|
||||
const { fetchURL, fetchJSON, state, mutations } = vi.hoisted(() => ({
|
||||
fetchURL: vi.fn(),
|
||||
fetchJSON: vi.fn(),
|
||||
state: { user: { username: "admin" }, enforcedUserDefaults: {} },
|
||||
mutations: { closeTopPrompt: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/api", async () => ({
|
||||
usersApi: await vi.importActual("@/api/users.js"),
|
||||
settingsApi: {},
|
||||
authApi: {},
|
||||
accessApi: {},
|
||||
}));
|
||||
vi.mock("@/api/utils.ts", () => ({ fetchURL, fetchJSON }));
|
||||
vi.mock("@/router/index.ts", () => ({ router: {} }));
|
||||
vi.mock("@/store", () => ({ state, mutations }));
|
||||
vi.mock("@/store/state.ts", () => ({ state }));
|
||||
vi.mock("@/store/mutations.ts", () => ({ mutations }));
|
||||
vi.mock("@/store/eventBus", () => ({ eventBus: { emit: vi.fn() } }));
|
||||
vi.mock("@/notify", () => ({ notify: { showSuccessToast: vi.fn(), showError: vi.fn() } }));
|
||||
vi.mock("@/i18n/index.ts", () => ({ default: { global: { t: (key) => key } } }));
|
||||
vi.mock("@/utils/auth", () => ({ validateLogin: vi.fn() }));
|
||||
vi.mock("@/components/settings/UserProfilePreferences.vue", () => ({ default: { render: () => null } }));
|
||||
|
||||
import { get } from "@/api/users.js";
|
||||
import UserEdit from "./UserEdit.vue";
|
||||
import UserEditPreferences from "./UserEditPreferences.vue";
|
||||
|
||||
let app, root;
|
||||
function openPreferences(user) {
|
||||
createUserEditSession({ user, profileUser: { ...user } });
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(UserEditPreferences);
|
||||
app.config.globalProperties.$t = (key) => key;
|
||||
app.mount(root);
|
||||
}
|
||||
|
||||
function passwordChangeToggle() {
|
||||
return root.querySelector('input[aria-label="settings.requirePasswordChange"]');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
app = undefined;
|
||||
root?.remove();
|
||||
destroyUserEditSession();
|
||||
state.enforcedUserDefaults = {};
|
||||
fetchURL.mockReset();
|
||||
fetchJSON.mockReset();
|
||||
});
|
||||
|
||||
describe("password changes in user edit preferences", () => {
|
||||
it.each([false, true])("passes the changed requirement to the parent save (initially %s)", async (initial) => {
|
||||
const user = { username: "user", loginMethod: "password", requirePasswordChange: initial };
|
||||
applySectionsToFlatUser(user, sectionsFromFlatUser(user));
|
||||
const editor = {
|
||||
...UserEdit.data(),
|
||||
user: { ...user },
|
||||
profileUser: { ...user },
|
||||
loaded: true,
|
||||
groupsReady: true,
|
||||
isNew: false,
|
||||
enforcedAccount: {},
|
||||
enforcedAccountPermissions: {},
|
||||
enforcedPreferences: {},
|
||||
$t: (key) => key,
|
||||
};
|
||||
for (const [name, method] of Object.entries(UserEdit.methods)) {
|
||||
editor[name] = method.bind(editor);
|
||||
}
|
||||
editor.saveGroups = vi.fn();
|
||||
editor.syncEditAccountForm();
|
||||
editor.originalSnapshot = editor.buildEditableSnapshot();
|
||||
|
||||
openPreferences(user);
|
||||
await nextTick();
|
||||
const toggle = passwordChangeToggle();
|
||||
expect(toggle?.checked).toBe(initial);
|
||||
toggle.click();
|
||||
await nextTick();
|
||||
expect(getUserEditSession().user.requirePasswordChange).toBe(!initial);
|
||||
expect(getUserEditSession().profileUser.requirePasswordChange).toBe(!initial);
|
||||
|
||||
await editor.save({ preventDefault: vi.fn() });
|
||||
expect(fetchURL).toHaveBeenCalledTimes(1);
|
||||
const [url, request] = fetchURL.mock.calls[0];
|
||||
expect(url).toMatch(/\/api\/users\?username=user$/);
|
||||
expect(request.method).toBe("PATCH");
|
||||
const body = JSON.parse(request.body);
|
||||
expect(body).toEqual({
|
||||
which: ["requirePasswordChange"],
|
||||
data: { requirePasswordChange: !initial },
|
||||
});
|
||||
|
||||
app.unmount();
|
||||
root.remove();
|
||||
fetchJSON.mockResolvedValue({ ...user, ...body.data });
|
||||
openPreferences(await get(user.username));
|
||||
await nextTick();
|
||||
expect(passwordChangeToggle()?.checked).toBe(!initial);
|
||||
});
|
||||
|
||||
it("hides the toggle for externally authenticated users", async () => {
|
||||
openPreferences({ username: "user", loginMethod: "oidc" });
|
||||
await nextTick();
|
||||
expect(passwordChangeToggle()).toBeNull();
|
||||
});
|
||||
|
||||
it("disables an enforced requirement", async () => {
|
||||
state.enforcedUserDefaults = { account: { requirePasswordChange: true } };
|
||||
openPreferences({ username: "user", loginMethod: "password", requirePasswordChange: true });
|
||||
await nextTick();
|
||||
expect(passwordChangeToggle()?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,14 @@
|
||||
:account="editAccount"
|
||||
:enforced="enforcedAccount"
|
||||
:enforced-permissions="enforcedAccountPermissions"
|
||||
:show-require-password-change="session.user?.loginMethod === 'password'"
|
||||
respect-enforced-policy
|
||||
@account-change="onEditAccountChange"
|
||||
/>
|
||||
<UserProfilePreferences
|
||||
v-if="session"
|
||||
v-model="profileSections"
|
||||
hide-account-options
|
||||
:enforced="enforcedPreferences"
|
||||
:default-expanded-section="null"
|
||||
respect-enforced-policy
|
||||
@@ -61,6 +63,7 @@ export default {
|
||||
sessionUnsubscribe: null,
|
||||
editAccount: {
|
||||
lockPassword: false,
|
||||
requirePasswordChange: false,
|
||||
disableSettings: false,
|
||||
disableUpdateNotifications: false,
|
||||
showAdvancedProfile: false,
|
||||
@@ -113,6 +116,7 @@ export default {
|
||||
const user = this.session?.user || {};
|
||||
const permissions = user.permissions || {};
|
||||
this.editAccount.lockPassword = !!user.lockPassword;
|
||||
this.editAccount.requirePasswordChange = !!user.requirePasswordChange;
|
||||
this.editAccount.disableSettings = !!user.disableSettings;
|
||||
this.editAccount.disableUpdateNotifications = !!user.disableUpdateNotifications;
|
||||
this.editAccount.showAdvancedProfile = !!user.showAdvancedProfile;
|
||||
@@ -130,6 +134,7 @@ export default {
|
||||
const user = {
|
||||
...this.session.user,
|
||||
lockPassword: this.editAccount.lockPassword,
|
||||
requirePasswordChange: this.editAccount.requirePasswordChange,
|
||||
disableSettings: this.editAccount.disableSettings,
|
||||
disableUpdateNotifications: this.editAccount.disableUpdateNotifications,
|
||||
showAdvancedProfile: this.editAccount.showAdvancedProfile,
|
||||
@@ -143,6 +148,7 @@ export default {
|
||||
sections.account = {
|
||||
...(sections.account || {}),
|
||||
lockPassword: this.editAccount.lockPassword,
|
||||
requirePasswordChange: this.editAccount.requirePasswordChange,
|
||||
disableSettings: this.editAccount.disableSettings,
|
||||
disableUpdateNotifications: this.editAccount.disableUpdateNotifications,
|
||||
showAdvancedProfile: this.editAccount.showAdvancedProfile,
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
/>
|
||||
</template>
|
||||
</ProfileEnforceableField>
|
||||
<div class="preference-field-block">
|
||||
<div v-if="!hideAccountOptions" class="preference-field-block">
|
||||
<h4>{{ $t("profileSettings.accountOptions") }}</h4>
|
||||
<div class="settings-items">
|
||||
<ProfilePreferenceToggle
|
||||
@@ -715,7 +715,7 @@
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
v-if="sectionVisible('accountOptions')"
|
||||
v-if="sectionVisible('accountOptions') && !hideAccountOptions"
|
||||
aria-label="accountOptions"
|
||||
name="accountOptions"
|
||||
:accordion="!sectionKey"
|
||||
@@ -825,6 +825,11 @@ export default {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
/** Suppress the account options block when a parent renders UserDefaultsAccountSection alongside this component. */
|
||||
hideAccountOptions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "change", "enforced-change", "theme-color", "locale-change"],
|
||||
data() {
|
||||
|
||||
@@ -30,6 +30,21 @@
|
||||
aria-label="Edit Links"
|
||||
@click="openSidebarLinksPrompt" class="material-symbols action">edit</i>
|
||||
</div>
|
||||
<!-- Source switcher only in navigation mode and when multiple sources exist (hidden in shares).
|
||||
Kept outside the scrollable content area so it does not scroll horizontally with the folder tree. -->
|
||||
<div
|
||||
v-if="mode === 'navigation' && !isShare && sourceNames.length > 1"
|
||||
class="source-card-container"
|
||||
ref="sourceCardContainer"
|
||||
>
|
||||
<ExpandDropdown
|
||||
class="sidebar-source-switcher"
|
||||
:model-value="activeSource"
|
||||
:options="sourceDropdownOptions"
|
||||
:aria-label="$t('general.source')"
|
||||
@update:model-value="navigateToSource"
|
||||
/>
|
||||
</div>
|
||||
<!-- Scrollable Content Area -->
|
||||
<div class="sidebar-links-content">
|
||||
<!-- Links Mode -->
|
||||
@@ -131,20 +146,6 @@
|
||||
</template>
|
||||
<!-- Navigation Mode -->
|
||||
<template v-else>
|
||||
<!-- Source switcher only when multiple sources (hidden in shares) -->
|
||||
<div
|
||||
v-if="!isShare && sourceNames.length > 1"
|
||||
class="source-card-container"
|
||||
ref="sourceCardContainer"
|
||||
>
|
||||
<ExpandDropdown
|
||||
class="sidebar-source-switcher"
|
||||
:model-value="activeSource"
|
||||
:options="sourceDropdownOptions"
|
||||
:aria-label="$t('general.source')"
|
||||
@update:model-value="navigateToSource"
|
||||
/>
|
||||
</div>
|
||||
<FileTree
|
||||
:currentPath="currentPath"
|
||||
:currentSource="currentSource"
|
||||
@@ -897,11 +898,17 @@ a.sidebar-link-button {
|
||||
|
||||
.source-card-container {
|
||||
position: relative;
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
/* When the source switcher sits above the scroll area, the content keeps its tree flush under the divider. */
|
||||
.source-card-container + .sidebar-links-content {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.sidebar-source-switcher {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
>close</i>
|
||||
</div>
|
||||
<div v-if="showPwaInstall" class="button release-banner">
|
||||
<button type="button" @click="installPwa">{{ $t("pwa.install") }}</button>
|
||||
<a type="button" @click="installPwa">{{ $t("pwa.install") }}</a>
|
||||
<i @click="dismissPwaInstall" role="button" tabindex="0" aria-label="close-banner" class="material-symbols">close</i>
|
||||
</div>
|
||||
<SidebarSettings v-if="isSettings"></SidebarSettings>
|
||||
|
||||
@@ -716,7 +716,9 @@
|
||||
"clearAllDescription": "سيؤدي زر مسح المكتمل إلى مسح جميع التحميلات في حالة الانتهاء، بما في ذلك حالات الخطأ والإيقاف المؤقت والتعارض.",
|
||||
"uploadSettings": "إعدادات التحميل",
|
||||
"downloadChunkSizeMb": "حجم قطعة التحميل بالميجابايت",
|
||||
"downloadChunkSizeMbHelp": "حجم القطع للتحميلات المقطعة. سيتم تنزيل الملفات الأكبر من هذا الحجم على شكل أجزاء لمنع حدوث مشكلات في المهلة (على سبيل المثال، أخطاء Cloudflare 524). يؤدي الإعداد إلى 0 إلى تعطيل التنزيلات المقطعة."
|
||||
"downloadChunkSizeMbHelp": "حجم القطع للتحميلات المقطعة. سيتم تنزيل الملفات الأكبر من هذا الحجم على شكل أجزاء لمنع حدوث مشكلات في المهلة (على سبيل المثال، أخطاء Cloudflare 524). يؤدي الإعداد إلى 0 إلى تعطيل التنزيلات المقطعة.",
|
||||
"uploadStallTimeoutSeconds": "مهلة توقف الرفع بالثواني",
|
||||
"uploadStallTimeoutSecondsHelp": "عدد الثواني التي يمكن أن يستمر فيها الرفع دون أي تقدم قبل إيقافه مؤقتًا. ارفعها في الاتصالات البطيئة أو غير المستقرة، أو عندما تُقرأ الملفات من أقراص بطيئة. القيم الأقل من 1 تستخدم القيمة الافتراضية وهي 10 ثوانٍ."
|
||||
},
|
||||
"profileSettings": {
|
||||
"showTypeColumn": "إظهار عمود النوع",
|
||||
|
||||
@@ -821,6 +821,8 @@
|
||||
"uploadChunkSizeMbHelp": "Обикновено 5 до 50 MB, по-малък е по-добър за нестабилни или ограничени връзки. Задаването на 0 ще изключи качването на части.",
|
||||
"downloadChunkSizeMb": "Размер на частите за изтегляне в MB",
|
||||
"downloadChunkSizeMbHelp": "Размер на частите за изтегляне на части. Файлове, по-големи от този размер, ще бъдат изтегляни на части, за да се предотвратят проблеми с изтичане на време (напр. Cloudflare 524 грешки). Задаването на 0 изключва изтеглянето на части.",
|
||||
"uploadStallTimeoutSeconds": "Време за изчакване при блокирало качване (секунди)",
|
||||
"uploadStallTimeoutSecondsHelp": "Секунди, през които едно качване може да е без напредък, преди да бъде поставено на пауза. Увеличете стойността при бавни или нестабилни връзки или когато файловете се четат от бавни дискове. Стойности под 1 използват стойността по подразбиране от 10 секунди.",
|
||||
"clearAll": "Изчистването на завършените трябва да премахва грешки, паузи и конфликти",
|
||||
"clearAllDescription": "Бутонът за изчистване на завършените ще изчисти всички качвания в завършено състояние, включително грешки, паузи и конфликти.",
|
||||
"uploadSettings": "Настройки за качване"
|
||||
|
||||
+349
-347
File diff suppressed because it is too large.
Load diff
@@ -716,7 +716,9 @@
|
||||
"clearAllDescription": "Mit der Schaltfläche Abgeschlossen löschen werden alle Uploads im abgeschlossenen Zustand gelöscht, einschließlich Fehler-, Pausen- und Konfliktstatus.",
|
||||
"uploadSettings": "Upload-Einstellungen",
|
||||
"downloadChunkSizeMb": "Größe des heruntergeladenen Stücks in MB",
|
||||
"downloadChunkSizeMbHelp": "Chunk-Größe für Chunked-Downloads. Dateien, die größer als diese Größe sind, werden in Chunks heruntergeladen, um Timeout-Probleme zu vermeiden (z. B. Cloudflare 524-Fehler). Mit dem Wert 0 werden Chunked Downloads deaktiviert."
|
||||
"downloadChunkSizeMbHelp": "Chunk-Größe für Chunked-Downloads. Dateien, die größer als diese Größe sind, werden in Chunks heruntergeladen, um Timeout-Probleme zu vermeiden (z. B. Cloudflare 524-Fehler). Mit dem Wert 0 werden Chunked Downloads deaktiviert.",
|
||||
"uploadStallTimeoutSeconds": "Zeitlimit für stockende Uploads in Sekunden",
|
||||
"uploadStallTimeoutSecondsHelp": "Sekunden, die ein Upload ohne Fortschritt bleiben darf, bevor er pausiert wird. Erhöhen Sie den Wert bei langsamen oder instabilen Verbindungen oder wenn die Dateien von langsamen Datenträgern gelesen werden. Werte unter 1 verwenden den Standardwert von 10 Sekunden."
|
||||
},
|
||||
"colors": {
|
||||
"red": "rot",
|
||||
|
||||
@@ -716,7 +716,9 @@
|
||||
"clearAllDescription": "Το κουμπί Clear completed θα διαγράψει όλες τις μεταφορτώσεις σε κατάσταση ολοκλήρωσης, συμπεριλαμβανομένων των καταστάσεων σφάλματος, παύσης και σύγκρουσης.",
|
||||
"uploadSettings": "Ρυθμίσεις φόρτωσης",
|
||||
"downloadChunkSizeMb": "Μέγεθος τμήματος λήψης σε MB",
|
||||
"downloadChunkSizeMbHelp": "Μέγεθος τεμαχίου για λήψεις σε τεμάχια. Αρχεία μεγαλύτερα από αυτό το μέγεθος θα κατεβαίνουν σε κομμάτια για να αποφευχθούν προβλήματα χρονικού ορίου (π.χ. σφάλματα Cloudflare 524). Η ρύθμιση σε 0 απενεργοποιεί τις ομαδοποιημένες λήψεις."
|
||||
"downloadChunkSizeMbHelp": "Μέγεθος τεμαχίου για λήψεις σε τεμάχια. Αρχεία μεγαλύτερα από αυτό το μέγεθος θα κατεβαίνουν σε κομμάτια για να αποφευχθούν προβλήματα χρονικού ορίου (π.χ. σφάλματα Cloudflare 524). Η ρύθμιση σε 0 απενεργοποιεί τις ομαδοποιημένες λήψεις.",
|
||||
"uploadStallTimeoutSeconds": "Χρονικό όριο διακοπής μεταφόρτωσης σε δευτερόλεπτα",
|
||||
"uploadStallTimeoutSecondsHelp": "Δευτερόλεπτα που μπορεί να περάσουν χωρίς καμία πρόοδο σε μια μεταφόρτωση πριν τεθεί σε παύση. Αυξήστε το σε αργές ή ασταθείς συνδέσεις ή όταν τα αρχεία διαβάζονται από αργούς δίσκους. Τιμές μικρότερες από 1 χρησιμοποιούν την προεπιλογή των 10 δευτερολέπτων."
|
||||
},
|
||||
"colors": {
|
||||
"red": "κόκκινο",
|
||||
|
||||
@@ -841,6 +841,8 @@
|
||||
"uploadChunkSizeMbHelp": "Typically 5 to 50 MB, smaller is better for unstable or restricted connections. Setting to 0 will disable upload chunking.",
|
||||
"downloadChunkSizeMb": "Download chunk size in MB",
|
||||
"downloadChunkSizeMbHelp": "Chunk size for chunked downloads. Files larger than this size will be downloaded in chunks to prevent timeout issues (e.g., Cloudflare 524 errors). Setting to 0 disables chunked downloads.",
|
||||
"uploadStallTimeoutSeconds": "Upload stall timeout in seconds",
|
||||
"uploadStallTimeoutSecondsHelp": "Seconds an upload can go without any progress before it is paused. Increase it on slow or unstable connections, or when the files are read from slow disks. Values below 1 use the default of 10 seconds.",
|
||||
"clearAll": "Clear completed should remove error, pause, and conflict",
|
||||
"clearAllDescription": "The clear completed button will clear all uploads in a finished state, including error, paused, and conflict states.",
|
||||
"uploadSettings": "Upload Settings"
|
||||
|
||||
@@ -716,7 +716,9 @@
|
||||
"clearAllDescription": "Hará que el botón de \"Limpiar completados\" en la ventana de subir archivos borre todos los estados: Finalizado, error, pausa y conflicto.",
|
||||
"uploadSettings": "Opciones de subida",
|
||||
"downloadChunkSizeMb": "Tamaño del fragmento de descarga en MB",
|
||||
"downloadChunkSizeMbHelp": "Tamaño de fragmento para descargas. Los archivos mayores que este tamaño se descargarán en fragmentos, lo cual puede ayudar a evitar problemas de tiempo de espera (por ejemplo, errores Cloudflare 524). Si lo pones en 0, se desactivarán las descargas fragmentadas."
|
||||
"downloadChunkSizeMbHelp": "Tamaño de fragmento para descargas. Los archivos mayores que este tamaño se descargarán en fragmentos, lo cual puede ayudar a evitar problemas de tiempo de espera (por ejemplo, errores Cloudflare 524). Si lo pones en 0, se desactivarán las descargas fragmentadas.",
|
||||
"uploadStallTimeoutSeconds": "Tiempo de espera sin avance de la subida en segundos",
|
||||
"uploadStallTimeoutSecondsHelp": "Segundos que una subida puede estar sin avanzar antes de pausarse. Auméntalo en conexiones lentas o inestables, o si los archivos se leen de discos lentos. Con valores menores que 1 se usa el valor por defecto de 10 segundos."
|
||||
},
|
||||
"colors": {
|
||||
"red": "Rojo",
|
||||
|
||||
@@ -841,6 +841,8 @@
|
||||
"uploadChunkSizeMbHelp": "Normalean 5 eta 50 MB artean; txikiagoa hobea da konexio ezegonkor edo mugatuetarako. 0 ezarriz gero, zatikako igoera desgaitzen da.",
|
||||
"downloadChunkSizeMb": "Deskarga-zatien tamaina (MB)",
|
||||
"downloadChunkSizeMbHelp": "Zatikako deskargetarako zatien tamaina. Tamaina hau baino handiagoak diren fitxategiak zatika deskargatuko dira denbora-mugako arazoak saihesteko (adib. Cloudflare-ren 524 akatsak). 0 ezarriz gero, zatikako deskargak desgaitzen dira.",
|
||||
"uploadStallTimeoutSeconds": "Igoeraren geldialdi-denbora segundotan",
|
||||
"uploadStallTimeoutSecondsHelp": "Igoera bat aurrerapenik gabe egon daitekeen segundo kopurua, pausatu aurretik. Handitu ezazu konexio motel edo ezegonkorretan, edo fitxategiak disko motelretatik irakurtzen direnean. 1 baino txikiagoko balioek 10 segundoko balio lehenetsia erabiltzen dute.",
|
||||
"clearAll": "\"Garbitu osatuak\" aukerak akatsak, pausak eta gatazkak ere kendu behar ditu",
|
||||
"clearAllDescription": "\"Garbitu osatuak\" botoiak amaitutako egoeran dauden igoera guztiak garbituko ditu, akats, pausa eta gatazka egoerak barne.",
|
||||
"uploadSettings": "Igoeraren ezarpenak"
|
||||
|
||||
+267
-265
File diff suppressed because it is too large.
Load diff
@@ -716,7 +716,9 @@
|
||||
"clearAllDescription": "הכפתור 'נקה את ההשלמות' ינקה את כל ההעלאות במצב 'הושלם', כולל מצבי שגיאה, השהיה וקונפליקט.",
|
||||
"uploadSettings": "הגדרות העלאה",
|
||||
"downloadChunkSizeMb": "גודל החלק להורדה במגה-בייט",
|
||||
"downloadChunkSizeMbHelp": "גודל החלקים להורדות בחלקים. קבצים שגודלם עולה על גודל זה יועברו בחלקים כדי למנוע בעיות של זמן המתנה (לדוגמה, שגיאות Cloudflare 524). הגדרה ל-0 מבטלת הורדות בחלקים."
|
||||
"downloadChunkSizeMbHelp": "גודל החלקים להורדות בחלקים. קבצים שגודלם עולה על גודל זה יועברו בחלקים כדי למנוע בעיות של זמן המתנה (לדוגמה, שגיאות Cloudflare 524). הגדרה ל-0 מבטלת הורדות בחלקים.",
|
||||
"uploadStallTimeoutSeconds": "זמן קצוב להעלאה תקועה בשניות",
|
||||
"uploadStallTimeoutSecondsHelp": "מספר השניות שהעלאה יכולה להישאר ללא התקדמות לפני שהיא מושהית. הגדילו אותו בחיבורים איטיים או לא יציבים, או כשהקבצים נקראים מדיסקים איטיים. ערכים הנמוכים מ-1 משתמשים בברירת המחדל של 10 שניות."
|
||||
},
|
||||
"colors": {
|
||||
"red": "אדום",
|
||||
|
||||
@@ -716,7 +716,9 @@
|
||||
"clearAllDescription": "A befejezett állapot törlése gomb az összes befejezett állapotban lévő feltöltést törli, beleértve a hiba, a szüneteltetés és a konfliktus állapotokat is.",
|
||||
"uploadSettings": "Feltöltési beállítások",
|
||||
"downloadChunkSizeMb": "Letöltési darabok mérete MB-ban",
|
||||
"downloadChunkSizeMbHelp": "Darabolt letöltések esetén a darabolt méret. Az ennél nagyobb méretű fájlok letöltése darabokban történik, hogy elkerülhetők legyenek az időkorlátozási problémák (pl. Cloudflare 524 hiba). A 0-ra állítás kikapcsolja a darabolt letöltéseket."
|
||||
"downloadChunkSizeMbHelp": "Darabolt letöltések esetén a darabolt méret. Az ennél nagyobb méretű fájlok letöltése darabokban történik, hogy elkerülhetők legyenek az időkorlátozási problémák (pl. Cloudflare 524 hiba). A 0-ra állítás kikapcsolja a darabolt letöltéseket.",
|
||||
"uploadStallTimeoutSeconds": "Feltöltés elakadási időkorlátja másodpercben",
|
||||
"uploadStallTimeoutSecondsHelp": "Hány másodpercig maradhat egy feltöltés előrehaladás nélkül, mielőtt szüneteltetjük. Növelje lassú vagy instabil kapcsolatoknál, illetve ha a fájlokat lassú lemezekről olvassák. Az 1-nél kisebb értékek az alapértelmezett 10 másodpercet használják."
|
||||
},
|
||||
"colors": {
|
||||
"red": "piros",
|
||||
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user