Compare commits

..
132 Commits
Author SHA1 Message Date
Graham Steffaniak edaa8c536d updated changelog 2026-01-24 08:35:09 -06:00
Graham Steffaniak 52d57017f2 updated to disable all onlyoffice (#1888) 2026-01-23 14:34:49 -06:00
Graham Steffaniak 1dd219ad18 updated readme 2026-01-23 14:15:18 -06:00
Graham Steffaniak e584c30225 updated readme 2026-01-23 14:13:37 -06:00
Graham Steffaniak 801c029b03 Fix file actions (#1887) 2026-01-23 14:02:33 -06:00
Graham Steffaniak bf46e977c4 updated changelog 2026-01-23 14:02:13 -06:00
Graham Steffaniak c02a994880 fix file actions for scope 2026-01-23 13:51:02 -06:00
Graham Steffaniak 2cf352876c Merge branch 'dev/v1.2.1' of github.com:gtsteffaniak/filebrowser into dev/v1.2.1 2026-01-23 13:29:04 -06:00
Graham Steffaniak fb2c97d05a Db cache size change (#1886) 2026-01-23 13:28:01 -06:00
Graham Steffaniak 0bd58c14ee updated db cache size 2026-01-22 11:30:09 -06:00
Graham Steffaniak 09376139f1 Merge branch 'dev/v1.2.1' of github.com:gtsteffaniak/filebrowser into dev/v1.2.1 2026-01-22 10:52:07 -06:00
Graham Steffaniak 59495b7f2f Db package changes (#1883) 2026-01-22 10:51:56 -06:00
Graham Steffaniak d258333f26 Merge branch 'dev/v1.2.1' of github.com:gtsteffaniak/filebrowser into dev/v1.2.1 2026-01-22 10:28:08 -06:00
Graham Steffaniak fe57dd5edc updated with db name changes 2026-01-22 10:27:46 -06:00
Graham Steffaniak b96636279e updated config and preview (#1878) 2026-01-22 10:26:46 -06:00
Graham Steffaniak 3025442ff8 updated config and preview 2026-01-21 18:18:58 -06:00
Graham Steffaniak 715a044af1 merged 2026-01-21 17:00:39 -06:00
Graham Steffaniak a28d515b90 Merge branch 'dev/v1.2.1' of github.com:gtsteffaniak/filebrowser into dev/v1.2.1 2026-01-21 16:58:59 -06:00
+4 990d53fa95 Upgrade imaging (#1877)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-21 16:36:14 -06:00
Graham Steffaniak f0068d0765 fix only office integration on 1.2.x (#1873) 2026-01-21 13:27:05 -06:00
Kevin B. 8fd13a40bc Fix listing issues (#1867) 2026-01-21 13:27:05 -06:00
Graham Steffaniak 941841fca1 show new rules style (#1870) 2026-01-21 13:27:05 -06:00
Graham Steffaniak 0235546a8f fix only office integration on 1.2.x (#1873) 2026-01-21 11:41:37 -06:00
Kevin B. 66482cd1d5 Fix listing issues (#1867) 2026-01-21 12:09:45 -05:00
Graham Steffaniak d26189968f show new rules style (#1870) 2026-01-21 10:33:52 -06:00
Graham Steffaniak eb518bac56 updated build for windows (#1859) 2026-01-20 09:24:18 -06:00
Graham Steffaniak d82e7f2986 updated changelog 2026-01-20 08:59:43 -06:00
Graham Steffaniak 7cd3f8374e added last minute bugfixes (#1857) 2026-01-20 08:50:40 -06:00
Graham Steffaniak 4e0f0453f3 updated sharing features (#1852) 2026-01-19 13:48:35 -06:00
Maxime Borges 98246b2981 i18n: Remove unecessary colon for API keys title (#1848) 2026-01-19 10:21:31 -06:00
Maxime Borges d2c07ffd3c settings: do not center settings title (#1849) 2026-01-19 10:21:17 -06:00
Graham SteffaniakandKevin B. 43cbadbeb8 UI improvements shelf (#1843)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-01-18 17:26:39 -06:00
Kevin B. 25dc426522 Change to plyr package directly + fixes and improvements (#1833) 2026-01-16 20:47:35 -05:00
Graham Steffaniak 27dbd509b2 Bugfix 1.2.0 (#1840) 2026-01-16 11:22:20 -06:00
Graham Steffaniak 3716c4e4c6 Change quick scan and delete purge (#1835) 2026-01-15 10:34:14 -06:00
Graham Steffaniak 7aabdadf3e Improved api format (#1834) 2026-01-14 18:21:26 -06:00
Graham Steffaniak 264de1ce88 more consistent permissions, functions (#1832) 2026-01-14 11:06:19 -06:00
Graham Steffaniak 4aae24cf11 bulk delete enhancements + duplicate detector deletion feature (#1830) 2026-01-13 11:00:33 -06:00
Graham Steffaniak 0968c44d22 updated scrolling 2026-01-12 14:21:01 -06:00
Graham Steffaniak a15fe4eaa5 Merge branch 'dev/v1.2.0' of github.com:gtsteffaniak/filebrowser into dev/v1.2.0 2026-01-12 12:12:14 -06:00
Graham Steffaniak a433a8a8d4 make disk check and rules more efficient (#1822) 2026-01-12 11:35:25 -06:00
Graham Steffaniak b5219ae6b6 Merge branch 'dev/v1.2.0' of github.com:gtsteffaniak/filebrowser into dev/v1.2.0 2026-01-12 10:46:19 -06:00
Graham SteffaniakandSmilingPie d9f91c7c65 Chunked downloading (#1821)
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
2026-01-10 11:12:32 -06:00
Kevin B. 4a85746850 Allow rename in previews (#1817) 2026-01-10 11:03:00 -06:00
Graham Steffaniak 3ae8aede25 Merge branch 'dev/v1.2.0' of github.com:gtsteffaniak/filebrowser into dev/v1.2.0 2026-01-09 17:31:33 -06:00
Graham Steffaniak 3990890721 Update login encoding (#1816) 2026-01-09 12:48:26 -06:00
Graham SteffaniakandSmilingPie 726116d831 Search multiple sources (#1815)
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
2026-01-08 13:35:51 -06:00
Graham Steffaniak 8274f2c715 Merge branch 'dev/v1.2.0' of github.com:gtsteffaniak/filebrowser into dev/v1.2.0 2026-01-08 10:07:48 -06:00
Graham SteffaniakandSmilingPie 32969d0777 Merge v1.1.6 1.2.0 (#1813)
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
2026-01-07 09:11:56 -06:00
Graham Steffaniak 8f25186136 Merge branch 'main' into dev/v1.2.0 2026-01-07 08:40:48 -06:00
Graham Steffaniak e18a73c97a two pass loading with loading bar (#1800) 2026-01-05 10:45:56 -06:00
Aleksandr Baryshnikov e5cb88ba5d feat: initial support for webdav (#1764) 2026-01-05 09:22:51 -06:00
Kevin B.andGraham Steffaniak 50b9ee8b31 make breadcrumbs act as a drop area (#1785)
Co-authored-by: Graham Steffaniak <gtsteff@gmail.com>
2026-01-04 16:45:38 -06:00
Graham Steffaniak a0e266fadf updated with more indexing changes (#1793) 2026-01-04 13:51:10 -06:00
Graham Steffaniak 377ae33e1b refactor folder size updates (#1782) 2026-01-01 18:36:35 -06:00
Kevin B. e14f2d7a4b Fix fileWatcher file selection + spacebar shortcut (#1770) 2025-12-31 17:16:52 -05:00
Graham Steffaniak fac091f1b3 Merge branch 'main' into dev/v1.2.0 2025-12-31 13:20:37 -06:00
Graham Steffaniak 5330d6b093 updated changelog 2025-12-31 13:19:19 -06:00
Graham Steffaniak 692f99251d updated with a few more small updates (#1768) 2025-12-29 16:09:07 -06:00
Graham Steffaniak 31e70519c1 updated loading spinners (#1766) 2025-12-29 11:05:34 -06:00
Graham Steffaniak 0b88a90048 Merge branch 'main' into dev/v1.2.0 2025-12-28 15:55:05 -06:00
Graham Steffaniak 79d7f07943 updated conditional rules format (#1761) 2025-12-28 15:49:58 -06:00
Graham Steffaniak b562edaaeb make indexing data persistent within cacheDir (#1760) 2025-12-28 11:30:07 -06:00
Graham Steffaniak 33ede042da efficiency tweaks for indexing (#1759) 2025-12-28 09:20:08 -06:00
Graham Steffaniak 036a0ed6c8 updated indexing functionality (#1755) 2025-12-26 16:22:02 -06:00
Graham Steffaniak 1842ff561b updated root scanner not to purge 2025-12-21 08:15:44 -06:00
Graham Steffaniak aed9f7c135 add tail/watch file watcher tool (#1738) 2025-12-20 15:40:29 -06:00
Graham Steffaniak c6600a0665 updated memory pressure (#1741) 2025-12-20 14:10:20 -06:00
Graham Steffaniak 3e3d3decc6 updated indexing design (#1728) 2025-12-16 15:14:50 -06:00
Graham Steffaniak c58b298eb5 Merge branch 'beta/v1.1.4' into dev/v1.2.0 2025-12-13 14:40:49 -06:00
Graham Steffaniak 98ba8095db updated build 2025-12-13 09:54:44 -06:00
Graham Steffaniak 68fb573059 improved cacheDir checks (#1723) 2025-12-13 08:50:05 -06:00
Graham Steffaniak 5747c40dca updated translation 2025-12-12 18:00:04 -06:00
Graham Steffaniak 598bd366db added search image show toggle (#1721) 2025-12-12 17:58:20 -06:00
Graham Steffaniak e3be3300dc File list loading spinner (#1720) 2025-12-12 15:44:27 -06:00
Graham Steffaniak 416aa75446 updated with new logger (#1719) 2025-12-12 15:24:27 -06:00
Graham Steffaniak cfb3b9e869 updated with fix for special characters (#1718) 2025-12-12 14:23:30 -06:00
Graham Steffaniak 166f1bf232 invalidate isDir cache (#1716) 2025-12-12 14:06:41 -06:00
Graham Steffaniak 451f9ac69f update with access rules for shares (#1714) 2025-12-12 12:35:25 -06:00
Graham Steffaniak b2fa1780ed updated with access rule changes (#1713) 2025-12-12 11:27:49 -06:00
Kevin B. 9255bc174b Fix/Improve some behaviors in nextPrevious (#1707) 2025-12-10 16:14:55 -06:00
Kevin B. a3a074d7b6 Fix navigation for files with special characters (#1711) 2025-12-10 14:12:21 -06:00
Claudio YanesandGraham Steffaniak aefe16b508 Implements socket activation (#1698)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2025-12-10 11:54:50 -06:00
Graham Steffaniak 728ee72e2a updated with indexing test fixes (#1699) 2025-12-03 19:33:42 -06:00
Graham Steffaniak e94075de6c updated scanner complexity 2025-12-02 11:08:28 -06:00
Graham Steffaniak c56bafb4e9 fixed some indexing issues 2025-12-02 10:58:37 -06:00
Graham Steffaniak a6e045cc85 Merge tag 'v1.1.3-beta' into experimental-sqlite-indexing 2025-12-02 09:55:22 -06:00
Graham Steffaniak 690685459e updated favicon to svg only (#1694) 2025-12-02 09:09:11 -06:00
Graham Steffaniak 6f6244b2fb revert download behavior (#1691) 2025-12-02 08:54:45 -06:00
Graham Steffaniak 05fcd31909 Clear access cache aggressively (#1690) 2025-12-01 18:28:07 -06:00
Graham Steffaniak a4107a734a continue user dir creation on error (#1689) 2025-12-01 18:06:04 -06:00
Graham Steffaniak 991be79521 scanner size increment changes (#1688) 2025-12-01 17:33:18 -06:00
Graham Steffaniak 8755e82780 improved sqlite implementation 2025-11-30 08:01:14 -06:00
Graham Steffaniak d1b3a045af updated with exclusive lock 2025-11-29 17:51:06 -06:00
Graham Steffaniak b5b365c6a7 Merge branch 'experimental-sqlite-indexing' of github.com:gtsteffaniak/filebrowser into experimental-sqlite-indexing 2025-11-29 17:46:41 -06:00
Graham Steffaniak c33a876033 improved sql indexing 2025-11-29 17:43:20 -06:00
Graham Steffaniak 42778b92bb updated with tuning 2025-11-28 19:59:15 -06:00
Graham Steffaniak ff3b66b79d adjust cache settings 2025-11-28 19:59:15 -06:00
Graham Steffaniak daedeef78d expirmental features 2025-11-28 19:59:15 -06:00
Graham Steffaniak edba1a80ef updated with a few fixes (#1678) 2025-11-28 12:08:17 -06:00
Graham Steffaniak 77fb6bdabc updated with tuning 2025-11-27 14:40:43 -06:00
Graham Steffaniak 97c37caa1c adjust cache settings 2025-11-26 18:37:39 -06:00
Graham Steffaniak 02b28284ca expirmental features 2025-11-26 18:18:12 -06:00
Graham Steffaniak 6a489c6f7c Fix sidebar custom link validation (#1665) 2025-11-26 16:27:29 -06:00
Kevin B.andGraham Steffaniak ba443398ca Improve captions styles in plyr (#1661)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2025-11-26 16:06:52 -06:00
Graham Steffaniak 17ff68c00d fix utf-8 filename prefix (#1664) 2025-11-26 16:06:34 -06:00
Graham Steffaniak cb71dd8940 fixed concurrent media duration issue (#1663) 2025-11-26 15:24:12 -06:00
Graham Steffaniak 764155d612 updated indexing changes (#1662) 2025-11-26 14:42:43 -06:00
Graham Steffaniak 9013152d9d Merge branch 'dev/v1.1.2' of github.com:gtsteffaniak/filebrowser into dev/v1.1.2 2025-11-26 09:14:09 -06:00
27bc000cda Experiment with sqllite (#1660)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-26 09:13:50 -06:00
8f4e420e73 Experiment with sqllite (#1660)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-26 09:12:09 -06:00
Graham Steffaniak e4d8a5cf20 fix editor issues (#1654) 2025-11-24 13:07:59 -06:00
Kevin B.andGraham Steffaniak fa8ceb50e6 Fix drag and drop and rows in normal view (#1651)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2025-11-24 12:12:12 -06:00
Graham Steffaniak c8073db4e6 access control download changes (#1653) 2025-11-24 10:02:39 -06:00
Graham Steffaniak 23fac83e7e adjusted list duration 2025-11-24 09:44:40 -06:00
Graham Steffaniak f36ca31c7f updated some build and comments (#1652) 2025-11-24 09:39:21 -06:00
Graham Steffaniak c0466e96fc updated translation for chinese 2025-11-24 09:05:20 -06:00
Graham Steffaniak dc753fe326 consolidate share url creation to backend api (#1647) 2025-11-24 08:18:32 -06:00
Graham Steffaniak c39a1937b4 updated with various bugfixes (#1646) 2025-11-21 19:30:37 -06:00
Graham Steffaniak 5d38b1e66a Download notification (#1645) 2025-11-21 17:31:31 -06:00
Graham Steffaniak e5e93b4b70 Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into dev/v1.1.1 2025-11-21 17:10:18 -06:00
eaedbbe05e Fix user notifications (#1644)
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-21 16:27:58 -06:00
Graham Steffaniak c6d5f32bd0 Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into dev/v1.1.1 2025-11-21 16:23:26 -06:00
Graham Steffaniak ee022bc4a9 Fix only office viewing (#1643) 2025-11-21 15:21:54 -06:00
Kevin B. daabefa6c3 Update spanish translation (#1636) 2025-11-21 15:00:28 -06:00
Graham Steffaniak 86aebde566 Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into dev/v1.1.1 2025-11-21 10:59:58 -06:00
Graham Steffaniak a3b0fc0038 Upgrade ffmpeg 8.0.1 (#1634) 2025-11-20 13:50:57 -06:00
dependabot[bot]andGraham Steffaniak 695364c2df Bump golang.org/x/crypto from 0.44.0 to 0.45.0 in /backend in the go_modules group across 1 directory (#1627)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-20 13:36:46 -06:00
Graham Steffaniak 58a01ff986 Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into dev/v1.1.1 2025-11-20 13:36:10 -06:00
Graham Steffaniak 3298111560 Add exif tool (#1625) 2025-11-19 17:54:48 -06:00
Graham Steffaniak 3ca9f54de0 updated with compile safe i18n checks (#1623) 2025-11-19 16:48:44 -06:00
Graham Steffaniak 97d16d1663 add exiftool for orientation 2025-11-19 16:46:47 -06:00
173 changed files with 4161 additions and 6145 deletions

No files matched your search

+2 -75
View File
@@ -2,80 +2,6 @@
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).
## v1.2.5-beta
**Notes**:
- Dependency Updates for frontend and backend packages
- Upgrade to go 1.26
- file list prompt interaction behavior, single-click always selects, double-click navigates. (#1911)
**BugFixes**:
- .ssa and .ass subtitle files get their styling removed during conversion (#1933)
- custom icons issue
- new database message for noauth (#1935)
## v1.2.4-beta
**Notes**:
- Changed `/api/media/subtitles` api endpoint to better support subtitles.
- Correcting some errors in French language (#1947)
- add Dutch `nl.json` for frontend i18n -- thanks @Stephan-P (#1957)
- share banner and icon images automatically serve a scaled down 1024x1024
**BugFixes**:
- Can't access directories with space (and possibly other special characters) when browsing a public link share (#1956)
- External ASS subtitles are not usable in web preview (#1933)
- Filename blocking rename/move (#1950)
- enforcedOtp: true blocks all user settings updates with HTTP 400 (#1962)
- Share favicon URL not showing up and Share description causing endless loading (#1911)
- folder sizes not updated for changes to filesystem outside of filebrowser (#1974)
## v1.2.3-beta
**Notes**:
- Removed upload api behavior to assume paths ending in "/" are folders, strictly uses isDir query param
- renamed public upload api query param from `targetPath` to `path`. see swagger docs.
**BugFixes**:
- fix stuck motion preview popup window issue
- Chunked uploads ignore User Scope and are saved to Source Root (#1894)
- share delete with user scope issue
- fix favicon pwa icon, having the PWA respect the favicon/loginIcon (#1899)
- delete prompt icons show image previews
## v1.2.2-beta
**New Features**:
- Resizable sidebar (#1896)
- OIDC Authentication: Change Button Text via `frontend.oidcLoginButtonText` (#1708)
- improved favicon processing (#1899)
- supports more formats
- supports larger images
- automatcially generates multiple favicon sizes on startup for non-svg images. Custom svg favicons need a companion *.png to exist broad compatibilty
- Enhanced media playback: Ability to control the queue from your device's lock screen and notification panel - Will also show
metadata of the current playing media if available (#1917)
**Notes**:
- Better text file content detection (#1726)
- More url encoding changes for API which should make things more consistent. Open issues if you see path/source not found errors.
- adjustments to the startup behavior for sqlite index for reusing the previous database on startup
- CTRL + B disables sticky sidebar forever (#1869)
- added context menu back to duplicate finder
- Sharing a link for uploads - folder/file access and UX polishing (#1902)
- improved listing view and scrolling performance
- improved image viewer which will utilize recent thumbnails as a placeholder when loading the full image.
- Small reorganization of "Share" settings to make the popup clear (#1826)
**BugFixes**:
- Added docker default `FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"` back -- mistakenly removed. see https://filebrowserquantum.com/en/docs/getting-started/config/#3-default-locations. (#1891)
- Fix brand text in login screen (#1898)
- Sidebar links cointains urls with 2x repeated source name (#1847)
- 2 factor auth getting overwritten if you edit the auth from admin (#1819)
- New created user's files are listing error, Probably related with language setting. (#1565)
- thumbnail generation cpu/memory and concurrency bug.
- added 75MB filesize limit for image previews
- optimized concurrency for large vs small images
## v1.2.1-beta
@@ -83,7 +9,8 @@ All notable changes to this project will be documented in this file. For commit
- Global disable onlyoffice editor via "*" file option to disable all files for a specific user. (#1533)
**Notes**:
- upgraded imaging package and improved thumbnail generation performance. (#1797) (#1850)
- upgraded imaging package and improved thumbnail generation performance. (#1797) (#1850
)
- updated download api to use repeated `file` query param for path instead of `files` with comma. See swagger for details (#1881)
**BugFixes**:
+1 -1
View File
@@ -6,6 +6,7 @@ WORKDIR /app
COPY ./backend ./
RUN apk update && apk add --no-cache gcc musl-dev upx
ENV CGO_ENABLED=1
ENV GOEXPERIMENT=greenteagc
RUN go build -tags mupdf,musl -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
@@ -26,7 +27,6 @@ FROM alpine:latest
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata curl exiftool
RUN adduser -D -s /bin/true -u 1000 filebrowser
+1 -1
View File
@@ -4,6 +4,7 @@ ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add upx
ENV GOEXPERIMENT=greenteagc
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
@@ -22,7 +23,6 @@ RUN npm run build:docker
FROM alpine:latest
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata curl
RUN adduser -D -s /bin/true -u 1000 filebrowser
+4 -59
View File
@@ -10,9 +10,6 @@ import (
"os"
"path/filepath"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/go-logger/logger"
)
// Cache interface for caching operations
@@ -32,61 +29,9 @@ type FileCache struct {
}
}
// NewFileCache creates a new FileCache
func NewFileCache(dir string) (*FileCache, error) {
cacheDir := filepath.Join(dir, "diskcache")
// Migrate existing cache files from old structure (if any)
if err := migrateOldCacheStructure(dir, cacheDir); err != nil {
logger.Warningf("failed to migrate old cache structure: %v\n", err)
}
// Create the cache directory immediately with proper permissions
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
}
// migrateOldCacheStructure moves cache files from old structure (dir/a/...) to new structure (dir/diskcache/a/...)
func migrateOldCacheStructure(oldDir, newDir string) error {
// Read the old cache directory
entries, err := os.ReadDir(oldDir)
if err != nil {
if os.IsNotExist(err) {
return nil // Directory doesn't exist yet, nothing to migrate
}
return err
}
migrated := 0
for _, entry := range entries {
// Only migrate single-character directories (cache hash directories like a, b, c, etc.)
if !entry.IsDir() || len(entry.Name()) != 1 {
continue
}
// Skip if it's already the diskcache directory
if entry.Name() == "d" && filepath.Join(oldDir, entry.Name()) == newDir {
continue
}
oldPath := filepath.Join(oldDir, entry.Name())
newPath := filepath.Join(newDir, entry.Name())
// Create parent directory in new location
if err := os.MkdirAll(newDir, fileutils.PermDir); err != nil {
return err
}
// Move the directory
if err := os.Rename(oldPath, newPath); err != nil {
// If rename fails (e.g., cross-device), skip this one
continue
}
migrated++
}
return nil
return &FileCache{dir: dir}, nil
}
func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
@@ -95,11 +40,11 @@ func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
defer mu.Unlock()
fileName := f.getFileName(key)
if err := os.MkdirAll(filepath.Dir(fileName), fileutils.PermDir); err != nil {
if err := os.MkdirAll(filepath.Dir(fileName), 0700); err != nil {
return err
}
if err := os.WriteFile(fileName, value, fileutils.PermFile); err != nil {
if err := os.WriteFile(fileName, value, 0600); err != nil {
return err
}
@@ -6,7 +6,6 @@ import (
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/stretchr/testify/require"
)
@@ -20,9 +19,6 @@ func TestFileCache(t *testing.T) {
cachedFilePath = "a/62/a62f2225bf70bfaccbc7f1ef2a397836717377de"
)
// Set up file permissions before creating cache
fileutils.SetFsPermissions(0644, 0755)
// Create temporary directory for the cache
cacheDir, err := os.MkdirTemp("", cacheRoot)
require.NoError(t, err)
@@ -32,20 +28,19 @@ func TestFileCache(t *testing.T) {
require.NoError(t, err)
// store new key
// Note: NewFileCache creates a "diskcache" subdirectory, so the actual path includes it
err = cache.Store(ctx, key, []byte(value))
require.NoError(t, err)
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, value)
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, value)
// update existing key
err = cache.Store(ctx, key, []byte(newValue))
require.NoError(t, err)
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, newValue)
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, newValue)
// delete key
err = cache.Delete(ctx, key)
require.NoError(t, err)
exists := fileExists(filepath.Join(cacheDir, "diskcache", cachedFilePath))
exists := fileExists(filepath.Join(cacheDir, cachedFilePath))
require.False(t, exists)
}
+2 -2
View File
@@ -219,7 +219,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
indexing.Initialize(&settings.Source{
Name: "test",
Path: "/mock/path",
}, true, false) // true for mock mode, false for isNewDb
}, true) // true for mock mode
// Get the index and set up mock data
idx := indexing.GetIndex("test")
@@ -329,7 +329,7 @@ func TestOverrideFileToDirectory(t *testing.T) {
indexing.Initialize(&settings.Source{
Name: "test",
Path: "/mock/path",
}, true, false) // true for mock mode, false for isNewDb
}, true) // true for mock mode
// Get the index and set up mock data
idx := indexing.GetIndex("test")
+161 -38
View File
@@ -2,6 +2,7 @@ package files
import (
"context"
"encoding/base64"
"fmt"
"io"
"os"
@@ -10,6 +11,8 @@ import (
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/dhowden/tag"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
@@ -36,18 +39,6 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
metadataCount++
}
}
// Process files concurrently using goroutines
var wg sync.WaitGroup
var mu sync.Mutex // Protects processedCount
// Refresh directory to update folder sizes
wg.Go(func() {
indexPath := idx.MakeIndexPath(response.Path, true)
err := idx.RefreshDirectory(indexPath, false)
if err != nil {
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
}
})
// Set hasMetadata flag if there are files with potential metadata
if metadataCount > 0 {
@@ -61,6 +52,9 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
// Create a single shared FFmpegService instance for all files to coordinate concurrency
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
if sharedFFmpegService != nil {
// Process files concurrently using goroutines
var wg sync.WaitGroup
var mu sync.Mutex // Protects processedCount
for i := range response.Files {
fileItem := &response.Files[i]
@@ -94,10 +88,10 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
}
}
// Wait for all goroutines to complete
wg.Wait()
}
}
// Wait for all goroutines to complete
wg.Wait()
}
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping)
@@ -219,16 +213,17 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
}
// Handle subtitles if requested
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
if exists {
info.GetSubtitles(parentInfo)
}
if opts.ExtractEmbeddedSubtitles {
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
info.Subtitles = append(info.Subtitles, subtitles...)
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetReducedMetadata(parentPath, true)
if exists {
info.DetectSubtitles(parentInfo)
err := info.LoadSubtitleContent()
if err != nil {
logger.Debug("failed to load subtitle content: " + err.Error())
}
}
}
return
}
@@ -243,14 +238,23 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
} else {
// Copy metadata to ExtendedFileInfo
info.Metadata = extItem.Metadata
info.HasMetadata = true
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
info.HasPreview = extItem.Metadata != nil && extItem.Metadata.AlbumArt != ""
}
return
}
// Process text content for non-video, non-audio files
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
// Check if file is text before reading content
isText, err := utils.IsTextFile(info.RealPath)
if err != nil {
logger.Debugf("could not check file type for: "+info.RealPath, info.Name, err)
return
}
if !isText {
// Not a text file, skip content extraction
return
}
content, err := getContent(info.RealPath)
if err != nil {
logger.Debugf("could not get content for file: "+info.RealPath, info.Name, err)
@@ -331,11 +335,11 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
return nil
}
// Extract album art if available (stored as raw bytes, auto-encoded to base64 in JSON)
// Extract album art and encode as base64 with strict size limits
if picture := m.Picture(); picture != nil && picture.Data != nil {
// Size limit to prevent memory issues (max 5MB)
// More aggressive size limit to prevent memory issues (max 5MB)
if len(picture.Data) <= 5*1024*1024 {
item.Metadata.AlbumArt = picture.Data
item.Metadata.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
} else {
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, len(picture.Data))
}
@@ -742,30 +746,149 @@ func WriteFile(source, path string, in io.Reader) error {
}
// getContent reads and returns the file content if it's considered an editable text file.
// This is a wrapper around utils.IsTextFile that also returns the file content.
func getContent(realPath string) (string, error) {
// First check if it's a text file using the consolidated validation logic
isText, err := utils.IsTextFile(realPath)
const headerSize = 4096
// Thresholds for detecting binary-like content (these can be tuned)
const maxNullBytesInHeaderAbs = 10 // Max absolute null bytes in header
const maxNullByteRatioInHeader = 0.1 // Max 10% null bytes in header
const maxNullByteRatioInFile = 0.05 // Max 5% null bytes in the entire file
const maxNonPrintableRuneRatio = 0.05 // Max 5% non-printable runes in the entire file
// Open file
f, err := os.Open(realPath)
if err != nil {
return "", err
}
if !isText {
// Not a text file, return empty string (no error, just not text)
return "", nil
}
defer f.Close()
// It's a text file, read and return the content
// Read header
headerBytes := make([]byte, headerSize)
n, err := f.Read(headerBytes)
if err != nil && err != io.EOF {
return "", err
}
actualHeader := headerBytes[:n]
// --- Start of new heuristic checks ---
if n > 0 {
// Trim header to last complete UTF-8 rune to avoid false negatives
// when the header read cuts off in the middle of a multi-byte sequence.
// We decode runes from the end until we find a valid one, trimming
// any incomplete sequences at the end.
trimmedHeader := actualHeader
for len(trimmedHeader) > 0 {
lastRune, size := utf8.DecodeLastRune(trimmedHeader)
if lastRune != utf8.RuneError {
// Found a valid complete rune
break
}
// RuneError occurred - this could be an incomplete sequence or invalid byte
// Trim the last byte and try again
if size == 1 && len(trimmedHeader) > 0 {
trimmedHeader = trimmedHeader[:len(trimmedHeader)-1]
} else {
// Shouldn't happen, but break to avoid infinite loop
break
}
}
// 1. Basic Check: Is the header valid UTF-8?
// If not, it's unlikely an editable UTF-8 text file.
// Use trimmed header to avoid false negatives from truncated sequences
if len(trimmedHeader) > 0 && !utf8.Valid(trimmedHeader) {
return "", nil // Not an error, just not the text file we want
}
// 2. Check for excessive null bytes in the header
nullCountInHeader := 0
for _, b := range actualHeader {
if b == 0x00 {
nullCountInHeader++
}
}
// Reject if too many nulls absolutely or relatively in the header
if nullCountInHeader > 0 { // Only perform check if there are any nulls
if nullCountInHeader > maxNullBytesInHeaderAbs ||
(float64(nullCountInHeader)/float64(n) > maxNullByteRatioInHeader) {
return "", nil // Too many nulls in header
}
}
// 3. Check for other non-text ASCII control characters in the header
// (C0 controls excluding \t, \n, \r)
for _, b := range actualHeader {
if b < 0x20 && b != '\t' && b != '\n' && b != '\r' {
return "", nil // Found problematic control character
}
// C1 control characters (0x80-0x9F) would be caught by utf8.Valid if part of invalid sequences,
// or by the non-printable rune check later if they form valid (but undesirable) codepoints.
}
// Optional: Use http.DetectContentType for an additional check on the header
// contentType := http.DetectContentType(actualHeader)
// if !strings.HasPrefix(contentType, "text/") && contentType != "application/octet-stream" {
// // If it's clearly a non-text MIME type (e.g., "image/jpeg"), reject it.
// // "application/octet-stream" is ambiguous, so we rely on other heuristics.
// return "", nil
// }
}
// --- End of new heuristic checks for header ---
// Now read the full file (original logic)
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
}
// Handle empty file (return specific marker string)
// Handle empty file (original logic - returns specific string)
if len(content) == 0 {
return "empty-file-x6OlSil", nil
}
return string(content), nil
stringContent := string(content)
// 4. Final UTF-8 validation for the entire file
// (This is crucial as the header might be fine, but the rest of the file isn't)
if !utf8.ValidString(stringContent) {
return "", nil
}
// 5. Check for excessive null bytes in the entire file content
if len(content) > 0 { // Check only for non-empty files
totalNullCount := 0
for _, b := range content {
if b == 0x00 {
totalNullCount++
}
}
if float64(totalNullCount)/float64(len(content)) > maxNullByteRatioInFile {
return "", nil // Too many nulls in the entire file
}
}
// 6. Check for excessive non-printable runes in the entire file content
// (Excluding tab, newline, carriage return, which are common in text files)
if len(stringContent) > 0 { // Check only for non-empty strings
nonPrintableRuneCount := 0
totalRuneCount := 0
for _, r := range stringContent {
totalRuneCount++
// unicode.IsPrint includes letters, numbers, punctuation, symbols, and spaces.
// It excludes control characters. We explicitly allow \t, \n, \r.
if !unicode.IsPrint(r) && r != '\t' && r != '\n' && r != '\r' {
nonPrintableRuneCount++
}
}
if totalRuneCount > 0 { // Avoid division by zero
if float64(nonPrintableRuneCount)/float64(totalRuneCount) > maxNonPrintableRuneRatio {
return "", nil // Too many non-printable runes
}
}
}
// The file has passed all checks and is considered editable text.
return stringContent, nil
}
func IsNamedPipe(mode os.FileMode) bool {
-36
View File
@@ -1,36 +0,0 @@
package fileutils
import (
"io/fs"
"net/http"
)
var (
assetFs fs.FS
)
// Custom dirFS to handle both embedded and non-embedded file systems
type dirFS struct {
http.Dir
}
// Implement the Open method for dirFS, which wraps http.Dir
func (d dirFS) Open(name string) (fs.File, error) {
return d.Dir.Open(name)
}
// InitAssetFS initializes the asset filesystem for the application
// This should be called once during startup before http or preview services start
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool) {
if useEmbedded {
assetFs = embeddedAssets
} else {
// Dev mode: Serve files from http/dist directory
assetFs = dirFS{Dir: http.Dir("http/dist")}
}
}
// GetAssetFS returns the initialized asset filesystem
func GetAssetFS() fs.FS {
return assetFs
}
+4 -7
View File
@@ -67,17 +67,15 @@ func GenerateOtpForUser(user *users.User, userStore *users.Storage) (string, err
secretText := key.Secret()
nonce := ""
secretToStore := secretText
if settings.Config.Auth.TotpSecret != "" {
// If an encryption key is provided, encrypt the secret.
secretToStore, nonce, err = encryptSecret(secretText, encryptionKey)
secretText, nonce, err = encryptSecret(secretText, encryptionKey)
if err != nil {
return "", fmt.Errorf("failed to encrypt TOTP secret: %w", err)
}
}
// set cache so verify can attempt to use it but not require it for user yet.
TotpCache.Set(user.Username, secretToStore+"||"+nonce)
// Use the original base32 secret in the OTP URL, not the encrypted version
TotpCache.Set(user.Username, secretText+"||"+nonce)
url := fmt.Sprintf("otpauth://totp/%v?secret=%v", "FileBrowser Quantum: "+user.Username, secretText)
return url, nil
}
@@ -183,13 +181,12 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
Algorithm: TOTPAlgorithm,
})
if err != nil {
logger.Errorf("error during TOTP validation for user %s: %v", user.Username, err)
logger.Errorf("error during TOTP validation: %v", err)
}
if !valid {
logger.Warningf("Invalid TOTP code for user %s", user.Username)
return fmt.Errorf("invalid OTP token")
}
if totpSecret != "" || !user.OtpEnabled {
if totpSecret != "" {
user.TOTPSecret = totpSecret // The encrypted or plaintext secret
user.TOTPNonce = totpNonce // The nonce if encrypted, or empty if plaintext
user.OtpEnabled = true // Enable OTP for the user
+7 -17
View File
@@ -85,23 +85,6 @@ func runCLI() bool {
}
subcommand := os.Args[2]
// Handle old syntax: "set -u user,pass -c config.yaml"
if subcommand == "-u" {
// Parse flags starting from os.Args[2:] to include the -u flag
err := setCmd.Parse(os.Args[2:])
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
logger.Debugf("setUser called with args: %v, config: %v, admin: %v", os.Args, dbConfig, asAdmin)
err = setUser(dbConfig, asAdmin)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false
}
// New pattern: subcommand-based (e.g., "set user" or "set rule")
// Parse flags starting from os.Args[3:] to skip the subcommand
err := setCmd.Parse(os.Args[3:])
@@ -110,6 +93,13 @@ func runCLI() bool {
os.Exit(1)
}
switch subcommand {
case "-u":
err := setUser(dbConfig, asAdmin)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false
case "rule":
err := setRule(dbConfig, fsPath, indexPath, ruleCategory, value, allow)
if err != nil {
+9 -25
View File
@@ -3,7 +3,6 @@ package cmd
import (
"context"
"fmt"
"io/fs"
"os"
"os/signal"
"syscall"
@@ -19,7 +18,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/icons"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
@@ -118,7 +116,7 @@ func StartFilebrowser() {
}
// Initialize shared index database before starting HTTP service
isNewDb, err := indexing.InitializeIndexDB()
wasRecreated, err := indexing.InitializeIndexDB()
if err != nil {
logger.Fatalf("Failed to initialize index database: %v", err)
}
@@ -126,15 +124,19 @@ func StartFilebrowser() {
// Set indexing storage for persistence
if store != nil && store.Indexing != nil {
indexing.SetIndexingStorage(store.Indexing)
if isNewDb {
// If the index database was recreated (fresh or corrupted), reset all complexities
if wasRecreated {
if err := store.Indexing.ResetAllComplexities(); err != nil {
logger.Errorf("Failed to reset index complexities: %v", err)
} else {
logger.Infof("Successfully reset all index complexities to 0")
}
}
}
for _, source := range settings.Config.Server.SourceMap {
go indexing.Initialize(source, false, isNewDb)
go indexing.Initialize(source, false)
}
validateUserInfo(!dbExists)
validateOfficeIntegration()
@@ -182,32 +184,14 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
cacheDir := settings.Config.Server.CacheDir
numWorkers := settings.Config.Server.NumImageProcessors
ffmpeg.SetFFmpegPaths()
// Initialize asset filesystem before starting services
if settings.Env.EmbeddedFs {
embeddedAssets := fbhttp.GetEmbeddedAssets()
subAssets, err := fs.Sub(embeddedAssets, "embed")
if err != nil {
logger.Fatalf("Failed to create sub filesystem: %v", err)
}
fileutils.InitAssetFS(subAssets, true)
} else {
fileutils.InitAssetFS(nil, false)
}
// Start preview service
// setup disk cache
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
if err != nil {
logger.Fatalf("Error starting preview service: %v", err)
}
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
// Generate PWA icons after preview service is initialized
if err := icons.GeneratePWAIcons(); err != nil {
logger.Warningf("Failed to generate PWA icons: %v", err)
}
fbhttp.StartHttp(ctx, store, shutdownComplete)
return nil
}
+19 -26
View File
@@ -278,8 +278,6 @@ func setupFrontend(generate bool) {
}
// Load custom favicon if configured
loadCustomFavicon()
// Generate PWA icons from custom favicon if needed
generatePWAIcons()
}
func setupMedia() {
@@ -728,7 +726,7 @@ func setDefaults(generate bool) Settings {
}
// validateCustomImage validates a custom image file path and returns the absolute path or error
func validateCustomImage(configPath, imageName string, allowedFormats []string) (absolutePath string, err error) {
func validateCustomImage(configPath, imageName string, maxSize int64, allowedFormats []string) (absolutePath string, err error) {
// Get absolute path
absolutePath, err = filepath.Abs(configPath)
if err != nil {
@@ -736,11 +734,16 @@ func validateCustomImage(configPath, imageName string, allowedFormats []string)
}
// Check if file exists
_, err = os.Stat(absolutePath)
stat, err := os.Stat(absolutePath)
if err != nil {
return "", fmt.Errorf("could not access file: %w", err)
}
// Check file size
if stat.Size() > maxSize {
return "", fmt.Errorf("file too large (%d bytes), maximum allowed is %d bytes", stat.Size(), maxSize)
}
// Validate file format
ext := strings.ToLower(filepath.Ext(absolutePath))
validFormat := false
@@ -759,7 +762,8 @@ func validateCustomImage(configPath, imageName string, allowedFormats []string)
func loadCustomFavicon() {
const imageName = "favicon"
allowedFormats := []string{".ico", ".png", ".svg", ".jpg", ".jpeg", ".gif", ".webp"}
const maxSize = 1024 * 1024 // 1MB
allowedFormats := []string{".ico", ".png", ".svg"}
// Set default embedded favicon path
Env.FaviconEmbeddedPath = "img/icons/favicon.svg"
@@ -772,7 +776,7 @@ func loadCustomFavicon() {
}
// Validate custom favicon
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, allowedFormats)
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, maxSize, allowedFormats)
if err != nil {
logger.Warningf("Custom favicon validation failed: %v, using default", err)
Config.Frontend.Favicon = ""
@@ -788,29 +792,18 @@ func loadCustomFavicon() {
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
}
// generatePWAIcons sets up PWA icon paths (actual generation happens after preview service starts)
func generatePWAIcons() {
// Set PWA icons directory in cache
Env.PWAIconsDir = filepath.Join(Config.Server.CacheDir, "icons")
// Initialize with default paths (all icons will be generated)
Env.PWAIcon192 = "icons/pwa-icon-192.png"
Env.PWAIcon256 = "icons/pwa-icon-256.png"
Env.PWAIcon512 = "icons/pwa-icon-512.png"
// Create icons directory with configured permissions
// Note: Parent cache directory should already exist from testCacheDirSpeed()
if err := os.MkdirAll(Env.PWAIconsDir, fileutils.PermDir); err != nil {
logger.Warningf("Failed to create PWA icons directory %s: %v", Env.PWAIconsDir, err)
}
}
func loadLoginIcon() {
const imageName = "login icon"
const maxSize = 1024 * 1024 // 1MB
allowedFormats := []string{".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}
// Set default embedded icon path - just use favicon.svg (light/dark handled by CSS)
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg"
// Set default embedded icon path based on dark mode preference
isDarkMode := Config.UserDefaults.DarkMode != nil && *Config.UserDefaults.DarkMode
if isDarkMode {
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg" // Dark mode: dark background
} else {
Env.LoginIconEmbeddedPath = "img/icons/favicon-light.svg" // Light mode: light background
}
// Check if a custom login icon path is configured
if Config.Frontend.LoginIcon == "" {
@@ -820,7 +813,7 @@ func loadLoginIcon() {
}
// Validate custom login icon
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, allowedFormats)
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, maxSize, allowedFormats)
if err != nil {
logger.Warningf("Custom login icon validation failed: %v, using default", err)
Env.LoginIconPath = Env.LoginIconEmbeddedPath
+51 -95
View File
@@ -14,7 +14,6 @@ import (
"sort"
"strings"
"golang.org/x/tools/go/packages"
"gopkg.in/yaml.v3"
)
@@ -112,93 +111,74 @@ func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, De
return comments, secrets, deprecated, nil
}
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields.
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields
func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
absDir, err := filepath.Abs(dir)
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
if err != nil {
return nil, nil, nil, err
}
var files []*ast.File
cfg := &packages.Config{
Mode: packages.NeedSyntax | packages.NeedFiles,
Dir: absDir,
ParseFile: func(fset *token.FileSet, filename string, src []byte) (*ast.File, error) {
return parser.ParseFile(fset, filename, src, parser.ParseComments)
},
}
pkgs, err := packages.Load(cfg, ".")
if err == nil && len(pkgs) > 0 {
for _, pkg := range pkgs {
files = append(files, pkg.Syntax...)
}
}
if len(files) == 0 {
// Fallback for directories outside a module (e.g. test temp dirs): parse .go files directly
files, err = parseDirGoFiles(absDir)
if err != nil || len(files) == 0 {
return nil, nil, nil, err
}
}
comments := make(CommentsMap)
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
for _, file := range files {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
for _, pkg := range pkgs {
for _, file := range pkg.Files {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
}
typeName := ts.Name.Name
commentMap := make(map[string]string)
secretMap := make(map[string]bool)
deprecatedMap := make(map[string]bool)
comments[typeName] = commentMap
secrets[typeName] = secretMap
deprecated[typeName] = deprecatedMap
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
name := field.Names[0].Name
var parts []string
var fullComment string
if field.Doc != nil {
docText := strings.TrimSpace(field.Doc.Text())
parts = append(parts, docText)
fullComment += docText + " "
}
if field.Comment != nil {
commentText := strings.TrimSpace(field.Comment.Text())
parts = append(parts, commentText)
fullComment += commentText
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
}
typeName := ts.Name.Name
commentMap := make(map[string]string)
secretMap := make(map[string]bool)
deprecatedMap := make(map[string]bool)
comments[typeName] = commentMap
secrets[typeName] = secretMap
deprecated[typeName] = deprecatedMap
if len(parts) > 0 {
commentMap[name] = strings.Join(parts, " : ")
}
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
continue
}
name := field.Names[0].Name
var parts []string
var fullComment string
// Check if field should be treated as secret
if strings.Contains(strings.ToLower(fullComment), "secret:") {
secretMap[name] = true
}
if field.Doc != nil {
docText := strings.TrimSpace(field.Doc.Text())
parts = append(parts, docText)
fullComment += docText + " "
}
if field.Comment != nil {
commentText := strings.TrimSpace(field.Comment.Text())
parts = append(parts, commentText)
fullComment += commentText
}
// Check if field should be treated as deprecated
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
deprecatedMap[name] = true
if len(parts) > 0 {
commentMap[name] = strings.Join(parts, " : ")
}
// Check if field should be treated as secret
if strings.Contains(strings.ToLower(fullComment), "secret:") {
secretMap[name] = true
}
// Check if field should be treated as deprecated
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
deprecatedMap[name] = true
}
}
}
}
@@ -207,29 +187,6 @@ func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, Deprecate
return comments, secrets, deprecated, nil
}
// parseDirGoFiles parses all .go files in dir and returns their ASTs. Used when the directory
// is not inside a Go module (e.g. test temp dirs) so packages.Load would fail.
func parseDirGoFiles(dir string) ([]*ast.File, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
fset := token.NewFileSet()
var files []*ast.File
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
continue
}
path := filepath.Join(dir, e.Name())
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil, err
}
files = append(files, f)
}
return files, nil
}
// PathToTypeField represents a mapping from YAML path to Go type and field info
type PathToTypeField struct {
TypeName string
@@ -753,7 +710,6 @@ func GenerateYaml() {
}
setupLogging()
setupFs()
setupAuth(true)
// Save original paths before setupSources modifies them (for YAML generation)
+3 -8
View File
@@ -36,10 +36,6 @@ type Environment struct {
FaviconPath string `json:"-"` // resolved favicon path (filesystem or embedded)
FaviconIsCustom bool `json:"-"` // true if favicon is from custom filesystem path
FaviconEmbeddedPath string `json:"-"` // embedded asset path for default favicon
PWAIconsDir string `json:"-"` // directory where generated PWA icons are stored
PWAIcon192 string `json:"-"` // path to 192x192 PWA icon
PWAIcon256 string `json:"-"` // path to 256x256 PWA icon
PWAIcon512 string `json:"-"` // path to 512x512 PWA icon
}
type Server struct {
@@ -269,10 +265,9 @@ type Frontend struct {
ExternalLinks []ExternalLink `json:"externalLinks"`
DisableNavButtons bool `json:"disableNavButtons"` // disable the nav buttons in the sidebar
Styling StylingConfig `json:"styling"`
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
Description string `json:"description"` // description that shows up in html head meta description
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
OIDCLoginButtonText string `json:"oidcLoginButtonText"` // text to display on the OIDC login button
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
Description string `json:"description"` // description that shows up in html head meta description
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
}
type StylingConfig struct {
+4 -10
View File
@@ -63,18 +63,12 @@ type FileOptions struct {
// SanitizeUserPath prevents path traversal attacks by cleaning and validating user input.
// Rule 1: Do Not Use User Input in File Paths (without validation)
func SanitizeUserPath(userPath string) (string, error) {
// Clean resolves . and .. segments
clean := filepath.Clean(userPath)
// Split the path into segments to check for path traversal attempts
// We check if ".." appears as a complete path segment (not just in a filename)
segments := strings.Split(clean, string(filepath.Separator))
for _, segment := range segments {
// Check if any segment is exactly ".." (path traversal attempt)
// This catches any ".." that filepath.Clean couldn't resolve (i.e., escape attempts)
if segment == ".." {
return "", fmt.Errorf("invalid path: path traversal detected")
}
// Reject absolute paths or attempts to escape scope
if strings.HasPrefix(clean, "..") {
return "", fmt.Errorf("invalid path")
}
return clean, nil
-43
View File
@@ -1,43 +0,0 @@
package utils
import (
"errors"
"os"
)
type SubtitleTrack struct {
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language"` // language code
Title string `json:"title,omitempty"` // title/description
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
Embedded bool `json:"embedded"` // true for embedded subtitles, false for external files
}
func GetSubtitleSidecarContent(subtitlePath string) (string, error) {
// validate the size is not too large
info, err := os.Stat(subtitlePath)
if err != nil {
return "", err
}
if info.Size() > 1024*1024*50 { // 50MB
return "", errors.New("subtitle file is too large")
}
// First check if it's a text file using the consolidated validation logic
isText, err := IsTextFile(subtitlePath)
if err != nil {
return "", err
}
if !isText {
// Not a text file, return empty string (no error, just not text)
return "", nil
}
// read the file content
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
return string(content), nil
}
+125 -42
View File
@@ -1,65 +1,148 @@
package utils
import (
"io"
"os"
"unicode"
"unicode/utf8"
)
// IsTextFile checks if a file is viewable/editable as text (like with cat or a text editor).
// It returns true if the file appears to be text, false if it's likely binary.
// This function uses simple heuristics:
// IsTextFile checks if a file is likely a text file by examining its content.
// It returns true if the file appears to be a valid text file, false otherwise.
// This function performs various heuristics to detect text files:
// - Valid UTF-8 encoding
// - Not full of null bytes (which indicate binary data)
// - Limited null bytes
// - Limited non-printable characters
func IsTextFile(realPath string) (bool, error) {
const sampleSize = 8192 // Read first 8KB to check
const maxNullByteRatio = 0.05 // Reject if more than 5% null bytes
// Read sample from file
const headerSize = 4096
// Thresholds for detecting binary-like content (these can be tuned)
const maxNullBytesInHeaderAbs = 10 // Max absolute null bytes in header
const maxNullByteRatioInHeader = 0.1 // Max 10% null bytes in header
const maxNullByteRatioInFile = 0.05 // Max 5% null bytes in the entire file
const maxNonPrintableRuneRatio = 0.05 // Max 5% non-printable runes in the entire file
// Open file
f, err := os.Open(realPath)
if err != nil {
return false, err
}
defer f.Close()
// Read header
headerBytes := make([]byte, headerSize)
n, err := f.Read(headerBytes)
if err != nil && err != io.EOF {
return false, err
}
actualHeader := headerBytes[:n]
// --- Start of heuristic checks ---
if n > 0 {
// Trim header to last complete UTF-8 rune to avoid false negatives
// when the header read cuts off in the middle of a multi-byte sequence.
// We decode runes from the end until we find a valid one, trimming
// any incomplete sequences at the end.
trimmedHeader := actualHeader
for len(trimmedHeader) > 0 {
lastRune, size := utf8.DecodeLastRune(trimmedHeader)
if lastRune != utf8.RuneError {
// Found a valid complete rune
break
}
// RuneError occurred - this could be an incomplete sequence or invalid byte
// Trim the last byte and try again
if size == 1 && len(trimmedHeader) > 0 {
trimmedHeader = trimmedHeader[:len(trimmedHeader)-1]
} else {
// Shouldn't happen, but break to avoid infinite loop
break
}
}
// 1. Basic Check: Is the header valid UTF-8?
// If not, it's unlikely an editable UTF-8 text file.
// Use trimmed header to avoid false negatives from truncated sequences
if len(trimmedHeader) > 0 && !utf8.Valid(trimmedHeader) {
return false, nil // Not an error, just not the text file we want
}
// 2. Check for excessive null bytes in the header
nullCountInHeader := 0
for _, b := range actualHeader {
if b == 0x00 {
nullCountInHeader++
}
}
// Reject if too many nulls absolutely or relatively in the header
if nullCountInHeader > 0 { // Only perform check if there are any nulls
if nullCountInHeader > maxNullBytesInHeaderAbs ||
(float64(nullCountInHeader)/float64(n) > maxNullByteRatioInHeader) {
return false, nil // Too many nulls in header
}
}
// 3. Check for other non-text ASCII control characters in the header
// (C0 controls excluding \t, \n, \r)
for _, b := range actualHeader {
if b < 0x20 && b != '\t' && b != '\n' && b != '\r' {
return false, nil // Found problematic control character
}
}
}
// Now read the full file (original logic)
content, err := os.ReadFile(realPath)
if err != nil {
return false, err
}
// Empty files are considered text
// Handle empty file (empty files are considered text)
if len(content) == 0 {
return true, nil
}
// Use sample for large files to avoid reading entire file into memory
sample := content
if len(content) > sampleSize {
sample = content[:sampleSize]
}
// Check 1: Count null bytes - binary files typically have many nulls
nullCount := 0
for _, b := range sample {
if b == 0x00 {
nullCount++
}
}
if nullCount > 0 {
nullRatio := float64(nullCount) / float64(len(sample))
if nullRatio > maxNullByteRatio {
return false, nil
}
}
stringContent := string(content)
// Check 2: Validate UTF-8 encoding
// Trim sample to last complete UTF-8 rune to avoid false negatives
trimmedSample := sample
for len(trimmedSample) > 0 {
lastRune, size := utf8.DecodeLastRune(trimmedSample)
if lastRune != utf8.RuneError {
break
}
if size == 1 {
trimmedSample = trimmedSample[:len(trimmedSample)-1]
} else {
break
}
}
if len(trimmedSample) > 0 && !utf8.Valid(trimmedSample) {
// 4. Final UTF-8 validation for the entire file
// (This is crucial as the header might be fine, but the rest of the file isn't)
if !utf8.ValidString(stringContent) {
return false, nil
}
// 5. Check for excessive null bytes in the entire file content
if len(content) > 0 { // Check only for non-empty files
totalNullCount := 0
for _, b := range content {
if b == 0x00 {
totalNullCount++
}
}
if float64(totalNullCount)/float64(len(content)) > maxNullByteRatioInFile {
return false, nil // Too many nulls in the entire file
}
}
// 6. Check for excessive non-printable runes in the entire file content
// (Excluding tab, newline, carriage return, which are common in text files)
if len(stringContent) > 0 { // Check only for non-empty strings
nonPrintableRuneCount := 0
totalRuneCount := 0
for _, r := range stringContent {
totalRuneCount++
// unicode.IsPrint includes letters, numbers, punctuation, symbols, and spaces.
// It excludes control characters. We explicitly allow \t, \n, \r.
if !unicode.IsPrint(r) && r != '\t' && r != '\n' && r != '\r' {
nonPrintableRuneCount++
}
}
if totalRuneCount > 0 { // Avoid division by zero
if float64(nonPrintableRuneCount)/float64(totalRuneCount) > maxNonPrintableRuneRatio {
return false, nil // Too many non-printable runes
}
}
}
// The file has passed all checks and is considered editable text.
return true, nil
}
+7
View File
@@ -247,6 +247,7 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
continue
}
oldFullPath := l.Path
l.Source = newSource
l.Path = newPath
@@ -263,8 +264,14 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
s.shareCache[l.Hash] = l
}
s.mu.Unlock()
logger.Info("share updated", "hash", l.Hash, "fromPath", oldFullPath, "toPath", l.Path)
updated++
}
if updated == 0 {
logger.Warning("no matching shares to update for provided source/path")
}
return updated, nil
}
+5 -23
View File
@@ -4,11 +4,9 @@ import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -63,24 +61,9 @@ func createIndexDB(name string, journalMode string, lockingMode string, batchSiz
// batchSize: number of items per batch transaction
// cacheSizeMB: cache size in megabytes
// disableReuse: true to delete and recreate DB on startup, false to reuse existing (default)
// Returns the IndexDB and a boolean indicating if the database is new (true) or reused (false)
// Returns the IndexDB and a boolean indicating if the database was recreated (true) or reused (false)
func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int, disableReuse bool) (*IndexDB, bool, error) {
isNewDb := disableReuse // If disableReuse is true, we're creating fresh
// Check if database file exists (only if reuse is enabled)
if !disableReuse {
// Database files are stored in sql subdirectory (see sqlite.go:207)
dbPath := filepath.Join(settings.Config.Server.CacheDir, "sql", fmt.Sprintf("index_%s.db", name))
_, err := os.Stat(dbPath)
if os.IsNotExist(err) {
// Database file doesn't exist, so this is a new database
isNewDb = true
} else if err != nil {
// Some other error occurred
logger.Warningf("Error checking database file: %v", err)
isNewDb = true
}
}
wasRecreated := disableReuse // If disableReuse is true, we're creating fresh
idxDB, err := createIndexDB(name, journalMode, "", batchSize, cacheSizeMB, disableReuse)
if err != nil {
@@ -92,8 +75,7 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
if !disableReuse {
if err := idxDB.checkIntegrityAndRecreateIfNeeded(); err != nil {
// Database was recreated due to corruption
logger.Warningf("[DB_INIT] SQLite database was found with issues which could be due to a dirty shutdown, recreating: tmp/index_%s.db", name)
isNewDb = true
wasRecreated = true
idxDB.Close()
// Recreate the database connection
@@ -109,7 +91,7 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
return nil, false, err
}
go idxDB.startPeriodicCleanup()
return idxDB, isNewDb, nil
return idxDB, wasRecreated, nil
}
// checkIntegrityAndRecreateIfNeeded checks database integrity.
@@ -541,7 +523,7 @@ func (db *IndexDB) ExplainQueryPlan(query string, args ...interface{}) (string,
if err := rows.Scan(&id, &parent, &notused, &detail); err != nil {
return "", err
}
fmt.Fprintf(&plan, "%d|%d|%s\n", id, parent, detail)
plan.WriteString(fmt.Sprintf("%d|%d|%s\n", id, parent, detail))
}
return plan.String(), rows.Err()
}
+1 -1
View File
@@ -77,7 +77,7 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if passwordUser && enforcedOtp && !existingUser.OtpEnabled {
if passwordUser && enforcedOtp && !user.OtpEnabled {
return errors.ErrNoTotpConfigured
}
if user.LoginMethod == "" {
+3 -4
View File
@@ -3,12 +3,11 @@ package ffmpeg
import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/go-cache/cache"
)
var (
MediaCache = cache.NewCache[[]utils.SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration gets cached for 24 hours
MediaCache = cache.NewCache[[]SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration gets cached for 24 hours
)
+136 -33
View File
@@ -10,10 +10,20 @@ import (
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
// SubtitleTrack represents a subtitle track (embedded or external file)
type SubtitleTrack struct {
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language,omitempty"` // language code
Title string `json:"title,omitempty"` // title/description
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
Content string `json:"content,omitempty"` // subtitle content
IsFile bool `json:"isFile"` // true for external files, false for embedded streams
}
// FFProbeOutput represents the JSON output from ffprobe
type FFProbeOutput struct {
Streams []struct {
@@ -26,7 +36,7 @@ type FFProbeOutput struct {
}
// DetectAllSubtitles finds both embedded and external subtitle tracks
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []utils.SubtitleTrack {
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []SubtitleTrack {
key := "all_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
// Check cache first
@@ -34,7 +44,7 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
return cached
}
var allSubtitles []utils.SubtitleTrack
var allSubtitles []SubtitleTrack
// First, get embedded subtitles
embeddedSubs := detectEmbeddedSubtitles(videoPath)
@@ -50,25 +60,9 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
return allSubtitles
}
// DetectEmbeddedSubtitles detects embedded subtitle streams using ffprobe.
// This is the public API that can be called from other packages.
// Returns empty array if ffprobe is not available or fails.
func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.SubtitleTrack {
// Check cache first
key := "embedded_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
if cached, ok := MediaCache.Get(key); ok {
return cached
}
// Detect embedded subtitles
subtitles := detectEmbeddedSubtitles(videoPath)
// Cache the results
MediaCache.Set(key, subtitles)
return subtitles
}
// detectEmbeddedSubtitles uses ffprobe to find embedded subtitle tracks
// Always runs ffprobe - results are cached for performance
func detectEmbeddedSubtitles(realPath string) []utils.SubtitleTrack {
func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
cmd := exec.Command("ffprobe",
"-v", "quiet",
"-print_format", "json",
@@ -88,13 +82,13 @@ func detectEmbeddedSubtitles(realPath string) []utils.SubtitleTrack {
return nil
}
var subtitles []utils.SubtitleTrack
var subtitles []SubtitleTrack
for _, stream := range probeOutput.Streams {
if stream.CodecType == "subtitle" {
track := utils.SubtitleTrack{
Index: &stream.Index,
Codec: stream.CodecName,
Embedded: true, // This is an embedded subtitle
track := SubtitleTrack{
Index: &stream.Index,
Codec: stream.CodecName,
IsFile: false, // This is an embedded subtitle
}
// Extract language and title from tags
@@ -123,8 +117,8 @@ func detectEmbeddedSubtitles(realPath string) []utils.SubtitleTrack {
}
// detectExternalSubtitles finds external subtitle files in the same directory
func detectExternalSubtitles(videoPath string, parentDir string) []utils.SubtitleTrack {
var subtitles []utils.SubtitleTrack
func detectExternalSubtitles(videoPath string, parentDir string) []SubtitleTrack {
var subtitles []SubtitleTrack
// Get the base name of the video (without extension)
videoBaseName := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
@@ -137,9 +131,9 @@ func detectExternalSubtitles(videoPath string, parentDir string) []utils.Subtitl
subtitlePath := filepath.Join(parentDir, videoBaseName+ext)
if _, err := os.Stat(subtitlePath); err == nil {
// File exists
track := utils.SubtitleTrack{
Name: filepath.Base(subtitlePath),
Embedded: false, // This is an external file
track := SubtitleTrack{
Name: filepath.Base(subtitlePath),
IsFile: true, // This is an external file
}
// Try to infer language from filename patterns like "video.en.srt"
@@ -176,11 +170,120 @@ func ExtractSubtitleContent(videoPath string, streamIndex int) (string, error) {
return string(output), nil
}
// LoadSubtitleFile loads a subtitle file and returns its raw content
func LoadSubtitleFile(subtitlePath string) (string, error) {
// LoadAndConvertSubtitleFile loads a subtitle file and converts it to WebVTT format
func LoadAndConvertSubtitleFile(subtitlePath string) (string, error) {
ext := strings.ToLower(filepath.Ext(subtitlePath))
if ext == ".vtt" {
// Already WebVTT, just read it
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
return string(content), nil
}
if ext == ".srt" {
// Convert SRT to WebVTT using ffmpeg
cmd := exec.Command("ffmpeg",
"-i", subtitlePath,
"-c:s", "webvtt",
"-f", "webvtt",
"-") // output to stdout
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to convert SRT to WebVTT: %v", err)
}
return string(output), nil
}
// For other formats, try to read as plain text and hope for the best
// This is a fallback - ideally you'd want format-specific converters
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
return string(content), nil
// Basic conversion wrapper for non-VTT formats
vttHeader := "WEBVTT\n\n"
return vttHeader + string(content), nil
}
// ExtractSingleSubtitle extracts content for a specific subtitle track by array index
func ExtractSingleSubtitle(videoPath string, parentDir string, trackIndex int, modtime time.Time) (SubtitleTrack, error) {
// Get all subtitle tracks
allTracks := DetectAllSubtitles(videoPath, parentDir, modtime)
if trackIndex >= len(allTracks) {
return SubtitleTrack{}, fmt.Errorf("subtitle track %d not found (only %d tracks available)", trackIndex, len(allTracks))
}
track := allTracks[trackIndex]
// Load content based on type
if track.IsFile {
// Load external subtitle file
subtitlePath := filepath.Join(parentDir, track.Name)
content, err := LoadAndConvertSubtitleFile(subtitlePath)
if err != nil {
return SubtitleTrack{}, fmt.Errorf("failed to load external subtitle: %v", err)
}
track.Content = content
} else {
// Extract embedded subtitle content
if track.Index == nil {
return SubtitleTrack{}, fmt.Errorf("embedded subtitle track has no stream index")
}
content, err := ExtractSubtitleContent(videoPath, *track.Index)
if err != nil {
return SubtitleTrack{}, fmt.Errorf("failed to extract embedded subtitle: %v", err)
}
track.Content = content
}
return track, nil
}
// LoadAllSubtitleContent loads the actual content for all detected subtitle tracks
func LoadAllSubtitleContent(videoPath string, subtitles []SubtitleTrack, modtime time.Time) error {
for idx := range subtitles {
subtitle := &subtitles[idx]
// Check if content is already cached
contentKey := fmt.Sprintf("subtitle_content:%s:%d:%s", videoPath, idx, modtime.Format(time.RFC3339))
if cached, ok := SubtitleContentCache.Get(contentKey); ok {
subtitle.Content = cached
continue
}
var content string
var err error
if subtitle.IsFile {
// Load external subtitle file content and convert to WebVTT
subtitlePath := filepath.Join(filepath.Dir(videoPath), subtitle.Name)
content, err = LoadAndConvertSubtitleFile(subtitlePath)
if err != nil {
logger.Debug("failed to read/convert subtitle file " + subtitlePath + ": " + err.Error())
continue
}
} else {
// Load embedded subtitle content
if subtitle.Index == nil {
logger.Debug("embedded subtitle has no stream index")
continue
}
content, err = ExtractSubtitleContent(videoPath, *subtitle.Index)
if err != nil {
logger.Debug("failed to extract embedded subtitle: " + err.Error())
continue
}
}
subtitle.Content = content
// Cache the content for future requests
SubtitleContentCache.Set(contentKey, content)
}
return nil
}
+37 -37
View File
@@ -5,7 +5,7 @@ go 1.25.0
require (
github.com/asdine/storm/v3 v3.2.1
github.com/coreos/go-oidc/v3 v3.17.0
github.com/coreos/go-systemd/v22 v22.7.0
github.com/coreos/go-systemd/v22 v22.6.0
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gen2brain/go-fitz v1.24.15
@@ -16,22 +16,21 @@ require (
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-logger v1.0.2
github.com/kovidgoyal/imaging v1.8.19
github.com/mattn/go-sqlite3 v1.14.34
github.com/mattn/go-sqlite3 v1.14.33
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.48.0
golang.org/x/image v0.36.0
golang.org/x/mod v0.33.0
golang.org/x/net v0.50.0
golang.org/x/oauth2 v0.35.0
golang.org/x/sys v0.41.0
golang.org/x/crypto v0.47.0
golang.org/x/image v0.35.0
golang.org/x/mod v0.32.0
golang.org/x/net v0.49.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sys v0.40.0
golang.org/x/time v0.14.0
golang.org/x/tools v0.42.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.46.1
modernc.org/sqlite v1.44.3
)
require (
@@ -53,17 +52,16 @@ require (
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.3 // indirect
github.com/MirrexOne/unqueryvet v1.4.0 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.64.5 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/air-verse/air v1.64.2 // indirect
github.com/alecthomas/chroma/v2 v2.21.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.0.2 // indirect
github.com/alexkohler/prealloc v1.0.1 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect
github.com/ashanbrown/makezero/v2 v2.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -73,7 +71,7 @@ require (
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/bombsimon/wsl/v5 v5.6.0 // indirect
github.com/bombsimon/wsl/v5 v5.3.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
@@ -85,7 +83,7 @@ require (
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
@@ -99,15 +97,15 @@ 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.0.1 // indirect
github.com/ebitengine/purego v0.10.0-alpha.4 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/ghostiam/protogetter v0.3.18 // indirect
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
@@ -130,21 +128,21 @@ require (
github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/godoc-lint/godoc-lint v0.11.1 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/golang/geo v0.0.0-20260129164528-943061e2742c // indirect
github.com/golang/geo v0.0.0-20260120070133-792bb8583fbb // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.10.1 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/golangci-lint/v2 v2.8.0 // indirect
github.com/golangci/golines v0.14.0 // indirect
github.com/golangci/misspell v0.7.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
@@ -166,7 +164,7 @@ require (
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/jupiterrider/ffi v0.6.0 // indirect
github.com/jupiterrider/ffi v0.5.1 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
@@ -196,7 +194,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.14.0 // indirect
github.com/mgechev/revive v1.13.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/moricho/tparallel v0.3.2 // indirect
@@ -205,7 +203,7 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
github.com/nunnatsa/ginkgolinter v0.21.2 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -230,8 +228,8 @@ require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.23.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/securego/gosec/v2 v2.22.11 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.4.0 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
@@ -264,21 +262,23 @@ require (
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.11.1 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/arangolint v0.3.1 // indirect
go.augendre.info/fatcontext v0.9.0 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.68.0 // indirect
honnef.co/go/tools v0.6.1 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+89 -86
View File
@@ -73,18 +73,18 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/MirrexOne/unqueryvet v1.5.3 h1:LpT3rsH+IY3cQddWF9bg4C7jsbASdGnrOSofY8IPEiw=
github.com/MirrexOne/unqueryvet v1.5.3/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
github.com/MirrexOne/unqueryvet v1.4.0 h1:6KAkqqW2KUnkl9Z0VuTphC3IXRPoFqEkJEtyxxHj5eQ=
github.com/MirrexOne/unqueryvet v1.4.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4=
github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/air-verse/air v1.64.2 h1:H49xKRa4r3uFUycmCCA+JectVuZLNMPGdypLFpoHVcY=
github.com/air-verse/air v1.64.2/go.mod h1:SOwv08NTvKextanAY819AK0FK6XzQO5/tC6s1R+rk8o=
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/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
@@ -96,16 +96,14 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ=
github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
github.com/alexkohler/prealloc v1.0.2 h1:MPo8cIkGkZytq7WNH9UHv3DIX1mPz1RatPXnZb0zHWQ=
github.com/alexkohler/prealloc v1.0.2/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
github.com/alexkohler/prealloc v1.0.1 h1:A9P1haqowqUxWvU9nk6tQ7YktXIHf+LQM9wPRhuteEE=
github.com/alexkohler/prealloc v1.0.1/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc=
github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus=
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
@@ -156,8 +154,8 @@ github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
github.com/bombsimon/wsl/v5 v5.6.0 h1:4z+/sBqC5vUmSp1O0mS+czxwH9+LKXtCWtHH9rZGQL8=
github.com/bombsimon/wsl/v5 v5.6.0/go.mod h1:Uqt2EfrMj2NV8UGoN1f1Y3m0NpUVCsUdrNCdet+8LvU=
github.com/bombsimon/wsl/v5 v5.3.0 h1:nZWREJFL6U3vgW/B1lfDOigl+tEF6qgs6dGGbFeR0UM=
github.com/bombsimon/wsl/v5 v5.3.0/go.mod h1:Gp8lD04z27wm3FANIUPZycXp+8huVsn0oxc+n4qfV9I=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -184,8 +182,8 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
@@ -201,8 +199,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -244,8 +242,8 @@ 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.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.0-alpha.4 h1:JzPbdf+cqbyT9sZtP4xnqelwUXwf7LvD8xKS6+ofTds=
github.com/ebitengine/purego v0.10.0-alpha.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -267,16 +265,16 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gen2brain/go-fitz v1.24.15 h1:sJNB1MOWkqnzzENPHggFpgxTwW0+S5WF/rM5wUBpJWo=
github.com/gen2brain/go-fitz v1.24.15/go.mod h1:SftkiVbTHqF141DuiLwBBM65zP7ig6AVDQpf2WlHamo=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0=
github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
github.com/ghostiam/protogetter v0.3.18 h1:yEpghRGtP9PjKvVXtEzGpYfQj1Wl/ZehAfU6fr62Lfo=
github.com/ghostiam/protogetter v0.3.18/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
@@ -357,8 +355,8 @@ github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQi
github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ=
github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus=
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
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-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
@@ -367,8 +365,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM=
github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo=
github.com/godoc-lint/godoc-lint v0.11.1 h1:z9as8Qjiy6miRIa3VRymTa+Gt2RLnGICVikcvlUVOaA=
github.com/godoc-lint/godoc-lint v0.11.1/go.mod h1:BAqayheFSuZrEAqCRxgw9MyvsM+S/hZwJbU1s/ejRj8=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -393,8 +391,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
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-20260129164528-943061e2742c h1:ysO2h2Odnl1AJM1I2Lm/fa6JvO0pECMSt2CwBaa+ITo=
github.com/golang/geo v0.0.0-20260129164528-943061e2742c/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/geo v0.0.0-20260120070133-792bb8583fbb h1:OhyJ/wXEqRssvvFcXP8Wzoyn0fbiqWgjHXLKj3ZOU+4=
github.com/golang/geo v0.0.0-20260120070133-792bb8583fbb/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -434,12 +432,12 @@ github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarog
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
github.com/golangci/golangci-lint/v2 v2.10.1 h1:flhw5Px6ojbLyEFzXvJn5B2HEdkkRlkhE1SnmCbQBiE=
github.com/golangci/golangci-lint/v2 v2.10.1/go.mod h1:dBsrOk6zj0vDhlTv+IiJGqkDokR24IVTS7W3EVfPTQY=
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
github.com/golangci/golangci-lint/v2 v2.8.0 h1:wJnr3hJWY3eVzOUcfwbDc2qbi2RDEpvLmQeNFaPSNYA=
github.com/golangci/golangci-lint/v2 v2.8.0/go.mod h1:xl+HafQ9xoP8rzw0z5AwnO5kynxtb80e8u02Ej/47RI=
github.com/golangci/golines v0.14.0 h1:xt9d3RKBjhasA3qpoXs99J2xN2t6eBlpLHt0TrgyyXc=
github.com/golangci/golines v0.14.0/go.mod h1:gf555vPG2Ia7mmy2mzmhVQbVjuK8Orw0maR1G4vVAAQ=
github.com/golangci/misspell v0.7.0 h1:4GOHr/T1lTW0hhR4tgaaV1WS/lJ+ncvYCoFKmqJsj0c=
github.com/golangci/misspell v0.7.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
@@ -473,8 +471,8 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
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=
@@ -545,8 +543,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
github.com/jupiterrider/ffi v0.6.0 h1:UX378KcZvH5c8qgLi9KL/bL82SZTHdRspZ+jj7bvBng=
github.com/jupiterrider/ffi v0.6.0/go.mod h1:PqZ5Go6X9by8CIXgfprxfMPYmn8oT5m2O7AA56s64bY=
github.com/jupiterrider/ffi v0.5.1 h1:l7ANXU+Ex33LilVa283HNaf/sTzCrrht7D05k6T6nlc=
github.com/jupiterrider/ffi v0.5.1/go.mod h1:x7xdNKo8h0AmLuXfswDUBxUsd2OqUP4ekC8sCnsmbvo=
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
@@ -624,12 +622,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgechev/revive v1.14.0 h1:CC2Ulb3kV7JFYt+izwORoS3VT/+Plb8BvslI/l1yZsc=
github.com/mgechev/revive v1.14.0/go.mod h1:MvnujelCZBZCaoDv5B3foPo6WWgULSSFxvfxp7GsPfo=
github.com/mgechev/revive v1.13.0 h1:yFbEVliCVKRXY8UgwEO7EOYNopvjb1BFbmYqm9hZjBM=
github.com/mgechev/revive v1.13.0/go.mod h1:efJfeBVCX2JUumNQ7dtOLDja+QKj9mYGgEZA7rt5u+0=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -661,8 +659,8 @@ github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhK
github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8=
github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4=
github.com/nunnatsa/ginkgolinter v0.21.2 h1:khzWfm2/Br8ZemX8QM1pl72LwM+rMeW6VUbQ4rzh0Po=
github.com/nunnatsa/ginkgolinter v0.21.2/go.mod h1:GItSI5fw7mCGLPmkvGYrr1kEetZe7B593jcyOpyabsY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
@@ -673,10 +671,10 @@ github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
@@ -700,6 +698,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
@@ -759,8 +759,8 @@ github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tM
github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
github.com/securego/gosec/v2 v2.23.0 h1:h4TtF64qFzvnkqvsHC/knT7YC5fqyOCItlVR8+ptEBo=
github.com/securego/gosec/v2 v2.23.0/go.mod h1:qRHEgXLFuYUDkI2T7W7NJAmOkxVhkR0x9xyHOIcMNZ0=
github.com/securego/gosec/v2 v2.22.11 h1:tW+weM/hCM/GX3iaCV91d5I6hqaRT2TPsFM1+USPXwg=
github.com/securego/gosec/v2 v2.22.11/go.mod h1:KE4MW/eH0GLWztkbt4/7XpyH0zJBBnu7sYB4l6Wn7Mw=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
@@ -769,8 +769,8 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
@@ -802,6 +802,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
@@ -852,8 +853,6 @@ github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pH
github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM=
github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk=
github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs=
@@ -879,8 +878,8 @@ go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo=
go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE=
go-simpler.org/sloglint v0.11.1 h1:xRbPepLT/MHPTCA6TS/wNfZrDzkGvCCqUv4Bdwc3H7s=
go-simpler.org/sloglint v0.11.1/go.mod h1:2PowwiCOK8mjiF+0KGifVOT8ZsCNiFzvfyJeJOIt8MQ=
go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50=
go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA=
go.augendre.info/arangolint v0.3.1 h1:n2E6p8f+zfXSFLa2e2WqFPp4bfvcuRdd50y6cT65pSo=
go.augendre.info/arangolint v0.3.1/go.mod h1:6ZKzEzIZuBQwoSvlKT+qpUfIbBfFCE5gbAoTg0/117g=
go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE=
go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
@@ -891,6 +890,8 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
@@ -908,8 +909,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -920,16 +921,16 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk=
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 h1:HDjDiATsGqvuqvkDvgJjD1IgPrVekcSXVVE21JwvzGE=
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -955,8 +956,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -999,16 +1000,16 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1070,6 +1071,7 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=
@@ -1078,8 +1080,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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=
@@ -1096,8 +1098,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1153,8 +1155,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -1260,6 +1262,7 @@ gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -1269,22 +1272,22 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/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.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -1293,8 +1296,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/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.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
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=
+5
View File
@@ -8,6 +8,7 @@ import (
libError "errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
@@ -397,6 +398,10 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
}
password := r.Header.Get("X-SHARE-PASSWORD")
password, err := url.QueryUnescape(password)
if err != nil {
return http.StatusUnauthorized, err
}
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return http.StatusUnauthorized, nil
+14 -8
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -393,10 +394,13 @@ func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions, stats
for _, fileInfo := range checksumGroup.Files {
// Remove the user scope from path
adjustedPath := "/" + strings.TrimPrefix(fileInfo.Path, opts.combinedPath)
adjustedPath := strings.TrimPrefix(fileInfo.Path, opts.combinedPath)
if adjustedPath == "" {
adjustedPath = "/"
}
resultGroup = append(resultGroup, &indexing.SearchResult{
Path: adjustedPath,
Source: opts.source,
Type: fileInfo.Type,
Size: fileInfo.Size,
Modified: fileInfo.ModTime.Format(time.RFC3339),
@@ -555,10 +559,7 @@ func mergeGroupsByChecksum(groups []duplicateGroupWithChecksums) []duplicateGrou
func prepDuplicatesOptions(r *http.Request, d *requestContext) (*duplicatesOptions, error) {
source := r.URL.Query().Get("source")
scope, err := utils.SanitizeUserPath(r.URL.Query().Get("scope"))
if err != nil {
return nil, fmt.Errorf("invalid scope: %v", err)
}
scope := r.URL.Query().Get("scope")
minSizeMbStr := r.URL.Query().Get("minSizeMb")
// First pass uses filename matching for speed, then checksums verify final groups
@@ -567,14 +568,19 @@ func prepDuplicatesOptions(r *http.Request, d *requestContext) (*duplicatesOptio
// Default minimum size: 1MB
minSizeMb := int64(1)
if minSizeMbStr != "" {
if _, err = fmt.Sscanf(minSizeMbStr, "%d", &minSizeMb); err != nil {
if _, err := fmt.Sscanf(minSizeMbStr, "%d", &minSizeMb); err != nil {
return nil, fmt.Errorf("invalid minSizeMb parameter: %w", err)
}
}
// Convert MB to bytes
minSize := minSizeMb * 1024 * 1024
searchScope := strings.TrimPrefix(scope, ".")
unencodedScope, err := url.PathUnescape(scope)
if err != nil {
return nil, fmt.Errorf("invalid path encoding: %v", err)
}
searchScope := strings.TrimPrefix(unencodedScope, ".")
index := indexing.GetIndex(source)
if index == nil {
+23 -15
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
@@ -130,18 +131,23 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusOK, nil
}
path := r.URL.Query().Get("path")
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
linesStr := r.URL.Query().Get("lines")
// Decode URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
var err error
path, err = utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
// Parse lines parameter
lines := 10 // default
@@ -276,23 +282,25 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusForbidden, fmt.Errorf("user is not allowed to read file content")
}
path := r.URL.Query().Get("path")
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
linesStr := r.URL.Query().Get("lines")
intervalStr := r.URL.Query().Get("interval")
var err error
// Decode URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
path = cleanPath
// Parse lines parameter
lines := 10 // default
if linesStr != "" {
+27 -12
View File
@@ -15,7 +15,6 @@ import (
_ "net/http/pprof"
"github.com/coreos/go-systemd/v22/activation"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/events"
@@ -28,9 +27,14 @@ import (
//go:embed embed/*
var assets embed.FS
// GetEmbeddedAssets returns the embedded assets filesystem
func GetEmbeddedAssets() embed.FS {
return assets
// Custom dirFS to handle both embedded and non-embedded file systems
type dirFS struct {
http.Dir
}
// Implement the Open method for dirFS, which wraps http.Dir
func (d dirFS) Open(name string) (fs.File, error) {
return d.Dir.Open(name)
}
var (
@@ -52,10 +56,20 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
}()
}
// Get the asset filesystem from fileutils
assetFs = fileutils.GetAssetFS()
if assetFs == nil {
logger.Fatal("Asset filesystem not initialized. Call fileutils.InitAssetFS first.")
// Determine filesystem mode and set asset paths
if settings.Env.EmbeddedFs {
// Embedded mode: Serve files from the embedded assets
assetFs, err = fs.Sub(assets, "embed")
if err != nil {
logger.Fatalf("fs.Sub failed: %v", err)
}
entries, err := fs.ReadDir(assetFs, ".")
if err != nil || len(entries) == 0 {
logger.Fatalf("Could not embed frontend. Does dist exist? %v", err)
}
} else {
// Dev mode: Serve files from http/dist directory
assetFs = dirFS{Dir: http.Dir("http/dist")}
}
// In development mode, we want to reload the templates on each request.
@@ -74,9 +88,6 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
devMode: settings.Env.IsDevMode,
}
// Initialize cached PWA manifest
InitializePWAManifest()
router := http.NewServeMux()
// API group routing
api := http.NewServeMux()
@@ -198,10 +209,14 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
publicRoutes.HandleFunc("GET /share/", withOrWithoutUser(indexHandler))
// Static and index file handlers
staticPrefix := config.Server.BaseURL + "static/"
router.Handle(staticPrefix, http.HandlerFunc(staticAssetHandler))
publicRoutes.Handle("GET /static/", http.HandlerFunc(staticAssetHandler))
// Standard browser favicon route
// Standard browser favicon and manifest routes
router.HandleFunc("GET /favicon.svg", http.HandlerFunc(staticAssetHandler))
router.HandleFunc("GET /site.webmanifest", http.HandlerFunc(staticAssetHandler))
router.HandleFunc("GET /manifest.json", http.HandlerFunc(staticAssetHandler))
router.HandleFunc(config.Server.BaseURL, withOrWithoutUser(indexHandler))
router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler)
+36 -55
View File
@@ -4,39 +4,46 @@ import (
"bytes"
"fmt"
"net/http"
"os"
"net/url"
"path/filepath"
"strconv"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
// subtitlesHandler handles subtitle requests for both external files and embedded streams
// @Summary Get subtitle content
// @Description Returns raw subtitle content from external files or embedded streams
// subtitlesHandler handles subtitle extraction requests
// @Summary Extract embedded subtitles
// @Description Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content
// @Tags Subtitles
// @Accept json
// @Produce text/plain
// @Produce text/vtt
// @Param path query string true "Index path to the video file"
// @Param source query string true "Source name for the desired source"
// @Param name query string true "Subtitle track name (filename for external, descriptive name for embedded)"
// @Param embedded query bool false "Whether this is an embedded stream (true) or external file (false), defaults to false"
// @Success 200 {string} string "Raw subtitle content in original format"
// @Param index query int false "Stream index for embedded subtitle extraction, defaults to 0"
// @Success 200 {string} string "Raw WebVTT subtitle content"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Resource not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/media/subtitles [get]
func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
name := r.URL.Query().Get("name")
embedded := r.URL.Query().Get("embedded") == "true"
indexParam := r.URL.Query().Get("index")
if name == "" {
return http.StatusBadRequest, fmt.Errorf("name parameter is required")
if indexParam == "" {
indexParam = "0" // default to first subtitle stream
}
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
userscope, err := d.user.GetScopeForSourceName(source)
@@ -48,54 +55,28 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, err := idx.GetRealPath(userscope, path)
if err != nil {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
parentDir := filepath.Dir(realPath)
var content string
if !embedded {
content, err = utils.GetSubtitleSidecarContent(filepath.Join(parentDir, name))
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to get subtitle sidecar content: %v", err)
}
} else {
// For embedded subtitles, we need to find the stream index by name
// Get file modification time for caching
fileInfo, err := os.Stat(realPath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to stat file: %v", err)
}
// Detect embedded subtitles
embeddedSubs := ffmpeg.DetectEmbeddedSubtitles(realPath, fileInfo.ModTime())
// Find the subtitle track by name
var streamIndex *int
for _, sub := range embeddedSubs {
if sub.Name == name {
streamIndex = sub.Index
break
}
}
if streamIndex == nil {
return http.StatusNotFound, fmt.Errorf("embedded subtitle track '%s' not found", name)
}
content, err = ffmpeg.ExtractSubtitleContent(realPath, *streamIndex)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to extract embedded subtitle: %v", err)
}
metadata, exists := idx.GetMetadataInfo(userscope, true, false)
if !exists {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
// Return raw content with appropriate content type
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
index, err := strconv.Atoi(indexParam)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid index parameter: %v", err)
}
parentDir := filepath.Dir(realPath)
subtitle, err := ffmpeg.ExtractSingleSubtitle(realPath, parentDir, index, metadata.ModTime)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to extract subtitle: %v", err)
}
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
w.Header().Set("Content-Disposition", "inline")
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, name, time.Now(), bytes.NewReader([]byte(content)))
http.ServeContent(w, r, fmt.Sprintf("%s-%d.vtt", subtitle.Name, index),
time.Now(), bytes.NewReader([]byte(subtitle.Content)))
return http.StatusOK, nil
}
+17 -16
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"runtime"
"slices"
"strings"
@@ -25,7 +26,6 @@ import (
type requestContext struct {
user *users.User
shareUser *users.User
fileInfo iteminfo.ExtendedFileInfo
token string
share *share.Link
@@ -51,7 +51,12 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
func withHashFileHelper(fn handleFunc) handleFunc {
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
path := r.URL.Query().Get("path")
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
path, err := url.PathUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
@@ -91,6 +96,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
if source.Config.Private {
return http.StatusForbidden, fmt.Errorf("the target source is private")
}
// Get file information with options
getContent := r.URL.Query().Get("content") == "true"
getMetadata := r.URL.Query().Get("metadata") == "true"
@@ -98,42 +104,37 @@ func withHashFileHelper(fn handleFunc) handleFunc {
if link.DisableFileViewer || reachedDownloadsLimit {
getContent = false
}
data.shareUser, err = store.Users.Get(link.UserID)
shareCreatedByUser, err := store.Users.Get(link.UserID)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
}
// get user scope path from share
userScope, err := data.shareUser.GetScopeForSourceName(source.Name)
userScope, err := shareCreatedByUser.GetScopeForSourceName(source.Name)
if err != nil {
return http.StatusForbidden, err
}
// so trim user scope from link.Path
pathWithoutUserScope := utils.JoinPathAsUnix("/", strings.TrimPrefix(link.Path, userScope), path)
if !strings.HasSuffix(pathWithoutUserScope, "/") {
pathWithoutUserScope = pathWithoutUserScope + "/"
}
data.IndexPath = pathWithoutUserScope
if r.Method == "POST" && strings.Contains(r.URL.Path, "/resources") {
// no need to fetch file info for uploads
return fn(w, r, data)
userScopedPath := utils.JoinPathAsUnix("/", strings.TrimPrefix(link.Path, userScope), path)
if !strings.HasSuffix(userScopedPath, "/") {
userScopedPath = userScopedPath + "/"
}
data.IndexPath = userScopedPath
file, err := FileInfoFasterFunc(utils.FileOptions{
Path: pathWithoutUserScope,
Path: userScopedPath,
Source: source.Name,
Expand: true,
Content: getContent,
Metadata: getMetadata,
AlbumArt: strings.Contains(r.URL.Path, "/preview"),
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
ShowHidden: link.ShowHidden,
FollowSymlinks: true,
}, store.Access, data.shareUser)
}, store.Access, shareCreatedByUser)
if err != nil {
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
return errToStatus(err), fmt.Errorf("error fetching share from server")
}
file.Token = link.Token
file.Source = link.Hash
file.Source = ""
file.Hash = link.Hash
if !link.EnableOnlyOffice || link.DisableFileViewer || reachedDownloadsLimit {
file.OnlyOfficeId = ""
+1 -19
View File
@@ -80,14 +80,6 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, providedPath)
return http.StatusBadRequest, errors.New("missing required parameters: path + source/hash are required")
}
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(providedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
providedPath = cleanPath
themeMode := utils.Ternary(d.user.DarkMode, "dark", "light")
var sourceInfo *settings.Source
var ok bool
@@ -110,8 +102,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
// Build file info based on whether this is a share or regular request
// Regular user request
logger.Debugf("OnlyOffice user request: request path=%s", path)
var fileInfo *iteminfo.ExtendedFileInfo
fileInfo, err = files.FileInfoFaster(utils.FileOptions{
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: path,
Source: source,
Expand: false,
@@ -345,15 +336,6 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, path)
return returnOnlyOfficeError(w, r, 400, "missing required parameters: path + source/hash are required")
}
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
logger.Errorf("OnlyOffice callback: invalid path: %v", err)
return returnOnlyOfficeError(w, r, 400, "invalid path")
}
path = cleanPath
var sourceInfo *settings.Source
var ok bool
if d.fileInfo.Hash != "" {
+7 -6
View File
@@ -16,6 +16,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-logger/logger"
@@ -86,6 +87,10 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.ExtendedFileInfo) (int, error) {
idx := indexing.GetIndex(file.Source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source not found: %s", file.Source)
}
fd, err := os.Open(file.RealPath)
if err != nil {
return http.StatusInternalServerError, err
@@ -157,7 +162,7 @@ func getDirectoryPreview(r *http.Request, d *requestContext) (*iteminfo.Extended
func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
previewSize := r.URL.Query().Get("size")
if !(previewSize == "large" || previewSize == "original" || previewSize == "xlarge") {
if !(previewSize == "large" || previewSize == "original") {
previewSize = "small"
}
if !d.fileInfo.HasPreview {
@@ -178,13 +183,9 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
isImage := strings.HasPrefix(d.fileInfo.Type, "image")
ext := strings.ToLower(filepath.Ext(d.fileInfo.Name))
resizable := iteminfo.ResizableImageTypes[ext]
// If image is under 500KB, serve the original instead of generating a preview
const maxSizeForOriginal = 500 * 1024 // 500KB
if (!resizable || config.Server.DisableResize || d.fileInfo.Size < maxSizeForOriginal) && isImage {
if (!resizable || config.Server.DisableResize) && isImage {
return rawFileHandler(w, r, d.fileInfo)
}
seekPercentage := 0
percentage := r.URL.Query().Get("atPercentage")
if percentage != "" {
+61 -52
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
@@ -76,8 +77,8 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
// Get all "file" parameter values (supports repeated params)
files := r.URL.Query()["file"]
if len(files) == 0 {
encodedFiles := r.URL.Query()["file"]
if len(encodedFiles) == 0 {
return http.StatusBadRequest, fmt.Errorf("no files specified")
}
@@ -88,17 +89,16 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
actualSourceName := sourceInfo.Name
// Process each file path
// Decode and process each file path
fileList := []string{}
for _, file := range files {
// Rule 1: Validate each file path to prevent path traversal
cleanFile, err := utils.SanitizeUserPath(file)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid file path: %v", err)
for _, encodedFile := range encodedFiles {
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
file, decodeErr := url.PathUnescape(encodedFile)
if decodeErr != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", decodeErr)
}
// Join the share path with the requested path
filePath := utils.JoinPathAsUnix(d.share.Path, cleanFile)
filePath := utils.JoinPathAsUnix(d.share.Path, file)
fileList = append(fileList, filePath)
}
@@ -107,8 +107,8 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if err == errors.ErrDownloadNotAllowed {
return http.StatusForbidden, errors.ErrDownloadNotAllowed
}
logger.Errorf("public share handler: error processing filelist: %v with error %v", files, err)
return status, fmt.Errorf("error processing filelist: %v", files)
logger.Errorf("public share handler: error processing filelist: %v with error %v", encodedFiles, err)
return status, fmt.Errorf("error processing filelist: %v", encodedFiles)
}
return status, nil
}
@@ -143,7 +143,7 @@ func publicGetResourceHandler(w http.ResponseWriter, r *http.Request, d *request
// @Accept multipart/form-data
// @Produce json
// @Param hash query string true "Share hash for authentication"
// @Param path query string true "path within the share to upload to. Must be relative to share root."
// @Param targetPath query string true "Target path within the share to upload to. Must be relative to share root."
// @Param override query bool false "If true, overwrite existing files/folders. Defaults to false."
// @Param action query string false "Upload action: 'override' to replace files, 'rename' to auto-rename"
// @Param file formData file true "File to upload"
@@ -159,15 +159,21 @@ func publicUploadHandler(w http.ResponseWriter, r *http.Request, d *requestConte
if d.share.ShareType != "upload" && !d.share.AllowCreate {
return http.StatusForbidden, fmt.Errorf("uploading is disabled for this share")
}
if !d.share.AllowReplacements && r.URL.Query().Get("action") == "override" {
return http.StatusForbidden, fmt.Errorf("cannot overwrite files for this share")
}
// Go automatically decodes query params
path, err := url.QueryUnescape(r.URL.Query().Get("targetPath"))
if err != nil {
logger.Debugf("invalid path encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
fullPath := filepath.Join(d.share.Path, path)
source := config.Server.SourceMap[d.share.Source].Name
// adjust query params to match resourcePostHandler
q := r.URL.Query()
q.Set("source", source)
q.Set("path", d.IndexPath)
q.Set("path", fullPath)
r.URL.RawQuery = q.Encode()
status, err := resourcePostHandler(w, r, d)
if err != nil {
@@ -217,6 +223,8 @@ func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestCont
if d.share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled for upload shares")
}
d.fileInfo.Source = d.share.Source
d.fileInfo.Path = utils.JoinPathAsUnix(d.share.Path, r.URL.Query().Get("path"))
status, err := previewHelperFunc(w, r, d)
if err != nil {
logger.Errorf("public preview handler: error getting preview with error %v", err)
@@ -254,16 +262,14 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if !d.share.AllowModify {
return http.StatusForbidden, fmt.Errorf("edit permission not allowed for this share")
}
// Go automatically decodes query params
path := r.URL.Query().Get("path")
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
// Decode the URL-encoded path
path, err = url.QueryUnescape(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
resolvedPath := utils.JoinPathAsUnix(d.share.Path, cleanPath)
resolvedPath := utils.JoinPathAsUnix(d.share.Path, path)
err = files.WriteFile(source, resolvedPath, r.Body)
// hide the error
if err != nil {
@@ -273,26 +279,44 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusOK, nil
}
// deprecated -- see publicBulkDeleteHandler
func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.share.AllowDelete {
return http.StatusForbidden, fmt.Errorf("delete is not allowed for this share")
}
// TODO source := r.URL.Query().Get("source")
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
indexPath := utils.JoinPathAsUnix(d.share.Path, path)
source, err := d.share.GetSourceName()
if err != nil {
return http.StatusNotFound, fmt.Errorf("source not available")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: d.IndexPath,
Source: d.share.Source,
}, store.Access, d.shareUser)
Path: indexPath,
Source: source,
Expand: false,
ShowHidden: d.share.ShowHidden,
}, store.Access, d.user)
if err != nil {
return http.StatusNotFound, fmt.Errorf("resource not available")
}
err = files.DeleteFiles(d.share.Source, fileInfo.RealPath, fileInfo.Type == "directory")
// delete thumbnails
preview.DelThumbs(r.Context(), *fileInfo)
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
if err != nil {
logger.Errorf("public delete handler: error deleting resource with error %v", err)
return http.StatusInternalServerError, fmt.Errorf("an error occured while deleting the resource")
}
// delete thumbnails
preview.DelThumbs(r.Context(), *fileInfo)
return http.StatusOK, nil
}
@@ -314,10 +338,10 @@ func publicBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestC
if !d.share.AllowDelete {
return http.StatusForbidden, fmt.Errorf("delete is not allowed for this share")
}
// hide the error
status, err := resourceBulkDeleteHandler(w, r, d)
if err != nil {
logger.Errorf("public bulk delete handler: error deleting resources with error %v", err)
return http.StatusInternalServerError, fmt.Errorf("an error occurred while processing the request")
}
return status, nil
@@ -392,15 +416,15 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return status, err
}
// getShareImage serves banner or favicon files for shares as resizable previews
// @Summary Get share image (banner or favicon) as preview
// @Description Returns a resizable preview (large size) for the banner or favicon file of a share
// getShareImage serves banner or favicon files for shares
// @Summary Get share image (banner or favicon)
// @Description Serves the banner or favicon file for a share
// @Tags Public Shares
// @Produce image/jpeg
// @Produce octet-stream
// @Param hash query string true "Share hash"
// @Param banner query bool false "Request banner file"
// @Param favicon query bool false "Request favicon file"
// @Success 200 {file} file "Preview image content (JPEG)"
// @Success 200 {file} binary "Image file content"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 403 {object} map[string]string "Permission denied"
// @Failure 404 {object} map[string]string "Asset not found"
@@ -445,24 +469,9 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
return http.StatusBadRequest, fmt.Errorf("invalid file type, must be image")
}
// Set file info in request context for preview generation
d.fileInfo = *fileInfo
q := r.URL.Query()
if isBanner {
q.Set("size", "xlarge")
} else {
q.Set("size", "small")
}
r.URL.RawQuery = q.Encode()
// Use the preview helper to generate and serve a resized preview
status, err := previewHelperFunc(w, r, d)
if err != nil {
logger.Errorf("error generating preview for share asset: source=%v path=%v error=%v", sourceName, assetPath, err)
return http.StatusNotFound, fmt.Errorf("preview not available for this asset")
}
return status, err
// Serve the file
http.ServeFile(w, r, fileInfo.RealPath)
return 0, nil
}
func getShareImagePartsHelper(share *share.Link, isBanner bool) (string, string, error) {
-10
View File
@@ -113,16 +113,6 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
source := r.URL.Query().Get("source")
fileList := r.URL.Query()["file"]
// Rule 1: Validate all user-provided file paths to prevent path traversal
for i, filePath := range fileList {
cleanPath, err := utils.SanitizeUserPath(filePath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid file path: %v", err)
}
fileList[i] = cleanPath
}
return rawFilesHandler(w, r, d, source, fileList)
}
+107 -77
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
@@ -70,8 +71,17 @@ func validateMoveOperation(src, dst string, isSrcDir bool) error {
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [get]
func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
getContent := r.URL.Query().Get("content") == "true"
getMetadata := r.URL.Query().Get("metadata") == "true"
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
@@ -126,9 +136,20 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
return http.StatusForbidden, fmt.Errorf("user is not allowed to delete")
}
path := r.URL.Query().Get("path")
// TODO source := r.URL.Query().Get("source")
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
if path == "/" {
return http.StatusForbidden, fmt.Errorf("cannot delete your user's root directory")
}
@@ -203,10 +224,6 @@ type MoveCopyResponse struct {
// @Failure 500 {object} map[string]string "Internal server error - all deletions failed"
// @Router /api/resources/bulk/delete [post]
func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
filePermUser := d.user
if d.share != nil {
filePermUser = d.shareUser
}
// Check permissions - either user delete permission or share delete permission
if d.share == nil {
if !d.user.Permissions.Delete {
@@ -252,29 +269,18 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
}
if d.share != nil {
indexPath := utils.JoinPathAsUnix(d.share.Path, item.Path)
source, err := d.share.GetSourceName()
if err != nil {
return http.StatusNotFound, fmt.Errorf("source not available")
}
// get user scope path from share
userScope, err := d.shareUser.GetScopeForSourceName(source)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: item.Path,
Message: "user does not have access",
})
continue
}
withoutUserScope := strings.TrimPrefix(d.share.Path, userScope)
indexPath := utils.JoinPathAsUnix(withoutUserScope, item.Path)
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: indexPath,
Source: source,
ShowHidden: true,
}, store.Access, filePermUser)
}, store.Access, d.user)
if err != nil {
return http.StatusNotFound, fmt.Errorf("resource not available")
}
@@ -304,7 +310,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
}
// Check user scope for this source
_, err := filePermUser.GetScopeForSourceName(item.Source)
_, err := d.user.GetScopeForSourceName(item.Source)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
@@ -330,7 +336,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
Path: idx.MakeIndexPath(item.Path, false),
Source: item.Source,
ShowHidden: true,
}, store.Access, filePermUser)
}, store.Access, d.user)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
@@ -369,7 +375,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "url encoded destination path where to place the files inside the destination source"
// @Param path query string true "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory"
// @Param source query string true "Name for the desired filebrowser destination source name, default is used if not provided"
// @Param override query bool false "Override existing file if true"
// @Param isDir query bool false "Explicitly specify if the resource is a directory"
@@ -381,23 +387,35 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
// @Router /api/resources [post]
func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
unescapedPath := path
source := r.URL.Query().Get("source")
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
path = cleanPath
if d.share == nil && !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
filePermUser := d.user
var err error
accessStore := store.Access
// if share is not nil, then set accessStore to nil
if d.share != nil {
filePermUser = d.shareUser
accessStore = nil
} else {
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
logger.Debugf("invalid source encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
unescapedPath, err = url.QueryUnescape(path)
if err != nil {
logger.Debugf("invalid path encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
if !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
// Path is now handled by FileInfoFaster which will apply user scope
path = unescapedPath
}
isDir := r.URL.Query().Get("isDir") == "true"
// Determine if this is a directory based on isDir query param or trailing slash (for backwards compatibility)
isDirParam := r.URL.Query().Get("isDir")
isDir := isDirParam == "true" || strings.HasSuffix(unescapedPath, "/")
fileOpts := utils.FileOptions{
Path: path,
Source: source,
@@ -409,21 +427,10 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
logger.Debugf("source %s not found", source)
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
userscope, err := filePermUser.GetScopeForSourceName(source)
if err != nil {
logger.Debugf("error getting scope from source name: %v", err)
return http.StatusForbidden, err
}
userscope = strings.TrimRight(userscope, "/")
fullIndexPath := utils.JoinPathAsUnix(userscope, path)
// get scoped path
realPath, _, _ := idx.GetRealPath(fullIndexPath)
realPath, _, _ := idx.GetRealPath(path)
// Check access control for the target path
if !store.Access.Permitted(idx.Path, path, filePermUser.Username) {
if accessStore != nil && !accessStore.Permitted(idx.Path, path, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
@@ -442,6 +449,14 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Directories creation on POST.
if isDir {
// Get user scope to resolve full index path for directory creation
var userScope string
userScope, err = d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
// Create a new FileOptions with the full index path
dirOpts := fileOpts
dirOpts.Path = fullIndexPath
@@ -487,7 +502,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
var fileInfo *iteminfo.ExtendedFileInfo
fileInfo, err = files.FileInfoFaster(fileOpts, store.Access, filePermUser)
fileInfo, err = files.FileInfoFaster(fileOpts, accessStore, d.user)
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
@@ -537,16 +552,23 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// close file before moving
outFile.Close()
// Move the completed file from the temp location to the final destination
err = files.MoveResource(false, source, source, tempFilePath, realPath, store.Share, store.Access)
err = fileutils.MoveFile(tempFilePath, realPath)
if err != nil {
logger.Debugf("could not move file from %v to %v: %v", tempFilePath, realPath, err)
return http.StatusInternalServerError, fmt.Errorf("could not move file from chunked folder to destination: %v", err)
}
// Refresh index with user scope
userScope, scopeErr := d.user.GetScopeForSourceName(source)
if scopeErr == nil {
fullIndexPath := utils.JoinPathAsUnix(userScope, fileOpts.Path)
go files.RefreshIndex(source, fullIndexPath, false, false) //nolint:errcheck
}
}
return http.StatusOK, nil
}
fileInfo, err := files.FileInfoFaster(fileOpts, store.Access, filePermUser)
fileInfo, err := files.FileInfoFaster(fileOpts, accessStore, d.user)
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
@@ -556,6 +578,13 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
preview.DelThumbs(r.Context(), *fileInfo)
}
// Get user scope to resolve full index path for write operation
userScope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
err = files.WriteFile(fileOpts.Source, fullIndexPath, r.Body)
if err != nil {
logger.Debugf("error writing file: %v", err)
@@ -580,15 +609,20 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// @Router /api/resources [put]
func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
source := r.URL.Query().Get("source")
path := r.URL.Query().Get("path")
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
path = cleanPath
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// Only allow PUT for files.
if strings.HasSuffix(path, "/") {
return http.StatusMethodNotAllowed, nil
@@ -656,7 +690,6 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
// Process each item
for _, item := range req.Items {
// Validate all fields are provided
if item.FromSource == "" || item.FromPath == "" || item.ToSource == "" || item.ToPath == "" {
item.Message = "fromSource, fromPath, toSource, and toPath are required"
@@ -669,26 +702,26 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
response.Failed = append(response.Failed, item)
continue
}
cleanFromPath, err := utils.SanitizeUserPath(item.FromPath)
if err != nil {
item.Message = fmt.Sprintf("invalid fromPath: %v", err)
// Check for root path modifications
if item.ToPath == "/" || item.FromPath == "/" {
item.Message = "cannot modify root directory"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
response.Failed = append(response.Failed, item)
continue
}
cleanToPath, err := utils.SanitizeUserPath(item.ToPath)
if err != nil {
item.Message = fmt.Sprintf("invalid toPath: %v", err)
response.Failed = append(response.Failed, item)
continue
}
item.FromPath = cleanFromPath
item.ToPath = cleanToPath
// Get user scopes for both sources
// For shares, paths are already absolute, so use empty scope
userscopeSrc := ""
userscopeDst := ""
if d.share == nil {
var err error
userscopeSrc, err = d.user.GetScopeForSourceName(item.FromSource)
if err != nil {
item.Message = "source not available"
@@ -734,11 +767,6 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
// Build full index paths for access control
fullSrcIndexPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
fullDstIndexPath := utils.JoinPathAsUnix(userscopeDst, item.ToPath)
if fullDstIndexPath == "/" || fullSrcIndexPath == "/" {
item.Message = "source or destination is the root or unautharized directory"
response.Failed = append(response.Failed, item)
continue
}
// Check access control for both source and destination paths
if !store.Access.Permitted(srcIdx.Path, fullSrcIndexPath, d.user.Username) {
@@ -942,8 +970,10 @@ func patchAction(ctx context.Context, params patchActionParams) error {
}
func inspectIndex(w http.ResponseWriter, r *http.Request) {
path := r.URL.Query().Get("path")
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
// Decode the URL-encoded path
path, _ := url.QueryUnescape(encodedPath)
isNotDir := r.URL.Query().Get("isDir") == "false" // default to isDir true
index := indexing.GetIndex(source)
if index == nil {
+7 -7
View File
@@ -3,6 +3,7 @@ package http
import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"strings"
@@ -120,13 +121,8 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
query := r.URL.Query().Get("query")
sourcesParam := r.URL.Query().Get("sources")
sourceParam := r.URL.Query().Get("source") // deprecated, but still supported
scope := r.URL.Query().Get("scope") // Go automatically decodes query params
scope := r.URL.Query().Get("scope")
largest := r.URL.Query().Get("largest") == "true"
cleanScope, err := utils.SanitizeUserPath(scope)
if err != nil {
return nil, fmt.Errorf("invalid scope: %v", err)
}
scope = cleanScope
var sources []string
if sourcesParam != "" {
@@ -145,10 +141,14 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
}
}
unencodedScope, err := url.PathUnescape(scope)
if err != nil {
return nil, fmt.Errorf("invalid path encoding: %v", err)
}
if len(query) < settings.Config.Server.MinSearchLength && !largest {
return nil, fmt.Errorf("query is too short, minimum length is %d", settings.Config.Server.MinSearchLength)
}
searchScope := strings.TrimPrefix(scope, ".")
searchScope := strings.TrimPrefix(unencodedScope, ".")
// If multiple sources, always use user scope (ignore searchScope)
if len(sources) > 1 {
+15 -19
View File
@@ -116,8 +116,13 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/share [get]
func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
encodedPath := r.URL.Query().Get("path")
sourceName := r.URL.Query().Get("source")
// Decode the URL-encoded path
path, err := url.PathUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
sourceInfo, ok := config.Server.NameToSource[sourceName] // backend source is path
if !ok {
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", sourceName)
@@ -358,14 +363,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusForbidden, err
}
providedPath := body.Path
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(providedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
body.Path = utils.JoinPathAsUnix(userscope, cleanPath)
body.Path = utils.JoinPathAsUnix(userscope, providedPath)
body.Path = utils.AddTrailingSlashIfNotExists(body.Path)
// validate path exists as file or folder
_, _, err = idx.GetRealPath(body.Path)
@@ -422,17 +420,23 @@ type DirectDownloadResponse struct {
// @Router /api/share/direct [get]
func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Extract query parameters
path := r.URL.Query().Get("path")
encodedPath := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
duration := r.URL.Query().Get("duration")
downloadCountStr := r.URL.Query().Get("count")
downloadSpeedStr := r.URL.Query().Get("speed")
// Validate required parameters
if path == "" || source == "" {
if encodedPath == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// Validate source exists
sourceInfo, ok := config.Server.NameToSource[source]
if !ok {
@@ -622,14 +626,6 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
commonShare.Source = ""
commonShare.Path = ""
commonShare.SidebarLinks = []users.SidebarLink{}
for _, link := range shareLink.SidebarLinks {
if link.Category == "download" && shareLink.ShareType == "upload" {
continue
} else {
commonShare.SidebarLinks = append(commonShare.SidebarLinks, link)
}
}
return renderJSON(w, r, commonShare)
}
+20 -171
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"text/template"
@@ -16,7 +15,6 @@ import (
)
var templateRenderer *TemplateRenderer
var cachedManifest PWAManifest // generated at startup
type TemplateRenderer struct {
templates *template.Template
@@ -75,16 +73,11 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
staticURL := config.Server.BaseURL + "public/static"
description := config.Frontend.Description
title := config.Frontend.Name
banner := staticURL + "/pwa-icon-512.png" // Use largest generated icon for best quality
banner := staticURL + "/img/icons/android-chrome-512x512.png"
disableSidebar := false
// Use custom favicon if configured and validated, otherwise fall back to default
// Determine the correct favicon extension based on type
faviconExt := ".svg" // Default to SVG
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
faviconExt = ".png"
}
favicon := staticURL + "/favicon" + faviconExt
favicon := staticURL + "/favicon"
shareHash := ""
data := make(map[string]interface{})
disableNavButtons := settings.Config.Frontend.DisableNavButtons
@@ -111,7 +104,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
if d.share.DisableSidebar {
disableSidebar = true
}
if strings.HasPrefix(strings.ToLower(d.share.Banner), "http") {
if strings.HasPrefix(d.share.Banner, "http") {
banner = d.share.Banner
} else {
_, _, err := getShareImagePartsHelper(d.share, true)
@@ -119,7 +112,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
banner = fmt.Sprintf("%s%spublic/api/share/image?banner=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
}
}
if strings.HasPrefix(strings.ToLower(d.share.Favicon), "http") {
if strings.HasPrefix(d.share.Favicon, "http") {
favicon = d.share.Favicon
} else {
_, _, err := getShareImagePartsHelper(d.share, false)
@@ -134,17 +127,18 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
// Load loading spinners CSS from static files
loadingSpinnersCSS := ""
cssPath := "css/loadingSpinners.css"
cssPath := "static/css/loadingSpinners.css"
cssContent, err := fs.ReadFile(assetFs, cssPath)
if err == nil {
loadingSpinnersCSS = string(cssContent)
}
// Determine OpenGraph image: use banner if set, otherwise use largest available icon (512x512)
// Determine OpenGraph image: use banner if set, otherwise use dedicated OG image or fallback to app icon
ogImage := banner
if banner == staticURL+"/pwa-icon-512.png" {
// Note: 512x512 is square; OpenGraph prefers 1200x630 (1.91:1 ratio) but square works fine
ogImage = staticURL + "/pwa-icon-512.png"
if banner == staticURL+"/img/icons/android-chrome-512x512.png" {
// No custom banner set - check if dedicated OG image exists, otherwise use 512x512 icon
// Note: 512x512 is square; OpenGraph prefers 1200x630 (1.91:1 ratio) for optimal display
ogImage = staticURL + "/img/icons/android-chrome-512x512.png"
}
// Construct the full URL for the current request
@@ -161,23 +155,6 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
fullURL = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.Path)
}
// Determine PWA icon URLs based on custom favicon settings
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
// If custom favicon is set and it's SVG, use the favicon directly
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
pwaIcon192 = favicon
pwaIcon256 = favicon
pwaIcon512 = favicon
} else if settings.Env.FaviconIsCustom {
// For custom PNG/ICO favicons, use generated PWA icons
pwaIcon192 = staticURL + "/pwa-icon-192.png"
pwaIcon256 = staticURL + "/pwa-icon-256.png"
pwaIcon512 = staticURL + "/pwa-icon-512.png"
}
data["htmlVars"] = map[string]interface{}{
"title": title,
"customCSS": config.Frontend.Styling.CustomCSSRaw,
@@ -189,17 +166,13 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"favicon": favicon,
"loginIcon": loginIcon,
"color": defaultThemeColor,
"winIcon": staticURL + "/mstile-256x256.png",
"appIcon": staticURL + "/apple-touch-icon.png",
"winIcon": staticURL + "/img/icons/mstile-144x144.png",
"appIcon": staticURL + "/img/icons/android-chrome-512x512.png",
"description": description,
"loadingSpinnersCSS": loadingSpinnersCSS,
"banner": banner,
"image": ogImage,
"url": fullURL,
"pwaIcon192": pwaIcon192,
"pwaIcon256": pwaIcon256,
"pwaIcon512": pwaIcon512,
"manifestURL": staticURL + "/site.webmanifest",
}
// variables consumed by frontend as json
data["globalVars"] = map[string]interface{}{
@@ -229,7 +202,6 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"loginIcon": loginIcon,
"disableSidebar": disableSidebar,
"shareHash": shareHash,
"oidcLoginButtonText": config.Frontend.OIDCLoginButtonText,
}
// Marshal each variable to JSON strings for direct template usage
@@ -277,16 +249,6 @@ func setContentType(w http.ResponseWriter, path string) {
}
}
// manifestHandler serves the cached PWA manifest
func manifestHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/manifest+json")
w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
if err := json.NewEncoder(w).Encode(cachedManifest); err != nil {
http.Error(w, "Failed to serve manifest", http.StatusInternalServerError)
}
}
// staticAssetHandler serves static assets exactly as the frontend build produces them
func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
const maxAge = 86400 // 1 day
@@ -302,47 +264,18 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
// Handle special routes that need path mapping
var assetPath string
switch path {
case "site.webmanifest":
manifestHandler(w, r)
return
case "favicon.svg":
// For custom SVG favicons, serve the SVG directly
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
case "favicon.svg", "favicon":
// Handle custom favicon from filesystem
if settings.Env.FaviconIsCustom {
http.ServeFile(w, r, settings.Env.FaviconPath)
return
}
// Use embedded default SVG
// Use embedded default
assetPath = settings.Env.FaviconEmbeddedPath
case "favicon.png":
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-512.png")
if _, err := os.Stat(iconPath); err == nil {
http.ServeFile(w, r, iconPath)
return
}
}
// Fall back to embedded default favicon.png
assetPath = "img/icons/favicon.png"
case "favicon-32x32.png",
"pwa-icon-192.png", "pwa-icon-256.png", "pwa-icon-512.png",
"apple-touch-icon.png":
// Serve generated icons from cache directory
iconPath := filepath.Join(settings.Env.PWAIconsDir, path)
if _, err := os.Stat(iconPath); err == nil {
http.ServeFile(w, r, iconPath)
return
}
// Fall back to embedded favicon.png if generation failed
assetPath = "img/icons/favicon.png"
case "mstile-256x256.png":
// Windows tile - redirect to pwa-icon-256.png (they're identical)
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-256.png")
if _, err := os.Stat(iconPath); err == nil {
http.ServeFile(w, r, iconPath)
return
}
// Fall back to embedded favicon.png if generation failed
assetPath = "img/icons/favicon.png"
case "manifest.json":
assetPath = "img/icons/manifest.json"
case "site.webmanifest":
assetPath = "img/icons/site.webmanifest"
case "loginIcon":
// Handle custom login icon from filesystem
if settings.Env.LoginIconIsCustom {
@@ -395,87 +328,3 @@ func indexHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
}
return handleWithStaticData(w, r, d, "index.html", "text/html")
}
// PWAManifest represents the web app manifest structure
type PWAManifest struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
Icons []PWAIcon `json:"icons"`
StartURL string `json:"start_url"`
Display string `json:"display"`
BackgroundColor string `json:"background_color"`
ThemeColor string `json:"theme_color"`
Description string `json:"description"`
}
// PWAIcon represents an icon in the web app manifest
type PWAIcon struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
Purpose string `json:"purpose"`
}
// generatePWAManifest creates the PWA manifest structure
func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwaIcon256, pwaIcon512 string) PWAManifest {
shortName := name
if len(name) > 12 {
shortName = name[:12]
}
return PWAManifest{
Name: name,
ShortName: shortName,
StartURL: baseURL,
Display: "standalone",
BackgroundColor: "#ffffff",
ThemeColor: themeColor,
Description: description,
Icons: []PWAIcon{
{
Src: pwaIcon192,
Sizes: "192x192",
Type: "image/png",
Purpose: "any",
},
{
Src: pwaIcon256,
Sizes: "256x256",
Type: "image/png",
Purpose: "any maskable",
},
{
Src: pwaIcon512,
Sizes: "512x512",
Type: "image/png",
Purpose: "any",
},
},
}
}
// InitializePWAManifest generates and caches the PWA manifest at startup
func InitializePWAManifest() {
staticURL := config.Server.BaseURL + "public/static"
title := config.Frontend.Name
description := config.Frontend.Description
defaultThemeColor := "#455a64"
// Determine PWA icon URLs based on custom favicon settings
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
favicon := staticURL + "/favicon"
pwaIcon192 = favicon
pwaIcon256 = favicon
pwaIcon512 = favicon
} else if settings.Env.FaviconIsCustom {
pwaIcon192 = staticURL + "/pwa-icon-192.png"
pwaIcon256 = staticURL + "/pwa-icon-256.png"
pwaIcon512 = staticURL + "/pwa-icon-512.png"
}
cachedManifest = generatePWAManifest(title, description, config.Server.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
}
+3 -27
View File
@@ -21,18 +21,8 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/otp/generate [post]
func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
username := r.URL.Query().Get("username")
if username == "" {
username = d.user.Username
}
if username != d.user.Username && !d.user.Permissions.Admin {
return http.StatusForbidden, fmt.Errorf("you are not authorized to generate OTP for this user")
}
targetUser, err := store.Users.Get(username)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
}
url, err := auth.GenerateOtpForUser(targetUser, store.Users)
logger.Debug("Generating OTP for user:", d.user.Username)
url, err := auth.GenerateOtpForUser(d.user, store.Users)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err)
}
@@ -61,21 +51,7 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if code == "" {
return http.StatusUnauthorized, fmt.Errorf("code is required")
}
targetUser := d.user
username := r.URL.Query().Get("username")
// If admin is verifying for another user
if username != "" && username != d.user.Username && d.user.Permissions.Admin {
var err error
targetUser, err = store.Users.Get(username)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
}
logger.Debugf("Admin %s verifying OTP for user: %s", d.user.Username, targetUser.Username)
}
err := auth.VerifyTotpCode(targetUser, code, store.Users)
err := auth.VerifyTotpCode(d.user, code, store.Users)
if err != nil {
return http.StatusUnauthorized, fmt.Errorf("invalid OTP token")
}
-78
View File
@@ -7,7 +7,6 @@ import (
"net/http"
"sort"
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
@@ -92,83 +91,6 @@ func prepForFrontend(u *users.User) {
u.TOTPNonce = ""
u.Scopes = u.GetFrontendScopes()
u.SidebarLinks = u.GetFrontendSidebarLinks()
u.Locale = normalizeLocale(u.Locale)
}
// normalizeLocale converts various locale formats (xx_xx, xx-xx, xxxx) to camelCase format (xxXX)
// Frontend expects camelCase for compound locales (zhCN, ptBR, etc.) as shown in select option values
func normalizeLocale(locale string) string {
if locale == "" {
return locale
}
// Convert to lowercase for processing
lower := strings.ToLower(locale)
// Special case mappings (standard locale codes to frontend camelCase format)
specialCases := map[string]string{
"cs": "cz", // Czech
"uk": "ua", // Ukrainian
"zh-cn": "zhCN",
"zh_cn": "zhCN",
"zhcn": "zhCN",
"zh-tw": "zhTW",
"zh_tw": "zhTW",
"zhtw": "zhTW",
"pt-br": "ptBR",
"pt_br": "ptBR",
"ptbr": "ptBR",
"sv-se": "svSE",
"sv_se": "svSE",
"svse": "svSE",
"nl-be": "nlBE",
"nl_be": "nlBE",
"nlbe": "nlBE",
}
// Check special cases first
if normalized, ok := specialCases[lower]; ok {
return normalized
}
// If already in camelCase format (4+ chars, has uppercase), return as-is
if len(locale) >= 4 {
// Check if it's a known camelCase locale
knownCamelCase := map[string]bool{
"zhCN": true, "zhTW": true, "ptBR": true, "svSE": true, "nlBE": true,
}
if knownCamelCase[locale] {
return locale
}
}
// Handle xx_xx or xx-xx format: convert to xxXX (camelCase)
parts := strings.FieldsFunc(lower, func(r rune) bool {
return r == '_' || r == '-'
})
if len(parts) == 2 {
// Convert to camelCase: first part lowercase, second part capitalized
first := parts[0]
second := parts[1]
if len(second) > 0 {
second = strings.ToUpper(second[:1]) + second[1:]
}
normalized := first + second
// Check if this matches a known compound locale
knownCompound := map[string]string{
"zhcn": "zhCN", "zhtw": "zhTW", "ptbr": "ptBR",
"svse": "svSE", "nlbe": "nlBE",
}
if normalizedVal, ok := knownCompound[normalized]; ok {
return normalizedVal
}
return normalized
}
// Single part locale (en, fr, de, etc.) - return as-is (lowercase is fine)
return lower
}
// userDeleteHandler deletes a user by ID.
-249
View File
@@ -1,249 +0,0 @@
package icons
import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/kovidgoyal/imaging"
)
// sourceType represents the type of favicon source
type sourceType int
const (
sourceCustomSVG sourceType = iota
sourceCustomRaster
sourceDefault
)
// iconSize defines an icon size to generate
type iconSize struct {
size int
name string
env *string
}
// determineSourceType determines which type of favicon source we have
func determineSourceType(faviconPath string) sourceType {
if !settings.Env.FaviconIsCustom {
return sourceDefault
}
ext := strings.ToLower(filepath.Ext(faviconPath))
switch ext {
case ".svg":
return sourceCustomSVG
default:
return sourceCustomRaster
}
}
// loadDefaultFavicon loads the embedded default favicon PNG
func loadDefaultFavicon() ([]byte, error) {
assetFs := fileutils.GetAssetFS()
if assetFs == nil {
return nil, errors.New("asset filesystem not initialized")
}
pngPath := "img/icons/favicon.png"
sourceData, err := fs.ReadFile(assetFs, pngPath)
if err != nil {
return nil, fmt.Errorf("failed to read default favicon PNG: %w", err)
}
logger.Debug("Loaded default embedded favicon")
return sourceData, nil
}
// findRasterCompanion looks for a raster companion file for an SVG favicon
// Returns the companion data and path, or error if not found
func findRasterCompanion(svgPath string) ([]byte, string, error) {
basePath := svgPath[:len(svgPath)-len(filepath.Ext(svgPath))]
rasterExts := []string{".png", ".jpg", ".jpeg", ".webp", ".gif"}
for _, ext := range rasterExts {
companionPath := basePath + ext
if _, err := os.Stat(companionPath); err == nil {
data, readErr := os.ReadFile(companionPath)
if readErr != nil {
continue
}
return data, companionPath, nil
}
}
return nil, "", fmt.Errorf("no raster companion found at %s.{png,jpg,webp,gif}", basePath)
}
// convertRasterToPNG decodes any raster format and re-encodes as PNG
func convertRasterToPNG(rasterData []byte, sourceExt string) ([]byte, error) {
img, err := imaging.Decode(bytes.NewReader(rasterData))
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
var buf bytes.Buffer
if err := imaging.Encode(&buf, img, imaging.PNG); err != nil {
return nil, fmt.Errorf("failed to convert to PNG: %w", err)
}
logger.Debugf("Converted %s to PNG for icon generation", sourceExt)
return buf.Bytes(), nil
}
// loadFaviconSource loads and prepares the favicon source data as PNG
// Returns PNG data ready for resizing into icon sizes
func loadFaviconSource() ([]byte, error) {
sourceType := determineSourceType(settings.Env.FaviconPath)
switch sourceType {
case sourceCustomSVG:
logger.Debugf("Processing custom SVG favicon: %s", settings.Env.FaviconPath)
return handleSVGFavicon(settings.Env.FaviconPath)
case sourceCustomRaster:
logger.Debugf("Processing custom favicon: %s", settings.Env.FaviconPath)
return handleRasterFavicon(settings.Env.FaviconPath)
case sourceDefault:
logger.Debug("Using default embedded favicon")
return loadDefaultFavicon()
default:
return nil, errors.New("unknown icon source type")
}
}
// handleSVGFavicon processes an SVG favicon by finding its raster companion
func handleSVGFavicon(svgPath string) ([]byte, error) {
logger.Debug("Looking for companion for SVG favicon")
companionData, companionPath, err := findRasterCompanion(svgPath)
if err != nil {
logger.Warningf("SVG favicon has no companion: %v", err)
logger.Warning("Falling back to default embedded favicon")
logger.Warning("For best Apple/iOS compatibility, provide a PNG/JPG companion alongside your SVG")
return loadDefaultFavicon()
}
logger.Debugf("Found companion: %s", companionPath)
return convertRasterToPNG(companionData, filepath.Ext(companionPath))
}
// handleRasterFavicon processes a raster favicon (PNG, JPEG, GIF, WebP, etc.)
func handleRasterFavicon(faviconPath string) ([]byte, error) {
sourceData, err := os.ReadFile(faviconPath)
if err != nil {
return nil, fmt.Errorf("failed to read favicon: %w", err)
}
return convertRasterToPNG(sourceData, filepath.Ext(faviconPath))
}
// getIconSizesToGenerate returns all icon sizes that need to be generated
func getIconSizesToGenerate() []iconSize {
return []iconSize{
// Browser favicon (32x32 PNG for Safari/older browsers)
{32, "favicon-32x32.png", nil},
// PWA icons (PNG required for Apple PWA compatibility)
{192, "pwa-icon-192.png", &settings.Env.PWAIcon192},
{256, "pwa-icon-256.png", &settings.Env.PWAIcon256}, // Also serves as Windows tile
{512, "pwa-icon-512.png", &settings.Env.PWAIcon512},
// Platform-specific icons
{180, "apple-touch-icon.png", nil}, // iOS home screen (required for Apple)
}
}
// generateIconSizes generates all required icon sizes from source PNG data
func generateIconSizes(sourceData []byte) error {
previewService := preview.GetService()
if previewService == nil {
return errors.New("preview service not initialized")
}
iconSizes := getIconSizesToGenerate()
allSuccess := true
generatedCount := 0
for _, icon := range iconSizes {
if err := generateSingleIcon(previewService, sourceData, icon); err != nil {
logger.Warningf("Failed to generate %s: %v", icon.name, err)
allSuccess = false
continue
}
// Update environment variable if specified
if icon.env != nil {
*icon.env = filepath.Join("icons", icon.name)
}
generatedCount++
}
if allSuccess {
logger.Debugf("Successfully generated %d icon sizes", generatedCount)
} else {
logger.Warningf("Generated %d/%d icon sizes (some failed)", generatedCount, len(iconSizes))
}
return nil
}
// generateSingleIcon generates a single icon file at the specified size
func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon iconSize) error {
outputPath := filepath.Join(settings.Env.PWAIconsDir, icon.name)
// Create output file
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer outFile.Close()
// Resize image using the preview service's Resize method
err = previewService.Resize(
bytes.NewReader(sourceData),
outFile,
preview.ResizeOptions{
Width: icon.size,
Height: icon.size,
ResizeMode: preview.ResizeModeFill,
Quality: preview.QualityHigh,
Format: preview.FormatPng,
},
)
if err != nil {
os.Remove(outputPath)
return fmt.Errorf("resize failed: %w", err)
}
return nil
}
// GeneratePWAIcons generates all PWA and platform icon sizes from favicon source
// All icons are generated as PNG for maximum compatibility (Apple devices, PWA, older browsers)
func GeneratePWAIcons() error {
logger.Info("Generating PWA icons...")
// Load and prepare source data as PNG
sourceData, err := loadFaviconSource()
if err != nil {
return fmt.Errorf("failed to load favicon source: %w", err)
}
// Generate all icon sizes
if err := generateIconSizes(sourceData); err != nil {
return fmt.Errorf("failed to generate icon sizes: %w", err)
}
return nil
}
+10 -25
View File
@@ -331,14 +331,18 @@ func InitializeIndexDB() (bool, error) {
batchSize := settings.Config.Server.IndexSqlConfig.BatchSize
cacheSizeMB := settings.Config.Server.IndexSqlConfig.CacheSizeMB
disableReuse := settings.Config.Server.IndexSqlConfig.DisableReuse
var isNewDb bool
indexDB, isNewDb, err = dbsql.NewIndexDB("all", journalMode, batchSize, cacheSizeMB, disableReuse)
var wasRecreated bool
indexDB, wasRecreated, err = dbsql.NewIndexDB("all", journalMode, batchSize, cacheSizeMB, disableReuse)
if err != nil {
logger.Fatalf("failed to initialize index database: %v", err)
return false, err
}
return isNewDb, nil
if wasRecreated {
logger.Warningf("Index database was recreated, all index complexities will be reset to 0")
}
return wasRecreated, nil
}
// GetIndexDB returns the shared index database.
@@ -370,7 +374,7 @@ func SetIndexingStorage(storage *dbindex.Storage) {
indexingStorage = storage
}
func Initialize(source *settings.Source, mock bool, isNewDb bool) {
func Initialize(source *settings.Source, mock bool) {
indexesMutex.Lock()
// Use shared database - all sources are differentiated by the source column
if indexDB == nil {
@@ -399,8 +403,7 @@ func Initialize(source *settings.Source, mock bool, isNewDb bool) {
if !newIndex.Config.DisableIndexing {
logger.Infof("initializing index: [%v]", newIndex.Name)
// Start multi-scanner system (each scanner will do its own initial scan)
// Pass isNewDb flag so setupMultiScanner knows whether to load persisted complexity
go newIndex.setupMultiScanner(isNewDb)
go newIndex.setupIndexingScanners()
} else {
newIndex.Status = "ready"
logger.Debug("indexing disabled for source: " + newIndex.Name)
@@ -1292,19 +1295,11 @@ func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
simpleType := strings.Split(fileInfo.Type, "/")[0]
switch fileInfo.Type {
case "image/heic", "image/heif":
if fileInfo.Size > iteminfo.LargeFileSizeThreshold {
fileInfo.HasPreview = false
return
}
fileInfo.HasPreview = settings.CanConvertImage("heic")
return
}
switch simpleType {
case "image":
if fileInfo.Size > iteminfo.LargeFileSizeThreshold {
fileInfo.HasPreview = false
return
}
fileInfo.HasPreview = true
return
case "video":
@@ -1654,17 +1649,7 @@ func (idx *Index) Save() error {
Scanners: scanners,
}
err := indexingStorage.Save(info)
if err != nil {
return err
}
// Send SSE update event after successful save
if err := idx.SendSourceUpdateEvent(); err != nil {
logger.Errorf("[%s] Failed to send source update event: %v", idx.Name, err)
}
return nil
return indexingStorage.Save(info)
}
// Load restores index and scanner information from the database
+1 -2
View File
@@ -395,7 +395,7 @@ func (s *Scanner) checkForNewChildDirectories() {
_, exists := existingScanners[dirPath]
if !exists && dirPath != "/" {
logger.Debugf("Detected new directory, creating scanner: [%s]", dirPath)
newScanner := s.idx.createScanner(dirPath)
newScanner := s.idx.createChildScanner(dirPath)
s.idx.mu.Lock()
s.idx.scanners[dirPath] = newScanner
@@ -507,7 +507,6 @@ func (s *Scanner) updateSchedule() {
// 0: unknown
func (s *Scanner) updateComplexity() {
s.complexity = calculateComplexity(s.fullScanTime, s.numDirs)
// Set smartModifier based on complexity level
if modifier, ok := complexityModifier[s.complexity]; ok {
s.smartModifier = modifier
+32 -8
View File
@@ -179,11 +179,16 @@ func (idx *Index) PostScan() error {
logger.Errorf("Failed to shrink memory: %v", err)
}
// Persist index and scanner information (SSE event will be sent by Save())
// Persist index and scanner information
if err := idx.Save(); err != nil {
logger.Errorf("Failed to save index persistence: %v", err)
}
// Send update event to notify frontend that stats may have changed
if err := idx.SendSourceUpdateEvent(); err != nil {
logger.Errorf("Error sending source update event: %v", err)
}
// Clear scan session tracking when all scanners complete
idx.mu.Lock()
idx.scanSessionStartTime = 0
@@ -197,6 +202,7 @@ func (idx *Index) PostScan() error {
func (idx *Index) SendSourceUpdateEvent() error {
if idx.mock {
logger.Debug("Skipping source update event for mock index.")
return nil
}
reducedIndex, err := GetIndexInfo(idx.Name, true)
@@ -218,8 +224,7 @@ func (idx *Index) SendSourceUpdateEvent() error {
}
// setupMultiScanner creates and starts the multi-scanner system
// isNewDb: if true, skip loading persisted complexity values (database is new or recreated)
func (idx *Index) setupMultiScanner(isNewDb bool) {
func (idx *Index) setupMultiScanner() {
// Load persisted index and scanner information
if err := idx.Load(); err != nil {
logger.Errorf("Failed to load persisted index data for [%v]: %v", idx.Name, err)
@@ -241,7 +246,7 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
// Load persisted scanner info if available
var persistedScanners map[string]*indexingdb.PersistedScannerInfo
if indexingStorage != nil && !isNewDb {
if indexingStorage != nil {
info, err := indexingStorage.GetByPath(idx.Path)
if err == nil && info != nil {
persistedScanners = info.Scanners
@@ -249,7 +254,8 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
}
// Create and start root scanner
rootScanner := idx.createScanner("/")
rootScanner := idx.createRootScanner()
// Restore persisted stats for root scanner if available
if persistedScanners != nil {
if rootInfo, ok := persistedScanners["/"]; ok {
rootScanner.complexity = rootInfo.Complexity
@@ -271,9 +277,9 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
// Create child scanner for each top-level directory
for _, dirPath := range topLevelDirs {
childScanner := idx.createScanner(dirPath)
childScanner := idx.createChildScanner(dirPath)
// Restore persisted stats for child scanner if available (and DB is not new)
// Restore persisted stats for child scanner if available
if persistedScanners != nil {
if childInfo, ok := persistedScanners[dirPath]; ok {
childScanner.complexity = childInfo.Complexity
@@ -296,8 +302,20 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
logger.Debugf("Created %d scanners for [%v] (1 root + %d children)", len(topLevelDirs)+1, idx.Name, len(topLevelDirs))
}
// createRootScanner creates a scanner for the root directory (non-recursive)
func (idx *Index) createRootScanner() *Scanner {
return &Scanner{
scanPath: "/",
idx: idx,
stopChan: make(chan struct{}),
currentSchedule: 0,
fullScanCounter: 0,
complexity: 0, // 0 = unknown until first full scan completes
}
}
// createChildScanner creates a scanner for a specific child directory (recursive)
func (idx *Index) createScanner(dirPath string) *Scanner {
func (idx *Index) createChildScanner(dirPath string) *Scanner {
return &Scanner{
scanPath: dirPath,
idx: idx,
@@ -308,6 +326,12 @@ func (idx *Index) createScanner(dirPath string) *Scanner {
}
}
// Legacy function kept for backwards compatibility - now deprecated
func (idx *Index) setupIndexingScanners() {
// Use new multi-scanner system
idx.setupMultiScanner()
}
// GetScannerStatus returns detailed information about all active scanners
func (idx *Index) GetScannerStatus() map[string]interface{} {
idx.mu.RLock()
+4 -4
View File
@@ -13,7 +13,7 @@ func BenchmarkFillIndex(b *testing.B) {
Initialize(&settings.Source{
Name: "test",
Path: "/srv",
}, true, false) // mock=true, isNewDb=false
}, true)
idx := GetIndex("test")
if idx == nil {
b.Fatal("index is nil")
@@ -79,9 +79,9 @@ func TestMultiScannerMutex(t *testing.T) {
}
// Create multiple scanners
scanner1 := idx.createScanner("/")
scanner2 := idx.createScanner("/test1/")
scanner3 := idx.createScanner("/test2/")
scanner1 := idx.createRootScanner()
scanner2 := idx.createChildScanner("/test1/")
scanner3 := idx.createChildScanner("/test2/")
idx.scanners["/"] = scanner1
idx.scanners["/test1/"] = scanner2
-4
View File
@@ -9,10 +9,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
const (
LargeFileSizeThreshold = 75 * 1024 * 1024 // 75MB
)
var AllFiletypeOptions = []string{
"image",
"audio",
+12 -12
View File
@@ -3,7 +3,7 @@ package iteminfo
import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
)
type ItemInfo struct {
@@ -40,21 +40,21 @@ type MediaMetadata struct {
Genre string `json:"genre,omitempty"` // music/video genre
Track int `json:"track,omitempty"` // track number
Duration int `json:"duration,omitempty"` // duration in seconds
AlbumArt []byte `json:"albumArt,omitempty"` // album art image data (automatically base64-encoded in JSON)
AlbumArt string `json:"albumArt,omitempty"` // base64 encoded album art / video thumbnail
}
// for efficiency, a response will be a pointer to the data
// extra calculated fields can be added here
type ExtendedFileInfo struct {
FileInfo
Content string `json:"content,omitempty"` // text content of a file, if requested
Subtitles []utils.SubtitleTrack `json:"subtitles,omitempty"` // subtitles for video files
Metadata *MediaMetadata `json:"metadata,omitempty"` // media metadata for audio/video files (includes duration)
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
Token string `json:"token,omitempty"` // token for the file -- used for sharing
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source,omitempty"` // associated index source for the file
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
HasMetadata bool `json:"hasMetadata"` // whether the file or folder has metadata
RealPath string `json:"-"`
Content string `json:"content,omitempty"` // text content of a file, if requested
Subtitles []ffmpeg.SubtitleTrack `json:"subtitles,omitempty"` // subtitles for video files
Metadata *MediaMetadata `json:"metadata,omitempty"` // media metadata for audio/video files (includes duration)
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
Token string `json:"token,omitempty"` // token for the file -- used for sharing
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source,omitempty"` // associated index source for the file
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
HasMetadata bool `json:"hasMetadata"` // whether the file or folder has metadata
RealPath string `json:"-"`
}
+21 -46
View File
@@ -8,9 +8,19 @@ import (
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/go-logger/logger"
)
type SubtitleTrack struct {
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language,omitempty"` // language code
Title string `json:"title,omitempty"` // title/description
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
IsFile bool `json:"isFile"` // true for external files, false for embedded
}
type FFProbeOutput struct {
Streams []struct {
Index int `json:"index"`
@@ -21,55 +31,20 @@ type FFProbeOutput struct {
} `json:"streams"`
}
// GetSubtitles detects external subtitle files for video files.
// Embedded subtitles should be detected by ffmpeg and passed as embeddedSubs parameter.
func (i *ExtendedFileInfo) GetSubtitles(parentInfo *FileInfo) {
// detects subtitles for video files.
func (i *ExtendedFileInfo) DetectSubtitles(parentInfo *FileInfo) {
if !strings.HasPrefix(i.Type, "video") {
logger.Debug("subtitles are not supported for this file : " + i.Name)
return
}
ext := filepath.Ext(i.Name)
baseWithoutExt := strings.TrimSuffix(i.Name, ext)
// Use unified subtitle detection that finds both embedded and external
parentDir := filepath.Dir(i.RealPath)
i.Subtitles = ffmpeg.DetectAllSubtitles(i.RealPath, parentDir, i.ModTime)
}
// Collect external subtitle files
var externalSubs []utils.SubtitleTrack
if parentInfo != nil && parentInfo.Files != nil {
for _, f := range parentInfo.Files {
fileExt := filepath.Ext(f.Name)
fileBase := strings.TrimSuffix(f.Name, fileExt)
// Check if this file has the same base name and a subtitle extension
if fileBase == baseWithoutExt && SubtitleExts[strings.ToLower(fileExt)] {
track := utils.SubtitleTrack{
Name: f.Name,
Embedded: false,
}
// Try to infer language from filename patterns like "video.en.srt"
parts := strings.Split(fileBase, ".")
if len(parts) > 1 {
lastPart := parts[len(parts)-1]
if len(lastPart) == 2 || len(lastPart) == 3 {
track.Language = lastPart
}
}
externalSubs = append(externalSubs, track)
}
}
}
// Sort external subtitles alphabetically for consistent ordering
sort.Slice(externalSubs, func(i, j int) bool {
return externalSubs[i].Name < externalSubs[j].Name
})
i.Subtitles = append(i.Subtitles, externalSubs...)
// Sort all subtitles for consistent ordering
sort.Slice(i.Subtitles, func(a, b int) bool {
return strings.ToLower(i.Subtitles[a].Name) < strings.ToLower(i.Subtitles[b].Name)
})
// Note: Content is NOT loaded here. Frontend will call /api/media/subtitles for each track
// LoadSubtitleContent loads the actual content for all detected subtitle tracks
func (i *ExtendedFileInfo) LoadSubtitleContent() error {
return ffmpeg.LoadAllSubtitleContent(i.RealPath, i.Subtitles, i.ModTime)
}
func (info *FileInfo) SortItems() {
+2 -2
View File
@@ -49,7 +49,7 @@ func TestMain(m *testing.M) {
}
func BenchmarkSearchAllIndexes(b *testing.B) {
Initialize(&settings.Source{Name: "test", Path: "/srv"}, true, false) // mock=true, isNewDb=false
Initialize(&settings.Source{Name: "test", Path: "/srv"}, true)
idx := GetIndex("test")
idx.CreateMockData(50, 3) // 50 dirs, 3 files per dir
@@ -143,7 +143,7 @@ func TestSearchWhileIndexing(t *testing.T) {
IndexingDisabled: false,
}
Initialize(source, true, false) // mock=true, isNewDb=false
Initialize(source, true)
idx := GetIndex("test")
if idx == nil {
t.Fatal("Failed to get test index")
+1 -5
View File
@@ -6,10 +6,9 @@ import (
)
// bufferPool manages reusable buffers to reduce allocations
// Buffers start at 128KB (first retry level) and can grow up to 512KB
var bufferPool = sync.Pool{
New: func() interface{} {
// Create 128KB buffers by default to match first retry level
// Create 128KB buffers by default
return bytes.NewBuffer(make([]byte, 0, 128*1024))
},
}
@@ -28,9 +27,6 @@ func getBuffer() *bytes.Buffer {
// putBuffer returns a buffer to the pool
func putBuffer(buf *bytes.Buffer) {
// Don't pool buffers that grew too large (>1MB)
// This prevents memory bloat from images with large headers (256KB/512KB retries)
// Typical case: 128KB buffers are reused
// Edge case: 256KB/512KB buffers are discarded and GC'd
if buf.Cap() > 1024*1024 {
return
}
-74
View File
@@ -1,74 +0,0 @@
package preview
import (
"sync"
"testing"
"github.com/stretchr/testify/require"
)
// TestBufferPool_GetPutBalanced verifies that getBuffer/putBuffer can be used
// in a loop without leaking or panicking, and that buffers are returned to the pool.
func TestBufferPool_GetPutBalanced(t *testing.T) {
const iterations = 1000
for i := 0; i < iterations; i++ {
buf := getBuffer()
require.NotNil(t, buf)
require.NotNil(t, buf.Bytes())
putBuffer(buf)
}
}
// TestBufferPool_Reuse verifies that put buffers are reused (same capacity after get).
func TestBufferPool_Reuse(t *testing.T) {
buf1 := getBuffer()
cap1 := buf1.Cap()
putBuffer(buf1)
buf2 := getBuffer()
cap2 := buf2.Cap()
putBuffer(buf2)
require.Equal(t, cap1, cap2, "pool should return buffers with same capacity")
require.GreaterOrEqual(t, cap1, 128*1024, "default buffer should be at least 128KB")
}
// TestBufferPool_LargeBufferNotReused verifies that buffers that grew beyond 1MB
// are not put back in the pool (to avoid retaining large allocations).
func TestBufferPool_LargeBufferNotReused(t *testing.T) {
buf := getBuffer()
// Grow the buffer past the 1MB threshold
growth := make([]byte, 2*1024*1024)
_, _ = buf.Write(growth)
require.Greater(t, buf.Cap(), 1024*1024)
putBuffer(buf)
// After putBuffer, a large buffer is not pooled. Get several buffers and
// ensure we still get valid 128KB-capacity buffers (pool wasn't polluted).
for i := 0; i < 10; i++ {
b := getBuffer()
require.NotNil(t, b)
// Pool buffers are 128KB cap; if we got a fresh one it's 128KB
require.GreaterOrEqual(t, b.Cap(), 128*1024, "getBuffer should return usable buffer")
putBuffer(b)
}
}
// TestBufferPool_ConcurrentGetPut stresses the pool under concurrent use (as in Resize).
func TestBufferPool_ConcurrentGetPut(t *testing.T) {
const goroutines = 50
const perGoroutine = 20
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < perGoroutine; i++ {
buf := getBuffer()
_, _ = buf.Write([]byte("test"))
putBuffer(buf)
}
}()
}
wg.Wait()
}
+7 -1
View File
@@ -28,7 +28,13 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
return nil, ctx.Err()
}
// Serialize access to the entire go-fitz operation block (required for CGO thread safety)
// Acquire document semaphore
if err := s.acquireDoc(ctx); err != nil {
return nil, err
}
defer s.releaseDoc()
// 1. Serialize access to the entire go-fitz operation block
s.docGenMutex.Lock()
defer s.docGenMutex.Unlock()
+9 -2
View File
@@ -15,7 +15,14 @@ func docEnabled() bool {
}
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]byte, error) {
// Reference it to prevent unused field warning when building without mupdf
_ = &s.docGenMutex
// Acquire document semaphore
if err := s.acquireDoc(ctx); err != nil {
return nil, err
}
defer s.releaseDoc()
s.docGenMutex.Lock()
defer s.docGenMutex.Unlock()
return nil, nil
}
+10 -10
View File
@@ -9,7 +9,7 @@ import (
// This function handles all FFmpeg-related logic and parameters
func (s *Service) convertHEICToJPEGWithFFmpeg(ctx context.Context, filePath string, previewSize string) ([]byte, error) {
// Check if FFmpeg service is available
if s.ffmpegService == nil {
if s.imageService == nil {
return nil, fmt.Errorf("FFmpeg is not available")
}
@@ -18,11 +18,11 @@ func (s *Service) convertHEICToJPEGWithFFmpeg(ctx context.Context, filePath stri
return nil, ctx.Err()
}
// Acquire FFmpeg service semaphore
if err := s.ffmpegService.Acquire(ctx); err != nil {
// Acquire image service semaphore
if err := s.imageService.Acquire(ctx); err != nil {
return nil, err
}
defer s.ffmpegService.Release()
defer s.imageService.Release()
// Determine target dimensions and quality based on preview size
var width, height int
@@ -41,14 +41,14 @@ func (s *Service) convertHEICToJPEGWithFFmpeg(ctx context.Context, filePath stri
}
// Use tile-based conversion for correct full-resolution image reconstruction
return s.ffmpegService.ConvertHEICToJPEG(ctx, filePath, width, height, quality)
return s.imageService.ConvertHEICToJPEG(ctx, filePath, width, height, quality)
}
// convertImageWithFFmpeg converts any image file (including problematic JPEGs) to resized JPEG using FFmpeg
// This is used as a fallback for JPEG files that Go's standard decoder can't handle (extended sequential, etc.)
func (s *Service) convertImageWithFFmpeg(ctx context.Context, filePath string, previewSize string) ([]byte, error) {
// Check if FFmpeg service is available
if s.ffmpegService == nil {
if s.imageService == nil {
return nil, fmt.Errorf("FFmpeg is not available for JPEG fallback")
}
@@ -57,11 +57,11 @@ func (s *Service) convertImageWithFFmpeg(ctx context.Context, filePath string, p
return nil, ctx.Err()
}
// Acquire FFmpeg service semaphore
if err := s.ffmpegService.Acquire(ctx); err != nil {
// Acquire image service semaphore
if err := s.imageService.Acquire(ctx); err != nil {
return nil, err
}
defer s.ffmpegService.Release()
defer s.imageService.Release()
// Determine target dimensions and quality based on preview size
var width, height int
@@ -80,5 +80,5 @@ func (s *Service) convertImageWithFFmpeg(ctx context.Context, filePath string, p
}
// Use direct conversion which works for all image formats including problematic JPEGs
return s.ffmpegService.ConvertImageToJPEG(ctx, filePath, width, height, quality)
return s.imageService.ConvertImageToJPEG(ctx, filePath, width, height, quality)
}
+318 -67
View File
@@ -1,10 +1,13 @@
package preview
import (
"bytes"
"context"
"fmt"
"image"
"image/jpeg"
"io"
"strings"
exif "github.com/dsoprea/go-exif/v3"
exifcommon "github.com/dsoprea/go-exif/v3/common"
@@ -140,98 +143,334 @@ func (s *Service) FormatFromExtension(ext string) (Format, error) {
}
}
type ResizeOptions struct {
Width int
Height int
Format Format
ResizeMode ResizeMode
Quality Quality
JpegQuality int // JPEG encoding quality (1-100), 0 means use Quality default
type resizeConfig struct {
format Format
resizeMode ResizeMode
quality Quality
jpegQuality int // JPEG encoding quality (1-100)
}
func (s *Service) Resize(in io.Reader, out io.Writer, opts ResizeOptions) error {
return s.ResizeWithSize(in, out, 0, opts)
type Option func(*resizeConfig)
func WithFormat(format Format) Option {
return func(config *resizeConfig) {
config.format = format
}
}
// ResizeWithSize resizes an image with file size information for appropriate semaphore selection
func (s *Service) ResizeWithSize(in io.Reader, out io.Writer, fileSize int64, opts ResizeOptions) error {
// Set defaults
if opts.ResizeMode == 0 {
opts.ResizeMode = ResizeModeFit
func WithMode(mode ResizeMode) Option {
return func(config *resizeConfig) {
config.resizeMode = mode
}
if opts.Quality == 0 {
opts.Quality = QualityMedium
}
func WithQuality(quality Quality) Option {
return func(config *resizeConfig) {
config.quality = quality
config.jpegQuality = quality.jpegQuality()
}
if opts.JpegQuality == 0 {
opts.JpegQuality = opts.Quality.jpegQuality()
}
func (s *Service) Resize(in io.Reader, width, height int, out io.Writer, options ...Option) error {
// First, detect format and get dimensions (lightweight operation)
format, wrappedReader, imgWidth, imgHeight, err := s.detectFormatAndSize(in)
if err != nil {
return fmt.Errorf("failed to detect image format: %w", err)
}
// Skip format detection - format is already known (FormatJpeg from CreatePreviewFromReaderWithSize)
// This avoids unnecessary I/O and DecodeConfig issues with corrupted files
var wrappedReader = in
var err error
// Use image service semaphore for image processing
if s.imageService != nil {
if acquireErr := s.imageService.Acquire(context.Background()); acquireErr != nil {
return acquireErr
}
defer s.imageService.Release()
}
if opts.Format == FormatHeic {
opts.Format = FormatJpeg
config := resizeConfig{
format: format,
resizeMode: ResizeModeFit,
quality: QualityMedium,
jpegQuality: QualityMedium.jpegQuality(),
}
for _, option := range options {
option(&config)
}
// Ensure JPEG quality is set based on quality setting
if opts.JpegQuality == 0 {
opts.JpegQuality = opts.Quality.jpegQuality()
if config.jpegQuality == 0 {
config.jpegQuality = config.quality.jpegQuality()
}
// Try to use embedded EXIF thumbnail for JPEGs (only for low quality to keep it simple)
if opts.Format == FormatJpeg && opts.Quality == QualityLow {
if format == FormatHeic {
config.format = FormatJpeg
}
// Try to use embedded EXIF thumbnail for JPEGs (saves MASSIVE memory and CPU)
// This is especially beneficial for large images (e.g., 4000x3000 photos)
if format == FormatJpeg && imgWidth > width*2 && imgHeight > height*2 {
thm, newWrappedReader, errThm := getEmbeddedThumbnail(wrappedReader)
wrappedReader = newWrappedReader
if errThm == nil && len(thm) > 0 {
_, err = out.Write(thm)
if err == nil {
return nil
// Decode the EXIF thumbnail to check its size
thmImg, _, thmErr := image.Decode(bytes.NewReader(thm))
if thmErr == nil {
bounds := thmImg.Bounds()
thmWidth := bounds.Dx()
thmHeight := bounds.Dy()
// If EXIF thumbnail is larger than or equal to requested size
// and quality is Low, return it as-is (no need to resize down)
if config.quality == QualityLow && thmWidth >= width && thmHeight >= height {
if config.format == FormatJpeg {
return jpeg.Encode(out, thmImg, &jpeg.Options{Quality: config.jpegQuality})
}
return imaging.Encode(out, thmImg, config.format.toImaging())
}
// If EXIF thumbnail is smaller but still reasonable (at least 40% of target),
minThumbSize := width * 40 / 100
if thmWidth >= minThumbSize || thmHeight >= minThumbSize {
var resizedImg image.Image
switch config.resizeMode {
case ResizeModeFill:
resizedImg = imaging.Fill(thmImg, width, height, imaging.Center, config.quality.resampleFilter())
case ResizeModeFit:
resizedImg = imaging.Fit(thmImg, width, height, config.quality.resampleFilter())
default:
resizedImg = imaging.Fit(thmImg, width, height, config.quality.resampleFilter())
}
if config.format == FormatJpeg {
return jpeg.Encode(out, resizedImg, &jpeg.Options{Quality: config.jpegQuality})
}
return imaging.Encode(out, resizedImg, config.format.toImaging())
}
}
}
}
// Decode the image - try imaging library first, fall back to format-specific decoder if it fails
img, err := imaging.Decode(wrappedReader, imaging.AutoOrientation(true))
// For HEIC files, try without AutoOrientation first since it might not work properly
// Try decoding with default settings (auto color space conversion)
img, err := imaging.Decode(wrappedReader)
if err != nil {
// Imaging library failed, try format-specific standard decoder as fallback
// Reset reader if possible
if seeker, ok := wrappedReader.(io.Seeker); ok {
_, _ = seeker.Seek(0, io.SeekStart)
}
// Use format-specific decoder (more reliable than image.Decode auto-detection)
if opts.Format == FormatJpeg {
img, err = jpeg.Decode(wrappedReader)
// If decoding fails due to ICC profile issues (e.g., unsupported grayscale colorspace),
// retry with color space conversion disabled
if bytes.Contains([]byte(err.Error()), []byte("colorspace")) ||
bytes.Contains([]byte(err.Error()), []byte("ICC profile")) {
// Reset reader to beginning
if seeker, ok := wrappedReader.(io.Seeker); ok {
_, _ = seeker.Seek(0, io.SeekStart)
}
// Try again without color space conversion
img, err = imaging.Decode(wrappedReader, imaging.ColorSpace(imaging.NO_CHANGE_OF_COLORSPACE))
if err != nil {
// Return error with JPEG-specific terms so FFmpeg fallback can catch it
return fmt.Errorf("failed to decode image: %w", err)
}
} else {
// For other formats, try auto-detection
img, _, err = image.Decode(wrappedReader)
if err != nil {
return fmt.Errorf("failed to decode image: %w", err)
return fmt.Errorf("failed to decode image: %w", err)
}
}
// Note: For HEIC files processed via FFmpeg, orientation is handled automatically
// Use the quality setting requested by caller
resampleFilter := config.quality.resampleFilter()
switch config.resizeMode {
case ResizeModeFill:
img = imaging.Fill(img, width, height, imaging.Center, resampleFilter)
case ResizeModeFit:
img = imaging.Fit(img, width, height, resampleFilter)
default:
img = imaging.Fit(img, width, height, resampleFilter)
}
// Use optimized JPEG encoding with quality control for better performance
if config.format == FormatJpeg {
// Further optimize quality for small thumbnails
jpegQuality := config.jpegQuality
return jpeg.Encode(out, img, &jpeg.Options{Quality: jpegQuality})
}
return imaging.Encode(out, img, config.format.toImaging())
}
// detectFormatAndSize detects format and dimensions without full decode
func (s *Service) detectFormatAndSize(in io.Reader) (Format, io.Reader, int, int, error) {
const maxHeaderSize = 64 * 1024 // 64KB should be enough for format detection
// Try to work with seekable readers efficiently
if seeker, ok := in.(io.Seeker); ok {
originalPos, err := seeker.Seek(0, io.SeekCurrent)
if err == nil {
// Seek to start
if _, err := seeker.Seek(0, io.SeekStart); err == nil {
headerBuf := make([]byte, maxHeaderSize)
n, readErr := in.Read(headerBuf)
// Handle EOF gracefully (file smaller than header size)
if readErr == nil || readErr == io.EOF {
if n >= 2 {
headerBuf = headerBuf[:n]
isFullFile := (readErr == io.EOF) || (n < maxHeaderSize)
// Try magic byte detection first (fastest)
format := s.detectFormatFromMagicBytes(headerBuf, n)
// Try to decode config for dimensions
reader := bytes.NewReader(headerBuf)
config, imgFormat, configErr := image.DecodeConfig(reader)
if configErr == nil {
// Successfully got config
if format < 0 {
format = s.parseFormat(imgFormat)
}
if format >= 0 {
if isFullFile {
// Small file - use the buffer we already have
return format, bytes.NewReader(headerBuf), config.Width, config.Height, nil
}
// Large file - seek back and use original reader
_, _ = seeker.Seek(originalPos, io.SeekStart)
return format, in, config.Width, config.Height, nil
}
}
// Restore position and fall through
_, _ = seeker.Seek(originalPos, io.SeekStart)
}
}
}
}
}
// Resize
resampleFilter := opts.Quality.resampleFilter()
switch opts.ResizeMode {
case ResizeModeFill:
img = imaging.Fill(img, opts.Width, opts.Height, imaging.Center, resampleFilter)
case ResizeModeFit:
fallthrough
default:
img = imaging.Fit(img, opts.Width, opts.Height, resampleFilter)
// Fallback: read all (for non-seekable or if header detection failed)
allData, err := io.ReadAll(in)
if err != nil {
return -1, nil, 0, 0, fmt.Errorf("failed to read image data: %w", err)
}
if len(allData) == 0 {
return -1, nil, 0, 0, fmt.Errorf("image data is empty: %w", ErrUnsupportedFormat)
}
// Encode
if opts.Format == FormatJpeg {
return jpeg.Encode(out, img, &jpeg.Options{Quality: opts.JpegQuality})
reader := bytes.NewReader(allData)
config, imgFormat, err := image.DecodeConfig(reader)
if err != nil {
return -1, nil, 0, 0, fmt.Errorf("image.DecodeConfig failed: %w", ErrUnsupportedFormat)
}
format := s.parseFormat(imgFormat)
if format < 0 {
return -1, nil, 0, 0, fmt.Errorf("unsupported image format '%s': %w", imgFormat, ErrUnsupportedFormat)
}
return format, bytes.NewReader(allData), config.Width, config.Height, nil
}
// detectFormatFromMagicBytes detects image format from magic bytes in file header
// Returns -1 if no format is detected (since FormatJpeg = 0)
func (s *Service) detectFormatFromMagicBytes(header []byte, n int) Format {
if n < 2 {
return -1
}
// JPEG: FF D8 (the third byte can vary, so we only check first two)
if header[0] == 0xFF && header[1] == 0xD8 {
return FormatJpeg
}
// PNG: 89 50 4E 47 0D 0A 1A 0A
if n >= 8 && header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47 {
return FormatPng
}
// GIF: GIF87a or GIF89a
if n >= 6 && (string(header[0:6]) == "GIF87a" || string(header[0:6]) == "GIF89a") {
return FormatGif
}
// BMP: BM (42 4D)
if n >= 2 && header[0] == 0x42 && header[1] == 0x4D {
return FormatBmp
}
// TIFF: Little-endian (49 49 2A 00) or Big-endian (4D 4D 00 2A)
if n >= 4 {
if (header[0] == 0x49 && header[1] == 0x49 && header[2] == 0x2A && header[3] == 0x00) ||
(header[0] == 0x4D && header[1] == 0x4D && header[2] == 0x00 && header[3] == 0x2A) {
return FormatTiff
}
}
// WebP: RIFF...WEBP (starts with RIFF, contains WEBP at offset 8)
if n >= 12 && string(header[0:4]) == "RIFF" && string(header[8:12]) == "WEBP" {
return FormatWebp
}
// HEIC/HEIF: ftyp box (starts with 4-byte size, then 'ftyp', then brand)
// Common brands: heic, heif, mif1
if n >= 12 {
// Check for ftyp at offset 4
if string(header[4:8]) == "ftyp" {
// Check for HEIC/HEIF brands
brand := string(header[8:12])
if brand == "heic" || brand == "heif" || brand == "mif1" {
return FormatHeic
}
}
}
// Netpbm formats: P1-P7 (ASCII) or binary variants
if n >= 2 && header[0] == 'P' {
switch header[1] {
case '1', '4': // PBM (P1=ASCII, P4=binary)
return FormatPbm
case '2', '5': // PGM (P2=ASCII, P5=binary)
return FormatPgm
case '3', '6': // PPM (P3=ASCII, P6=binary)
return FormatPpm
case '7': // PAM
return FormatPam
}
}
return -1 // No format detected
}
// parseFormat parses the image format string and returns the Format enum
func (s *Service) parseFormat(imgFormat string) Format {
if imgFormat == "heif" {
imgFormat = "heic"
}
// Handle case variations - image.DecodeConfig might return "jpeg" or "JPEG"
imgFormat = strings.ToLower(imgFormat)
// Manual mapping to ensure compatibility (bypasses generated enum ParseFormat)
switch imgFormat {
case "jpeg", "jpg":
return FormatJpeg
case "png":
return FormatPng
case "gif":
return FormatGif
case "tiff", "tif":
return FormatTiff
case "bmp":
return FormatBmp
case "heic", "heif":
return FormatHeic
case "webp":
return FormatWebp
case "pbm":
return FormatPbm
case "pgm":
return FormatPgm
case "ppm":
return FormatPpm
case "pam":
return FormatPam
default:
return -1
}
return imaging.Encode(out, img, opts.Format.toImaging())
}
func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
@@ -261,18 +500,30 @@ func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
_, _ = seeker.Seek(originalPos, io.SeekStart)
wrappedReader = in
} else {
// Read error - cannot extract thumbnail, return error to fall back to full decode
// Read error - fallback to reading all
_, _ = seeker.Seek(originalPos, io.SeekStart)
return nil, in, fmt.Errorf("failed to read header for thumbnail extraction: %w", readErr)
allData, _ := io.ReadAll(in)
headerBuf = allData
wrappedReader = bytes.NewReader(allData)
}
} else {
// Seek failed - cannot extract thumbnail without seeking, return error to fall back to full decode
return nil, in, fmt.Errorf("reader is not seekable, cannot extract thumbnail")
// Seek failed, read all
allData, _ := io.ReadAll(in)
headerBuf = allData
wrappedReader = bytes.NewReader(allData)
}
} else {
// Non-seekable reader - cannot extract thumbnail without loading entire file
// Return error to fall back to full decode rather than loading everything into memory
return nil, in, fmt.Errorf("reader is not seekable, cannot extract thumbnail without loading entire file")
// Non-seekable reader - must read and combine
buf := make([]byte, maxExifSize)
n, err := io.ReadFull(in, buf)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return nil, nil, err
}
headerBuf = buf[:n]
// Read remaining data and combine
remaining, _ := io.ReadAll(in)
combined := append(headerBuf, remaining...)
wrappedReader = bytes.NewReader(combined)
}
// Try to find EXIF header
+76 -81
View File
@@ -33,13 +33,17 @@ func TestMain(m *testing.M) {
func TestService_Resize(t *testing.T) {
testCases := map[string]struct {
options ResizeOptions
options []Option
width int
height int
source func(t *testing.T) afero.File
matcher func(t *testing.T, reader io.Reader)
wantErr bool
}{
"fill upscale": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 50, 20)
@@ -47,7 +51,9 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"fill downscale": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -55,7 +61,9 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"fit upscale": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFit},
options: []Option{WithMode(ResizeModeFit)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 50, 20)
@@ -63,7 +71,9 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(50, 20),
},
"fit downscale": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFit},
options: []Option{WithMode(ResizeModeFit)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -71,7 +81,9 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 75),
},
"keep original format": {
options: ResizeOptions{Width: 100, Height: 100, Format: FormatPng},
options: []Option{},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayPng(t, 200, 150)
@@ -79,7 +91,9 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatPng),
},
"convert to jpeg": {
options: ResizeOptions{Width: 100, Height: 100, Format: FormatJpeg},
options: []Option{WithFormat(FormatJpeg)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -87,7 +101,9 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatJpeg),
},
"convert to png": {
options: ResizeOptions{Width: 100, Height: 100, Format: FormatPng},
options: []Option{WithFormat(FormatPng)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -95,7 +111,9 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatPng),
},
"convert to gif": {
options: ResizeOptions{Width: 100, Height: 100, Format: FormatGif},
options: []Option{WithFormat(FormatGif)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -103,7 +121,9 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatGif),
},
"convert to tiff": {
options: ResizeOptions{Width: 100, Height: 100, Format: FormatTiff},
options: []Option{WithFormat(FormatTiff)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -111,7 +131,9 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatTiff),
},
"convert to bmp": {
options: ResizeOptions{Width: 100, Height: 100, Format: FormatBmp},
options: []Option{WithFormat(FormatBmp)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -119,7 +141,9 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatBmp),
},
"convert to unknown": {
options: ResizeOptions{Width: 100, Height: 100, Format: Format(-1)},
options: []Option{WithFormat(Format(-1))},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -127,7 +151,9 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatJpeg),
},
"resize png": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayPng(t, 200, 150)
@@ -135,7 +161,9 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize gif": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayGif(t, 200, 150)
@@ -143,7 +171,9 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize tiff": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayTiff(t, 200, 150)
@@ -151,7 +181,9 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize bmp": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayBmp(t, 200, 150)
@@ -159,7 +191,9 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize with high quality": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityHigh},
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityHigh)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -167,7 +201,9 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize with medium quality": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityMedium},
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityMedium)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -175,7 +211,9 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize with low quality": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -183,7 +221,9 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize with unknown quality": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: Quality(-1)},
options: []Option{WithMode(ResizeModeFill), WithQuality(Quality(-1))},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -191,7 +231,9 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"get thumbnail from file with APP0 JFIF": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return openFile(t, "testdata/gray-sample.jpg")
@@ -199,7 +241,9 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(125, 128),
},
"get thumbnail from file without APP0 JFIF": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return openFile(t, "testdata/20130612_142406.jpg")
@@ -207,7 +251,9 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(320, 240),
},
"resize from file without IFD1 thumbnail": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return openFile(t, "testdata/IMG_2578.JPG")
@@ -215,7 +261,9 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize for higher quality levels": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityMedium},
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityMedium)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
return openFile(t, "testdata/gray-sample.jpg")
@@ -223,7 +271,9 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"broken file": {
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFit},
options: []Option{WithMode(ResizeModeFit)},
width: 100,
height: 100,
source: func(t *testing.T) afero.File {
t.Helper()
fs := afero.NewMemMapFs()
@@ -247,7 +297,7 @@ func TestService_Resize(t *testing.T) {
defer source.Close()
buf := &bytes.Buffer{}
err := svc.Resize(source, buf, test.options)
err := svc.Resize(source, test.width, test.height, buf, test.options...)
if (err != nil) != test.wantErr {
t.Fatalf("GetMarketSpecs() error = %v, wantErr %v", err, test.wantErr)
}
@@ -448,58 +498,3 @@ func TestCacheKeyConsistency(t *testing.T) {
require.Equal(t, cacheKey3, cacheKey4, "Cache keys should be consistent for same parameters")
require.NotEqual(t, cacheKey1, cacheKey3, "Different seek percentages should produce different cache keys")
}
// TestResize_ConcurrencyLimit verifies that imageSem works for basic concurrency control
func TestResize_ConcurrencyLimit(t *testing.T) {
tmpDir := t.TempDir()
svc := NewPreviewGenerator(2, tmpDir)
require.NotNil(t, svc.imageSem, "imageSem must be non-nil to test concurrency limit")
// Simple smoke test - just verify Resize works with the semaphore
source := newGrayJpeg(t, 100, 100)
defer source.Close()
out := &bytes.Buffer{}
err := svc.Resize(source, out, ResizeOptions{Width: 50, Height: 50})
require.NoError(t, err)
require.Greater(t, out.Len(), 0)
}
// TestResize_ConcurrencyLimit_NoFFmpeg verifies that imageSem works correctly
func TestResize_ConcurrencyLimit_NoFFmpeg(t *testing.T) {
tmpDir := t.TempDir()
svc := NewPreviewGenerator(1, tmpDir)
require.NotNil(t, svc.imageSem, "imageSem should exist for concurrency control")
source := newGrayJpeg(t, 100, 100)
defer source.Close()
out := &bytes.Buffer{}
err := svc.Resize(source, out, ResizeOptions{Width: 50, Height: 50})
require.NoError(t, err)
require.Greater(t, out.Len(), 0)
}
// TestCreatePreviewFromReader_UsesConcurrencyLimit verifies that CreatePreviewFromReader
// goes through Resize and thus respects the imageSem semaphore (smoke test).
func TestCreatePreviewFromReader_UsesConcurrencyLimit(t *testing.T) {
tmpDir := t.TempDir()
svc := NewPreviewGenerator(2, tmpDir)
source := newGrayJpeg(t, 200, 200)
defer source.Close()
data, err := io.ReadAll(source)
require.NoError(t, err)
// Get options for small preview
options, err := getPreviewOptions("small")
require.NoError(t, err)
// Create preview from reader
out, err := svc.CreatePreview(bytes.NewReader(data), int64(len(data)), options)
require.NoError(t, err)
require.NotEmpty(t, out)
// Same via another call for parity
out2, err := svc.CreatePreview(bytes.NewReader(data), int64(len(data)), options)
require.NoError(t, err)
require.Equal(t, out, out2)
}
+4 -1
View File
@@ -21,8 +21,11 @@ type officePreviewResponse struct {
}
// GenerateOfficePreview generates a preview for an office document using OnlyOffice.
// Note: Global image processor semaphore is acquired at GeneratePreviewWithMD5 level
func (s *Service) GenerateOfficePreview(ctx context.Context, filetype, key, title, url string) ([]byte, error) {
if err := s.acquireOffice(ctx); err != nil {
return nil, err
}
defer s.releaseOffice()
data := []byte{}
// Create the request payload
requestPayload := map[string]interface{}{
+154 -353
View File
@@ -4,10 +4,10 @@ import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -32,24 +32,19 @@ type Service struct {
fileCache diskcache.Interface
cacheDir string // Cache directory used for thumbnails and temp files
debug bool
docGenMutex sync.Mutex // Mutex to serialize access to doc generation (required for CGO thread safety with go-fitz)
ffmpegService *ffmpeg.FFmpegService // Shared FFmpeg service for video and HEIC/JPEG fallback
imageSem chan struct{} // Semaphore for small image decode/encode (<8MB)
imageLargeSem chan struct{} // Semaphore for large image decode/encode (>=8MB), nil if only 1 processor
docGenMutex sync.Mutex // Mutex to serialize access to doc generation
docSemaphore chan struct{} // Semaphore for document generation
officeSem chan struct{} // Semaphore for office document processing
videoService *ffmpeg.FFmpegService
imageService *ffmpeg.FFmpegService
memoryTracker *MemoryTracker // Memory-aware tracker for image processing
}
// Calculate split between small and large imaging library processors
// Distribution formula:
//
// 1 processor: single image processor for all sizes
// 2 processors: 1 large, 1 small
// 3-6 processors: 2 large, rest small
// 7+ processors: 3 large, rest small
func NewPreviewGenerator(concurrencyLimit int, cacheDir string) *Service {
if concurrencyLimit < 1 {
concurrencyLimit = 1
}
// get round up half value of concurrencyLimit for FFmpeg operations
// get round up half value of concurrencyLimit
ffmpegConcurrencyLimit := (concurrencyLimit + 1) / 2
actualCacheDir := cacheDir
@@ -79,81 +74,54 @@ func NewPreviewGenerator(concurrencyLimit int, cacheDir string) *Service {
logger.Error(err)
}
// Single FFmpeg service shared by video preview and HEIC/JPEG fallback
ffmpegService := ffmpeg.NewFFmpegService(ffmpegConcurrencyLimit, settings.Config.Integrations.Media.Debug, filepath.Join(actualCacheDir, "heic"))
videoService := ffmpeg.NewFFmpegService(ffmpegConcurrencyLimit, settings.Config.Integrations.Media.Debug, "")
imageService := ffmpeg.NewFFmpegService(concurrencyLimit, settings.Config.Integrations.Media.Debug, filepath.Join(actualCacheDir, "heic"))
var imageSem, imageLargeSem chan struct{}
// Create memory tracker for image processing
// Limit to 500MB of concurrent image processing memory
maxMemoryMB := 500
memoryTracker := NewMemoryTracker(concurrencyLimit, maxMemoryMB)
if concurrencyLimit == 1 {
// Single processor: no split, imageLargeSem will be nil
imageSem = make(chan struct{}, 1)
imageLargeSem = nil
} else {
var largeLimit int
if concurrencyLimit >= 7 {
largeLimit = 3
} else if concurrencyLimit >= 3 {
largeLimit = 2
} else {
largeLimit = 1
}
smallLimit := concurrencyLimit - largeLimit
imageSem = make(chan struct{}, smallLimit)
imageLargeSem = make(chan struct{}, largeLimit)
logger.Debugf("Image processor split: %d small, %d large (total: %d)", smallLimit, largeLimit, concurrencyLimit)
}
// Total max memory = concurrencyLimit × 50MB
// Example: concurrencyLimit=10 → ~500MB max
settings.Env.MuPdfAvailable = docEnabled()
return &Service{
fileCache: fileCache,
cacheDir: actualCacheDir,
debug: settings.Config.Integrations.Media.Debug,
ffmpegService: ffmpegService,
imageSem: imageSem,
imageLargeSem: imageLargeSem,
docSemaphore: make(chan struct{}, 1), // must be 1 because cgo thread limit
officeSem: make(chan struct{}, concurrencyLimit),
videoService: videoService,
imageService: imageService,
memoryTracker: memoryTracker,
}
}
// Global image processor semaphore methods
// These are used to ensure FFmpeg and document operations also respect the global image processor limit
func (s *Service) acquireImageSem(ctx context.Context) error {
// Document semaphore methods
func (s *Service) acquireDoc(ctx context.Context) error {
select {
case s.imageSem <- struct{}{}:
case s.docSemaphore <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) releaseImageSem() {
<-s.imageSem
func (s *Service) releaseDoc() {
<-s.docSemaphore
}
func (s *Service) acquireImageLargeSem(ctx context.Context) error {
if s.imageLargeSem == nil {
// Fall back to imageSem if imageLargeSem is not available (single processor case)
return s.acquireImageSem(ctx)
}
// Office semaphore methods
func (s *Service) acquireOffice(ctx context.Context) error {
select {
case s.imageLargeSem <- struct{}{}:
case s.officeSem <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) releaseImageLargeSem() {
if s.imageLargeSem == nil {
// Fall back to imageSem if imageLargeSem is not available (single processor case)
s.releaseImageSem()
return
}
<-s.imageLargeSem
func (s *Service) releaseOffice() {
<-s.officeSem
}
func StartPreviewGenerator(concurrencyLimit int, cacheDir string) error {
@@ -164,11 +132,6 @@ func StartPreviewGenerator(concurrencyLimit int, cacheDir string) error {
return nil
}
// GetService returns the preview service instance (can be nil if not started)
func GetService() *Service {
return service
}
func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, url string, seekPercentage int) ([]byte, error) {
if !file.HasPreview {
return nil, ErrUnsupportedMedia
@@ -181,10 +144,10 @@ func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, prev
// Generate fast cache key based on file metadata
var cacheHash string
if file.Metadata != nil && len(file.Metadata.AlbumArt) > 0 {
if file.Metadata != nil && file.Metadata.AlbumArt != "" {
// For audio with album art, hash the album art content
hasher := md5.New()
_, _ = hasher.Write(file.Metadata.AlbumArt)
_, _ = hasher.Write([]byte(file.Metadata.AlbumArt))
cacheHash = hex.EncodeToString(hasher.Sum(nil))
} else {
// For all other files, use fast metadata-based hash
@@ -203,191 +166,6 @@ func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, prev
return GeneratePreviewWithMD5(ctx, file, previewSize, url, seekPercentage, cacheHash)
}
// filePreviewType represents the type of preview generation needed
type filePreviewType int
const (
previewTypeDocument filePreviewType = iota
previewTypeOffice
previewTypeHEIC
previewTypeImage
previewTypeVideo
previewTypeAudio
previewTypeUnsupported
)
// determinePreviewType determines what type of preview generation is needed
func determinePreviewType(file iteminfo.ExtendedFileInfo) filePreviewType {
// Check document conversion first
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
return previewTypeDocument
}
// Check office files
if file.OnlyOfficeId != "" {
return previewTypeOffice
}
// Check HEIC with conversion enabled
if strings.HasPrefix(file.Type, "image/heic") &&
*settings.Config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview] {
return previewTypeHEIC
}
// Check by MIME type prefix
switch {
case strings.HasPrefix(file.Type, "image"):
return previewTypeImage
case strings.HasPrefix(file.Type, "video"):
return previewTypeVideo
case strings.HasPrefix(file.Type, "audio") && file.Metadata != nil && len(file.Metadata.AlbumArt) > 0:
return previewTypeAudio
default:
return previewTypeUnsupported
}
}
// generateRawPreview generates the initial preview image bytes based on file type
func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int, hash string) ([]byte, error) {
previewType := determinePreviewType(file)
switch previewType {
case previewTypeDocument:
return s.generateDocumentPreview(ctx, file, hash)
case previewTypeOffice:
return s.generateOfficeFilePreview(ctx, file, officeUrl)
case previewTypeHEIC:
return s.generateHEICPreview(ctx, file, previewSize)
case previewTypeImage:
return s.generateImagePreview(ctx, file, previewSize)
case previewTypeVideo:
return s.generateVideoPreviewBytes(ctx, file, seekPercentage)
case previewTypeAudio:
if file.Metadata != nil && len(file.Metadata.AlbumArt) > 0 {
return file.Metadata.AlbumArt, nil
}
return nil, nil
case previewTypeUnsupported:
ext := strings.ToLower(filepath.Ext(file.Name))
return nil, fmt.Errorf("unsupported media type: %s", ext)
default:
return nil, errors.New("unknown preview type")
}
}
// generateDocumentPreview generates preview for PDF and document files
func (s *Service) generateDocumentPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, hash string) ([]byte, error) {
tempFilePath := filepath.Join(s.cacheDir, "thumbnails", "docs", hash) + ".txt"
imageBytes, err := s.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page
if err != nil {
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
}
return imageBytes, nil
}
// generateOfficeFilePreview generates preview for Office files via OnlyOffice
func (s *Service) generateOfficeFilePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, officeUrl string) ([]byte, error) {
imageBytes, err := s.GenerateOfficePreview(ctx, filepath.Ext(file.Name), file.OnlyOfficeId, file.Name, officeUrl)
if err != nil {
return nil, fmt.Errorf("failed to create image for office file: %w", err)
}
return imageBytes, nil
}
// generateHEICPreview generates preview for HEIC files using FFmpeg
func (s *Service) generateHEICPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize string) ([]byte, error) {
imageBytes, err := s.convertHEICToJPEGWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
}
return imageBytes, nil
}
// generateVideoPreviewBytes generates preview frame from video file
func (s *Service) generateVideoPreviewBytes(ctx context.Context, file iteminfo.ExtendedFileInfo, seekPercentage int) ([]byte, error) {
// Check if this video format is enabled for preview generation
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
if !settings.CanConvertVideo(ext) {
return nil, fmt.Errorf("video preview generation is disabled for .%s files in settings", ext)
}
videoSeekPercentage := seekPercentage
if videoSeekPercentage == 0 {
videoSeekPercentage = 10
}
imageBytes, err := s.GenerateVideoPreview(ctx, file.RealPath, videoSeekPercentage)
if err != nil {
// Don't log client cancellations as errors
if ctx.Err() != context.Canceled {
logger.Errorf("Video preview generation failed for '%s' (path: %s, seek: %d%%): %v",
file.Name, file.RealPath, videoSeekPercentage, err)
}
return nil, fmt.Errorf("failed to create image for video file: %w", err)
}
return imageBytes, nil
}
// generateImagePreview generates preview for regular image files
func (s *Service) generateImagePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize string) ([]byte, error) {
// Stream from file instead of os.ReadFile so we don't load every image fully into memory
f, err := os.Open(file.RealPath)
if err != nil {
logger.Errorf("Failed to open image file '%s' (path: %s): %v", file.Name, file.RealPath, err)
return nil, fmt.Errorf("failed to open image file: %w", err)
}
defer f.Close()
options, err := getPreviewOptions(previewSize)
if err != nil {
return nil, err
}
imageBytes, err := s.CreatePreview(f, file.Size, options)
if err != nil {
// For JPEG files, try FFmpeg fallback if initial decode failed
if strings.HasPrefix(file.Type, "image/jpeg") {
return handleJPEGFallback(ctx, s, file, previewSize, err)
}
return nil, fmt.Errorf("failed to create image preview: %w", err)
}
if len(imageBytes) < 100 {
logger.Errorf("Generated image too small for '%s' (type: %s): %d bytes", file.Name, file.Type, len(imageBytes))
return nil, fmt.Errorf("generated image is too small, likely an error occurred: %d bytes", len(imageBytes))
}
return imageBytes, nil
}
// handleJPEGFallback attempts to use FFmpeg for problematic JPEG files
// This is preview-specific logic for user files, not used for icon generation
func handleJPEGFallback(ctx context.Context, s *Service, file iteminfo.ExtendedFileInfo, previewSize string, originalErr error) ([]byte, error) {
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
if !enableJPEGFallback {
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg conversion disabled in settings): %w", originalErr)
}
if s.ffmpegService == nil {
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg not available): %w", originalErr)
}
logger.Debugf("JPEG decode failed for '%s', falling back to FFmpeg: %v", file.Name, originalErr)
imageBytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to resize preview image with FFmpeg fallback: %w", err)
}
return imageBytes, nil
}
func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int, fileMD5 string) ([]byte, error) {
// Note: fileMD5 is actually a cache hash (metadata-based), not a true file content MD5
// Validate that cache hash is not empty to prevent cache corruption
@@ -402,61 +180,82 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
return nil, ctx.Err()
}
// Acquire global image processor semaphore for ALL operations
const largeFileSizeThreshold = 8 * 1024 * 1024 // 8MB
if file.Size >= largeFileSizeThreshold && service.imageLargeSem != nil {
if err := service.acquireImageLargeSem(ctx); err != nil {
return nil, err
}
defer service.releaseImageLargeSem()
} else {
if err := service.acquireImageSem(ctx); err != nil {
return nil, err
}
defer service.releaseImageSem()
}
ext := strings.ToLower(filepath.Ext(file.Name))
var (
err error
imageBytes []byte
)
// Check if context is cancelled after acquiring semaphore
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Enforce file size limit for image preview generation to prevent memory exhaustion
if strings.HasPrefix(file.Type, "image") && file.Size > iteminfo.LargeFileSizeThreshold {
message := fmt.Sprintf("Image file too large for preview: %s (size: %d bytes, limit: %d bytes)",
file.Name, file.Size, iteminfo.LargeFileSizeThreshold)
logger.Warning(message)
return nil, errors.New(message)
}
// Generate hash for temp file paths
// Generate thumbnail image from video
hasher := md5.New()
_, _ = hasher.Write([]byte(CacheKey(fileMD5, previewSize, seekPercentage)))
hash := hex.EncodeToString(hasher.Sum(nil))
// Generate raw preview based on file type
imageBytes, err := service.generateRawPreview(ctx, file, previewSize, officeUrl, seekPercentage, hash)
if err != nil {
return nil, err
}
// Check if HEIC conversion is enabled (guaranteed non-nil after defaults)
convertHEIC := strings.HasPrefix(file.Type, "image/heic") && *settings.Config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview]
// For HEIC files, we've already done the resize/conversion, cache and return directly
previewType := determinePreviewType(file)
if previewType == previewTypeHEIC {
// Generate an image from office document
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
tempFilePath := filepath.Join(service.cacheDir, "thumbnails", "docs", hash) + ".txt"
imageBytes, err = service.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page
if err != nil {
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
}
} else if file.OnlyOfficeId != "" {
imageBytes, err = service.GenerateOfficePreview(ctx, filepath.Ext(file.Name), file.OnlyOfficeId, file.Name, officeUrl)
if err != nil {
return nil, fmt.Errorf("failed to create image for office file: %w", err)
}
} else if convertHEIC {
// HEIC files need FFmpeg conversion to JPEG with proper size/quality handling
imageBytes, err = service.convertHEICToJPEGWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
}
// For HEIC files, we've already done the resize/conversion, so cache and return directly
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache HEIC image: %v", err)
}
return imageBytes, nil
}
// For regular images that were already resized, cache and return
if previewType == previewTypeImage {
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache image: %v", err)
} else if strings.HasPrefix(file.Type, "image") {
imageBytes, err = os.ReadFile(file.RealPath)
if err != nil {
logger.Errorf("Failed to read image file '%s' (path: %s): %v", file.Name, file.RealPath, err)
return nil, fmt.Errorf("failed to read image file: %w", err)
}
return imageBytes, nil
} else if strings.HasPrefix(file.Type, "video") {
// Check if this video format is enabled for preview generation
ext = strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
if !settings.CanConvertVideo(ext) {
return nil, fmt.Errorf("video preview generation is disabled for .%s files in settings", ext)
}
videoSeekPercentage := seekPercentage
if videoSeekPercentage == 0 {
videoSeekPercentage = 10
}
imageBytes, err = service.GenerateVideoPreview(ctx, file.RealPath, videoSeekPercentage)
if err != nil {
// Don't log client cancellations as errors
if ctx.Err() != context.Canceled {
logger.Errorf("Video preview generation failed for '%s' (path: %s, seek: %d%%): %v",
file.Name, file.RealPath, videoSeekPercentage, err)
}
return nil, fmt.Errorf("failed to create image for video file: %w", err)
}
} else if strings.HasPrefix(file.Type, "audio") {
// Extract album artwork from audio files
if file.Metadata != nil && file.Metadata.AlbumArt != "" {
imageBytes, err = base64.StdEncoding.DecodeString(file.Metadata.AlbumArt)
if err != nil {
return nil, fmt.Errorf("failed to decode album artwork: %w", err)
}
} else {
return nil, nil
}
} else {
return nil, fmt.Errorf("unsupported media type: %s", ext)
}
// Check if context was cancelled during processing
@@ -464,47 +263,62 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
return nil, ctx.Err()
}
// Validate generated image size
if len(imageBytes) < 100 {
logger.Errorf("Generated image too small for '%s' (type: %s): %d bytes - likely an error occurred",
file.Name, file.Type, len(imageBytes))
return nil, fmt.Errorf("generated image is too small, likely an error occurred: %d bytes", len(imageBytes))
}
// Resize if needed (for videos, audio, documents, office files)
if previewSize != "original" {
options, err := getPreviewOptions(previewSize)
// resize image
resizedBytes, err := service.CreatePreview(imageBytes, previewSize)
if err != nil {
return nil, err
}
// Check if this is an unsupported JPEG format (extended sequential, etc.)
errMsg := err.Error()
if strings.HasPrefix(file.Type, "image/jpeg") &&
(strings.Contains(errMsg, "unrecognised marker") ||
strings.Contains(errMsg, "invalid JPEG format") ||
strings.Contains(errMsg, "Huffman")) {
resizedBytes, err := service.CreatePreview(bytes.NewReader(imageBytes), 0, options)
if err != nil {
// For JPEG files, try FFmpeg fallback if resize failed
if strings.HasPrefix(file.Type, "image/jpeg") {
resizedBytes, err = handleJPEGFallback(ctx, service, file, previewSize, err)
if err != nil {
return nil, err
// Check if JPEG FFmpeg conversion is enabled (guaranteed non-nil after defaults)
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
// Only attempt FFmpeg fallback if it's enabled and FFmpeg service is available
if enableJPEGFallback && service.imageService != nil {
// Fall back to FFmpeg for problematic JPEG files
logger.Debugf("JPEG decode failed for '%s', falling back to FFmpeg: %v", file.Name, err)
resizedBytes, err = service.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to resize preview image with FFmpeg fallback: %w", err)
}
// Cache and return the FFmpeg-converted image
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, resizedBytes); err != nil {
logger.Errorf("failed to cache FFmpeg-converted image: %v", err)
}
return resizedBytes, nil
}
} else {
return nil, fmt.Errorf("failed to resize preview image: %w", err)
// If FFmpeg fallback is disabled or not available, return the original error
if !enableJPEGFallback {
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg conversion disabled in settings): %w", err)
}
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg not available): %w", err)
}
return nil, fmt.Errorf("failed to resize preview image: %w", err)
}
// Cache and return resized image
// Cache and return
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err := service.fileCache.Store(ctx, cacheKey, resizedBytes); err != nil {
logger.Errorf("failed to cache resized image: %v", err)
}
return resizedBytes, nil
} else {
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache original image: %v", err)
}
return imageBytes, nil
}
// Cache and return original size
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache original image: %v", err)
}
return imageBytes, nil
}
func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int) ([]byte, error) {
@@ -517,44 +331,31 @@ func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previe
return GeneratePreviewWithMD5(ctx, file, previewSize, officeUrl, seekPercentage, cacheHash)
}
// getPreviewOptions returns resize options for the given preview size
func getPreviewOptions(previewSize string) (ResizeOptions, error) {
switch previewSize {
case "xlarge":
return ResizeOptions{
Width: 1024,
Height: 1024,
ResizeMode: ResizeModeFit,
Quality: QualityHigh,
Format: FormatJpeg,
}, nil
case "large":
return ResizeOptions{
Width: 640,
Height: 640,
ResizeMode: ResizeModeFit,
Quality: QualityHigh,
Format: FormatJpeg,
}, nil
case "small":
return ResizeOptions{
Width: 256,
Height: 256,
ResizeMode: ResizeModeFit,
Quality: QualityMedium,
Format: FormatJpeg,
}, nil
default:
return ResizeOptions{}, ErrUnsupportedFormat
}
}
func (s *Service) CreatePreview(data []byte, previewSize string) ([]byte, error) {
var (
width int
height int
options []Option
)
// CreatePreview resizes an image from a reader with the given options
func (s *Service) CreatePreview(reader io.Reader, fileSize int64, options ResizeOptions) ([]byte, error) {
switch previewSize {
case "large":
width, height = 640, 640
options = []Option{WithMode(ResizeModeFit), WithQuality(QualityHigh), WithFormat(FormatJpeg)}
case "small":
width, height = 256, 256
options = []Option{WithMode(ResizeModeFit), WithQuality(QualityMedium), WithFormat(FormatJpeg)}
default:
return nil, ErrUnsupportedFormat
}
input := bytes.NewReader(data)
output := &bytes.Buffer{}
if err := s.ResizeWithSize(reader, output, fileSize, options); err != nil {
if err := s.Resize(input, width, height, output, options...); err != nil {
return nil, err
}
return output.Bytes(), nil
}
+5 -5
View File
@@ -14,9 +14,9 @@ import (
// percentageSeek: percentage of video duration to seek to (0–100).
// Returns: JPEG image bytes.
func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath string, percentageSeek int) ([]byte, error) {
if s.ffmpegService == nil {
logger.Errorf("FFmpeg service not available for file '%s'", videoPath)
return nil, fmt.Errorf("FFmpeg service not available")
if s.videoService == nil {
logger.Errorf("Video service not available for file '%s'", videoPath)
return nil, fmt.Errorf("video service not available")
}
// Validate file exists before processing
@@ -26,14 +26,14 @@ func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath string, pe
}
var buf bytes.Buffer
err := s.ffmpegService.GenerateVideoPreviewStreaming(ctx, videoPath, percentageSeek, &buf)
err := s.videoService.GenerateVideoPreviewStreaming(ctx, videoPath, percentageSeek, &buf)
if err != nil {
return nil, err
}
previewBytes := buf.Bytes()
if len(previewBytes) == 0 {
logger.Errorf("FFmpeg service returned empty result for '%s'", videoPath)
logger.Errorf("Video service returned empty result for '%s'", videoPath)
return nil, fmt.Errorf("video preview generation returned empty result")
}
+16 -30
View File
@@ -1105,17 +1105,17 @@ const docTemplate = `{
},
"/api/media/subtitles": {
"get": {
"description": "Returns raw subtitle content from external files or embedded streams",
"description": "Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content",
"consumes": [
"application/json"
],
"produces": [
"text/plain"
"text/vtt"
],
"tags": [
"Subtitles"
],
"summary": "Get subtitle content",
"summary": "Extract embedded subtitles",
"parameters": [
{
"type": "string",
@@ -1132,22 +1132,15 @@ const docTemplate = `{
"required": true
},
{
"type": "string",
"description": "Subtitle track name (filename for external, descriptive name for embedded)",
"name": "name",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Whether this is an embedded stream (true) or external file (false), defaults to false",
"name": "embedded",
"type": "integer",
"description": "Stream index for embedded subtitle extraction, defaults to 0",
"name": "index",
"in": "query"
}
],
"responses": {
"200": {
"description": "Raw subtitle content in original format",
"description": "Raw WebVTT subtitle content",
"schema": {
"type": "string"
}
@@ -1571,7 +1564,7 @@ const docTemplate = `{
"parameters": [
{
"type": "string",
"description": "url encoded destination path where to place the files inside the destination source",
"description": "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory",
"name": "path",
"in": "query",
"required": true
@@ -3070,8 +3063,8 @@ const docTemplate = `{
},
{
"type": "string",
"description": "path within the share to upload to. Must be relative to share root.",
"name": "path",
"description": "Target path within the share to upload to. Must be relative to share root.",
"name": "targetPath",
"in": "query",
"required": true
},
@@ -3319,14 +3312,14 @@ const docTemplate = `{
},
"/public/api/share/image": {
"get": {
"description": "Returns a resizable preview (large size) for the banner or favicon file of a share",
"description": "Serves the banner or favicon file for a share",
"produces": [
"image/jpeg"
"application/octet-stream"
],
"tags": [
"Public Shares"
],
"summary": "Get share image (banner or favicon) as preview",
"summary": "Get share image (banner or favicon)",
"parameters": [
{
"type": "string",
@@ -3350,7 +3343,7 @@ const docTemplate = `{
],
"responses": {
"200": {
"description": "Preview image content (JPEG)",
"description": "Image file content",
"schema": {
"type": "file"
}
@@ -4082,11 +4075,8 @@ const docTemplate = `{
"type": "string"
},
"albumArt": {
"description": "album art image data (automatically base64-encoded in JSON)",
"type": "array",
"items": {
"type": "integer"
}
"description": "base64 encoded album art / video thumbnail",
"type": "string"
},
"artist": {
"description": "track artist",
@@ -4342,10 +4332,6 @@ const docTemplate = `{
"description": "display name",
"type": "string"
},
"oidcLoginButtonText": {
"description": "text to display on the OIDC login button",
"type": "string"
},
"styling": {
"$ref": "#/definitions/settings.StylingConfig"
}
+16 -30
View File
@@ -1094,17 +1094,17 @@
},
"/api/media/subtitles": {
"get": {
"description": "Returns raw subtitle content from external files or embedded streams",
"description": "Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content",
"consumes": [
"application/json"
],
"produces": [
"text/plain"
"text/vtt"
],
"tags": [
"Subtitles"
],
"summary": "Get subtitle content",
"summary": "Extract embedded subtitles",
"parameters": [
{
"type": "string",
@@ -1121,22 +1121,15 @@
"required": true
},
{
"type": "string",
"description": "Subtitle track name (filename for external, descriptive name for embedded)",
"name": "name",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Whether this is an embedded stream (true) or external file (false), defaults to false",
"name": "embedded",
"type": "integer",
"description": "Stream index for embedded subtitle extraction, defaults to 0",
"name": "index",
"in": "query"
}
],
"responses": {
"200": {
"description": "Raw subtitle content in original format",
"description": "Raw WebVTT subtitle content",
"schema": {
"type": "string"
}
@@ -1560,7 +1553,7 @@
"parameters": [
{
"type": "string",
"description": "url encoded destination path where to place the files inside the destination source",
"description": "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory",
"name": "path",
"in": "query",
"required": true
@@ -3059,8 +3052,8 @@
},
{
"type": "string",
"description": "path within the share to upload to. Must be relative to share root.",
"name": "path",
"description": "Target path within the share to upload to. Must be relative to share root.",
"name": "targetPath",
"in": "query",
"required": true
},
@@ -3308,14 +3301,14 @@
},
"/public/api/share/image": {
"get": {
"description": "Returns a resizable preview (large size) for the banner or favicon file of a share",
"description": "Serves the banner or favicon file for a share",
"produces": [
"image/jpeg"
"application/octet-stream"
],
"tags": [
"Public Shares"
],
"summary": "Get share image (banner or favicon) as preview",
"summary": "Get share image (banner or favicon)",
"parameters": [
{
"type": "string",
@@ -3339,7 +3332,7 @@
],
"responses": {
"200": {
"description": "Preview image content (JPEG)",
"description": "Image file content",
"schema": {
"type": "file"
}
@@ -4071,11 +4064,8 @@
"type": "string"
},
"albumArt": {
"description": "album art image data (automatically base64-encoded in JSON)",
"type": "array",
"items": {
"type": "integer"
}
"description": "base64 encoded album art / video thumbnail",
"type": "string"
},
"artist": {
"description": "track artist",
@@ -4331,10 +4321,6 @@
"description": "display name",
"type": "string"
},
"oidcLoginButtonText": {
"description": "text to display on the OIDC login button",
"type": "string"
},
"styling": {
"$ref": "#/definitions/settings.StylingConfig"
}
+18 -30
View File
@@ -447,10 +447,8 @@ definitions:
description: album name
type: string
albumArt:
description: album art image data (automatically base64-encoded in JSON)
items:
type: integer
type: array
description: base64 encoded album art / video thumbnail
type: string
artist:
description: track artist
type: string
@@ -657,9 +655,6 @@ definitions:
name:
description: display name
type: string
oidcLoginButtonText:
description: text to display on the OIDC login button
type: string
styling:
$ref: '#/definitions/settings.StylingConfig'
type: object
@@ -2499,7 +2494,8 @@ paths:
get:
consumes:
- application/json
description: Returns raw subtitle content from external files or embedded streams
description: Extracts embedded subtitle content from video files by stream index
and returns raw WebVTT content
parameters:
- description: Index path to the video file
in: query
@@ -2511,22 +2507,15 @@ paths:
name: source
required: true
type: string
- description: Subtitle track name (filename for external, descriptive name
for embedded)
- description: Stream index for embedded subtitle extraction, defaults to 0
in: query
name: name
required: true
type: string
- description: Whether this is an embedded stream (true) or external file (false),
defaults to false
in: query
name: embedded
type: boolean
name: index
type: integer
produces:
- text/plain
- text/vtt
responses:
"200":
description: Raw subtitle content in original format
description: Raw WebVTT subtitle content
schema:
type: string
"400":
@@ -2553,7 +2542,7 @@ paths:
additionalProperties:
type: string
type: object
summary: Get subtitle content
summary: Extract embedded subtitles
tags:
- Subtitles
/api/preview:
@@ -2863,7 +2852,7 @@ paths:
Supports file uploads and directory creation.
parameters:
- description: url encoded destination path where to place the files inside
the destination source
the destination source, a directory must end in / to create a directory
in: query
name: path
required: true
@@ -3847,10 +3836,10 @@ paths:
name: hash
required: true
type: string
- description: path within the share to upload to. Must be relative to share
root.
- description: Target path within the share to upload to. Must be relative to
share root.
in: query
name: path
name: targetPath
required: true
type: string
- description: If true, overwrite existing files/folders. Defaults to false.
@@ -4026,8 +4015,7 @@ paths:
- Public Shares
/public/api/share/image:
get:
description: Returns a resizable preview (large size) for the banner or favicon
file of a share
description: Serves the banner or favicon file for a share
parameters:
- description: Share hash
in: query
@@ -4043,10 +4031,10 @@ paths:
name: favicon
type: boolean
produces:
- image/jpeg
- application/octet-stream
responses:
"200":
description: Preview image content (JPEG)
description: Image file content
schema:
type: file
"400":
@@ -4067,7 +4055,7 @@ paths:
additionalProperties:
type: string
type: object
summary: Get share image (banner or favicon) as preview
summary: Get share image (banner or favicon)
tags:
- Public Shares
/public/api/shareinfo:
+438 -404
View File
File diff suppressed because it is too large. Load diff
+1 -4
View File
@@ -22,7 +22,7 @@
"dependencies": {
"@onlyoffice/document-editor-vue": "^1.4.0",
"ace-builds": "^1.24.2",
"axios": "^1.13.5",
"axios": "^1.7.9",
"clipboard": "^2.0.4",
"css-vars-ponyfill": "^2.4.3",
"dompurify": "^3.2.4",
@@ -41,9 +41,6 @@
"vue-lazyload": "^3.0.0",
"vue-router": "^4.3.0"
},
"overrides": {
"minimatch": "^10.2.1"
},
"devDependencies": {
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@intlify/unplugin-vue-i18n": "^4.0.0",
-1
View File
@@ -119,7 +119,6 @@ frontend:
favicon: "" # path to a favicon to use for the frontend
description: "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server" # description that shows up in html head meta description
loginIcon: "" # path to an image file for the login page icon
oidcLoginButtonText: "" # text to display on the OIDC login button
userDefaults:
editorQuickSave: false # show quick save button in editor
hideSidebarFileActions: false # hide the file actions in the sidebar
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 B

File renamed without changes.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Light background circle that matches light mode -->
<circle cx="16" cy="16" r="16" fill="#f5f5f5"/>
<!-- Back folder (orange, barely higher and left) -->
<path d="M5 8h6l2 2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2z"
fill="#FF9800"/>
<!-- Front folder (blue, almost same position) -->
<path d="M6 9h6l2 2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V11a2 2 0 0 1 2-2z"
fill="#2196F3"/>
</svg>

After

Width:  |  Height:  |  Size: 527 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Back folder (orange, barely higher and left) -->
<path d="M5 8h6l2 2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2z"
fill="#FF9800"/>
<!-- Front folder (blue, almost same position) -->
<path d="M6 9h6l2 2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V11a2 2 0 0 1 2-2z"
fill="#2196F3"/>
</svg>

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

@@ -0,0 +1,24 @@
{
"name": "FileBrowser Quantum",
"short_name": "FileBrowser Quantum",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-256x256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#2096f3",
"background_color": "#ffffff",
"display": "standalone"
}
-96
View File
@@ -1,96 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<symbol id="plyr-airplay" viewBox="0 0 18 18">
<path
d="M16 1H2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3v-2H3V3h12v8h-2v2h3a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1"
/>
<path d="M4 17h10l-5-6z" />
</symbol>
<symbol id="plyr-captions-off" viewBox="0 0 18 18">
<path
fill-opacity=".5"
fill-rule="evenodd"
d="M1 1c-.6 0-1 .4-1 1v11c0 .6.4 1 1 1h4.6l2.7 2.7c.2.2.4.3.7.3s.5-.1.7-.3l2.7-2.7H17c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1zm4.52 10.15c1.99 0 3.01-1.32 3.28-2.41l-1.29-.39c-.19.66-.78 1.45-1.99 1.45-1.14 0-2.2-.83-2.2-2.34 0-1.61 1.12-2.37 2.18-2.37 1.23 0 1.78.75 1.95 1.43l1.3-.41C8.47 4.96 7.46 3.76 5.5 3.76c-1.9 0-3.61 1.44-3.61 3.7s1.65 3.69 3.63 3.69m7.57 0c1.99 0 3.01-1.32 3.28-2.41l-1.29-.39c-.19.66-.78 1.45-1.99 1.45-1.14 0-2.2-.83-2.2-2.34 0-1.61 1.12-2.37 2.18-2.37 1.23 0 1.78.75 1.95 1.43l1.3-.41c-.28-1.15-1.29-2.35-3.25-2.35-1.9 0-3.61 1.44-3.61 3.7s1.65 3.69 3.63 3.69"
/>
</symbol>
<symbol id="plyr-captions-on" viewBox="0 0 18 18">
<path
fill-rule="evenodd"
d="M1 1c-.6 0-1 .4-1 1v11c0 .6.4 1 1 1h4.6l2.7 2.7c.2.2.4.3.7.3s.5-.1.7-.3l2.7-2.7H17c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1zm4.52 10.15c1.99 0 3.01-1.32 3.28-2.41l-1.29-.39c-.19.66-.78 1.45-1.99 1.45-1.14 0-2.2-.83-2.2-2.34 0-1.61 1.12-2.37 2.18-2.37 1.23 0 1.78.75 1.95 1.43l1.3-.41C8.47 4.96 7.46 3.76 5.5 3.76c-1.9 0-3.61 1.44-3.61 3.7s1.65 3.69 3.63 3.69m7.57 0c1.99 0 3.01-1.32 3.28-2.41l-1.29-.39c-.19.66-.78 1.45-1.99 1.45-1.14 0-2.2-.83-2.2-2.34 0-1.61 1.12-2.37 2.18-2.37 1.23 0 1.78.75 1.95 1.43l1.3-.41c-.28-1.15-1.29-2.35-3.25-2.35-1.9 0-3.61 1.44-3.61 3.7s1.65 3.69 3.63 3.69"
/>
</symbol>
<symbol id="plyr-download" viewBox="0 0 18 18">
<path
d="M9 13c.3 0 .5-.1.7-.3L15.4 7 14 5.6l-4 4V1H8v8.6l-4-4L2.6 7l5.7 5.7c.2.2.4.3.7.3m-7 2h14v2H2z"
/>
</symbol>
<symbol id="plyr-enter-fullscreen" viewBox="0 0 18 18">
<path
d="M10 3h3.6l-4 4L11 8.4l4-4V8h2V1h-7zM7 9.6l-4 4V10H1v7h7v-2H4.4l4-4z"
/>
</symbol>
<symbol id="plyr-exit-fullscreen" viewBox="0 0 18 18">
<path
d="M1 12h3.6l-4 4L2 17.4l4-4V17h2v-7H1zM16 .6l-4 4V1h-2v7h7V6h-3.6l4-4z"
/>
</symbol>
<symbol id="plyr-fast-forward" viewBox="0 0 18 18">
<path d="M7.875 7.171 0 1v16l7.875-6.171V17L18 9 7.875 1z" />
</symbol>
<symbol id="plyr-logo-vimeo" viewBox="0 0 18 18">
<path
d="M17 5.3c-.1 1.6-1.2 3.7-3.3 6.4-2.2 2.8-4 4.2-5.5 4.2-.9 0-1.7-.9-2.4-2.6C5 10.9 4.4 6 3 6c-.1 0-.5.3-1.2.8l-.8-1c.8-.7 3.5-3.4 4.7-3.5S7.7 3 8 4.8c.3 2 .8 6.1 1.8 6.1.9 0 2.5-3.4 2.6-4 .1-.9-.3-1.9-2.3-1.1.8-2.6 2.3-3.8 4.5-3.8q2.55.15 2.4 3.3"
/>
</symbol>
<symbol id="plyr-logo-youtube" viewBox="0 0 18 18">
<path
d="M16.8 5.8c-.2-1.3-.8-2.2-2.2-2.4C12.4 3 9 3 9 3s-3.4 0-5.6.4C2 3.6 1.3 4.5 1.2 5.8 1 7.1 1 9 1 9s0 1.9.2 3.2.8 2.2 2.2 2.4C5.6 15 9 15 9 15s3.4 0 5.6-.4c1.4-.3 2-1.1 2.2-2.4S17 9 17 9s0-1.9-.2-3.2M7 12V6l5 3z"
/>
</symbol>
<symbol id="plyr-muted" viewBox="0 0 18 18">
<path
d="m12.4 12.5 2.1-2.1 2.1 2.1 1.4-1.4L15.9 9 18 6.9l-1.4-1.4-2.1 2.1-2.1-2.1L11 6.9 13.1 9 11 11.1zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601z"
/>
</symbol>
<symbol id="plyr-pause" viewBox="0 0 18 18">
<path
d="M6 1H3c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1m6 0c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1z"
/>
</symbol>
<symbol id="plyr-pip" viewBox="0 0 18 18">
<path d="M13.293 3.293 7.022 9.564l1.414 1.414 6.271-6.271L17 7V1h-6z" />
<path
d="M13 15H3V5h5V3H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-6h-2z"
/>
</symbol>
<symbol id="plyr-play" viewBox="0 0 18 18">
<path
d="M15.562 8.1 3.87.225c-.818-.562-1.87 0-1.87.9v15.75c0 .9 1.052 1.462 1.87.9L15.563 9.9c.584-.45.584-1.35 0-1.8"
/>
</symbol>
<symbol id="plyr-restart" viewBox="0 0 18 18">
<path
d="m9.7 1.2.7 6.4 2.1-2.1c1.9 1.9 1.9 5.1 0 7-.9 1-2.2 1.5-3.5 1.5s-2.6-.5-3.5-1.5c-1.9-1.9-1.9-5.1 0-7 .6-.6 1.4-1.1 2.3-1.3l-.6-1.9C6 2.6 4.9 3.2 4 4.1 1.3 6.8 1.3 11.2 4 14c1.3 1.3 3.1 2 4.9 2 1.9 0 3.6-.7 4.9-2 2.7-2.7 2.7-7.1 0-9.9L16 1.9z"
/>
</symbol>
<symbol id="plyr-rewind" viewBox="0 0 18 18">
<path d="M10.125 1 0 9l10.125 8v-6.171L18 17V1l-7.875 6.171z" />
</symbol>
<symbol id="plyr-settings" viewBox="0 0 18 18">
<path
d="M16.135 7.784a2 2 0 0 1-1.23-2.969c.322-.536.225-.998-.094-1.316l-.31-.31c-.318-.318-.78-.415-1.316-.094a2 2 0 0 1-2.969-1.23C10.065 1.258 9.669 1 9.219 1h-.438c-.45 0-.845.258-.997.865a2 2 0 0 1-2.969 1.23c-.536-.322-.999-.225-1.317.093l-.31.31c-.318.318-.415.781-.093 1.317a2 2 0 0 1-1.23 2.969C1.26 7.935 1 8.33 1 8.781v.438c0 .45.258.845.865.997a2 2 0 0 1 1.23 2.969c-.322.536-.225.998.094 1.316l.31.31c.319.319.782.415 1.316.094a2 2 0 0 1 2.969 1.23c.151.607.547.865.997.865h.438c.45 0 .845-.258.997-.865a2 2 0 0 1 2.969-1.23c.535.321.997.225 1.316-.094l.31-.31c.318-.318.415-.781.094-1.316a2 2 0 0 1 1.23-2.969c.607-.151.865-.547.865-.997v-.438c0-.451-.26-.846-.865-.997M9 12a3 3 0 1 1 0-6 3 3 0 0 1 0 6"
/>
</symbol>
<symbol id="plyr-volume" viewBox="0 0 18 18">
<path
d="M15.6 3.3c-.4-.4-1-.4-1.4 0s-.4 1 0 1.4C15.4 5.9 16 7.4 16 9s-.6 3.1-1.8 4.3c-.4.4-.4 1 0 1.4.2.2.5.3.7.3.3 0 .5-.1.7-.3C17.1 13.2 18 11.2 18 9s-.9-4.2-2.4-5.7"
/>
<path
d="M11.282 5.282a.91.91 0 0 0 0 1.316c.735.735.995 1.458.995 2.402 0 .936-.425 1.917-.995 2.487a.91.91 0 0 0 0 1.316c.145.145.636.262 1.018.156a.7.7 0 0 0 .298-.156C13.773 11.733 14.13 10.16 14.13 9q.001-.255-.011-.51c-.053-.992-.319-2.005-1.522-3.208a.91.91 0 0 0-1.316 0m-7.495.726H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601z"
/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 5.9 KiB

+33 -1
View File
@@ -17,7 +17,7 @@
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-symbols-outlined.woff2" as="font" type="font/woff2" crossorigin>
<!-- Add to home screen for Android and modern mobile browsers -->
<link rel="manifest" href="{{ .htmlVars.manifestURL }}" crossorigin="use-credentials">
<link rel="manifest" id="manifestPlaceholder" crossorigin="use-credentials">
<meta name="theme-color" content="{{ .htmlVars.color }}">
<!-- Add to home screen for Safari on iOS/iPadOS -->
@@ -52,6 +52,38 @@
<!-- Injected variables come from backend server -->
<script>
window.globalVars = {{ .globalVars }};
var dynamicManifest = {
"name": window.globalVars.name || 'FileBrowser Quantum',
"short_name": window.globalVars.name || 'FileBrowser',
"icons": [
{
"src": window.location.origin + "{{ .htmlVars.staticURL }}/img/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": window.location.origin + "{{ .htmlVars.staticURL }}/img/icons/android-chrome-256x256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": window.location.origin + "{{ .htmlVars.staticURL }}/img/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
}
],
"start_url": window.location.origin + "{{ .htmlVars.baseURL }}",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "{{ .htmlVars.color }}",
"description": "{{ .htmlVars.description }}"
}
const stringManifest = JSON.stringify(dynamicManifest);
const blob = new Blob([stringManifest], { type: 'application/json' });
const manifestURL = URL.createObjectURL(blob);
document.querySelector('#manifestPlaceholder').setAttribute('href', manifestURL);
</script>
<style>
File renamed without changes.

Before

Width:  |  Height:  |  Size: 949 B

After

Width:  |  Height:  |  Size: 949 B

+55 -67
View File
@@ -1,5 +1,5 @@
import { fetchURL, adjustedData } from './utils'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { getApiPath, doubleEncode, getPublicApiPath } from '@/utils/url.js'
import { state, mutations } from '@/store'
import { notify } from '@/notify'
import { globalVars } from '@/utils/constants'
@@ -12,8 +12,8 @@ export async function fetchFiles(source, path, content = false, metadata = false
}
try {
const apiPath = getApiPath('api/resources', {
path: path,
source: source,
path: doubleEncode(path),
source: doubleEncode(source),
...(content && { content: 'true' }),
...(metadata && { metadata: 'true' })
})
@@ -32,6 +32,8 @@ async function resourceAction(source, path, method, content) {
throw new Error('no source provided')
}
try {
source = doubleEncode(source)
path = doubleEncode(path)
const apiPath = getApiPath('api/resources', { path, source })
let opts = { method }
if (content) {
@@ -74,12 +76,15 @@ export async function bulkDelete(items) {
},
body: JSON.stringify(items),
})
const data = await response.json()
// 200 = all succeeded, 207 = partial success (some succeeded, some failed)
// Both are valid responses that should be returned, not thrown as errors
if (response.status === 200 || response.status === 207) {
return data
}
// For other error status codes, throw an error
const error = new Error(data.message || response.statusText)
error.status = response.status
@@ -105,12 +110,12 @@ export async function download(format, files, shareHash = "") {
// Check if chunked download should be used (single file only)
const downloadChunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
const sizeThreshold = downloadChunkSizeMb * 1024 * 1024;
const useChunkedDownload =
downloadChunkSizeMb > 0 &&
files.length === 1 &&
!files[0].isDir &&
files[0].size &&
const useChunkedDownload =
downloadChunkSizeMb > 0 &&
files.length === 1 &&
!files[0].isDir &&
files[0].size &&
files[0].size >= sizeThreshold
if (useChunkedDownload) {
@@ -122,14 +127,14 @@ export async function download(format, files, shareHash = "") {
if (format !== 'zip') {
format = 'tar.gz'
}
// For non-share downloads, validate single source and build file list
let source = null
let filePaths = []
if (shareHash) {
// For shares, no source parameter needed, just paths
filePaths = files.map(file => file.path)
filePaths = files.map(file => encodeURIComponent(file.path))
} else {
// Validate all files are from the same source
for (let file of files) {
@@ -141,19 +146,19 @@ export async function download(format, files, shareHash = "") {
} else if (source !== file.source) {
throw new Error('All files must be from the same source for downloads')
}
filePaths.push(file.path)
filePaths.push(encodeURIComponent(file.path))
}
}
const params = {
file: filePaths, // Array of file paths - getApiPath will create repeated parameters
algo: format,
...(shareHash && { hash: shareHash }),
...(!shareHash && source && { source: source }),
...(state.shareInfo.token && { token: state.shareInfo.token }),
...(!shareHash && source && { source: encodeURIComponent(source) }),
...(state.share.token && { token: state.share.token }),
sessionId: state.sessionId
}
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'public/api/raw', params)
const url = window.origin + apiPath
@@ -167,7 +172,7 @@ export async function download(format, files, shareHash = "") {
link.style.display = 'none'
document.body.appendChild(link)
link.click()
// Clean up after a short delay
setTimeout(() => {
document.body.removeChild(link)
@@ -176,36 +181,36 @@ export async function download(format, files, shareHash = "") {
async function downloadChunked(file, shareHash = "") {
const chunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
if (chunkSizeMb === 0) {
throw new Error("Chunked download is disabled (chunk size is 0)")
}
const chunkSize = chunkSizeMb * 1024 * 1024 // Convert MB to bytes
const fileSize = file.size
// Extract filename from path if name is not available
const fileName = file.name || (file.path ? file.path.split('/').pop() : 'download')
// Add to download manager
const downloadId = downloadManager.add(file, shareHash)
downloadManager.setStatus(downloadId, "downloading")
// Show download prompt if not already shown (it should already be shown by downloadFiles, but check to be safe)
const hasDownloadPrompt = state.hovers && state.hovers.some(h => h.name === 'download');
if (!hasDownloadPrompt) {
mutations.showHover({ name: 'download' })
}
const params = {
file: file.path,
file: encodeURIComponent(file.path),
...(shareHash && { hash: shareHash }),
...(!shareHash && file.source && { source: file.source }),
...(state.shareInfo.token && { token: state.shareInfo.token }),
...(!shareHash && file.source && { source: encodeURIComponent(file.source) }),
...(state.share.token && { token: state.share.token }),
sessionId: state.sessionId
}
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'public/api/raw', params)
const baseUrl = window.origin + apiPath
@@ -220,7 +225,7 @@ async function downloadChunked(file, shareHash = "") {
let loaded = 0
while (offset < fileSize) {
const download = downloadManager.findById(downloadId);
if (download && download.status === "cancelled") {
// Silently handle cancellation - don't throw error
@@ -244,7 +249,7 @@ async function downloadChunked(file, shareHash = "") {
// Track progress within the chunk using ReadableStream
const expectedChunkSize = end - offset + 1;
const reader = response.body.getReader();
const chunkParts = [];
let chunkLoaded = 0;
@@ -259,14 +264,14 @@ async function downloadChunked(file, shareHash = "") {
reading = false;
break;
}
chunkParts.push(value);
chunkLoaded += value.length;
// Calculate progress: only count up to expected chunk size to avoid over-counting
const chunkProgress = Math.min(chunkLoaded, expectedChunkSize);
const totalLoaded = offset + chunkProgress;
// Update progress in real-time, but throttle updates for performance
if (chunkLoaded - lastProgressUpdate >= progressUpdateInterval || chunkLoaded >= expectedChunkSize) {
downloadManager.updateProgress(downloadId, totalLoaded, fileSize);
@@ -290,19 +295,19 @@ async function downloadChunked(file, shareHash = "") {
chunk.set(part, position);
position += part.length;
}
// Only use the expected chunk size portion if server returned more (handles Range header issues)
const chunkToUse = chunk.byteLength > expectedChunkSize
? chunk.slice(0, expectedChunkSize).buffer
const chunkToUse = chunk.byteLength > expectedChunkSize
? chunk.slice(0, expectedChunkSize).buffer
: chunk.buffer;
chunks.push(chunkToUse)
// Always use expected chunk size for progress to avoid double-counting
loaded += expectedChunkSize
// Final progress update for this chunk
downloadManager.updateProgress(downloadId, loaded, fileSize)
offset = end + 1
}
@@ -355,8 +360,8 @@ export function post(
}
try {
const apiPath = getApiPath("api/resources", {
path: path,
source: source,
path: doubleEncode(path),
source: doubleEncode(source),
override: overwrite,
...(isDir && { isDir: 'true' })
});
@@ -448,7 +453,7 @@ export async function moveCopy(
'Content-Type': 'application/json',
'sessionId': state.sessionId
}
const response = await fetch(apiPath, {
method: 'PATCH',
headers: headers,
@@ -491,8 +496,8 @@ export async function checksum(source, path, algo) {
}
try {
const params = {
path: path,
source: source,
path: doubleEncode(path),
source: doubleEncode(source),
checksum: algo
}
const apiPath = getApiPath('api/resources', params)
@@ -511,8 +516,8 @@ export function getDownloadURL(source, path, inline, useExternal) {
}
try {
const params = {
source: source,
file: path,
source: encodeURIComponent(source),
file: encodeURIComponent(path),
...(inline && { inline: 'true' })
}
const apiPath = getApiPath('api/raw', params)
@@ -532,9 +537,9 @@ export function getPreviewURL(source, path, modified) {
}
try {
const params = {
path: path,
path: encodeURIComponent(path),
key: Date.parse(modified), // Use modified date as cache key
source: source,
source: encodeURIComponent(source),
inline: 'true'
}
const apiPath = getApiPath('api/preview', params)
@@ -563,9 +568,9 @@ export async function sources() {
export async function GetOfficeConfig(req) {
const params = {
path: req.path,
...(req.hash && { hash: req.hash }),
...(req.source && { source: req.source })
path: encodeURIComponent(req.path),
...(req.hash && { hash: encodeURIComponent(req.hash) }),
...(req.source && { source: encodeURIComponent(req.source) })
}
let apiPath = getApiPath('api/onlyoffice/config', params)
if (req.hash) {
@@ -573,21 +578,4 @@ export async function GetOfficeConfig(req) {
}
const res = await fetchURL(apiPath)
return await res.json()
}
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
try {
const apiPath = getApiPath('api/media/subtitles', {
source: source,
path: path,
name: subtitleName,
embedded: embedded.toString()
})
const res = await fetchURL(apiPath)
const content = await res.text()
return content
} catch (err) {
notify.showError(err.message || `Error fetching subtitle ${subtitleName}`)
throw err
}
}
+22 -20
View File
@@ -1,6 +1,6 @@
import { adjustedData } from "./utils";
import { notify } from "@/notify";
import { getPublicApiPath } from "@/utils/url.js";
import { getPublicApiPath, encodedPath, doubleEncode } from "@/utils/url.js";
import { state } from "@/store";
// ============================================================================
@@ -17,17 +17,18 @@ import { state } from "@/store";
* @returns {Promise<any>}
*/
export async function fetchPub(path, hash, password = "", content = false, metadata = false) {
path = encodedPath(path);
const params = {
path: path,
hash,
...(content && { content: 'true' }),
...(metadata && { metadata: 'true' }),
...(state.shareInfo.token && { token: state.shareInfo.token })
...(state.share.token && { token: state.share.token })
}
const apiPath = getPublicApiPath("resources", params);
const response = await fetch(apiPath, {
headers: {
"X-SHARE-PASSWORD": password || "",
"X-SHARE-PASSWORD": password ? encodeURIComponent(password) : "",
},
});
@@ -60,8 +61,10 @@ export async function fetchPub(path, hash, password = "", content = false, metad
export function getDownloadURL(share, files, inline=false) {
// Handle array of files for repeated 'file' parameters
const fileArray = Array.isArray(files) ? files : [files]
const filePaths = fileArray.map(file => encodeURIComponent(file))
const params = {
file: fileArray, // Array of file paths - getPublicApiPath will create repeated parameters
file: filePaths, // Array of file paths - getPublicApiPath will create repeated parameters
hash: share.hash,
token: share.token,
...(inline && { inline: 'true' })
@@ -73,18 +76,16 @@ export function getDownloadURL(share, files, inline=false) {
// Generate a preview URL for public shares
/**
* @param {string} path
* @param {string} size - The size parameter (small, large, original). Omit for default (small).
* @returns {string}
*/
export function getPreviewURL(path, size) {
export function getPreviewURL(path,size="small") {
try {
const params = {
path: path,
hash: state.shareInfo.hash,
path: encodeURIComponent(path),
size: size,
hash: state.share.hash,
inline: 'true',
// Only add size parameter if specified and not the default
...(size && size !== 'small' && { size: size }),
...(state.shareInfo.token && { token: state.shareInfo.token })
...(state.share.token && { token: state.share.token })
}
const apiPath = getPublicApiPath('preview', params)
return window.origin + apiPath
@@ -112,7 +113,7 @@ export function post(
}
try {
const apiPath = getPublicApiPath("resources", {
path: path,
targetPath: doubleEncode(path),
hash: hash,
override: overwrite,
...(isDir && { isDir: 'true' })
@@ -153,13 +154,13 @@ export function post(
// If parsing fails, use responseText or default message
errorMessage = request.responseText || errorMessage;
}
const error = new Error(errorMessage);
error.status = request.status;
// Show notification for upload errors
notify.showError(errorMessage);
reject(error);
}
};
@@ -169,7 +170,7 @@ export function post(
notify.showError("Network error during upload");
reject(error);
};
request.onabort = () => {
const error = new Error("Upload aborted");
notify.showError("Upload was aborted");
@@ -204,6 +205,7 @@ async function resourceAction(hash, path, method, content, token = "") {
if (sharePassword) {
headers["X-SHARE-PASSWORD"] = sharePassword;
}
path = doubleEncode(path)
const apiPath = getPublicApiPath('resources', { path, hash: hash, token: token })
const response = await fetch(apiPath, {
method,
@@ -236,15 +238,15 @@ export async function bulkDelete(items) {
if (!items || !Array.isArray(items) || items.length === 0) {
throw new Error('items array is required and must not be empty')
}
const hash = state.shareInfo?.hash;
if (!hash) {
throw new Error('share hash is required')
}
const params = {
hash: hash,
...(state.shareInfo.token && { token: state.shareInfo.token }),
...(state.share.token && { token: state.share.token }),
sessionId: state.sessionId
}
const apiPath = getPublicApiPath("resources/bulk/delete", params)
@@ -324,7 +326,7 @@ export async function moveCopy(
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(state.shareInfo.token && { 'X-Auth-Token': state.shareInfo.token })
...(state.share.token && { 'X-Auth-Token': state.share.token })
},
body: JSON.stringify(requestBody),
})
+6 -4
View File
@@ -4,12 +4,14 @@ import { getApiPath } from "@/utils/url.js";
export default async function search(base, sources, query, largest = false) {
try {
query = encodeURIComponent(query);
// Ensure sources is an array
const sourcesArray = Array.isArray(sources) ? sources : [sources];
const params = {
query: query,
sources: sourcesArray.join(",")
sources: encodeURIComponent(sourcesArray.join(","))
};
// Only include scope if searching a single source
@@ -18,7 +20,7 @@ export default async function search(base, sources, query, largest = false) {
if (!base.endsWith("/")) {
base += "/";
}
params.scope = base;
params.scope = encodeURIComponent(base);
}
if (largest) {
@@ -42,8 +44,8 @@ export async function findDuplicates(base, source, minSizeMb, useChecksum = fals
base += "/";
}
const params = {
scope: base,
source: source,
scope: encodeURIComponent(base),
source: encodeURIComponent(source),
minSizeMb: minSizeMb.toString()
};
+1 -1
View File
@@ -26,7 +26,7 @@ export async function list() {
*/
export async function get(path, source) {
try {
const params = { path, source };
const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
const apiPath = getApiPath("api/share", params);
let data = await fetchJSON(apiPath);
return adjustedData(data);
+21 -84
View File
@@ -17,7 +17,7 @@
<div class="context-menu-header">
<div
class="action button clickable"
v-if="showCreateButton"
v-if="!isSearchActive && permissions.create && !isShare"
@click="toggleShowCreate"
>
<i v-if="!showCreate" class="material-icons">add</i>
@@ -32,77 +32,71 @@
<span>{{ selectedCount }}</span>
</div>
</div>
<hr v-if="!isDuplicateFinder" class="divider">
<hr class="divider">
<action
v-if="showCreateActions"
v-if="showCreate && !isSearchActive"
icon="create_new_folder"
:label="$t('files.newFolder')"
@action="showHover('newDir')"
/>
<action
v-if="showCreateActions"
v-if="showCreate && !isSearchActive"
icon="note_add"
:label="$t('files.newFile')"
@action="showHover('newFile')"
/>
<action
v-if="showCreateActions"
v-if="showCreate && !isSearchActive"
icon="file_upload"
:label="$t('general.upload')"
@action="uploadFunc"
/>
<action
v-if="showInfo"
v-if="!showCreate && selectedCount == 1"
icon="info"
:label="$t('general.info')"
@action="showInfoHover"
show="info"
/>
<action
v-if="showDownload"
v-if="(!showCreate && permissions.download && selectedCount > 0)"
icon="file_download"
:label="$t('general.download')"
@action="startDownload"
:counter="selectedCount"
/>
<action
v-if="showShareAction"
v-if="(showCreate || selectedCount == 1) && showShare"
icon="share"
:label="$t('general.share')"
@action="showShareHover"
/>
<action
v-if="showRename"
v-if="!showCreate && selectedCount == 1 && permissions.modify && !isSearchActive"
icon="mode_edit"
:label="$t('general.rename')"
@action="showRenameHover"
/>
<action
v-if="showCopy"
v-if="!showCreate && selectedCount > 0 && permissions.modify"
icon="content_copy"
:label="$t('buttons.copyFile')"
show="copy"
/>
<action
v-if="showOpenParentFolder"
v-if="!showCreate && selectedCount == 1 && isSearchActive"
icon="folder"
:label="$t('buttons.openParentFolder')"
@action="openParentFolder"
/>
<action
v-if="showGoToItem"
icon="folder"
:label="$t('buttons.goToItem')"
@action="goToItem"
/>
<action
v-if="showMove"
v-if="!showCreate && selectedCount > 0 && permissions.modify"
icon="forward"
:label="$t('buttons.moveFile')"
show="move"
/>
<action
v-if="showSelectAll"
v-if="!showCreate && !isSearchActive && req?.items?.length > 0"
icon="select_all"
:label="$t('buttons.selectAll')"
@action="selectAllItems"
@@ -144,7 +138,7 @@
class="button no-select fb-shadow"
:class="{ 'dark-mode': isDarkMode }"
>
<action icon="info" :label="$t('general.info')" @action="showInfoHover"/>
<action icon="info" :label="$t('general.info')" show="info" />
<action v-if="showGoToRaw" icon="open_in_new" :label="$t('general.openFile')" @action="goToRaw()" />
<action v-if="shouldShowParentFolder()" icon="folder" :label="$t('buttons.openParentFolder')" @action="openParentFolder" />
<action v-if="isPreview && permissions.modify" icon="mode_edit" :label="$t('general.rename')" @action="showRenameHoverForPreview" />
@@ -187,12 +181,6 @@ export default {
},
},
computed: {
showGoToItem() {
return this.isDuplicateFinder && this.selectedCount == 1;
},
isDuplicateFinder() {
return getters.currentView() === "duplicateFinder";
},
permissions() {
return getters.permissions();
},
@@ -202,47 +190,7 @@ export default {
isShare() {
return getters.isShare();
},
showCreateActions() {
if (this.isDuplicateFinder) return false;
return this.showCreate && !this.isSearchActive;
},
showInfo() {
if (this.isDuplicateFinder) return this.selectedCount == 1;
return !this.showCreate && this.selectedCount == 1;
},
showDownload() {
if (this.isDuplicateFinder) return false;
return !this.showCreate && this.permissions.download && this.selectedCount > 0;
},
showShareAction() {
if (this.isDuplicateFinder) return false;
return (this.showCreate || this.selectedCount == 1) && this.showShare;
},
showRename() {
if (this.isDuplicateFinder) return false;
return !this.showCreate && this.selectedCount == 1 && this.permissions.modify && !this.isSearchActive;
},
showCopy() {
if (this.isDuplicateFinder) return false;
return !this.showCreate && this.selectedCount > 0 && this.permissions.modify;
},
showOpenParentFolder() {
return !this.showCreate && this.selectedCount == 1 && (this.isSearchActive || this.isDuplicateFinder);
},
showMove() {
if (this.isDuplicateFinder) return false;
return !this.showCreate && this.selectedCount > 0 && this.permissions.modify;
},
showSelectAll() {
if (this.isDuplicateFinder) return false;
return !this.showCreate && !this.isSearchActive && this.req?.items?.length > 0;
},
showCreateButton() {
if (this.isDuplicateFinder) return false;
return !this.isSearchActive && this.permissions.create && !this.isShare;
},
showSelectMultiple() {
if (this.isDuplicateFinder) return false;
if (this.isMultiple || this.isSearchActive) {
return false;
}
@@ -269,7 +217,6 @@ export default {
return cv == "markdownViewer" && this.permissions.modify;
},
showDelete() {
if (this.isDuplicateFinder) return false;
if (this.selectedCount == 0) {
return false;
}
@@ -299,7 +246,6 @@ export default {
return getters.currentPromptName() == "OverflowMenu";
},
showAccess() {
if (this.isDuplicateFinder) return false;
if (getters.isShare()) {
return false;
}
@@ -370,18 +316,6 @@ export default {
}
},
methods: {
showInfoHover() {
mutations.showHover({
name: "info",
props: {
item: getters.getFirstSelected(),
},
});
},
goToItem() {
const item = getters.getFirstSelected();
url.goToItem(item.source, item.path, {}, true);
},
hideTooltip() {
mutations.hideTooltip();
},
@@ -591,9 +525,12 @@ export default {
});
},
openParentFolder() {
const item = getters.getFirstSelected();
const parentPath = url.removeLastDir(item.path) || "/";
url.goToItem(item.source, parentPath, {}, this.isDuplicateFinder);
const item = state.selected.length > 0 ? state.selected[0] : state.req;
let parentPath = url.removeLastDir(item.path);
if (parentPath == "") {
parentPath = "/";
}
url.goToItem(state.req.source, parentPath, {});
},
selectAllItems() {
if (state.req && state.req.items && state.req.items.length > 0) {
+1 -1
View File
@@ -207,7 +207,7 @@ export default {
barColor = '#ff9800';
}
let percentage = this.pct >= 1 && this.pct < 7 ? 6 : this.pct;
let percentage = this.pct > 1 && this.pct < 7 ? 6 : this.pct;
if (percentage < 1) {
percentage = 0;
}
+1 -1
View File
@@ -929,7 +929,7 @@ body.rtl #search .boxes h3 {
overflow: hidden;
}
@media (max-width: 768px) {
@media (max-width: 800px) {
#search {
min-width: unset;
max-width: 60%;
+5 -11
View File
@@ -4,7 +4,6 @@
v-show="!isHidden && showShelf"
id="shelf"
:class="{ 'add-padding': addPadding }"
:style="moveWithSidebar"
>
<breadcrumbs v-if="showBreadcrumbs" :base="isShare ? `/share/${shareHash}` : undefined" />
<listing-header v-if="showListingHeader" :hasDuration="hasDuration" />
@@ -78,14 +77,6 @@ export default {
// Show duplicate finder actions when on that route and there are selected items
return this.duplicateFinderSelectedCount > 0;
},
moveWithSidebar() {
if (getters.isStickySidebar() && getters.isSidebarVisible()) {
return {
left: state.sidebar.width + 'em',
};
}
return {};
},
},
mounted() {
this.$nextTick(() => {
@@ -204,7 +195,7 @@ export default {
z-index: 1000;
right: 0;
left: 0;
transition: 0.2s ease;
transition: 0.3s ease;
box-sizing: border-box;
height: auto;
min-height: 0;
@@ -213,7 +204,7 @@ export default {
.shelf-slide-enter-active,
.shelf-slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease !important;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.shelf-slide-enter-from,
@@ -236,4 +227,7 @@ export default {
}
}
#main.moveWithSidebar #shelf {
padding-left: 20.5em;
}
</style>
+8 -14
View File
@@ -1,5 +1,5 @@
<template>
<div id="status-bar" :style="moveWithSidebar" :class="{ 'dark-mode-header': isDarkMode, 'active': showStatusBar }" @contextmenu.prevent.stop @touchstart.stop @touchend.stop>
<div id="status-bar" :class="{ 'dark-mode-header': isDarkMode, 'active': showStatusBar }" @contextmenu.prevent.stop @touchstart.stop @touchend.stop>
<div class="status-content" @contextmenu.prevent.stop @touchstart.stop @touchend.stop>
<div class="status-info">
<span v-if="selectedCount > 0">
@@ -42,9 +42,6 @@ export default {
},
computed: {
showStatusBar() {
if (getters.isShare() && state.shareInfo.shareType === "upload") {
return false;
}
return getters.currentView() === "listingView";
},
isDarkMode() {
@@ -111,14 +108,6 @@ export default {
directoryInfoText() {
return `${this.numDirs} ${this.foldersLabel} | ${this.numFiles} ${this.filesLabel} (${this.displayTotalSize})`;
},
moveWithSidebar() {
if (getters.isStickySidebar() && getters.isSidebarVisible()) {
return {
left: state.sidebar.width + 'em',
};
}
return {};
},
},
methods: {
updateGallerySize(event) {
@@ -178,7 +167,7 @@ export default {
overflow: hidden;
margin: 0;
padding: 0;
transition: bottom 0.5s ease, left 0.2s ease, width 0.2s ease;
transition: bottom 0.5s ease, margin-left 0.5s ease;
pointer-events: none;
}
@@ -187,6 +176,10 @@ export default {
pointer-events: auto;
}
#status-bar.moveWithSidebar {
margin-left: 20em;
}
.status-content {
display: flex;
align-items: center;
@@ -245,7 +238,7 @@ input[type="range"] {
}
/* Mobile styles */
@media (max-width: 768px) {
@media (max-width: 800px) {
#status-bar {
height: 3em;
bottom: -3em;
@@ -268,6 +261,7 @@ input[type="range"] {
input[type="range"] {
width: 7em;
height: 1.5em; /* For better touch */
}
.status-info {
@@ -190,6 +190,14 @@ export default {
const currentPath = state.req.path;
const source = state.req.source;
// debug log paths when dropping for testing
console.log("Drop target:", {
link,
targetPath,
currentPath,
items: this.items
});
// Normalize paths for comparison
const normalizePath = (path) => {
if (!path || path === "/") return "/";
@@ -201,7 +209,7 @@ export default {
if (normalizedTarget === normalizedCurrent) {
notify.showErrorToast(this.$t("files.sameFolder"));
console.error("Cannot move to same folder");
console.log("Cannot move to same folder");
return;
}
+109 -220
View File
@@ -1,62 +1,35 @@
<template>
<div class="image-ex-container" ref="container" @touchstart="touchStart" @touchmove="touchMove" @touchend="touchEnd" @dblclick="zoomAuto"
@mousedown="mousedownStart" @mousemove="mouseMove" @mouseup="mouseUp" @wheel="wheelMove">
<!-- Thumbnail placeholder (shown while full image loads, only if cached thumbnail exists) -->
<img
v-if="cachedThumbnailUrl && !fullImageLoaded && !isTiff"
:src="cachedThumbnailUrl"
class="image-ex-img"
ref="thumbnail"
/>
<!-- Loading spinner overlay (shown while full image loads) -->
<div v-if="!fullImageLoaded" class="image-loading-overlay">
<LoadingSpinner size="medium" />
</div>
<div v-if="!isLoaded">{{ $t('general.loading', { suffix: "..." }) }}</div>
<!-- Full image:
- Always loading in background via JavaScript
- Hidden until loaded if thumbnail exists, otherwise visible for progressive loading -->
<img
v-if="!isTiff"
class="image-ex-img"
ref="imgex"
@load="onLoad"
@error="onImageError"
:style="{ display: (cachedThumbnailUrl && !fullImageLoaded) ? 'none' : 'block' }"
/>
<canvas
v-else
ref="imgex"
class="image-ex-img"
:style="{ display: (cachedThumbnailUrl && !fullImageLoaded) ? 'none' : 'block' }"
></canvas>
<img v-if="!isTiff && isLoaded" :src="src" class="image-ex-img" ref="imgex" @load="onLoad" />
<canvas v-else-if="isLoaded" ref="imgex" class="image-ex-img"></canvas>
</div>
</template>
<script>
import { state, mutations, getters } from "@/store";
import { state, mutations } from "@/store";
import throttle from "@/utils/throttle";
import { notify } from "@/notify";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import { getBestCachedImage } from "@/utils/imageCache";
import { globalVars } from "@/utils/constants";
export default {
components: {
LoadingSpinner,
},
props: {
src: {
type: String,
default: null,
src: String,
moveDisabledTime: {
type: Number,
default: () => 200,
},
classList: {
type: Array,
default: () => [],
},
zoomStep: {
type: Number,
default: () => 0.25,
},
},
data() {
return {
moveDisabledTime: 200,
classList: [],
zoomStep: 0.25,
scale: 1,
lastX: null,
lastY: null,
@@ -66,15 +39,13 @@ export default {
moveDisabled: false,
disabledTimer: null,
imageLoaded: false,
fullImageLoaded: false,
loadTimeout: null,
position: {
center: { x: 0, y: 0 },
relative: { x: 0, y: 0 },
},
maxScale: 4,
minScale: 1,
isTiff: false,
minScale: 1, // Minimum scale is 1 (full frame view)
isTiff: false, // Determine if the image is a TIFF
// Swipe navigation properties
swipeStartTime: null,
swipeStartX: 0,
@@ -83,55 +54,19 @@ export default {
swipeCurrentY: 0,
isSwipeGesture: false,
hasStartedSwipe: false,
gestureDecided: false,
swipeMinDistance: 150,
swipeMaxTime: 500,
swipeMaxVerticalDistance: 50,
gestureDecided: false, // Track if we've made a decision about this gesture
swipeMinDistance: 150, // Minimum horizontal distance for swipe
swipeMaxTime: 500, // Maximum time for swipe in milliseconds
swipeMaxVerticalDistance: 50, // Maximum vertical movement to still be considered horizontal swipe
};
},
computed: {
source() {
return state.req?.source;
},
path() {
return state.req?.path;
},
isLoaded() {
return !("preview-img" in (state?.loading || {}));
},
cachedThumbnailUrl() {
if (!state?.req) {
return null;
}
if (!this.path || !state.req.hasPreview) {
return null;
}
// Don't use thumbnail for HEIC files that need conversion
const showFullSizeHeic = state.req?.type === "image/heic" && !state.isSafari && globalVars.mediaAvailable && !globalVars.disableHeicConversion;
if (showFullSizeHeic) {
return null;
}
// Get cached thumbnail URL (prefers large, falls back to small)
// For shares, use shareInfo.hash as the source; otherwise use this.source
const source = getters.isShare() ? state.shareInfo?.hash : this.source;
return getBestCachedImage(source, this.path, state.req?.modified);
},
},
mounted() {
this.isTiff = this.checkIfTiff(this.src);
// Step 1: Cache check happens automatically via thumbnailUrl computed property
// Step 2: Always start loading the real image
if (this.isTiff) {
this.decodeTiff(this.src);
} else {
// Use nextTick to ensure element exists
this.$nextTick(() => {
this.loadFullImage();
});
this.$refs.imgex.src = this.src;
}
let container = this.$refs.container;
this.classList.forEach((className) => container.classList.add(className));
if (getComputedStyle(container).width === "0px") {
@@ -144,61 +79,39 @@ export default {
window.addEventListener("resize", this.onResize);
},
beforeUnmount() {
// Clear any pending timeout
if (this.loadTimeout) {
clearTimeout(this.loadTimeout);
this.loadTimeout = null;
}
window.removeEventListener("resize", this.onResize);
document.removeEventListener("mouseup", this.onMouseUp);
},
computed: {
isLoaded() {
return !("preview-img" in state.loading);
},
},
watch: {
src: function () {
if (!this.src || !this.$refs.imgex) {
mutations.setLoading("preview-img", false);
return;
}
this.isTiff = this.checkIfTiff(this.src);
if (this.isTiff) {
this.decodeTiff(this.src);
} else {
this.$refs.imgex.src = this.src;
}
this.scale = 1; // Reset zoom level
this.position.relative = { x: 0, y: 0 }; // Reset position
this.showSpinner = true; // Show spinner while loading
this.resetSwipeTracking(); // Reset swipe tracking for new image
},
},
methods: {
loadFullImage() {
if (!this.src) return;
mutations.setLoading("preview-img", true);
// Set src directly via JavaScript to avoid Vue's HTML entity encoding in template bindings
// Vue HTML-encodes & to &amp; when using :src="src" in templates
this.$nextTick(() => {
if (this.$refs.imgex && 'src' in this.$refs.imgex) {
// Decode any HTML entities (Vue shouldn't encode props, but decode just in case)
const cleanSrc = String(this.src).replace(/&amp;/g, '&');
this.$refs.imgex.src = cleanSrc;
}
});
},
onLoad() {
// Step 3: Real image loaded - hide thumbnail and show image
this.imageLoaded = true;
this.fullImageLoaded = true;
// Clear the timeout if image loaded successfully
if (this.loadTimeout) {
clearTimeout(this.loadTimeout);
this.loadTimeout = null;
}
this.setCenter();
this.setCenter(); // Center the image after loading
this.showSpinner = false;
mutations.setLoading("preview-img", false);
},
onImageError(event) {
const img = event.target;
const actualSrc = img?.src || '';
// If the error is due to &amp; in URL, try to fix it
if (actualSrc && actualSrc.includes('&amp;')) {
const fixedSrc = actualSrc.replace(/&amp;/g, '&');
if (img) {
img.src = fixedSrc;
return; // Let it retry with fixed URL
}
}
this.imageLoaded = true;
this.fullImageLoaded = true;
mutations.setLoading("preview-img", false);
if (img) {
img.style.display = 'block';
}
},
checkIfTiff(src) {
const sufs = ["tif", "tiff", "dng", "cr2", "nef"];
const suff = src.split(".").pop().toLowerCase();
@@ -210,11 +123,13 @@ export default {
if (!response.ok) {
throw new Error("Network response was not ok");
}
const blob = await response.blob();
const blob = await response.blob(); // Convert response to a blob
const imgex = this.$refs.imgex;
if (imgex) {
// Create a URL for the blob and set it as the image source
imgex.src = URL.createObjectURL(blob);
imgex.onload = () => URL.revokeObjectURL(imgex.src);
imgex.onload = () => URL.revokeObjectURL(imgex.src); // Clean up URL object after loading
}
} catch (error) {
notify.showError("Error decoding TIFF");
@@ -232,20 +147,16 @@ export default {
setCenter() {
const container = this.$refs.container;
const img = this.$refs.imgex;
if (!container || !img || !img.clientWidth || !img.clientHeight) {
return;
return; // Exit if dimensions are unavailable
}
// Images are centered using CSS (top: 50%, left: 50%, transform: translate(-50%, -50%))
// Reset pan position when centering
this.position.relative = { x: 0, y: 0 };
this.position.center.x = Math.floor((container.clientWidth - img.clientWidth) / 2);
this.position.center.y = Math.floor((container.clientHeight - img.clientHeight) / 2);
// Update transform to reflect centered state
if (this.scale === 1) {
img.style.transform = 'translate(-50%, -50%) scale(1)';
} else {
img.style.transform = `translate(calc(-50% + ${this.position.relative.x}px), calc(-50% + ${this.position.relative.y}px)) scale(${this.scale})`;
}
img.style.left = `${this.position.center.x}px`;
img.style.top = `${this.position.center.y}px`;
},
mousedownStart(event) {
this.lastX = null;
@@ -266,6 +177,8 @@ export default {
this.lastX = null;
this.lastY = null;
this.lastTouchDistance = null;
// Initialize swipe tracking for single touch only when at full frame view (scale === 1)
if (event.targetTouches.length === 1 && this.scale === 1) {
const touch = event.targetTouches[0];
this.swipeStartTime = Date.now();
@@ -275,10 +188,13 @@ export default {
this.swipeCurrentY = touch.pageY;
this.isSwipeGesture = false;
this.hasStartedSwipe = false;
this.gestureDecided = false;
this.gestureDecided = false; // Reset decision for new touch
} else {
// Reset swipe tracking for multi-touch (zoom gestures) or when zoomed in
this.resetSwipeTracking();
}
if (event.targetTouches.length < 2) {
setTimeout(() => {
this.touches = 0;
@@ -289,6 +205,9 @@ export default {
event.preventDefault();
}
}
// Only prevent default if zoomed in (need to pan) or multi-touch (need to zoom)
// This allows nav-zone touches to work when at full frame view
if (this.scale > 1 || event.targetTouches.length >= 2) {
event.preventDefault();
}
@@ -311,31 +230,45 @@ export default {
event.preventDefault();
},
touchMove(event) {
// Update current swipe position for single touch, only when at full frame view
if (event.targetTouches.length === 1 && this.scale === 1) {
const touch = event.targetTouches[0];
this.swipeCurrentX = touch.pageX;
this.swipeCurrentY = touch.pageY;
// Only make gesture decision once per touch sequence
if (!this.gestureDecided) {
const deltaX = Math.abs(this.swipeCurrentX - this.swipeStartX);
const deltaY = Math.abs(this.swipeCurrentY - this.swipeStartY);
// Only decide after some meaningful movement
if (deltaX > 10 || deltaY > 10) {
this.gestureDecided = true;
if (deltaX > deltaY * 2) {
this.gestureDecided = true; // Mark that we've made a decision
if (deltaX > deltaY * 2) {
// Horizontal movement is significantly more than vertical - it's a swipe
this.isSwipeGesture = true;
event.preventDefault();
event.preventDefault(); // Prevent scrolling during swipe
} else {
// Not horizontal enough - it's a pan gesture
this.isSwipeGesture = false;
}
}
}
// If we've decided it's a swipe gesture, prevent default and don't do normal panning
if (this.gestureDecided && this.isSwipeGesture) {
event.preventDefault();
return;
return; // Block normal pan behavior for swipes
}
}
// Only prevent default if we're zoomed in or doing multi-touch zoom
if (this.scale > 1 || event.targetTouches.length >= 2) {
event.preventDefault();
}
// Normal touch move logic for pan/zoom (only runs if not a swipe gesture)
if (this.lastX === null) {
this.lastX = event.targetTouches[0].pageX;
this.lastY = event.targetTouches[0].pageY;
@@ -349,6 +282,7 @@ export default {
() => (this.moveDisabled = false),
this.moveDisabledTime
);
let p1 = event.targetTouches[0];
let p2 = event.targetTouches[1];
let touchDistance = Math.sqrt(
@@ -362,6 +296,7 @@ export default {
this.lastTouchDistance = touchDistance;
this.setZoom();
} else if (event.targetTouches.length === 1 && this.scale > 1) {
// Only allow panning when zoomed in
if (this.moveDisabled) return;
let x = event.targetTouches[0].pageX - this.lastX;
let y = event.targetTouches[0].pageY - this.lastY;
@@ -374,11 +309,8 @@ export default {
doMove(x, y) {
this.position.relative.x += x;
this.position.relative.y += y;
const img = this.$refs.imgex;
if (img) {
// Combine centering (-50%) with pan offset and scale
img.style.transform = `translate(calc(-50% + ${this.position.relative.x}px), calc(-50% + ${this.position.relative.y}px)) scale(${this.scale})`;
}
// Update the transform with separate translate and scale values
this.$refs.imgex.style.transform = `translate(${this.position.relative.x}px, ${this.position.relative.y}px) scale(${this.scale})`;
},
wheelMove(event) {
event.preventDefault()
@@ -387,26 +319,30 @@ export default {
},
setZoom() {
this.scale = Math.max(this.minScale, Math.min(this.maxScale, this.scale));
// If scale is back to 1 (full frame view), reset position to center
if (this.scale === 1) {
this.position.relative = { x: 0, y: 0 };
}
const img = this.$refs.imgex;
if (img) {
// Combine centering (-50%) with pan offset and scale
img.style.transform = `translate(calc(-50% + ${this.position.relative.x}px), calc(-50% + ${this.position.relative.y}px)) scale(${this.scale})`;
}
// Update the transform with both translate and scale values
this.$refs.imgex.style.transform = `translate(${this.position.relative.x}px, ${this.position.relative.y}px) scale(${this.scale})`;
},
pxStringToNumber(style) {
return +style.replace("px", "");
},
touchEnd(event) {
let handledSwipe = false;
// Only process swipe if it was a single touch, we detected a swipe gesture, and at full frame view
if (this.isSwipeGesture && this.swipeStartTime && this.scale === 1) {
const swipeEndTime = Date.now();
const swipeDuration = swipeEndTime - this.swipeStartTime;
const deltaX = this.swipeCurrentX - this.swipeStartX;
const deltaY = Math.abs(this.swipeCurrentY - this.swipeStartY);
const absDelataX = Math.abs(deltaX);
// Check if swipe meets criteria: fast, horizontal, and long enough
if (
swipeDuration <= this.swipeMaxTime &&
absDelataX >= this.swipeMinDistance &&
@@ -421,9 +357,13 @@ export default {
event.preventDefault();
}
}
// Only prevent default if we handled a swipe or were zoomed in (panning)
if (!handledSwipe && this.scale > 1) {
event.preventDefault();
}
// Reset swipe tracking
this.resetSwipeTracking();
},
resetSwipeTracking() {
@@ -434,49 +374,7 @@ export default {
this.swipeCurrentY = 0;
this.isSwipeGesture = false;
this.hasStartedSwipe = false;
this.gestureDecided = false;
},
},
watch: {
src: function (newSrc) {
if (!newSrc) {
mutations.setLoading("preview-img", false);
return;
}
// Clear any existing timeout
if (this.loadTimeout) {
clearTimeout(this.loadTimeout);
this.loadTimeout = null;
}
// Reset and reload when src changes
this.fullImageLoaded = false;
this.imageLoaded = false;
this.isTiff = this.checkIfTiff(newSrc);
// Cache check happens automatically via thumbnailUrl computed property
// Always load the real image
if (this.isTiff) {
this.decodeTiff(newSrc);
} else {
this.$nextTick(() => {
this.loadFullImage();
// Set a timeout to handle cases where image never loads
this.loadTimeout = setTimeout(() => {
if (!this.fullImageLoaded && !this.imageLoaded) {
// Show the image even if load event didn't fire (might be partially loaded)
this.fullImageLoaded = true;
mutations.setLoading("preview-img", false);
}
}, 30000); // 30 second timeout
});
}
this.scale = 1;
this.position.relative = { x: 0, y: 0 };
this.resetSwipeTracking();
this.gestureDecided = false; // Reset decision state
},
},
};
@@ -485,31 +383,22 @@ export default {
<style>
.image-ex-container {
max-width: 100%;
/* Image container max width */
max-height: 100%;
/* Image container max height */
overflow: hidden;
/* Hide overflow if image exceeds container */
position: relative;
/* Required for absolute positioning of child */
display: flex;
justify-content: center;
}
.image-ex-img {
max-width: 100%;
/* Image max width */
max-height: 100%;
/* Image max height */
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
object-fit: contain;
}
.image-loading-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
}
</style>
+5 -59
View File
@@ -33,11 +33,10 @@
import { globalVars } from "@/utils/constants";
import { getTypeInfo } from "@/utils/mimetype";
import { mutations, state, getters } from "@/store";
import { setImageLoaded } from "@/utils/imageCache";
// NEW: Define placeholder and error image URLs for easy configuration
const PLACEHOLDER_URL = globalVars.baseURL + "public/static/img/placeholder.png"; // A generic loading placeholder
const ERROR_URL = globalVars.baseURL + "public/static/img/placeholder.png";
const PLACEHOLDER_URL = globalVars.baseURL + "static/img/placeholder.png"; // A generic loading placeholder
const ERROR_URL = globalVars.baseURL + "static/img/placeholder.png";
export default {
name: "Icon",
@@ -61,18 +60,6 @@ export default {
type: String,
default: "",
},
source: {
type: String,
default: null,
},
path: {
type: String,
default: null,
},
modified: {
type: String,
default: null,
},
clickable: {
type: Boolean,
default: false,
@@ -201,15 +188,6 @@ export default {
// Prevent race conditions: only update if this is the image we still want.
if (this.imageTargetSrc === url) {
this.imageState = "loaded";
// Mark this image as loaded in our cache tracker
if (this.path) {
// For shares, use shareInfo.hash as the source; otherwise use this.source or state.req.source
const source = getters.isShare() ? state.shareInfo?.hash : (this.source || state.req.source);
const size = this.showLargeIcon ? 'large' : 'small';
// Use file's modified date if available, otherwise fall back to state.req.modified
const modified = this.modified || state.req.modified;
setImageLoaded(source, this.path, size, modified, url);
}
}
};
@@ -223,19 +201,9 @@ export default {
targetImage.src = url;
},
handleMouseEnter() {
// Always use large thumbnails for hover/popup preview
const imageUrl = this.thumbnailUrl + "&size=large";
if (this.imageState == "loaded") {
mutations.setPreviewSource(imageUrl);
// Store source/path/url/modified in state so PopupPreview can track it when image actually loads
if (this.path) {
// For shares, use shareInfo.hash as the source; otherwise use this.source or state.req.source
const source = getters.isShare() ? state.shareInfo?.hash : (this.source || state.req.source);
// Use file's modified date if available, otherwise fall back to state.req.modified
const modified = this.modified || state.req.modified;
state.popupPreviewSourceInfo = { source, path: this.path, size: 'large', url: imageUrl, modified };
}
}
if (!getters.previewPerms().motionVideoPreview || !this.hasMotion) {
return;
@@ -250,7 +218,7 @@ export default {
let index = 0;
const updateThumbnail = () => {
if (!state.popupPreviewSourceInfo || state.popupPreviewSourceInfo.path !== this.path) {
if (state.popupPreviewSource === "") {
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
return;
@@ -265,17 +233,6 @@ export default {
// Preload the next image
const nextIndex = (index + 1) % sequence.length;
const preloadImg = new Image();
preloadImg.onload = () => {
// Track that this image was loaded (motion preview frames use the same path)
if (this.path) {
// For shares, use shareInfo.hash as the source; otherwise use this.source or state.req.source
const source = getters.isShare() ? state.shareInfo?.hash : (this.source || state.req.source);
const frameUrl = sequence[nextIndex];
// Use file's modified date if available, otherwise fall back to state.req.modified
const modified = this.modified || state.req.modified;
setImageLoaded(source, this.path, 'large', modified, frameUrl);
}
};
preloadImg.src = sequence[nextIndex];
// Schedule next update
@@ -290,8 +247,6 @@ export default {
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
mutations.setPreviewSource("");
// Clear popup preview source info when mouse leaves
state.popupPreviewSourceInfo = null;
// UPDATED: Reset to the base thumbnail URL. The watcher will handle reloading it.
this.updateImageTargetSrc();
},
@@ -300,13 +255,9 @@ export default {
},
updateImageTargetSrc() {
let newSrc = this.thumbnailUrl || PLACEHOLDER_URL;
// If we need large thumbnails and have a thumbnail URL, append &size=large
// Otherwise use the URL as-is (defaults to small)
if (this.thumbnailUrl && this.showLargeIcon) {
newSrc = this.thumbnailUrl + "&size=large";
if (this.showLargeIcon) {
newSrc = (this.thumbnailUrl ? this.thumbnailUrl + "&size=large" : "") || PLACEHOLDER_URL;
}
if (this.imageTargetSrc !== newSrc) {
this.imageTargetSrc = newSrc;
}
@@ -361,11 +312,6 @@ export default {
this.svgPath = result.svgPath || "";
this.updateImageTargetSrc();
},
beforeUnmount() {
// Clean up any pending animation timeouts
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
},
};
</script>
+47 -70
View File
@@ -38,9 +38,6 @@
:thumbnailUrl="isThumbnailInView ? thumbnailUrl : ''"
:filename="name"
:hasPreview="hasPreview"
:modified="modified"
:path="path"
:source="source"
/>
</div>
@@ -49,14 +46,14 @@
<p
class="size"
:class="{ adjustment: quickDownloadEnabled }"
:data-order="humanSize"
:data-order="humanSize()"
>
{{ humanSize }}
{{ humanSize() }}
</p>
<p class="modified">
<time :datetime="modified">{{ formattedTime }}</time>
<time :datetime="modified">{{ getTime() }}</time>
</p>
<p v-if="hasDuration" class="duration">{{ formattedDuration }}</p>
<p v-if="hasDuration" class="duration">{{ getDuration() }}</p>
</div>
<Icon
@click.stop="downloadFile"
@@ -104,9 +101,6 @@
:thumbnailUrl="isThumbnailInView ? thumbnailUrl : ''"
:filename="name"
:hasPreview="hasPreview"
:modified="modified"
:path="path"
:source="source"
/>
</div>
@@ -115,14 +109,14 @@
<p
class="size"
:class="{ adjustment: quickDownloadEnabled }"
:data-order="humanSize"
:data-order="humanSize()"
>
{{ humanSize }}
{{ humanSize() }}
</p>
<p class="modified">
<time :datetime="modified">{{ formattedTime }}</time>
<time :datetime="modified">{{ getTime() }}</time>
</p>
<p v-if="hasDuration" class="duration">{{ formattedDuration }}</p>
<p v-if="hasDuration" class="duration">{{ getDuration() }}</p>
</div>
</div>
</template>
@@ -147,7 +141,7 @@ export default {
data() {
return {
isThumbnailInView: false,
isInView: false,
isInView: false, // Track if item is in viewport
touches: 0,
touchStartX: 0,
touchStartY: 0,
@@ -155,8 +149,8 @@ export default {
isSwipe: false,
isDraggedOver: false,
contextTimeout: null,
observer: null,
localSelected: false,
observer: null, // Store observer reference
localSelected: false, // Track local selection when updateGlobalState is false
};
},
props: {
@@ -274,7 +268,7 @@ export default {
let previewPath;
if (this.path) {
previewPath = this.path;
} else if (state?.req?.path && this.name) {
} else if (state.req.path && this.name) {
previewPath = url.joinPath(state.req.path, this.name);
} else {
return "";
@@ -283,7 +277,7 @@ export default {
// If forceFilesApi is true, always use authenticated files API
if (this.forceFilesApi) {
// @ts-ignore
return filesApi.getPreviewURL(this.source || state?.req?.source, previewPath, this.modified);
return filesApi.getPreviewURL(this.source || state.req.source, previewPath, this.modified);
}
if (getters.isShare()) {
@@ -298,50 +292,25 @@ export default {
isClickable() {
return this.clickable;
},
// Computed properties for display values - Vue caches these automatically!
humanSize() {
return this.type == "invalid_link"
? "invalid link"
: getHumanReadableFilesize(this.size);
},
formattedTime() {
return getters.getTime(this.modified);
},
formattedDuration() {
if (!this.metadata || !this.metadata.duration) {
return "";
}
const seconds = this.metadata.duration;
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
},
},
mounted() {
// Set up IntersectionObserver for lazy-loading thumbnails
// Set up IntersectionObserver with larger margins for smoother rendering
this.observer = new IntersectionObserver(this.handleIntersect, {
root: null,
rootMargin: "500px", // Reduced from 1500px for better performance
rootMargin: "1500px", // Start rendering 1500px before entering viewport for smooth scrolling
threshold: 0,
});
// Use $nextTick to ensure $el is available and is an Element
this.observer.observe(this.$el);
this.$nextTick(() => {
if (this.$el && this.$el instanceof Element) {
this.observer.observe(this.$el);
const rect = this.$el.getBoundingClientRect();
const isInViewport = rect.top < window.innerHeight + 500 && rect.bottom > -500;
if (isInViewport && this.hasPreview) {
this.isThumbnailInView = true;
this.isInView = true;
}
const rect = this.$el.getBoundingClientRect();
const isInViewport = rect.top < window.innerHeight + 1500 && rect.bottom > -1500;
if (isInViewport && this.hasPreview) {
this.isThumbnailInView = true;
this.isInView = true;
}
});
// Note: dragend listener moved to parent ListingView for better performance
document.addEventListener('dragend', this.dragEnd);
},
beforeUnmount() {
// Clean up observer
@@ -349,7 +318,7 @@ export default {
this.observer.disconnect();
this.observer = null;
}
// Note: dragend listener removed - handled by parent ListingView
document.removeEventListener('dragend', this.dragEnd);
},
methods: {
/** @param {MouseEvent} event */
@@ -419,22 +388,8 @@ export default {
mutations.addSelected(this.index);
}
} else {
// Build full item object similar to Search.vue
const selectedItem = {
name: this.name,
isDir: this.isDir,
source: this.source,
type: this.type,
size: this.size,
modified: this.modified,
path: this.path,
url: this.path,
index: this.index,
};
mutations.resetSelected();
// @ts-ignore
mutations.addSelected(selectedItem);
// Emit selection event for local handling
this.$emit('select', { index: this.index, selected: true });
}
mutations.showHover({
name: "ContextMenu",
@@ -457,6 +412,28 @@ export default {
}
});
},
humanSize() {
return this.type == "invalid_link"
? "invalid link"
: getHumanReadableFilesize(this.size);
},
getTime() {
// @ts-ignore
return getters.getTime(this.modified);
},
getDuration() {
if (!this.metadata || !this.metadata.duration) {
return "";
}
const seconds = this.metadata.duration;
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
},
/** @param {DragEvent} event */
dragLeave(event) {
// Only reset visual state for internal drags
+6 -14
View File
@@ -1,12 +1,11 @@
<template>
<div class="popup-preview" v-show="sourceInfo" ref="popup" :style="popupStyle">
<img v-if="sourceInfo" :src="sourceInfo.url" alt="Popup image" @load="onImageLoad" />
<div class="popup-preview" v-show="source" ref="popup" :style="popupStyle">
<img :src="source" alt="Popup image" />
</div>
</template>
<script>
import { state, getters } from "@/store";
import { setImageLoaded } from "@/utils/imageCache";
export default {
name: "PopupPreview",
@@ -21,7 +20,7 @@ export default {
};
},
watch: {
sourceInfo(newVal) {
source(newVal) {
if (newVal) {
this.$nextTick(() => {
this.positionPopup();
@@ -30,8 +29,8 @@ export default {
},
},
computed: {
sourceInfo() {
return state.popupPreviewSourceInfo;
source() {
return state.popupPreviewSource;
},
},
mounted() {
@@ -41,20 +40,13 @@ export default {
window.removeEventListener("mousemove", this.updateCursorPosition);
},
methods: {
onImageLoad() {
// Track that this image was loaded
if (this.sourceInfo) {
const { source, path, size, url, modified } = this.sourceInfo;
setImageLoaded(source, path, size, modified, url);
}
},
updateCursorPosition(event) {
this.cursorX = event.clientX;
this.cursorY = event.clientY;
if (!state.isMobile) this.positionPopup();
},
positionPopup() {
if (!this.sourceInfo) return;
if (!this.source) return;
const popup = this.$refs.popup;
if (!popup) return;
+1 -14
View File
@@ -1,5 +1,5 @@
<template>
<div class="scroll-wrapper" :class="{ 'halloween-theme': eventTheme === 'halloween' }" :style="halloweenBackgroundStyle" ref="wrapper">
<div class="scroll-wrapper" ref="wrapper">
<slot />
<div
class="custom-scrollbar"
@@ -35,7 +35,6 @@
<script>
import { state, mutations, getters } from "@/store";
import { globalVars } from "@/utils/constants";
const offsetFromBottomListing = 110;
const offsetFromBottomFull = 75;
@@ -64,18 +63,6 @@ export default {
isFolder() {
return this.category() === "folders";
},
eventTheme() {
return getters.eventTheme();
},
halloweenBackgroundStyle() {
if (this.eventTheme === 'halloween') {
return {
backgroundImage: `url("${globalVars.baseURL}static/img/halloween-pattern.svg")`,
backgroundRepeat: 'repeat'
};
}
return {};
},
},
methods: {
handleResize() {
+1 -1
View File
@@ -62,7 +62,7 @@ export default {
if (!state.shareInfo?.isPasswordProtected) {
return true
}
return state.shareInfo.passwordValid
return state.share.passwordValid
},
shareInfo() {
return state.shareInfo;
+39 -64
View File
@@ -3,7 +3,7 @@
<div
v-if="enabled && hasPrevious"
class="nav-zone nav-zone-left"
:style="leftZoneStyle"
:class="{ moveWithSidebar: moveWithSidebar }"
@touchstart="(e) => { handleTouchStart(e); toggleNavigation(e); }"
@touchmove="handleTouchMove"
></div>
@@ -28,15 +28,15 @@
@mouseleave="setHoverNav(false)"
class="nav-button nav-previous"
:class="{
moveWithSidebar: moveWithSidebar,
hidden: !showNav,
disabled: !hasPrevious,
dragging: dragState.type === 'previous',
active: dragState.atFullExtent && dragState.type === 'previous',
'dark-mode': isDarkMode,
'media-mode': isMediaQueueMode,
'sidebar-resizing': isSidebarResizing,
}"
:style="previousButtonStyle"
:style="dragState.type === 'previous' ? { transform: `translateY(-50%) translate(${dragState.deltaX}px, 0)` } : {}"
:aria-label="$t('general.previous')"
:title="$t('general.previous')"
>
@@ -57,7 +57,7 @@
@mouseleave="setHoverNav(false)"
class="nav-button nav-next"
:class="{ hidden: !showNav, dragging: dragState.type === 'next', active: dragState.atFullExtent && dragState.type === 'next','dark-mode': isDarkMode, 'media-mode': isMediaQueueMode}"
:style="nextButtonStyle"
:style="dragState.type === 'next' ? { transform: `translateY(-50%) translate(${dragState.deltaX}px, 0)` } : {}"
:aria-label="$t('general.next')"
:title="$t('general.next')"
>
@@ -108,7 +108,6 @@ export default {
tapTimeout: null,
triggered: false
},
isSidebarResizing: false, // Track if sidebar is being resized
};
},
computed: {
@@ -116,9 +115,6 @@ export default {
moveWithSidebar() {
return getters.isSidebarVisible() && getters.isStickySidebar();
},
sidebarWidth() {
return state.sidebar?.width || 20;
},
enabled() {
return state.navigation.enabled && getters.currentPrompt() == null;
},
@@ -161,45 +157,6 @@ export default {
// Use media queue when in media view, NOT in single/loop-single mode, and have a queue
return isMediaView && mode !== 'single' && mode !== 'loop-single' && hasQueue;
},
leftZoneStyle() {
const styles = {
pointerEvents: 'none',
zIndex: '-1',
background: 'transparent',
position: 'fixed',
top: '25%',
bottom: '25%',
width: '5em',
};
if (this.moveWithSidebar) {
styles.left = `${this.sidebarWidth}em`;
} else {
styles.left = '0';
}
return styles;
},
previousButtonStyle() {
const styles = {};
if (this.dragState.type === 'previous') {
styles.transform = `translateY(-50%) translate(${this.dragState.deltaX}px, 0)`;
}
// Calculate left position based on sidebar
if (this.moveWithSidebar) {
styles.left = `calc(${this.sidebarWidth}em + 1em)`; // When sidebar is sticky the position of the button will have a tiny padding
} else {
styles.left = '1em';
}
return styles;
},
nextButtonStyle() {
const styles = {};
if (this.dragState.type === 'next') {
styles.transform = `translateY(-50%) translate(${this.dragState.deltaX}px, 0)`;
}
// Next button doesn't need account for the sidebar
styles.right = '1em';
return styles;
}
},
watch: {
@@ -250,10 +207,6 @@ export default {
'state.navigation.nextLink'() {
this.showInitialNavigation();
},
'state.sidebar.isResizing'(newVal) {
// Track when sidebar is being resized to disable transitions
this.isSidebarResizing = newVal;
},
},
mounted() {
window.addEventListener("keydown", this.keyEvent);
@@ -383,7 +336,7 @@ export default {
try {
let res;
if (getters.isShare()) {
res = await publicApi.fetchPub(directoryPath, state.shareInfo.hash);
res = await publicApi.fetchPub(directoryPath, state.share.hash);
} else {
res = await filesApi.fetchFiles(state.req.source, directoryPath);
}
@@ -642,16 +595,14 @@ export default {
}
},
isClickInLeftZone(event) {
const emSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
const zoneWidth = 3 * emSize; // 3em in pixels
const sidebarOffset = this.moveWithSidebar ? (this.sidebarWidth * emSize) : 0;
const zoneWidth = 3 * parseFloat(getComputedStyle(document.documentElement).fontSize); // 3em in pixels
const sidebarOffset = this.moveWithSidebar ? 20 * parseFloat(getComputedStyle(document.documentElement).fontSize) : 0; // 20em in pixels
return event.clientX >= sidebarOffset && event.clientX <= (sidebarOffset + zoneWidth);
},
isClickInRightZone(event) {
const viewportWidth = window.innerWidth;
const emSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
const zoneWidth = 3 * emSize; // 3em in pixels
const zoneWidth = 3 * parseFloat(getComputedStyle(document.documentElement).fontSize); // 3em in pixels
return event.clientX >= (viewportWidth - zoneWidth) && event.clientX <= viewportWidth;
},
@@ -993,7 +944,7 @@ export default {
const zoneWidth = 5 * emSize; // 5em in pixels
// Check left zone
const sidebarOffset = this.moveWithSidebar ? (this.sidebarWidth * emSize) : 0; // Account for sidebar
const sidebarOffset = this.moveWithSidebar ? 20 * emSize : 0; // Account for sidebar
if (this.hasPrevious && event.clientX >= sidebarOffset && event.clientX <= (sidebarOffset + zoneWidth)) {
const viewportHeight = window.innerHeight;
const zoneTop = viewportHeight * 0.25; // 25% from top
@@ -1031,6 +982,11 @@ export default {
background: transparent; /* Invisible zones for mouse/touch detection */
}
.nav-zone-left {
left: 0;
}
.nav-zone-right {
right: 0;
}
@@ -1048,7 +1004,7 @@ export default {
background: var(--background);
color: var(--textPrimary);
cursor: pointer;
transition: opacity 0.4s ease, transform 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease, left 0.2s ease;
transition: opacity 0.4s ease, transform 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease;
pointer-events: auto;
z-index: 1001;
display: flex;
@@ -1081,9 +1037,13 @@ export default {
opacity: 1;
}
/* Disable transitions during sidebar resizing */
.nav-button.sidebar-resizing {
transition: opacity 0.4s ease, transform 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease;
.nav-previous {
left: 20px;
}
.nav-next {
right: 20px;
}
.nav-button.hidden {
@@ -1094,7 +1054,7 @@ export default {
}
/* Smooth show animation for better UX */
.nav-button:not(.hidden):not(.sidebar-resizing) {
.nav-button:not(.hidden) {
animation: nav-button-show 0.4s ease-out;
}
@@ -1112,7 +1072,7 @@ export default {
.nav-button.dragging {
z-index: 1002;
cursor: grabbing;
transition: none !important; /* Disable transitions during drag */
transition: none; /* Disable transitions during drag for immediate response */
}
.nav-button i.material-icons {
@@ -1133,6 +1093,14 @@ export default {
height: 44px;
}
.nav-previous {
left: 10px;
}
.nav-next {
right: 10px;
}
.nav-button i.material-icons {
font-size: 20px;
}
@@ -1145,8 +1113,15 @@ export default {
/* Ensure buttons don't interfere with scrollbars */
@media (max-width: 480px) {
.nav-previous {
left: 8px;
}
.nav-next {
right: 8px;
}
}
.moveWithSidebar {
margin-left: 20em;
}
</style>
+3 -6
View File
@@ -17,9 +17,9 @@
:name="getItemName(item.path)"
:isDir="item.type === 'directory'"
:source="item.source"
:type="item.type"
:size="item.size"
:modified="item.modified"
:type="item.type || 'file'"
:size="item.size || 0"
:modified="item.modified || ''"
:index="index"
:path="item.path"
:hasPreview="item.hasPreview"
@@ -107,7 +107,6 @@ export default {
size: item.size,
modified: item.modified,
previewUrl: previewUrl,
hasPreview: item.hasPreview,
});
} else if (!this.isListing) {
const item = state.req.items[state.selected[0]];
@@ -119,7 +118,6 @@ export default {
size: item.size,
modified: item.modified,
previewUrl: previewUrl,
hasPreview: item.hasPreview,
});
} else {
for (let index of state.selected) {
@@ -132,7 +130,6 @@ export default {
size: item.size,
modified: item.modified,
previewUrl: previewUrl,
hasPreview: item.hasPreview,
});
}
}
+12 -19
View File
@@ -38,7 +38,7 @@
:modified="item.originalItem?.modified || new Date().toISOString()"
:index="index"
:path="item.path"
:hasPreview="item.originalItem?.hasPreview && item.type !== 'directory' || false"
:hasPreview="item.originalItem?.hasPreview || false"
:metadata="item.originalItem?.metadata"
:hasDuration="item.originalItem?.hasDuration || false"
:updateGlobalState="false"
@@ -412,14 +412,13 @@ export default {
handleItemClick(item, _index, event) {
event.preventDefault();
event.stopPropagation();
// Display mode: single click navigates directly to the file
if (this.isDisplayMode) {
this.navigateToItem(item);
return;
}
// Browse mode: single click selects only, emit selected, do not navigate
// Create a synthetic event-like object for compatibility with existing methods
const syntheticEvent = {
currentTarget: {
dataset: {
@@ -429,17 +428,18 @@ export default {
preventDefault: () => {},
stopPropagation: () => {},
};
this.select(syntheticEvent);
if (state.user.singleClick) {
this.next(syntheticEvent);
} else {
this.select(syntheticEvent);
}
},
handleItemDblClick(item, _index, event) {
event.preventDefault();
event.stopPropagation();
// Double click: navigate (into folder or open file)
if (this.isDisplayMode) {
this.navigateToItem(item);
return;
}
// Create a synthetic event for double-click
const syntheticEvent = {
currentTarget: {
dataset: {
@@ -457,12 +457,9 @@ export default {
if (this.selected === path) {
this.selected = null;
this.selectedSource = null;
this.selectedType = null;
this.$emit("update:selected", {
path: this.current,
source: this.source,
type: 'directory',
isValid: !this.requireFileSelection,
source: this.source
});
return;
}
@@ -471,12 +468,9 @@ export default {
this.selected = path;
let clickedItem = this.items.find(item => item.path === path);
this.selectedSource = clickedItem ? clickedItem.source : this.source;
this.selectedType = clickedItem ? clickedItem.type : null;
this.$emit("update:selected", {
path: this.selected,
source: this.selectedSource,
type: this.selectedType,
isValid: this.selectedType === 'directory' ? !this.requireFileSelection : true,
source: this.selectedSource
});
},
createDir: async function () {
@@ -500,7 +494,6 @@ export default {
},
navigateToItem(item) {
mutations.closeHovers();
mutations.setNavigationTransitioning(true);
url.goToItem(item.source || state.req.source, item.path, undefined);
},
closePrompt() {
+147 -35
View File
@@ -4,6 +4,10 @@
</div>
<div class="card-content info-content">
<p v-if="selected.length > 1" class="info-description">
{{ $t("prompts.filesSelected", { count: selected.length }) }}
</p>
<div class="info-grid">
<!-- Basic Information Section -->
<div class="info-section">
@@ -20,15 +24,15 @@
<strong>{{ $t("prompts.typeName") }}</strong>
<span aria-label="info type">{{ type }}</span>
</div>
<div class="info-item" v-if="humanTime">
<div class="info-item" v-if="selected.length < 2 && humanTime">
<strong>{{ $t("prompts.lastModified") }}</strong>
<span aria-label="info last modified" :title="modTime">{{ humanTime }}</span>
</div>
<div class="info-item" v-if="source">
<div class="info-item" v-if="selected.length < 2 && source">
<strong>{{ $t("general.source") }}</strong>
<span aria-label="info source">{{ source }}</span>
</div>
<div class="info-item" v-if="filePath">
<div class="info-item" v-if="selected.length < 2 && filePath">
<strong>{{ $t("general.path") }}</strong>
<span aria-label="info path" class="break-word">{{ filePath }}</span>
</div>
@@ -41,6 +45,18 @@
<span aria-label="info has preview">{{ hasPreview ? "✓" : "✗" }}</span><!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
</div>
<!-- Directory Information Section -->
<div class="info-section" v-if="dir && selected.length === 0">
<h3 class="section-title">{{ $t("prompts.directoryInfo") }}</h3>
<div class="info-item">
<strong>{{ $t("prompts.numberFiles") }}</strong>
<span>{{ req.numFiles }}</span>
</div>
<div class="info-item">
<strong>{{ $t("prompts.numberDirs") }}</strong>
<span>{{ req.numDirs }}</span>
</div>
</div>
<!-- Additional Information Section -->
<div class="info-section" v-if="additionalInfo.length > 0">
@@ -97,17 +113,11 @@
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { formatTimestamp } from "@/utils/moment";
import { filesApi } from "@/api";
import { state, mutations } from "@/store";
import { state, getters, mutations } from "@/store";
import { notify } from "@/notify";
export default {
name: "info",
props: {
item: {
type: Object,
required: true,
},
},
data() {
return {
selectedHashAlgo: "md5",
@@ -119,52 +129,145 @@ export default {
closeHovers() {
return mutations.closeHovers;
},
req() {
return state.req;
},
selected() {
return state.selected;
},
selectedCount() {
return getters.selectedCount();
},
isListing() {
return getters.isListing();
},
humanSize() {
return getHumanReadableFilesize(this.item?.size || 0);
if (state.isSearchActive) {
return getHumanReadableFilesize(state.selected[0].size);
}
if (getters.selectedCount() === 0 || !this.isListing) {
return getHumanReadableFilesize(state.req.size);
}
let sum = 0;
for (let selected of this.selected) {
const item = typeof(selected) === 'number' ? state.req.items[selected] : selected;
sum += item.size;
}
return getHumanReadableFilesize(sum);
},
humanTime() {
if (!this.item?.modified) return "";
return formatTimestamp(this.item.modified, state.user.locale);
if (state.isSearchActive) {
return "";
}
const modifiedDate = getters.selectedCount() === 0
? state.req.modified
: getters.getFirstSelected()?.modified;
if (!modifiedDate) {
return "";
}
return formatTimestamp(modifiedDate, state.user.locale);
},
modTime() {
if (!this.item?.modified) return "";
return new Date(Date.parse(this.item.modified)).toLocaleString();
if (state.isSearchActive) {
return "";
}
const modifiedDate = getters.selectedCount() === 0
? state.req.modified
: getters.getFirstSelected()?.modified;
if (!modifiedDate) {
return "";
}
return new Date(Date.parse(modifiedDate)).toLocaleString();
},
name() {
return this.item?.name || "";
if (state.isSearchActive) {
return state.selected[0].name;
}
return getters.selectedCount() === 0
? state.req.name
: getters.getFirstSelected().name;
},
type() {
return this.item?.type || "";
if (state.isSearchActive) {
return state.selected[0].type;
}
return getters.selectedCount() === 0
? state.req.type
: getters.getFirstSelected().type;
},
displayName() {
return this.item?.name || "";
if (this.selected.length > 1) {
return this.$t("prompts.fileInfo");
}
return this.name;
},
dir() {
return this.item?.type === "directory";
if (state.isSearchActive) {
return state.selected[0].type === "directory";
}
return (
getters.selectedCount() > 1 ||
(getters.selectedCount() === 0
? state.req.type == "directory"
: getters.getFirstSelected().type == "directory")
);
},
source() {
return this.item?.source || "";
if (state.isSearchActive) {
return state.selected[0].source;
}
const currentSource = state.sources.current;
if (!currentSource || currentSource === "") {
return "";
}
return currentSource;
},
filePath() {
return this.item?.path || "";
if (state.isSearchActive) {
return state.selected[0].path;
}
if (getters.selectedCount() === 0) {
return state.route.path;
}
return getters.getFirstSelected()?.path || "";
},
hidden() {
return this.item?.hidden;
if (state.isSearchActive) {
return state.selected[0].hidden;
}
if (getters.selectedCount() === 0) {
return state.req.hidden;
}
return getters.getFirstSelected()?.hidden;
},
hasPreview() {
return this.item?.hasPreview;
if (state.isSearchActive) {
return state.selected[0].hasPreview;
}
if (getters.selectedCount() === 0) {
return state.req.hasPreview;
}
return getters.getFirstSelected()?.hasPreview;
},
additionalInfo() {
const info = [];
if (this.item?.token) {
info.push({ key: "token", label: this.$t("prompts.token"), value: this.item.token });
// Add more info fields here if needed
if (state.req.token) {
info.push({ key: "token", label: this.$t("prompts.token"), value: state.req.token });
}
if (this.item?.hash) {
info.push({ key: "hash", label: this.$t("general.hash"), value: this.item.hash });
if (state.req.hash) {
info.push({ key: "hash", label: this.$t("general.hash"), value: state.req.hash });
}
if (this.item?.onlyOfficeId) {
info.push({ key: "onlyOfficeId", label: this.$t("prompts.onlyOfficeId"), value: this.item.onlyOfficeId });
if (state.req.onlyOfficeId) {
info.push({ key: "onlyOfficeId", label: this.$t("prompts.onlyOfficeId"), value: state.req.onlyOfficeId });
}
return info;
@@ -172,21 +275,30 @@ export default {
},
methods: {
async generateHash() {
if (this.generatingHash || !this.item) return;
if (this.generatingHash) return;
this.hashResult = "";
this.generatingHash = true;
try {
const source = this.item.source;
const path = this.item.path;
let source, path;
if (state.isSearchActive) {
source = state.selected[0].source;
path = state.selected[0].path;
} else if (getters.selectedCount()) {
source = state.sources.current;
path = getters.getFirstSelected().path;
} else {
source = state.sources.current;
path = state.route.path;
}
const hash = await filesApi.checksum(source, path, this.selectedHashAlgo);
this.hashResult = hash;
} catch (err) {
this.hashResult = this.$t("prompts.errorGeneratingHash");
const errorMessage = err instanceof Error ? err.message : "Error generating hash";
notify.showError(errorMessage);
notify.showError(err.message || "Error generating hash");
} finally {
this.generatingHash = false;
}
+2 -2
View File
@@ -4,7 +4,7 @@
</div>
<div class="card-content">
<div v-if="showWrongCredentials" class="form-invalid">
<div v-if="showWrongCredentials" class="share__wrong__password">
{{ $t("login.wrongCredentials") }}
</div>
<div class="form-flex-group">
@@ -65,7 +65,7 @@ export default {
</script>
<style scoped>
.wrong__password {
.share__wrong__password {
color: #ff4757;
text-align: center;
padding: 1em 0;
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more