Compare commits

...
161 Commits
Author SHA1 Message Date
Graham Steffaniak 78397fc415 last minute changes (#1927) 2026-02-03 15:00:44 -06:00
Graham Steffaniak 5d01e1b1b8 updated with better image preloading (#1926) 2026-02-03 08:37:07 -06:00
Graham Steffaniak 3c0632d682 added baseurl to static assets (#1924) 2026-02-02 18:20:58 -06:00
Graham Steffaniak 85bde31ab8 Image viewer preload with thumbnail if available (#1923) 2026-02-02 17:41:48 -06:00
Graham Steffaniak fc42dab33a updated imaging semaphore more strict (#1921) 2026-02-02 14:36:44 -06:00
Kevin B. 1e66561366 Implement media session for plyr (#1917) 2026-02-02 09:53:44 -06:00
Graham Steffaniak 5a2e1f38b3 fix the album art on shares 2026-02-02 09:51:10 -06:00
Graham Steffaniak c2232f436a removed virtual scrolling v1.2.2 (#1920) 2026-02-02 09:05:54 -06:00
Graham Steffaniak b0b900c7ed consolidated share state (#1916) 2026-01-31 16:48:14 -06:00
Graham Steffaniak a8c6d05833 Share link UI changes (#1915) 2026-01-30 18:43:49 -06:00
Graham Steffaniak 0c23cc8324 updated with virtual scrolling (#1913) 2026-01-30 14:50:36 -06:00
Graham Steffaniak fe02b9422b better image processing concurrency rules (#1912) 2026-01-30 11:48:56 -06:00
Graham Steffaniak ad2aed47bc normalized user languages (#1907) 2026-01-28 18:25:25 -06:00
Graham Steffaniak 5b13673fff Merge branch 'dev/v1.2.2' of github.com:gtsteffaniak/filebrowser into dev/v1.2.2 2026-01-28 16:57:33 -06:00
+4 5c369404e8 Progressive web app favicon conversions (#1906)
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-28 16:57:22 -06:00
+4 af8cde651b Add context menu duplicates (#1905)
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-28 16:57:22 -06:00
+4 9e95977366 Fix otp issues (#1904)
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-28 16:57:22 -06:00
Kevin B. 063012477e Make sidebar resizable (#1896) 2026-01-28 16:57:22 -06:00
Kevin B. ad04c6833c Fix brand text in login screen (#1898) 2026-01-28 16:57:22 -06:00
Graham Steffaniak 45e8da383b tweak indexing behavior for existing sqlite index (#1895) 2026-01-28 16:57:22 -06:00
+4 4167d3e420 more bugfixes for 1.2.2 (#1892)
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-28 16:57:16 -06:00
dependabot[bot] 23f9d98dde Bump lodash from 4.17.21 to 4.17.23 in /frontend in the npm_and_yarn group across 1 directory (#1882)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-28 16:56:52 -06:00
+4 13162e20e5 Progressive web app favicon conversions (#1906)
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-28 16:56:38 -06:00
+4 d2214cc01c Add context menu duplicates (#1905)
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-28 15:23:08 -06:00
+4 b7835de1c9 Fix otp issues (#1904)
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-28 13:55:51 -06:00
Kevin B. a806476686 Make sidebar resizable (#1896) 2026-01-27 16:51:58 -06:00
Kevin B. 0296216787 Fix brand text in login screen (#1898) 2026-01-27 15:06:50 -06:00
Graham Steffaniak 84ec5c177d tweak indexing behavior for existing sqlite index (#1895) 2026-01-25 11:23:05 -06:00
+4 e72c47dc5e more bugfixes for 1.2.2 (#1892)
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-25 07:17:56 -06:00
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
170 changed files with 4180 additions and 15385 deletions

No files matched your search

+50
View File
@@ -2,6 +2,56 @@
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.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
**New Features**:
- 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)
- updated download api to use repeated `file` query param for path instead of `files` with comma. See swagger for details (#1881)
**BugFixes**:
- OnlyOffice stopped working after 1.2.0 upgrade due to download api changes
- Search result link to wrong path when search context other than / (#1863)
- File actions doesn't work if using user scopes (#1879)
- Drop items into folders doesn't work (#1860)
- duplicate finder context menu (#1862)
- File list in previews doesn't show the parent items (#1861)
## v1.2.0-beta
This is a major version update with many code changes which could cause unexpected behavior. Upgrades should proceed with caution and report any undesriable behavior by opening an issue on github. The index is now fully in sqlite database - see [Indexing Overview](https://filebrowserquantum.com/en/docs/features/indexing/) for more information.
+4 -2
View File
@@ -104,12 +104,12 @@ Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
webdav support | :warning:* | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Docker image size | 180 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 256 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
Min. Memory Requirements | 512 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
price | free | free | free | free tier | free tier | $99+ |
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -146,3 +146,5 @@ Can browse archive files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert documents | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert videos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
Can convert photos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
*Accessing files from FileBrowser via WebDav is a newly introduced and experimental feature.
+1
View File
@@ -27,6 +27,7 @@ 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
View File
@@ -23,6 +23,7 @@ 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
+59 -4
View File
@@ -10,6 +10,9 @@ import (
"os"
"path/filepath"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/go-logger/logger"
)
// Cache interface for caching operations
@@ -29,9 +32,61 @@ type FileCache struct {
}
}
// NewFileCache creates a new FileCache
func NewFileCache(dir string) (*FileCache, error) {
return &FileCache{dir: dir}, nil
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
}
func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
@@ -40,11 +95,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), 0700); err != nil {
if err := os.MkdirAll(filepath.Dir(fileName), fileutils.PermDir); err != nil {
return err
}
if err := os.WriteFile(fileName, value, 0600); err != nil {
if err := os.WriteFile(fileName, value, fileutils.PermFile); err != nil {
return err
}
@@ -6,6 +6,7 @@ import (
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/stretchr/testify/require"
)
@@ -19,6 +20,9 @@ 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)
@@ -28,19 +32,20 @@ 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, cachedFilePath), key, value)
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, value)
// update existing key
err = cache.Store(ctx, key, []byte(newValue))
require.NoError(t, err)
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, newValue)
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, newValue)
// delete key
err = cache.Delete(ctx, key)
require.NoError(t, err)
exists := fileExists(filepath.Join(cacheDir, cachedFilePath))
exists := fileExists(filepath.Join(cacheDir, "diskcache", cachedFilePath))
require.False(t, exists)
}
+4 -4
View File
@@ -208,7 +208,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
// Initialize the database first (use test helper to avoid permission issues)
if indexing.GetIndexDB() == nil {
db, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
db, _, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
@@ -219,7 +219,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
indexing.Initialize(&settings.Source{
Name: "test",
Path: "/mock/path",
}, true) // true for mock mode
}, true, false) // true for mock mode, false for isNewDb
// Get the index and set up mock data
idx := indexing.GetIndex("test")
@@ -318,7 +318,7 @@ func TestOverrideFileToDirectory(t *testing.T) {
// Initialize the database first (use test helper to avoid permission issues)
if indexing.GetIndexDB() == nil {
db, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
db, _, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
@@ -329,7 +329,7 @@ func TestOverrideFileToDirectory(t *testing.T) {
indexing.Initialize(&settings.Source{
Name: "test",
Path: "/mock/path",
}, true) // true for mock mode
}, true, false) // true for mock mode, false for isNewDb
// Get the index and set up mock data
idx := indexing.GetIndex("test")
+10 -141
View File
@@ -11,8 +11,6 @@ import (
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/dhowden/tag"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
@@ -245,16 +243,6 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
// 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)
@@ -746,149 +734,30 @@ 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) {
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)
// First check if it's a text file using the consolidated validation logic
isText, err := utils.IsTextFile(realPath)
if err != nil {
return "", err
}
defer f.Close()
// Read header
headerBytes := make([]byte, headerSize)
n, err := f.Read(headerBytes)
if err != nil && err != io.EOF {
return "", err
if !isText {
// Not a text file, return empty string (no error, just not text)
return "", nil
}
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)
// It's a text file, read and return the content
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
}
// Handle empty file (original logic - returns specific string)
// Handle empty file (return specific marker string)
if len(content) == 0 {
return "empty-file-x6OlSil", 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
return string(content), nil
}
func IsNamedPipe(mode os.FileMode) bool {
+36
View File
@@ -0,0 +1,36 @@
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
}
+6 -3
View File
@@ -67,15 +67,17 @@ 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.
secretText, nonce, err = encryptSecret(secretText, encryptionKey)
secretToStore, 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, secretText+"||"+nonce)
TotpCache.Set(user.Username, secretToStore+"||"+nonce)
// Use the original base32 secret in the OTP URL, not the encrypted version
url := fmt.Sprintf("otpauth://totp/%v?secret=%v", "FileBrowser Quantum: "+user.Username, secretText)
return url, nil
}
@@ -181,9 +183,10 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
Algorithm: TOTPAlgorithm,
})
if err != nil {
logger.Errorf("error during TOTP validation: %v", err)
logger.Errorf("error during TOTP validation for user %s: %v", user.Username, err)
}
if !valid {
logger.Warningf("Invalid TOTP code for user %s", user.Username)
return fmt.Errorf("invalid OTP token")
}
if totpSecret != "" {
+17 -7
View File
@@ -85,6 +85,23 @@ 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:])
@@ -93,13 +110,6 @@ 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 {
+24 -3
View File
@@ -3,6 +3,7 @@ package cmd
import (
"context"
"fmt"
"io/fs"
"os"
"os/signal"
"syscall"
@@ -116,17 +117,23 @@ func StartFilebrowser() {
}
// Initialize shared index database before starting HTTP service
if err := indexing.InitializeIndexDB(); err != nil {
isNewDb, err := indexing.InitializeIndexDB()
if err != nil {
logger.Fatalf("Failed to initialize index database: %v", err)
}
// Set indexing storage for persistence
if store != nil && store.Indexing != nil {
indexing.SetIndexingStorage(store.Indexing)
if isNewDb {
if err := store.Indexing.ResetAllComplexities(); err != nil {
logger.Errorf("Failed to reset index complexities: %v", err)
}
}
}
for _, source := range settings.Config.Server.SourceMap {
go indexing.Initialize(source, false)
go indexing.Initialize(source, false, isNewDb)
}
validateUserInfo(!dbExists)
validateOfficeIntegration()
@@ -174,12 +181,26 @@ 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)
}
// 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())
fbhttp.StartHttp(ctx, store, shutdownComplete)
return nil
}
+57 -54
View File
@@ -278,42 +278,42 @@ func setupFrontend(generate bool) {
}
// Load custom favicon if configured
loadCustomFavicon()
// Generate PWA icons from custom favicon if needed
generatePWAIcons()
}
func setupMedia() {
// If VideoPreview is not initialized, initialize with all types enabled
if Config.Integrations.Media.Convert.VideoPreview == nil {
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = true
}
return
// Save user's explicit config before applying defaults
userImageConfig := make(map[ImagePreviewType]*bool)
for k, v := range Config.Integrations.Media.Convert.ImagePreview {
userImageConfig[k] = v
}
// If VideoPreview map is empty, it means user didn't configure any video preview settings
// In this case, enable all by default
if len(Config.Integrations.Media.Convert.VideoPreview) == 0 {
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = true
}
return
// Re-initialize with defaults (gets wiped out during YAML unmarshal)
Config.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]*bool)
Config.Integrations.Media.Convert.ImagePreview[HEICImagePreview] = boolPtr(false) // HEIC defaults to disabled
Config.Integrations.Media.Convert.ImagePreview[JPEGImagePreview] = boolPtr(true) // JPEG fallback defaults to enabled
// Apply user overrides (only for keys explicitly set in YAML)
for k, v := range userImageConfig {
Config.Integrations.Media.Convert.ImagePreview[k] = v
}
// User has explicitly configured some video preview settings
// Start with all enabled, then apply user overrides
userConfig := make(map[VideoPreviewType]bool)
// VideoPreview: Merge user config with defaults
// Save user's explicit config from YAML
userVideoConfig := make(map[VideoPreviewType]*bool)
for k, v := range Config.Integrations.Media.Convert.VideoPreview {
userConfig[k] = v
userVideoConfig[k] = v
}
// Reset to defaults (all enabled)
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
// Re-initialize with defaults (all enabled)
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]*bool)
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = true
Config.Integrations.Media.Convert.VideoPreview[t] = boolPtr(true)
}
// Apply user overrides (only for explicitly set values)
for k, v := range userConfig {
// Apply user overrides (only for keys explicitly set in YAML)
for k, v := range userVideoConfig {
Config.Integrations.Media.Convert.VideoPreview[k] = v
}
}
@@ -680,7 +680,6 @@ func setDefaults(generate bool) Settings {
Frontend: Frontend{
Name: "FileBrowser Quantum",
},
UserDefaults: UserDefaults{
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
StickySidebar: true,
@@ -710,27 +709,26 @@ func setDefaults(generate bool) Settings {
Folder: boolPtr(true),
},
FileLoading: users.FileLoading{
MaxConcurrent: 10,
UploadChunkSize: 10, // 10MB
MaxConcurrent: 10,
UploadChunkSize: 10, // 10MB
DownloadChunkSize: 0, // 0MB, default to no chunking
},
},
}
// Initialize ImagePreview map with all supported types set to false by default
s.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]bool)
for _, t := range AllImagePreviewTypes {
s.Integrations.Media.Convert.ImagePreview[t] = false
}
// Initialize VideoPreview map with all supported types set to true by default
s.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
// Initialize ImagePreview map with defaults
s.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]*bool)
s.Integrations.Media.Convert.ImagePreview[HEICImagePreview] = boolPtr(false) // HEIC defaults to disabled
s.Integrations.Media.Convert.ImagePreview[JPEGImagePreview] = boolPtr(true) // JPEG fallback defaults to enabled
// Initialize VideoPreview map with defaults (all enabled)
s.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]*bool)
for _, t := range AllVideoPreviewTypes {
s.Integrations.Media.Convert.VideoPreview[t] = true
s.Integrations.Media.Convert.VideoPreview[t] = boolPtr(true)
}
return s
}
// validateCustomImage validates a custom image file path and returns the absolute path or error
func validateCustomImage(configPath, imageName string, maxSize int64, allowedFormats []string) (absolutePath string, err error) {
func validateCustomImage(configPath, imageName string, allowedFormats []string) (absolutePath string, err error) {
// Get absolute path
absolutePath, err = filepath.Abs(configPath)
if err != nil {
@@ -738,16 +736,11 @@ func validateCustomImage(configPath, imageName string, maxSize int64, allowedFor
}
// Check if file exists
stat, err := os.Stat(absolutePath)
_, 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
@@ -766,8 +759,7 @@ func validateCustomImage(configPath, imageName string, maxSize int64, allowedFor
func loadCustomFavicon() {
const imageName = "favicon"
const maxSize = 1024 * 1024 // 1MB
allowedFormats := []string{".ico", ".png", ".svg"}
allowedFormats := []string{".ico", ".png", ".svg", ".jpg", ".jpeg", ".gif", ".webp"}
// Set default embedded favicon path
Env.FaviconEmbeddedPath = "img/icons/favicon.svg"
@@ -780,7 +772,7 @@ func loadCustomFavicon() {
}
// Validate custom favicon
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, maxSize, allowedFormats)
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, allowedFormats)
if err != nil {
logger.Warningf("Custom favicon validation failed: %v, using default", err)
Config.Frontend.Favicon = ""
@@ -796,18 +788,29 @@ 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 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
}
// Set default embedded icon path - just use favicon.svg (light/dark handled by CSS)
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg"
// Check if a custom login icon path is configured
if Config.Frontend.LoginIcon == "" {
@@ -817,7 +820,7 @@ func loadLoginIcon() {
}
// Validate custom login icon
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, maxSize, allowedFormats)
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, allowedFormats)
if err != nil {
logger.Warningf("Custom login icon validation failed: %v, using default", err)
Env.LoginIconPath = Env.LoginIconEmbeddedPath
+16 -2
View File
@@ -1,11 +1,25 @@
package settings
func CanConvertImage(ext string) bool {
return MediaEnabled() && Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
if !MediaEnabled() {
return false
}
val := Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
if val == nil {
return false // Extension not in the configured list
}
return *val
}
func CanConvertVideo(ext string) bool {
return MediaEnabled() && Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
if !MediaEnabled() {
return false
}
val := Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
if val == nil {
return false // Extension not in the configured list
}
return *val
}
func MediaEnabled() bool {
+3 -59
View File
@@ -649,11 +649,6 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
}
mapNode := &yaml.Node{Kind: yaml.MappingNode}
// Special handling for ImagePreview maps - show all supported types
if isImagePreviewMap(v) {
return buildImagePreviewMap(v, comm, secrets, deprecated)
}
// Get and sort map keys alphabetically for consistent output
mapKeys := v.MapKeys()
keyStrings := make([]string, len(mapKeys))
@@ -707,16 +702,15 @@ func GenerateYaml() {
{
Path: ".",
Config: SourceConfig{
Conditionals: ConditionalFilter{
ItemRules: []ConditionalRule{
{}, // Add empty sample rule to show all fields in generated YAML
},
Rules: []ConditionalRule{
{}, // Add empty sample rule to show all fields in generated YAML
},
},
},
}
setupLogging()
setupFs()
setupAuth(true)
// Save original paths before setupSources modifies them (for YAML generation)
@@ -1136,53 +1130,3 @@ func AlignComments(input string) string {
return strings.Join(outputLines, "\n")
}
// isImagePreviewMap checks if the given map is an ImagePreview map
func isImagePreviewMap(v reflect.Value) bool {
if v.Kind() != reflect.Map {
return false
}
// Check if the map key type is ImagePreviewType
keyType := v.Type().Key()
return keyType.Name() == "ImagePreviewType"
}
// buildImagePreviewMap builds a YAML node for ImagePreview maps, showing all supported types
func buildImagePreviewMap(v reflect.Value, comm CommentsMap, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
mapNode := &yaml.Node{Kind: yaml.MappingNode}
// Get all supported image preview types from the global slice
for _, imageType := range AllImagePreviewTypes {
typeName := string(imageType)
// Create key node
keyNode := &yaml.Node{
Kind: yaml.ScalarNode,
Value: typeName,
}
// Get the value from the map, defaulting to false if not present
var value bool
if v.Len() > 0 {
// Look for the key in the map
for _, mapKey := range v.MapKeys() {
if mapKey.String() == typeName {
value = v.MapIndex(mapKey).Bool()
break
}
}
}
// Create value node
valNode := &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!bool",
Value: fmt.Sprintf("%t", value),
}
mapNode.Content = append(mapNode.Content, keyNode, valNode)
}
return mapNode, nil
}
+12 -6
View File
@@ -36,6 +36,10 @@ 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 {
@@ -101,14 +105,15 @@ type Media struct {
}
type FfmpegConvert struct {
ImagePreview map[ImagePreviewType]bool `json:"imagePreview"` // supported image preview formats. defaults to false for all types unless explicitly enabled.
VideoPreview map[VideoPreviewType]bool `json:"videoPreview"` // supported video preview formats. defaults to true for all types unless explicitly disabled.
ImagePreview map[ImagePreviewType]*bool `json:"imagePreview"` // supported image preview formats. defaults vary by type (see individual type docs)
VideoPreview map[VideoPreviewType]*bool `json:"videoPreview"` // supported video preview formats. defaults to true for all types unless explicitly disabled.
}
type ImagePreviewType string
const (
HEICImagePreview ImagePreviewType = "heic"
JPEGImagePreview ImagePreviewType = "jpeg" // only used as fallback for JPEG formats that can't otherwise be decoded
//RAWImagePreview ImagePreviewType = "raw"
)
@@ -119,6 +124,7 @@ func (i ImagePreviewType) String() string {
// AllImagePreviewTypes contains all supported image preview types.
var AllImagePreviewTypes = []ImagePreviewType{
HEICImagePreview,
JPEGImagePreview,
//RAWImagePreview,
}
@@ -263,9 +269,10 @@ 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
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
}
type StylingConfig struct {
@@ -350,5 +357,4 @@ type UserDefaults struct {
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
DefaultLandingPage string `json:"defaultLandingPage"` // deprecated: determined by sidebar link order since 1.1.0
}
+11 -11
View File
@@ -26,7 +26,7 @@ integrations:
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
@@ -52,7 +52,7 @@ integrations:
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
@@ -81,16 +81,16 @@ integrations:
`
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// Check explicitly set formats
if !config.Integrations.Media.Convert.VideoPreview[MP4VideoPreview] {
if !*config.Integrations.Media.Convert.VideoPreview[MP4VideoPreview] {
t.Error("Expected mp4 to be enabled")
}
if config.Integrations.Media.Convert.VideoPreview[WebMVideoPreview] {
if *config.Integrations.Media.Convert.VideoPreview[WebMVideoPreview] {
t.Error("Expected webm to be disabled")
}
if config.Integrations.Media.Convert.VideoPreview[MKVVideoPreview] {
if *config.Integrations.Media.Convert.VideoPreview[MKVVideoPreview] {
t.Error("Expected mkv to be disabled")
}
if config.Integrations.Media.Convert.VideoPreview[WMVVideoPreview] {
if *config.Integrations.Media.Convert.VideoPreview[WMVVideoPreview] {
t.Error("Expected wmv to be disabled")
}
@@ -100,7 +100,7 @@ integrations:
M4VVideoPreview, ThreeGPVideoPreview, TSVideoPreview, VOBVideoPreview,
}
for _, format := range unmentionedFormats {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled (not mentioned in config), but it was disabled", format)
}
}
@@ -155,13 +155,13 @@ integrations:
}
for _, format := range expectedEnabled {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled, but it was disabled", format)
}
}
for _, format := range expectedDisabled {
if config.Integrations.Media.Convert.VideoPreview[format] {
if *config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be disabled, but it was enabled", format)
}
}
@@ -182,7 +182,7 @@ integrations:
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
@@ -217,7 +217,7 @@ integrations:
for category, formats := range categories {
t.Run(category, func(t *testing.T) {
for _, format := range formats {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s (%s) to be enabled by default, but it was disabled", format, category)
}
}
+42 -125
View File
@@ -1,148 +1,65 @@
package utils
import (
"io"
"os"
"unicode"
"unicode/utf8"
)
// 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:
// 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:
// - Valid UTF-8 encoding
// - Limited null bytes
// - Limited non-printable characters
// - Not full of null bytes (which indicate binary data)
func IsTextFile(realPath string) (bool, error) {
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)
const sampleSize = 8192 // Read first 8KB to check
const maxNullByteRatio = 0.05 // Reject if more than 5% null bytes
// Read sample from file
content, err := os.ReadFile(realPath)
if err != nil {
return false, err
}
// Handle empty file (empty files are considered text)
// 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++
}
}
stringContent := string(content)
if nullCount > 0 {
nullRatio := float64(nullCount) / float64(len(sample))
if nullRatio > maxNullByteRatio {
return false, nil
}
}
// 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) {
// 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) {
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
}
@@ -1,4 +1,4 @@
package indexing
package dbindex
import (
"time"
@@ -1,4 +1,4 @@
package indexing
package dbindex
import (
"sync"
@@ -167,3 +167,37 @@ func (s *Storage) Flush() error {
}
return nil
}
// ResetAllComplexities sets the complexity to 0 for all indexes and their scanners.
// This should be called when the index database is recreated (either fresh start or after corruption).
func (s *Storage) ResetAllComplexities() error {
// Get all indexes from storage
infos, err := s.All()
if err != nil {
return err
}
// Reset complexity for each index
for _, info := range infos {
if info == nil {
continue
}
// Reset index-level complexity
info.Complexity = 0
// Reset scanner-level complexities
for _, scanner := range info.Scanners {
if scanner != nil {
scanner.Complexity = 0
}
}
// Save the updated info
if err := s.Save(info); err != nil {
return err
}
}
return nil
}
+29 -7
View File
@@ -4,9 +4,11 @@ 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"
)
@@ -41,7 +43,7 @@ func createIndexDB(name string, journalMode string, lockingMode string, batchSiz
BatchSize: batchSize,
CacheSizeKB: cacheSizeKB, // From config, converted to KB
SoftHeapLimitBytes: 16 * 1024 * 1024, // 16MB soft heap limit (reduced to minimize memory pressure)
CacheSpillThreshold: 200, // Spill dirty pages to disk when cache exceeds 200 pages (~800KB) - more aggressive
CacheSpillThreshold: 2000, // Spill dirty pages to disk when cache exceeds 2000 pages (~8MB)
MmapSize: 0, // Disable mmap to prevent additional OS page cache usage
Synchronous: "OFF", // No sync for maximum performance - safe since DB can be rebuilt
TempStore: "FILE", // FILE instead of MEMORY to reduce memory usage
@@ -61,33 +63,53 @@ 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)
func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int, disableReuse bool) (*IndexDB, error) {
// Returns the IndexDB and a boolean indicating if the database is new (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
}
}
idxDB, err := createIndexDB(name, journalMode, "", batchSize, cacheSizeMB, disableReuse)
if err != nil {
return nil, err
return nil, false, err
}
// For persistent databases, check integrity on startup
// If corrupted, delete and recreate the database
if !disableReuse {
if err := idxDB.checkIntegrityAndRecreateIfNeeded(); err != nil {
// Database was recreated, need to create a new connection
// 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
idxDB.Close()
// Recreate the database connection
idxDB, err = createIndexDB(name, journalMode, "", batchSize, cacheSizeMB, disableReuse)
if err != nil {
return nil, err
return nil, false, err
}
}
}
if err := idxDB.CreateIndexTable(); err != nil {
idxDB.Close()
return nil, err
return nil, false, err
}
go idxDB.startPeriodicCleanup()
return idxDB, nil
return idxDB, isNewDb, nil
}
// checkIntegrityAndRecreateIfNeeded checks database integrity.
+94 -24
View File
@@ -247,12 +247,22 @@ func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
db.SetMaxIdleConns(1)
}
// Calculate cache_size in pages (negative value) to ensure consistent unit handling
// SQLite's cache_size: positive = KB, negative = pages
// When using positive KB values, SQLite seems to have issues with cache_spill being less than cache_size
// So we convert to pages (negative) to ensure both cache_size and cache_spill use the same units
cacheSizeInPages := cfg.CacheSizeKB
if cfg.CacheSizeKB > 0 {
// Convert KB to pages: KB / 4 = pages, then negate to indicate pages
cacheSizeInPages = -(cfg.CacheSizeKB / 4)
}
basePragmas := []struct {
sql string
err string
}{
{fmt.Sprintf("PRAGMA journal_mode = %s;", cfg.JournalMode), "failed to set journal_mode"},
{fmt.Sprintf("PRAGMA cache_size = %d;", cfg.CacheSizeKB), "failed to set cache_size"},
{fmt.Sprintf("PRAGMA cache_size = %d;", cacheSizeInPages), "failed to set cache_size"},
{fmt.Sprintf("PRAGMA synchronous = %s;", cfg.Synchronous), "failed to set synchronous"},
{fmt.Sprintf("PRAGMA temp_store = %s;", cfg.TempStore), "failed to set temp_store"},
{fmt.Sprintf("PRAGMA locking_mode = %s;", cfg.LockingMode), "failed to set locking_mode"},
@@ -274,19 +284,7 @@ func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
}{{fmt.Sprintf("PRAGMA hard_heap_limit = %d;", cfg.HardHeapLimitBytes), "failed to set hard_heap_limit"}}, basePragmas...)
}
// Cache spill threshold - triggers early spilling to reduce memory usage
if cfg.CacheSpillThreshold > 0 {
basePragmas = append(basePragmas, struct {
sql string
err string
}{fmt.Sprintf("PRAGMA cache_spill = %d;", cfg.CacheSpillThreshold), "failed to set cache_spill"})
} else {
// Enable cache_spill by default (value of 1 enables it)
basePragmas = append(basePragmas, struct {
sql string
err string
}{"PRAGMA cache_spill = 1;", "failed to set cache_spill"})
}
// Cache spill will be set later, after all other pragmas, to avoid interaction issues
// Page size must be set before any tables are created
if cfg.PageSize > 0 {
@@ -333,6 +331,32 @@ func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
}
}
// Set cache_spill LAST, after all other pragmas are configured
// SQLite may adjust cache_spill based on internal constraints or formulas
if cfg.CacheSpillThreshold > 0 {
// Try setting cache_spill multiple times with different approaches
// Approach 1: Standard syntax
if _, err := db.Exec(fmt.Sprintf("PRAGMA cache_spill = %d;", cfg.CacheSpillThreshold)); err != nil {
logger.Warningf("[SQLITE_CONFIG] Failed to set cache_spill with standard syntax: %v", err)
}
// Immediately verify what value was actually set
var actualValue int64
if err := db.QueryRow("PRAGMA cache_spill").Scan(&actualValue); err == nil {
if actualValue != int64(cfg.CacheSpillThreshold) {
// SQLite adjusted our value - calculate what percentage it used
percentage := float64(actualValue) / float64(-cacheSizeInPages) * 100.0
logger.Warningf("[SQLITE_CONFIG] SQLite adjusted cache_spill from %d to %d pages (%.1f%% of cache_size). This may be due to internal constraints.",
cfg.CacheSpillThreshold, actualValue, percentage)
}
}
} else {
// Enable cache_spill with default behavior
if _, err := db.Exec("PRAGMA cache_spill = 1;"); err != nil {
logger.Warningf("[SQLITE_CONFIG] Failed to enable cache_spill: %v", err)
}
}
// Verify PRAGMA settings were applied correctly
if err := verifyPragmaSettings(db, cfg); err != nil {
logger.Warningf("Failed to verify PRAGMA settings: %v", err)
@@ -452,18 +476,39 @@ func verifyPragmaSettings(db *sql.DB, cfg *TempDBConfig) error {
cacheSizeKB = -cacheSize * 4 // pages * 4KB per page
}
// Expected cache_spill value in pages (cache_spill is always specified and returned in pages)
expectedCacheSpill := int64(cfg.CacheSpillThreshold)
// Convert numeric PRAGMA values to human-readable strings
tempStoreMap := map[string]string{"0": "DEFAULT", "1": "FILE", "2": "MEMORY"}
synchronousMap := map[string]string{"0": "OFF", "1": "NORMAL", "2": "FULL", "3": "EXTRA"}
autoVacuumMap := map[string]string{"0": "NONE", "1": "FULL", "2": "INCREMENTAL"}
tempStoreStr := tempStoreMap[tempStore]
if tempStoreStr == "" {
tempStoreStr = tempStore
}
synchronousStr := synchronousMap[synchronous]
if synchronousStr == "" {
synchronousStr = synchronous
}
autoVacuumStr := autoVacuumMap[autoVacuum]
if autoVacuumStr == "" {
autoVacuumStr = autoVacuum
}
// Log all settings
logger.Debugf("[SQLITE_CONFIG] Database PRAGMA settings verified:")
logger.Debugf(" cache_size: %d KB (configured: %d KB)", cacheSizeKB, cfg.CacheSizeKB)
logger.Debugf(" mmap_size: %d bytes (configured: %d bytes)", mmapSize, cfg.MmapSize)
logger.Debugf(" soft_heap_limit: %d bytes (configured: %d bytes)", softHeapLimit, cfg.SoftHeapLimitBytes)
logger.Debugf(" hard_heap_limit: %d bytes (configured: %d bytes)", hardHeapLimit, cfg.HardHeapLimitBytes)
logger.Debugf(" cache_spill: %d pages (configured: %d pages)", cacheSpill, cfg.CacheSpillThreshold)
logger.Debugf(" journal_mode: %s (configured: %s)", journalMode, cfg.JournalMode)
logger.Debugf(" synchronous: %s (configured: %s)", synchronous, cfg.Synchronous)
logger.Debugf(" temp_store: %s (configured: %s)", tempStore, cfg.TempStore)
logger.Debugf(" locking_mode: %s (configured: %s)", lockingMode, cfg.LockingMode)
logger.Debugf(" auto_vacuum: %s (configured: %s)", autoVacuum, cfg.AutoVacuum)
logger.Debugf(" cache_size : %d KB", cacheSizeKB)
logger.Debugf(" mmap_size : %d bytes", mmapSize)
logger.Debugf(" soft_heap_limit : %d bytes", softHeapLimit)
logger.Debugf(" hard_heap_limit : %d bytes", hardHeapLimit)
logger.Debugf(" cache_spill : %d pages", cacheSpill)
logger.Debugf(" journal_mode : %s", journalMode)
logger.Debugf(" synchronous : %s", synchronousStr)
logger.Debugf(" temp_store : %s", tempStoreStr)
logger.Debugf(" locking_mode : %s", lockingMode)
logger.Debugf(" auto_vacuum : %s", autoVacuumStr)
// Verify critical settings match
if cfg.CacheSizeKB != 0 {
@@ -484,6 +529,31 @@ func verifyPragmaSettings(db *sql.DB, cfg *TempDBConfig) error {
if cfg.HardHeapLimitBytes > 0 && hardHeapLimit != cfg.HardHeapLimitBytes {
logger.Warningf("[SQLITE_CONFIG] hard_heap_limit mismatch: got %d bytes, expected %d bytes", hardHeapLimit, cfg.HardHeapLimitBytes)
}
// Verify cache_spill matches expected value (accounting for unit conversion)
if cfg.CacheSpillThreshold > 0 && cacheSpill != expectedCacheSpill {
logger.Warningf("[SQLITE_CONFIG] cache_spill mismatch: got %d pages, expected %d pages (from threshold %d pages)", cacheSpill, expectedCacheSpill, cfg.CacheSpillThreshold)
// If cache_spill equals cache_size in pages, it means SQLite ignored our setting
var cacheSizePages int64
if cfg.CacheSizeKB > 0 {
cacheSizePages = int64(cfg.CacheSizeKB) / 4 // KB to pages
} else {
cacheSizePages = -int64(cfg.CacheSizeKB) // Already in pages (negative)
}
if cacheSpill == cacheSizePages {
logger.Warningf("[SQLITE_CONFIG] cache_spill equals cache_size (%d pages), indicating SQLite may have ignored our setting", cacheSizePages)
}
}
// Verify string-based PRAGMA settings match (using converted values)
if synchronousStr != cfg.Synchronous {
logger.Warningf("[SQLITE_CONFIG] synchronous mismatch: got %s, expected %s", synchronousStr, cfg.Synchronous)
}
if tempStoreStr != cfg.TempStore {
logger.Warningf("[SQLITE_CONFIG] temp_store mismatch: got %s, expected %s", tempStoreStr, cfg.TempStore)
}
if autoVacuumStr != cfg.AutoVacuum {
logger.Warningf("[SQLITE_CONFIG] auto_vacuum mismatch: got %s, expected %s", autoVacuumStr, cfg.AutoVacuum)
}
return nil
}
+3 -3
View File
@@ -6,7 +6,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/indexing"
"github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
@@ -19,7 +19,7 @@ type BoltStore struct {
Auth *auth.Storage
Settings *settings.Storage
Access *access.Storage
Indexing *indexing.Storage
Indexing *dbindex.Storage
}
// NewStorage creates a storage.Storage based on Bolt DB.
@@ -35,6 +35,6 @@ func NewStorage(db *storm.DB) (*BoltStore, error) {
Auth: authStore,
Settings: settings.NewStorage(settingsBackend{db: db}),
Access: access.NewStorage(db, userStore),
Indexing: indexing.NewStorage(indexingBackend{db: db}),
Indexing: dbindex.NewStorage(indexingBackend{db: db}),
}, nil
}
+9 -9
View File
@@ -5,15 +5,15 @@ import (
"github.com/asdine/storm/v3/q"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/indexing"
"github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
)
type indexingBackend struct {
db *storm.DB
}
func (s indexingBackend) All() ([]*indexing.IndexInfo, error) {
var v []*indexing.IndexInfo
func (s indexingBackend) All() ([]*dbindex.IndexInfo, error) {
var v []*dbindex.IndexInfo
err := s.db.All(&v)
if err == storm.ErrNotFound {
return v, errors.ErrNotExist
@@ -22,8 +22,8 @@ func (s indexingBackend) All() ([]*indexing.IndexInfo, error) {
return v, err
}
func (s indexingBackend) GetByPath(path string) (*indexing.IndexInfo, error) {
var v indexing.IndexInfo
func (s indexingBackend) GetByPath(path string) (*dbindex.IndexInfo, error) {
var v dbindex.IndexInfo
err := s.db.One("Path", path, &v)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
@@ -32,8 +32,8 @@ func (s indexingBackend) GetByPath(path string) (*indexing.IndexInfo, error) {
return &v, err
}
func (s indexingBackend) GetBySource(source string) ([]*indexing.IndexInfo, error) {
var v []*indexing.IndexInfo
func (s indexingBackend) GetBySource(source string) ([]*dbindex.IndexInfo, error) {
var v []*dbindex.IndexInfo
err := s.db.Select(q.Eq("Source", source)).Find(&v)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
@@ -41,12 +41,12 @@ func (s indexingBackend) GetBySource(source string) ([]*indexing.IndexInfo, erro
return v, err
}
func (s indexingBackend) Save(info *indexing.IndexInfo) error {
func (s indexingBackend) Save(info *dbindex.IndexInfo) error {
return s.db.Save(info)
}
func (s indexingBackend) Delete(path string) error {
err := s.db.DeleteStruct(&indexing.IndexInfo{Path: path})
err := s.db.DeleteStruct(&dbindex.IndexInfo{Path: path})
if err == storm.ErrNotFound {
return nil
}
-1
View File
@@ -116,7 +116,6 @@ type NonAdminEditable struct {
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
DefaultLandingPage string `json:"defaultLandingPage"` // deprecated: determined by sidebar link order instead
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
}
-4646
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
package ffmpeg
import "github.com/gtsteffaniak/filebrowser/backend/common/settings"
func CanConvertImage(ext string) bool {
if !MediaEnabled() {
return false
}
val := settings.Config.Integrations.Media.Convert.ImagePreview[settings.ImagePreviewType(ext)]
if val == nil {
return false // Extension not in the configured list
}
return *val
}
func CanConvertVideo(ext string) bool {
if !MediaEnabled() {
return false
}
val := settings.Config.Integrations.Media.Convert.VideoPreview[settings.VideoPreviewType(ext)]
if val == nil {
return false // Extension not in the configured list
}
return *val
}
func MediaEnabled() bool {
return settings.Env.FFmpegPath != "" && settings.Env.FFprobePath != ""
}
+8
View File
@@ -378,3 +378,11 @@ func (s *FFmpegService) ConvertHEICToJPEG(ctx context.Context, heicPath string,
return jpegBytes, nil
}
// ConvertImageToJPEG converts any image format (including problematic JPEGs) to standard JPEG using FFmpeg
// This handles extended JPEG formats that Go's image/jpeg decoder doesn't support (e.g., SOF1/C1 marker)
// and can be used as a fallback for images that fail to decode with Go's standard library.
func (s *FFmpegService) ConvertImageToJPEG(ctx context.Context, imagePath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
// Use the direct conversion method which works for all image formats
return s.ConvertHEICToJPEGDirect(ctx, imagePath, targetWidth, targetHeight, quality)
}
+21 -21
View File
@@ -5,32 +5,32 @@ 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.6.0
github.com/coreos/go-systemd/v22 v22.7.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
github.com/go-playground/validator/v10 v10.29.0
github.com/goccy/go-yaml v1.19.0
github.com/go-playground/validator/v10 v10.30.1
github.com/goccy/go-yaml v1.19.2
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-logger v1.0.2
github.com/gtsteffaniak/go-push v0.1.0
github.com/kovidgoyal/imaging v1.7.2
github.com/mattn/go-sqlite3 v1.14.32
github.com/kovidgoyal/imaging v1.8.19
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.46.0
golang.org/x/image v0.34.0
golang.org/x/mod v0.31.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.39.0
golang.org/x/sys v0.40.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.40.1
modernc.org/sqlite v1.44.3
)
require (
@@ -54,7 +54,7 @@ require (
github.com/Masterminds/semver/v3 v3.4.0 // 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.0 // 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
@@ -104,14 +104,14 @@ require (
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.12 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // 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
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/spec v0.22.2 // indirect
github.com/go-openapi/spec v0.22.3 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
@@ -134,7 +134,7 @@ require (
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-20251215195340-f7ee789d9fc1 // indirect
github.com/golang/geo v0.0.0-20260129164528-943061e2742c // 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
@@ -162,12 +162,14 @@ require (
github.com/jgautheron/goconst v1.8.2 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
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.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
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
github.com/kovidgoyal/go-shm v1.0.0 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
@@ -205,7 +207,6 @@ require (
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
github.com/polyfloyd/go-errorlint v1.8.0 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
@@ -268,17 +269,16 @@ require (
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-20251209150349-8475f28825e9 // 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/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.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.6.1 // indirect
modernc.org/libc v1.67.1 // indirect
modernc.org/libc v1.67.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+44 -94
View File
@@ -62,8 +62,6 @@ github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
@@ -75,35 +73,22 @@ 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.2.1 h1:M+zdXMq84g+E1YOLa7g7ExN3dWfZQrdDSTCM7gC+m/A=
github.com/MirrexOne/unqueryvet v1.2.1/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
github.com/MirrexOne/unqueryvet v1.3.0 h1:5slWSomgqpYU4zFuZ3NNOfOUxVPlXFDBPAVasZOGlAY=
github.com/MirrexOne/unqueryvet v1.3.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
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.63.1 h1:N6kD5niKKVx0wF2mW0mgK6LNfJqP5/lCAqm3WWl9vlw=
github.com/air-verse/air v1.63.1/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
github.com/air-verse/air v1.63.4 h1:Z+R4328Bja5QKFMTP0CNeT8aVWdb3D5kbbFvnXnuRhE=
github.com/air-verse/air v1.63.4/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
github.com/air-verse/air v1.63.7 h1:Wi85jUmyhGWcNpm/OUUvPiGNp65X+mUVKjr1N9HfZ3Q=
github.com/air-verse/air v1.63.7/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
github.com/air-verse/air v1.64.0 h1:lKEvIQzaOM5vAVKOdQxq1WrXLfLmzz+e+hPrhmgF6yo=
github.com/air-verse/air v1.64.0/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
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.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
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.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@@ -111,8 +96,6 @@ 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.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw=
github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE=
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=
@@ -184,8 +167,6 @@ github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E
github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70=
github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc=
github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI=
github.com/catenacyber/perfsprint v0.10.0 h1:AZj1mYyxbxLRqmnYOeguZXEQwWOgQGm2wzLI5d7Hl/0=
github.com/catenacyber/perfsprint v0.10.0/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc=
github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ=
github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc=
github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc=
@@ -218,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.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
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/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=
@@ -284,20 +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.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
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/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.17 h1:sjGPErP9o7i2Ym+z3LsQzBdLCNaqbYy2iJQPxGXg04Q=
github.com/ghostiam/protogetter v0.3.17/go.mod h1:AivIX1eKA/TcUmzZdzbl+Tb8tjIe8FcyG6JFyemQAH4=
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.2 h1:PMvP5f+LdR8p6B29npvChUXbD1vrNlKDf60NJtgMBOo=
github.com/go-critic/go-critic v0.14.2/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
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=
@@ -323,8 +300,8 @@ github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegO
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/spec v0.22.2 h1:KEU4Fb+Lp1qg0V4MxrSCPv403ZjBl8Lx1a83gIPU8Qc=
github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
@@ -352,8 +329,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk=
github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -386,12 +363,8 @@ github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
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.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godoc-lint/godoc-lint v0.10.1 h1:ZPUVzlDtJfA+P688JfPJPkI/SuzcBr/753yGIk5bOPA=
github.com/godoc-lint/godoc-lint v0.10.1/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7pDQg4NxMYLcBBsw=
github.com/godoc-lint/godoc-lint v0.10.2 h1:dksNgK+zebnVlj4Fx83CRnCmPO0qRat/9xfFsir1nfg=
github.com/godoc-lint/godoc-lint v0.10.2/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7pDQg4NxMYLcBBsw=
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.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=
@@ -418,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-20251215195340-f7ee789d9fc1 h1:D/2kgdp+Hl79R59gNSnTQPpjWKd/IVUYhuvkqWQG8N0=
github.com/golang/geo v0.0.0-20251215195340-f7ee789d9fc1/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
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/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=
@@ -459,14 +432,8 @@ 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.6.2 h1:jkMSVv36JmyTENcEertckvimvjPcD5qxNM7W7qhECvI=
github.com/golangci/golangci-lint/v2 v2.6.2/go.mod h1:fSIMDiBt9kzdpnvvV7GO6iWzyv5uaeZ+iPor+2uRczE=
github.com/golangci/golangci-lint/v2 v2.7.2 h1:AhBC+YeEueec4AGlIbvPym5C70Thx0JykIqXbdIXWx0=
github.com/golangci/golangci-lint/v2 v2.7.2/go.mod h1:pDijleoBu7e8sejMqyZ3L5n6geqe+cVvOAz2QImqqVc=
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.0.0-20250217134842-442fd0091d95 h1:AkK+w9FZBXlU/xUmBtSJN1+tAI4FIvy5WtnUnY8e4p8=
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95/go.mod h1:k9mmcyWKSTMcPPvQUCfRWWQ9VHJ1U9Dc0R7kaXAgtnQ=
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=
@@ -531,8 +498,6 @@ github.com/gtsteffaniak/go-cache v1.1.0 h1:ni/2cJpCxpGmaMZd92iTDIGOy+yUcMcxQuW7b
github.com/gtsteffaniak/go-cache v1.1.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
github.com/gtsteffaniak/go-logger v1.0.2 h1:S4AgR7yDu2geD8y2D9NyJVOBqpZRnl37r2mBi7GDNR0=
github.com/gtsteffaniak/go-logger v1.0.2/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
github.com/gtsteffaniak/go-push v0.1.0 h1:bMkin7Ce2xjTllPMk5q3IAxD2ZcMDGZICuQeeKDJDXo=
github.com/gtsteffaniak/go-push v0.1.0/go.mod h1:xZplgyS4bJdYmLbfEBbkETdrN/vQn6M/tim72fX2s1c=
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo=
@@ -540,8 +505,6 @@ github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1T
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@@ -565,6 +528,8 @@ github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjz
github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c=
github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8=
github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
@@ -591,8 +556,10 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok=
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
github.com/kovidgoyal/imaging v1.7.2 h1:mmT6k6Az3mC6dbqdZ6Q9KQCdZFWTAQ+q97NyGZgJ/2c=
github.com/kovidgoyal/imaging v1.7.2/go.mod h1:GdkCORjfZMMGFY0Pb7TDmRhj7PDhxF/QShKukSCj0VU=
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
github.com/kovidgoyal/imaging v1.8.19 h1:zWJdQqF2tfSKjvoB7XpLRhVGbYsze++M0iaqZ4ZkhNk=
github.com/kovidgoyal/imaging v1.8.19/go.mod h1:I0q8RdoEuyc4G8GFOF9CaluTUHQSf68d6TmsqpvfRI8=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -611,8 +578,6 @@ github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0
github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI=
github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ=
github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM=
github.com/ldez/gomoddirectives v0.7.1 h1:FaULkvUIG36hj6chpwa+FdCNGZBsD7/fO+p7CCsM6pE=
github.com/ldez/gomoddirectives v0.7.1/go.mod h1:auDNtakWJR1rC+YX7ar+HmveqXATBAyEK1KYpsIRW/8=
github.com/ldez/gomoddirectives v0.8.0 h1:JqIuTtgvFC2RdH1s357vrE23WJF2cpDCPFgA/TWDGpk=
github.com/ldez/gomoddirectives v0.8.0/go.mod h1:jutzamvZR4XYJLr0d5Honycp4Gy6GEg2mS9+2YX3F1Q=
github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o=
@@ -657,12 +622,10 @@ 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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/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.12.0 h1:Q+/kkbbwerrVYPv9d9efaPGmAO/NsxwW/nE6ahpQaCU=
github.com/mgechev/revive v1.12.0/go.mod h1:VXsY2LsTigk8XU9BpZauVLjVrhICMOV3k1lpB3CXrp8=
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=
@@ -708,9 +671,8 @@ 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.26.0 h1:1J4Wut1IlYZNEAWIV3ALrT9NfiaGW2cDCJQSFQMs/gE=
github.com/onsi/ginkgo/v2 v2.26.0/go.mod h1:qhEywmzWTBUY88kfO0BRvX4py7scov9yR+Az2oavUzw=
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=
@@ -734,8 +696,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/polyfloyd/go-errorlint v1.8.0 h1:DL4RestQqRLr8U4LygLw8g2DX6RN1eBJOpa2mzsrl1Q=
github.com/polyfloyd/go-errorlint v1.8.0/go.mod h1:G2W0Q5roxbLCt0ZQbdoxQxXktTjwNyDbEaj3n7jvl4s=
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=
@@ -799,10 +759,6 @@ 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.22.10 h1:ntbBqdWXnu46DUOXn+R2SvPo3PiJCDugTCgTW2g4tQg=
github.com/securego/gosec/v2 v2.22.10/go.mod h1:9UNjK3tLpv/w2b0+7r82byV43wCJDNtEDQMeS+H/g2w=
github.com/securego/gosec/v2 v2.22.11-0.20251204091113-daccba6b93d7 h1:rZg6IGn0ySYZwCX8LHwZoYm03JhG/cVAJJ3O+u3Vclo=
github.com/securego/gosec/v2 v2.22.11-0.20251204091113-daccba6b93d7/go.mod h1:9sr22NZO5Kfh7unW/xZxkGYTmj2484/fCiE54gw7UTY=
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=
@@ -825,8 +781,6 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
@@ -839,8 +793,6 @@ github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ=
github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI=
github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0=
github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I=
github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4=
github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk=
github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g=
github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -879,8 +831,6 @@ github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460=
github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M=
github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8=
github.com/tomarrell/wrapcheck/v2 v2.11.0 h1:BJSt36snX9+4WTIXeJ7nvHBQBcm1h2SjQMSlmQ6aFSU=
github.com/tomarrell/wrapcheck/v2 v2.11.0/go.mod h1:wFL9pDWDAbXhhPZZt+nG8Fu+h29TtnZ2MW6Lx4BRXIU=
github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is=
github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
@@ -959,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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
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=
@@ -971,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-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
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-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.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
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=
@@ -1006,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.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
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=
@@ -1050,8 +1000,8 @@ 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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
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=
@@ -1130,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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.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=
@@ -1148,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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
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=
@@ -1205,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.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
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=
@@ -1336,8 +1286,8 @@ 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.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=
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=
@@ -1346,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.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
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=
+2 -5
View File
@@ -394,13 +394,10 @@ 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)
if adjustedPath == "" {
adjustedPath = "/"
}
adjustedPath := "/" + strings.TrimPrefix(fileInfo.Path, opts.combinedPath)
resultGroup = append(resultGroup, &indexing.SearchResult{
Path: adjustedPath,
Source: opts.source,
Type: fileInfo.Type,
Size: fileInfo.Size,
Modified: fileInfo.ModTime.Format(time.RFC3339),
+4 -23
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
@@ -131,24 +130,15 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusOK, nil
}
encodedPath := r.URL.Query().Get("path")
path := 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
// Parse lines parameter
lines := 10 // default
if linesStr != "" {
@@ -282,21 +272,12 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusForbidden, fmt.Errorf("user is not allowed to read file content")
}
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
linesStr := r.URL.Query().Get("lines")
intervalStr := r.URL.Query().Get("interval")
// 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)
}
var err error
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
+9 -27
View File
@@ -15,6 +15,7 @@ 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"
@@ -27,14 +28,9 @@ import (
//go:embed embed/*
var assets embed.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)
// GetEmbeddedAssets returns the embedded assets filesystem
func GetEmbeddedAssets() embed.FS {
return assets
}
var (
@@ -56,20 +52,10 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
}()
}
// 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")}
// Get the asset filesystem from fileutils
assetFs = fileutils.GetAssetFS()
if assetFs == nil {
logger.Fatal("Asset filesystem not initialized. Call fileutils.InitAssetFS first.")
}
// In development mode, we want to reload the templates on each request.
@@ -209,14 +195,10 @@ 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 and manifest routes
// Standard browser favicon route
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)
+1 -11
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"fmt"
"net/http"
"net/url"
"path/filepath"
"strconv"
"time"
@@ -29,7 +28,7 @@ import (
// @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) {
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
indexParam := r.URL.Query().Get("index")
@@ -37,15 +36,6 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
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)
if err != nil {
return http.StatusForbidden, err
+2 -2
View File
@@ -96,7 +96,6 @@ 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"
@@ -125,6 +124,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
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,
@@ -134,7 +134,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
return errToStatus(err), fmt.Errorf("error fetching share from server")
}
file.Token = link.Token
file.Source = ""
file.Source = link.Hash
file.Hash = link.Hash
if !link.EnableOnlyOffice || link.DisableFileViewer || reachedDownloadsLimit {
file.OnlyOfficeId = ""
+5 -9
View File
@@ -255,17 +255,13 @@ func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token strin
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, settings.Config.Server.BaseURL)
}
var downloadURL string
escapedPath := url.QueryEscape(path)
downloadURL := fmt.Sprintf("%s/api/raw?file=%s&auth=%s",
strings.TrimSuffix(baseURL, "/"), escapedPath, token)
if hash != "" {
// Share download URL - don't expose source name, just use the path relative to share
filesParam := url.QueryEscape(path)
downloadURL = fmt.Sprintf("%s/public/api/raw?files=%s&hash=%s&token=%s&auth=%s",
strings.TrimSuffix(baseURL, "/"), filesParam, hash, token, token)
downloadURL = downloadURL + "&hash=" + hash
} else {
// Regular download URL - include source for non-share requests
filesParam := url.QueryEscape(source + "::" + path)
downloadURL = fmt.Sprintf("%s/api/raw?files=%s&auth=%s",
strings.TrimSuffix(baseURL, "/"), filesParam, token)
downloadURL = downloadURL + "&source=" + url.QueryEscape(source)
}
return downloadURL
+3 -2
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -79,7 +80,7 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
d.fileInfo = *fileInfo
status, err := previewHelperFunc(w, r, d)
if err != nil {
logger.Errorf("error getting preview: %v", err)
// Error already logged in previewHelperFunc or its callees
return errToStatus(err), err
}
return status, nil
@@ -201,7 +202,7 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
officeUrl := ""
if d.fileInfo.OnlyOfficeId != "" {
pathUrl := fmt.Sprintf("/api/raw?files=%s::%s", d.fileInfo.Source, d.fileInfo.Path)
pathUrl := fmt.Sprintf("/api/raw?file=%s&source=%s", url.QueryEscape(d.fileInfo.Path), url.QueryEscape(d.fileInfo.Source))
pathUrl = pathUrl + "&auth=" + d.token
if settings.Config.Server.InternalUrl != "" {
officeUrl = config.Server.InternalUrl + pathUrl
+23 -34
View File
@@ -22,11 +22,15 @@ import (
// publicRawHandler serves the raw content of a file, multiple files, or directory via a public share.
// @Summary Download files from a public share
// @Description Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.
// @Description
// @Description **Multiple Files:**
// @Description - Use repeated query parameters: `?file=file1.txt&file=file2.txt&file=file3.txt`
// @Description - This supports filenames containing commas and special characters
// @Tags Public Shares
// @Accept json
// @Produce octet-stream
// @Param hash query string true "Share hash for authentication"
// @Param files query string true "Comma-separated list of file paths. Example: 'file1.txt,folder/file2.txt'"
// @Param file query []string true "File path (can be repeated for multiple files)"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
@@ -71,12 +75,11 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if d.share.PerUserDownloadLimit {
d.share.IncrementUserDownload(d.user.Username)
}
encodedFiles := r.URL.Query().Get("files")
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
f, err := url.PathUnescape(encodedFiles)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
// Get all "file" parameter values (supports repeated params)
encodedFiles := r.URL.Query()["file"]
if len(encodedFiles) == 0 {
return http.StatusBadRequest, fmt.Errorf("no files specified")
}
// Get the actual source name from the share's source mapping
@@ -86,22 +89,26 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
actualSourceName := sourceInfo.Name
// Parse comma-separated file list
// Decode and process each file path
fileList := []string{}
for _, file := range strings.Split(f, ",") {
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, file)
fileList = append(fileList, filePath)
}
var status int
status, err = rawFilesHandler(w, r, d, actualSourceName, fileList)
status, err := rawFilesHandler(w, r, d, actualSourceName, fileList)
if err != nil {
if err == errors.ErrDownloadNotAllowed {
return http.StatusForbidden, errors.ErrDownloadNotAllowed
}
logger.Errorf("public share handler: error processing filelist: '%v' with error %v", f, err)
return status, fmt.Errorf("error processing filelist: %v", f)
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
}
@@ -156,11 +163,8 @@ func publicUploadHandler(w http.ResponseWriter, r *http.Request, d *requestConte
if !d.share.AllowReplacements && r.URL.Query().Get("action") == "override" {
return http.StatusForbidden, fmt.Errorf("cannot overwrite files for this share")
}
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)
}
// Go automatically decodes query params
path := r.URL.Query().Get("targetPath")
fullPath := filepath.Join(d.share.Path, path)
source := config.Server.SourceMap[d.share.Source].Name
// adjust query params to match resourcePostHandler
@@ -216,8 +220,6 @@ 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)
@@ -255,13 +257,8 @@ 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")
// Decode the URL-encoded path
path, err = url.QueryUnescape(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
resolvedPath := utils.JoinPathAsUnix(d.share.Path, path)
err = files.WriteFile(source, resolvedPath, r.Body)
// hide the error
@@ -276,15 +273,7 @@ func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestConte
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)
}
path := r.URL.Query().Get("path")
indexPath := utils.JoinPathAsUnix(d.share.Path, path)
source, err := d.share.GetSourceName()
if err != nil {
+6 -3
View File
@@ -94,10 +94,14 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
// @Description - The Content-Disposition header will always include both:
// @Description 1. `filename="..."`: An ASCII-safe version of the filename for compatibility.
// @Description 2. `filename*=utf-8"...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.
// @Description
// @Description **Multiple Files:**
// @Description - Use repeated query parameters: `?file=file1.txt&file=file2.txt&file=file3.txt`
// @Description - This supports filenames containing commas and special characters
// @Tags Resources
// @Accept json
// @Param source query string true "Source name for the files (required)"
// @Param files query string true "Comma-separated list of file paths (required)"
// @Param file query []string true "File path (can be repeated for multiple files)"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
@@ -108,8 +112,7 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
// @Router /api/raw [get]
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
source := r.URL.Query().Get("source")
filesParam := r.URL.Query().Get("files")
fileList := strings.Split(filesParam, ",")
fileList := r.URL.Query()["file"]
return rawFilesHandler(w, r, d, source, fileList)
}
+20 -56
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
@@ -71,17 +70,8 @@ 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) {
encodedPath := r.URL.Query().Get("path")
path := 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{
@@ -136,20 +126,9 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
return http.StatusForbidden, fmt.Errorf("user is not allowed to delete")
}
// TODO source := r.URL.Query().Get("source")
encodedPath := r.URL.Query().Get("path")
path := 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")
}
@@ -387,7 +366,6 @@ 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")
var err error
accessStore := store.Access
@@ -395,27 +373,16 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
if d.share != nil {
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)
}
// Go automatically decodes query params - no need for QueryUnescape
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
}
// 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, "/")
isDir := isDirParam == "true" || strings.HasSuffix(path, "/")
fileOpts := utils.FileOptions{
Path: path,
Source: source,
@@ -609,20 +576,8 @@ 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")
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)
}
path := r.URL.Query().Get("path")
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
@@ -793,7 +748,9 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
}
// Get real paths
realSrc, isSrcDir, err := srcIdx.GetRealPath(userscopeSrc, item.FromPath)
// Combine user scope with item paths BEFORE calling GetRealPath to avoid double scope application
fullSrcPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
realSrc, isSrcDir, err := srcIdx.GetRealPath(fullSrcPath)
if err != nil {
logger.Errorf("could not resolve source path: %v, item.FromPath: %v", err, item.FromPath)
item.Message = "could not resolve source path"
@@ -808,7 +765,9 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
}
// Check destination parent directory exists
parentDir, _, err := dstIdx.GetRealPath(userscopeDst, filepath.Dir(item.ToPath))
dstParentPath := filepath.Dir(item.ToPath)
fullDstParentPath := utils.JoinPathAsUnix(userscopeDst, dstParentPath)
parentDir, _, err := dstIdx.GetRealPath(fullDstParentPath)
if err != nil {
item.Message = "destination directory does not exist"
if d.share != nil {
@@ -938,6 +897,13 @@ func patchAction(ctx context.Context, params patchActionParams) error {
case "rename", "move":
idx := indexing.GetIndex(params.srcIndex)
srcPath := idx.MakeIndexPath(params.src, params.isSrcDir)
userScope := ""
userScope, _ = params.d.user.GetScopeForSourceName(params.srcIndex)
if userScope != "" && userScope != "/" {
// Strip the user scope from srcPath so FileInfoFaster doesn't double it
srcPath = strings.TrimPrefix(srcPath, userScope)
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: srcPath,
@@ -959,10 +925,8 @@ func patchAction(ctx context.Context, params patchActionParams) error {
}
func inspectIndex(w http.ResponseWriter, r *http.Request) {
encodedPath := r.URL.Query().Get("path")
path := 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 {
+2 -7
View File
@@ -3,7 +3,6 @@ package http
import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"strings"
@@ -121,7 +120,7 @@ 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")
scope := r.URL.Query().Get("scope") // Go automatically decodes query params
largest := r.URL.Query().Get("largest") == "true"
var sources []string
@@ -141,14 +140,10 @@ 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(unencodedScope, ".")
searchScope := strings.TrimPrefix(scope, ".")
// If multiple sources, always use user scope (ignore searchScope)
if len(sources) > 1 {
+10 -8
View File
@@ -420,23 +420,17 @@ type DirectDownloadResponse struct {
// @Router /api/share/direct [get]
func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Extract query parameters
encodedPath := r.URL.Query().Get("path")
path := 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 encodedPath == "" || source == "" {
if path == "" || 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 {
@@ -626,6 +620,14 @@ 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)
}
+50 -14
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"text/template"
@@ -73,7 +74,7 @@ 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 + "/img/icons/android-chrome-512x512.png"
banner := staticURL + "/pwa-icon-512.png" // Use largest generated icon for best quality
disableSidebar := false
// Use custom favicon if configured and validated, otherwise fall back to default
@@ -127,18 +128,17 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
// Load loading spinners CSS from static files
loadingSpinnersCSS := ""
cssPath := "static/css/loadingSpinners.css"
cssPath := "css/loadingSpinners.css"
cssContent, err := fs.ReadFile(assetFs, cssPath)
if err == nil {
loadingSpinnersCSS = string(cssContent)
}
// Determine OpenGraph image: use banner if set, otherwise use dedicated OG image or fallback to app icon
// Determine OpenGraph image: use banner if set, otherwise use largest available icon (512x512)
ogImage := banner
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"
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"
}
// Construct the full URL for the current request
@@ -155,6 +155,23 @@ 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,
@@ -166,13 +183,16 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"favicon": favicon,
"loginIcon": loginIcon,
"color": defaultThemeColor,
"winIcon": staticURL + "/img/icons/mstile-144x144.png",
"appIcon": staticURL + "/img/icons/android-chrome-512x512.png",
"winIcon": staticURL + "/mstile-256x256.png",
"appIcon": staticURL + "/apple-touch-icon.png",
"description": description,
"loadingSpinnersCSS": loadingSpinnersCSS,
"banner": banner,
"image": ogImage,
"url": fullURL,
"pwaIcon192": pwaIcon192,
"pwaIcon256": pwaIcon256,
"pwaIcon512": pwaIcon512,
}
// variables consumed by frontend as json
data["globalVars"] = map[string]interface{}{
@@ -197,11 +217,12 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"updateAvailable": utils.GetUpdateAvailableUrl(),
"disableNavButtons": disableNavButtons,
"userSelectableThemes": config.Frontend.Styling.CustomThemeOptions,
"enableHeicConversion": config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview] && settings.MediaEnabled(),
"enableHeicConversion": settings.CanConvertImage("heic"),
"eventBasedThemes": !config.Frontend.Styling.DisableEventBasedThemes,
"loginIcon": loginIcon,
"disableSidebar": disableSidebar,
"shareHash": shareHash,
"oidcLoginButtonText": config.Frontend.OIDCLoginButtonText,
}
// Marshal each variable to JSON strings for direct template usage
@@ -272,10 +293,25 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
}
// Use embedded default
assetPath = settings.Env.FaviconEmbeddedPath
case "manifest.json":
assetPath = "img/icons/manifest.json"
case "site.webmanifest":
assetPath = "img/icons/site.webmanifest"
case "pwa-icon.svg":
// Serve custom SVG favicon for PWA if configured
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
http.ServeFile(w, r, settings.Env.FaviconPath)
return
}
// Fall back to default favicon SVG
assetPath = settings.Env.FaviconEmbeddedPath
case "favicon-16x16.png", "favicon-32x32.png",
"pwa-icon-192.png", "pwa-icon-256.png", "pwa-icon-512.png",
"apple-touch-icon.png", "mstile-256x256.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 "loginIcon":
// Handle custom login icon from filesystem
if settings.Env.LoginIconIsCustom {
+27 -3
View File
@@ -21,8 +21,18 @@ 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) {
logger.Debug("Generating OTP for user:", d.user.Username)
url, err := auth.GenerateOtpForUser(d.user, store.Users)
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)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err)
}
@@ -51,7 +61,21 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if code == "" {
return http.StatusUnauthorized, fmt.Errorf("code is required")
}
err := auth.VerifyTotpCode(d.user, code, store.Users)
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)
if err != nil {
return http.StatusUnauthorized, fmt.Errorf("invalid OTP token")
}
+78
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"sort"
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
@@ -91,6 +92,83 @@ 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.
+1 -1
View File
@@ -17,7 +17,7 @@ func setupTestIndexForDeletion(t *testing.T) *Index {
// Initialize the database if not already done
if indexDB == nil {
var err error
indexDB, err = dbsql.NewIndexDB("test_deletion", "OFF", 1000, 32, false)
indexDB, _, err = dbsql.NewIndexDB("test_deletion", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
+40 -19
View File
@@ -12,7 +12,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
indexingdb "github.com/gtsteffaniak/filebrowser/backend/database/indexing"
"github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-cache/cache"
@@ -118,8 +118,8 @@ type Index struct {
var (
indexes map[string]*Index
indexesMutex sync.RWMutex
indexDB *dbsql.IndexDB // Shared database for all indexes
indexingStorage *indexingdb.Storage // Persistent storage for index metadata
indexDB *dbsql.IndexDB // Shared database for all indexes
indexingStorage *dbindex.Storage // Persistent storage for index metadata
)
type IndexStatus string
@@ -321,7 +321,8 @@ func (idx *Index) getStatusUnlocked() IndexStatus {
}
// InitializeIndexDB creates the shared index database for all sources.
func InitializeIndexDB() error {
// Returns true if the database was recreated (either fresh or after corruption).
func InitializeIndexDB() (bool, error) {
var err error
journalMode := "OFF"
if settings.Config.Server.IndexSqlConfig.WalMode {
@@ -330,13 +331,14 @@ func InitializeIndexDB() error {
batchSize := settings.Config.Server.IndexSqlConfig.BatchSize
cacheSizeMB := settings.Config.Server.IndexSqlConfig.CacheSizeMB
disableReuse := settings.Config.Server.IndexSqlConfig.DisableReuse
indexDB, err = dbsql.NewIndexDB("all", journalMode, batchSize, cacheSizeMB, disableReuse)
var isNewDb bool
indexDB, isNewDb, err = dbsql.NewIndexDB("all", journalMode, batchSize, cacheSizeMB, disableReuse)
if err != nil {
logger.Fatalf("failed to initialize index database: %v", err)
return err
return false, err
}
return nil
return isNewDb, nil
}
// GetIndexDB returns the shared index database.
@@ -364,11 +366,11 @@ func SetIndexDBForTesting(db *dbsql.IndexDB) {
}
// SetIndexingStorage sets the persistent storage for index metadata.
func SetIndexingStorage(storage *indexingdb.Storage) {
func SetIndexingStorage(storage *dbindex.Storage) {
indexingStorage = storage
}
func Initialize(source *settings.Source, mock bool) {
func Initialize(source *settings.Source, mock bool, isNewDb bool) {
indexesMutex.Lock()
// Use shared database - all sources are differentiated by the source column
if indexDB == nil {
@@ -397,7 +399,8 @@ func Initialize(source *settings.Source, mock bool) {
if !newIndex.Config.DisableIndexing {
logger.Infof("initializing index: [%v]", newIndex.Name)
// Start multi-scanner system (each scanner will do its own initial scan)
go newIndex.setupIndexingScanners()
// Pass isNewDb flag so setupMultiScanner knows whether to load persisted complexity
go newIndex.setupMultiScanner(isNewDb)
} else {
newIndex.Status = "ready"
logger.Debug("indexing disabled for source: " + newIndex.Name)
@@ -652,12 +655,12 @@ func (idx *Index) GetFsInfoCore(indexPath string, opts Options) (*iteminfo.FileI
if !isViewable {
return nil, errors.ErrNotIndexed
}
} else if isSkipped {
return nil, errors.ErrNotIndexed
}
} else if isSkipped {
return nil, errors.ErrNotIndexed
}
realSize, _ := idx.handleFile(dirInfo, indexPath, realPath, false, nil)
fileInfo := &iteminfo.FileInfo{
realSize, _ := idx.handleFile(dirInfo, indexPath, realPath, false, nil)
fileInfo := &iteminfo.FileInfo{
Path: indexPath,
ItemInfo: iteminfo.ItemInfo{
Name: baseName,
@@ -1289,11 +1292,19 @@ 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":
@@ -1613,9 +1624,9 @@ func (idx *Index) Save() error {
idx.mu.RLock()
// Collect scanner information
scanners := make(map[string]*indexingdb.PersistedScannerInfo)
scanners := make(map[string]*dbindex.PersistedScannerInfo)
for path, scanner := range idx.scanners {
scanners[path] = &indexingdb.PersistedScannerInfo{
scanners[path] = &dbindex.PersistedScannerInfo{
Path: path,
Complexity: scanner.complexity,
CurrentSchedule: scanner.currentSchedule,
@@ -1634,7 +1645,7 @@ func (idx *Index) Save() error {
idx.mu.RUnlock()
// Create IndexInfo for persistence
info := &indexingdb.IndexInfo{
info := &dbindex.IndexInfo{
Path: idx.Path, // Use real filesystem path as key
Source: idx.Name,
Complexity: complexity,
@@ -1643,7 +1654,17 @@ func (idx *Index) Save() error {
Scanners: scanners,
}
return indexingStorage.Save(info)
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
}
// Load restores index and scanner information from the database
+1 -1
View File
@@ -16,7 +16,7 @@ func setupTestIndex(t *testing.T) (*Index, string, func()) {
// Initialize the database if not already done
if indexDB == nil {
var err error
indexDB, err = dbsql.NewIndexDB("test_indexing", "OFF", 1000, 32, false)
indexDB, _, err = dbsql.NewIndexDB("test_indexing", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
+2 -1
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.createChildScanner(dirPath)
newScanner := s.idx.createScanner(dirPath)
s.idx.mu.Lock()
s.idx.scanners[dirPath] = newScanner
@@ -507,6 +507,7 @@ 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
+9 -33
View File
@@ -5,7 +5,7 @@ import (
"strconv"
"time"
indexingdb "github.com/gtsteffaniak/filebrowser/backend/database/indexing"
indexingdb "github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
"github.com/gtsteffaniak/filebrowser/backend/events"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -179,16 +179,11 @@ func (idx *Index) PostScan() error {
logger.Errorf("Failed to shrink memory: %v", err)
}
// Persist index and scanner information
// Persist index and scanner information (SSE event will be sent by Save())
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
@@ -202,7 +197,6 @@ 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)
@@ -224,7 +218,8 @@ func (idx *Index) SendSourceUpdateEvent() error {
}
// setupMultiScanner creates and starts the multi-scanner system
func (idx *Index) setupMultiScanner() {
// isNewDb: if true, skip loading persisted complexity values (database is new or recreated)
func (idx *Index) setupMultiScanner(isNewDb bool) {
// 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)
@@ -246,7 +241,7 @@ func (idx *Index) setupMultiScanner() {
// Load persisted scanner info if available
var persistedScanners map[string]*indexingdb.PersistedScannerInfo
if indexingStorage != nil {
if indexingStorage != nil && !isNewDb {
info, err := indexingStorage.GetByPath(idx.Path)
if err == nil && info != nil {
persistedScanners = info.Scanners
@@ -254,8 +249,7 @@ func (idx *Index) setupMultiScanner() {
}
// Create and start root scanner
rootScanner := idx.createRootScanner()
// Restore persisted stats for root scanner if available
rootScanner := idx.createScanner("/")
if persistedScanners != nil {
if rootInfo, ok := persistedScanners["/"]; ok {
rootScanner.complexity = rootInfo.Complexity
@@ -277,9 +271,9 @@ func (idx *Index) setupMultiScanner() {
// Create child scanner for each top-level directory
for _, dirPath := range topLevelDirs {
childScanner := idx.createChildScanner(dirPath)
childScanner := idx.createScanner(dirPath)
// Restore persisted stats for child scanner if available
// Restore persisted stats for child scanner if available (and DB is not new)
if persistedScanners != nil {
if childInfo, ok := persistedScanners[dirPath]; ok {
childScanner.complexity = childInfo.Complexity
@@ -302,20 +296,8 @@ func (idx *Index) setupMultiScanner() {
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) createChildScanner(dirPath string) *Scanner {
func (idx *Index) createScanner(dirPath string) *Scanner {
return &Scanner{
scanPath: dirPath,
idx: idx,
@@ -326,12 +308,6 @@ func (idx *Index) createChildScanner(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()
+5 -5
View File
@@ -13,7 +13,7 @@ func BenchmarkFillIndex(b *testing.B) {
Initialize(&settings.Source{
Name: "test",
Path: "/srv",
}, true)
}, true, false) // mock=true, isNewDb=false
idx := GetIndex("test")
if idx == nil {
b.Fatal("index is nil")
@@ -58,7 +58,7 @@ func TestMultiScannerMutex(t *testing.T) {
// Initialize the database if not already done
if indexDB == nil {
var err error
indexDB, err = dbsql.NewIndexDB("test_multiscanner", "OFF", 1000, 32, false)
indexDB, _, err = dbsql.NewIndexDB("test_multiscanner", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
@@ -79,9 +79,9 @@ func TestMultiScannerMutex(t *testing.T) {
}
// Create multiple scanners
scanner1 := idx.createRootScanner()
scanner2 := idx.createChildScanner("/test1/")
scanner3 := idx.createChildScanner("/test2/")
scanner1 := idx.createScanner("/")
scanner2 := idx.createScanner("/test1/")
scanner3 := idx.createScanner("/test2/")
idx.scanners["/"] = scanner1
idx.scanners["/test1/"] = scanner2
+9
View File
@@ -9,6 +9,10 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
const (
LargeFileSizeThreshold = 75 * 1024 * 1024 // 75MB
)
var AllFiletypeOptions = []string{
"image",
"audio",
@@ -56,6 +60,11 @@ var ResizableImageTypes = map[string]bool{
".tif": true,
".heic": true,
".heif": true,
".webp": true,
".pbm": true,
".pgm": true,
".ppm": true,
".pam": true,
".other": false,
}
+3 -3
View File
@@ -28,7 +28,7 @@ func setupMutateTestIndex(t *testing.T) *Index {
}
var err error
testDB, err := dbsql.NewIndexDB("test_init", "OFF", 1000, 32, false)
testDB, _, err := dbsql.NewIndexDB("test_init", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
@@ -214,7 +214,7 @@ func TestUpdateFileMetadata(t *testing.T) {
// Initialize the database if not already done
if indexDB == nil {
var err error
indexDB, err = dbsql.NewIndexDB("test_mutate", "OFF", 1000, 32, false)
indexDB, _, err = dbsql.NewIndexDB("test_mutate", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
@@ -274,7 +274,7 @@ func TestSetDirectoryInfo(t *testing.T) {
// Initialize the database if not already done
if indexDB == nil {
var err error
indexDB, err = dbsql.NewIndexDB("test_mutate", "OFF", 1000, 32, false)
indexDB, _, err = dbsql.NewIndexDB("test_mutate", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
+6 -6
View File
@@ -49,7 +49,7 @@ func TestMain(m *testing.M) {
}
func BenchmarkSearchAllIndexes(b *testing.B) {
Initialize(&settings.Source{Name: "test", Path: "/srv"}, true)
Initialize(&settings.Source{Name: "test", Path: "/srv"}, true, false) // mock=true, isNewDb=false
idx := GetIndex("test")
idx.CreateMockData(50, 3) // 50 dirs, 3 files per dir
@@ -118,7 +118,7 @@ func TestSearchWhileIndexing(t *testing.T) {
// Initialize the database if not already done
if indexDB == nil {
var err error
indexDB, err = dbsql.NewIndexDB("test_search_while", "OFF", 1000, 32, false)
indexDB, _, err = dbsql.NewIndexDB("test_search_while", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
@@ -143,7 +143,7 @@ func TestSearchWhileIndexing(t *testing.T) {
IndexingDisabled: false,
}
Initialize(source, true)
Initialize(source, true, false) // mock=true, isNewDb=false
idx := GetIndex("test")
if idx == nil {
t.Fatal("Failed to get test index")
@@ -181,7 +181,7 @@ func TestSearchIndexes(t *testing.T) {
// Initialize the database if not already done
if indexDB == nil {
var err error
indexDB, err = dbsql.NewIndexDB("test_search", "OFF", 1000, 32, false)
indexDB, _, err = dbsql.NewIndexDB("test_search", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
@@ -462,7 +462,7 @@ func TestSearchLargestModeExcludesRoot(t *testing.T) {
// Initialize the database if not already done
if indexDB == nil {
var err error
indexDB, err = dbsql.NewIndexDB("test_search_largest", "OFF", 1000, 32, false)
indexDB, _, err = dbsql.NewIndexDB("test_search_largest", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
@@ -558,7 +558,7 @@ func TestSearchLargestModeExcludesScopeDirectory(t *testing.T) {
// Initialize the database if not already done
if indexDB == nil {
var err error
indexDB, err = dbsql.NewIndexDB("test_search_scope", "OFF", 1000, 32, false)
indexDB, _, err = dbsql.NewIndexDB("test_search_scope", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
-1
View File
@@ -50,7 +50,6 @@ userDefaults:
deleteWithoutConfirming: false # delete files without confirmation
disableOnlyOfficeExt: ".md .txt .pdf" # list of file extensions to disable onlyoffice editor for
showSelectMultiple: false # show select multiple files on desktop
defaultLandingPage: "" # default landing page to use if no redirect is specified: eg. /files/mysource/mysubpath, /settings, etc.
#office:
# url: "http://localhost:9052"
+39
View File
@@ -0,0 +1,39 @@
package preview
import (
"bytes"
"sync"
)
// 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
return bytes.NewBuffer(make([]byte, 0, 128*1024))
},
}
// getBuffer gets a buffer from the pool
func getBuffer() *bytes.Buffer {
buf, ok := bufferPool.Get().(*bytes.Buffer)
if !ok {
// This should never happen, but provide a fallback
buf = bytes.NewBuffer(make([]byte, 0, 128*1024))
}
buf.Reset()
return buf
}
// 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
}
buf.Reset()
bufferPool.Put(buf)
}
+74
View File
@@ -0,0 +1,74 @@
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()
}
+1 -7
View File
@@ -28,13 +28,7 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
return nil, ctx.Err()
}
// 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
// Serialize access to the entire go-fitz operation block (required for CGO thread safety)
s.docGenMutex.Lock()
defer s.docGenMutex.Unlock()
+2 -9
View File
@@ -15,14 +15,7 @@ func docEnabled() bool {
}
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]byte, error) {
// Acquire document semaphore
if err := s.acquireDoc(ctx); err != nil {
return nil, err
}
defer s.releaseDoc()
s.docGenMutex.Lock()
defer s.docGenMutex.Unlock()
// Reference it to prevent unused field warning when building without mupdf
_ = &s.docGenMutex
return nil, nil
}
+47 -11
View File
@@ -8,21 +8,21 @@ import (
// convertHEICToJPEGWithFFmpeg converts a HEIC file to JPEG format using FFmpeg
// 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 {
return nil, fmt.Errorf("FFmpeg is not available")
}
// Check if context is cancelled before starting
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Use the shared image service
if s.imageService == nil {
return nil, fmt.Errorf("image service not available")
}
// Acquire image service semaphore
if err := s.imageService.Acquire(ctx); err != nil {
// Acquire FFmpeg service semaphore
if err := s.ffmpegService.Acquire(ctx); err != nil {
return nil, err
}
defer s.imageService.Release()
defer s.ffmpegService.Release()
// Determine target dimensions and quality based on preview size
var width, height int
@@ -39,10 +39,46 @@ func (s *Service) convertHEICToJPEGWithFFmpeg(ctx context.Context, filePath stri
width, height = 256, 256
quality = "5" // Medium quality
}
// Use tile-based conversion for correct full-resolution image reconstruction
result, err := s.imageService.ConvertHEICToJPEG(ctx, filePath, width, height, quality)
if err != nil {
return s.ffmpegService.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 {
return nil, fmt.Errorf("FFmpeg is not available for JPEG fallback")
}
// Check if context is cancelled before starting
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Acquire FFmpeg service semaphore
if err := s.ffmpegService.Acquire(ctx); err != nil {
return nil, err
}
return result, nil
defer s.ffmpegService.Release()
// Determine target dimensions and quality based on preview size
var width, height int
var quality string
switch previewSize {
case "large":
width, height = 640, 640
quality = "2" // High quality for FFmpeg -q:v
case "original":
// For original size - no scaling, maximum quality
width, height = 0, 0 // Signal to not apply scaling
quality = "1" // Maximum quality for original
default:
width, height = 256, 256
quality = "5" // Medium quality
}
// Use direct conversion which works for all image formats including problematic JPEGs
return s.ffmpegService.ConvertImageToJPEG(ctx, filePath, width, height, quality)
}
+146 -102
View File
@@ -1,10 +1,9 @@
package preview
import (
"bytes"
"context"
"fmt"
"image"
"image/jpeg"
"io"
exif "github.com/dsoprea/go-exif/v3"
@@ -21,6 +20,11 @@ gif
tiff
bmp
heic
webp
pbm
pgm
ppm
pam
)
*/
type Format int
@@ -39,6 +43,16 @@ func (x Format) toImaging() imaging.Format {
return imaging.BMP
case FormatHeic:
return imaging.JPEG
case FormatWebp:
return imaging.WEBP
case FormatPbm:
return imaging.PBM
case FormatPgm:
return imaging.PGM
case FormatPpm:
return imaging.PPM
case FormatPam:
return imaging.PAM
default:
return imaging.JPEG
}
@@ -56,7 +70,8 @@ type Quality int
func (x Quality) resampleFilter() imaging.ResampleFilter {
switch x {
case QualityHigh:
return imaging.Lanczos
// Use CatmullRom instead of Lanczos - faster with similar quality for thumbnails
return imaging.CatmullRom
case QualityMedium:
return imaging.Box
case QualityLow:
@@ -66,6 +81,20 @@ func (x Quality) resampleFilter() imaging.ResampleFilter {
}
}
// jpegQuality returns JPEG quality (1-100) based on Quality setting
func (x Quality) jpegQuality() int {
switch x {
case QualityHigh:
return 85 // Good quality, faster than 95+
case QualityMedium:
return 75 // Balanced quality/speed
case QualityLow:
return 65 // Lower quality, faster encoding
default:
return 75
}
}
/*
ENUM(
fit
@@ -96,68 +125,61 @@ func (s *Service) FormatFromExtension(ext string) (Format, error) {
return FormatTiff, nil
case imaging.BMP:
return FormatBmp, nil
case imaging.WEBP:
return FormatWebp, nil
case imaging.PBM:
return FormatPbm, nil
case imaging.PGM:
return FormatPgm, nil
case imaging.PPM:
return FormatPpm, nil
case imaging.PAM:
return FormatPam, nil
default:
return -1, ErrUnsupportedFormat
}
}
type resizeConfig struct {
format Format
resizeMode ResizeMode
quality Quality
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 Option func(*resizeConfig)
func WithFormat(format Format) Option {
return func(config *resizeConfig) {
config.format = format
}
func (s *Service) Resize(in io.Reader, out io.Writer, opts ResizeOptions) error {
return s.ResizeWithSize(in, out, 0, opts)
}
func WithMode(mode ResizeMode) Option {
return func(config *resizeConfig) {
config.resizeMode = mode
// 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 WithQuality(quality Quality) Option {
return func(config *resizeConfig) {
config.quality = quality
if opts.Quality == 0 {
opts.Quality = QualityMedium
}
}
func (s *Service) Resize(in io.Reader, width, height int, out io.Writer, options ...Option) error {
// Use image service semaphore for image processing
if s.imageService != nil {
if err := s.imageService.Acquire(context.Background()); err != nil {
return err
}
defer s.imageService.Release()
if opts.JpegQuality == 0 {
opts.JpegQuality = opts.Quality.jpegQuality()
}
format, wrappedReader, err := s.detectFormat(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
if opts.Format == FormatHeic {
opts.Format = FormatJpeg
}
config := resizeConfig{
format: format,
resizeMode: ResizeModeFit,
quality: QualityMedium,
}
for _, option := range options {
option(&config)
}
if format == FormatHeic {
config.format = FormatJpeg
}
if config.quality == QualityLow && format == FormatJpeg {
// Try to use embedded EXIF thumbnail for JPEGs (only for low quality to keep it simple)
if opts.Format == FormatJpeg && opts.Quality == QualityLow {
thm, newWrappedReader, errThm := getEmbeddedThumbnail(wrappedReader)
wrappedReader = newWrappedReader
if errThm == nil {
if errThm == nil && len(thm) > 0 {
_, err = out.Write(thm)
if err == nil {
return nil
@@ -165,78 +187,100 @@ func (s *Service) Resize(in io.Reader, width, height int, out io.Writer, options
}
}
// For HEIC files, try without AutoOrientation first since it might not work properly
img, err := imaging.Decode(wrappedReader)
// Decode the image - try imaging library first, fall back to format-specific decoder if it fails
img, err := imaging.Decode(wrappedReader, imaging.AutoOrientation(true))
if err != nil {
return fmt.Errorf("failed to decode image: %w", err)
// 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 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)
}
}
}
// Note: For HEIC files processed via FFmpeg, orientation is handled automatically
switch config.resizeMode {
// Resize
resampleFilter := opts.Quality.resampleFilter()
switch opts.ResizeMode {
case ResizeModeFill:
img = imaging.Fill(img, width, height, imaging.Center, config.quality.resampleFilter())
img = imaging.Fill(img, opts.Width, opts.Height, imaging.Center, resampleFilter)
case ResizeModeFit:
img = imaging.Fit(img, width, height, config.quality.resampleFilter())
fallthrough
default:
img = imaging.Fit(img, width, height, config.quality.resampleFilter())
img = imaging.Fit(img, opts.Width, opts.Height, resampleFilter)
}
return imaging.Encode(out, img, config.format.toImaging())
}
func (s *Service) detectFormat(in io.Reader) (Format, io.Reader, error) {
// Read all data into a buffer first to avoid consuming the reader twice
allData, err := io.ReadAll(in)
if err != nil {
return 0, nil, fmt.Errorf("failed to read image data: %w", err)
// Encode
if opts.Format == FormatJpeg {
return jpeg.Encode(out, img, &jpeg.Options{Quality: opts.JpegQuality})
}
// Create a reader for format detection
reader := bytes.NewReader(allData)
_, imgFormat, err := image.DecodeConfig(reader)
if err != nil {
return 0, nil, fmt.Errorf("image.DecodeConfig failed (data size: %d bytes): %s: %w", len(allData), err.Error(), ErrUnsupportedFormat)
}
if imgFormat == "heif" {
imgFormat = "heic"
}
format, err := ParseFormat(imgFormat)
if err != nil {
return 0, nil, ErrUnsupportedFormat
}
// Return a new reader with all the data for subsequent operations
return format, bytes.NewReader(allData), nil
return imaging.Encode(out, img, opts.Format.toImaging())
}
func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
// Read all data to avoid partial consumption issues
allData, err := io.ReadAll(in)
if err != nil {
return nil, nil, err
// Optimize memory: only read enough bytes for EXIF parsing (max 64KB)
// EXIF data is typically in the first 64KB of JPEG files
const maxExifSize = 0xffff // 64KB
var headerBuf []byte
var wrappedReader io.Reader
// Check if reader is seekable - if so, we can peek and seek back (most efficient)
if seeker, ok := in.(io.Seeker); ok {
originalPos, err := seeker.Seek(0, io.SeekCurrent)
if err == nil {
// Use buffer pool for temporary storage
buf := getBuffer()
defer putBuffer(buf)
// Read just the header
limitedReader := io.LimitReader(in, maxExifSize)
_, readErr := buf.ReadFrom(limitedReader)
if readErr == nil || readErr == io.EOF {
// Copy to permanent storage for EXIF parsing
headerBuf = make([]byte, buf.Len())
copy(headerBuf, buf.Bytes())
// Seek back to start for main processing
_, _ = seeker.Seek(originalPos, io.SeekStart)
wrappedReader = in
} else {
// Read error - cannot extract thumbnail, return error to fall back to full decode
_, _ = seeker.Seek(originalPos, io.SeekStart)
return nil, in, fmt.Errorf("failed to read header for thumbnail extraction: %w", readErr)
}
} 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")
}
} 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")
}
// Create a reader that can be returned for fallback processing
wrappedReader := bytes.NewReader(allData)
// Try to read up to 0xffff bytes for EXIF header parsing
// Use the actual data size if file is smaller
headSize := 0xffff
if len(allData) < headSize {
headSize = len(allData)
}
head := allData[:headSize]
// Try to find EXIF header
offsets := []int{12, 30}
var offset int
var exifErr error
for _, offset = range offsets {
if _, exifErr = exif.ParseExifHeader(head[offset:]); exifErr == nil {
break
if offset < len(headerBuf) {
if _, exifErr = exif.ParseExifHeader(headerBuf[offset:]); exifErr == nil {
break
}
}
}
@@ -249,7 +293,7 @@ func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
return nil, wrappedReader, err
}
_, index, err := exif.Collect(im, exif.NewTagIndex(), head[offset:])
_, index, err := exif.Collect(im, exif.NewTagIndex(), headerBuf[offset:])
if err != nil {
return nil, wrappedReader, err
}
+92 -78
View File
@@ -33,17 +33,13 @@ func TestMain(m *testing.M) {
func TestService_Resize(t *testing.T) {
testCases := map[string]struct {
options []Option
width int
height int
options ResizeOptions
source func(t *testing.T) afero.File
matcher func(t *testing.T, reader io.Reader)
wantErr bool
}{
"fill upscale": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 50, 20)
@@ -51,9 +47,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"fill downscale": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -61,9 +55,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"fit upscale": {
options: []Option{WithMode(ResizeModeFit)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFit},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 50, 20)
@@ -71,9 +63,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(50, 20),
},
"fit downscale": {
options: []Option{WithMode(ResizeModeFit)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFit},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -81,9 +71,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 75),
},
"keep original format": {
options: []Option{},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, Format: FormatPng},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayPng(t, 200, 150)
@@ -91,9 +79,7 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatPng),
},
"convert to jpeg": {
options: []Option{WithFormat(FormatJpeg)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, Format: FormatJpeg},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -101,9 +87,7 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatJpeg),
},
"convert to png": {
options: []Option{WithFormat(FormatPng)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, Format: FormatPng},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -111,9 +95,7 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatPng),
},
"convert to gif": {
options: []Option{WithFormat(FormatGif)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, Format: FormatGif},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -121,9 +103,7 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatGif),
},
"convert to tiff": {
options: []Option{WithFormat(FormatTiff)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, Format: FormatTiff},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -131,9 +111,7 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatTiff),
},
"convert to bmp": {
options: []Option{WithFormat(FormatBmp)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, Format: FormatBmp},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -141,9 +119,7 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatBmp),
},
"convert to unknown": {
options: []Option{WithFormat(Format(-1))},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, Format: Format(-1)},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -151,9 +127,7 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatJpeg),
},
"resize png": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayPng(t, 200, 150)
@@ -161,9 +135,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize gif": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayGif(t, 200, 150)
@@ -171,9 +143,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize tiff": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayTiff(t, 200, 150)
@@ -181,9 +151,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize bmp": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayBmp(t, 200, 150)
@@ -191,9 +159,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize with high quality": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityHigh)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityHigh},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -201,9 +167,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize with medium quality": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityMedium)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityMedium},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -211,9 +175,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize with low quality": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -221,9 +183,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize with unknown quality": {
options: []Option{WithMode(ResizeModeFill), WithQuality(Quality(-1))},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: Quality(-1)},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -231,9 +191,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"get thumbnail from file with APP0 JFIF": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
source: func(t *testing.T) afero.File {
t.Helper()
return openFile(t, "testdata/gray-sample.jpg")
@@ -241,9 +199,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(125, 128),
},
"get thumbnail from file without APP0 JFIF": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
source: func(t *testing.T) afero.File {
t.Helper()
return openFile(t, "testdata/20130612_142406.jpg")
@@ -251,9 +207,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(320, 240),
},
"resize from file without IFD1 thumbnail": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
source: func(t *testing.T) afero.File {
t.Helper()
return openFile(t, "testdata/IMG_2578.JPG")
@@ -261,9 +215,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize for higher quality levels": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityMedium)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityMedium},
source: func(t *testing.T) afero.File {
t.Helper()
return openFile(t, "testdata/gray-sample.jpg")
@@ -271,9 +223,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"broken file": {
options: []Option{WithMode(ResizeModeFit)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFit},
source: func(t *testing.T) afero.File {
t.Helper()
fs := afero.NewMemMapFs()
@@ -291,14 +241,13 @@ func TestService_Resize(t *testing.T) {
for name, test := range testCases {
t.Run(name, func(t *testing.T) {
// Use a temporary directory for cache to avoid creating directories in the source tree
tmpDir := t.TempDir()
svc := NewPreviewGenerator(1, tmpDir)
source := test.source(t)
defer source.Close()
buf := &bytes.Buffer{}
err := svc.Resize(source, test.width, test.height, buf, test.options...)
err := svc.Resize(source, buf, test.options)
if (err != nil) != test.wantErr {
t.Fatalf("GetMarketSpecs() error = %v, wantErr %v", err, test.wantErr)
}
@@ -439,10 +388,26 @@ func TestService_FormatFromExtension(t *testing.T) {
ext: ".tiff",
want: FormatTiff,
},
"tif": {
ext: ".tif",
want: FormatTiff,
},
"bmp": {
ext: ".bmp",
want: FormatBmp,
},
"heic": {
ext: ".heic",
want: FormatHeic,
},
"heif": {
ext: ".heif",
want: FormatHeic,
},
"webp": {
ext: ".webp",
want: FormatWebp,
},
"unknown": {
ext: ".mov",
wantErr: ErrUnsupportedFormat,
@@ -451,7 +416,6 @@ func TestService_FormatFromExtension(t *testing.T) {
for name, test := range testCases {
t.Run(name, func(t *testing.T) {
// Use a temporary directory for cache to avoid creating directories in the source tree
tmpDir := t.TempDir()
svc := NewPreviewGenerator(1, tmpDir)
got, err := svc.FormatFromExtension(test.ext)
@@ -484,3 +448,53 @@ 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)
out, err := svc.CreatePreviewFromReader(bytes.NewReader(data), "small")
require.NoError(t, err)
require.NotEmpty(t, out)
// Same via CreatePreview (bytes) for parity
out2, err := svc.CreatePreview(data, "small")
require.NoError(t, err)
require.Equal(t, out, out2)
}
+1 -4
View File
@@ -21,11 +21,8 @@ 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{}{
+368 -49
View File
@@ -8,6 +8,8 @@ import (
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
@@ -20,6 +22,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/kovidgoyal/imaging"
)
var (
@@ -29,21 +32,27 @@ var (
)
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
docSemaphore chan struct{} // Semaphore for document generation
officeSem chan struct{} // Semaphore for office document processing
videoService *ffmpeg.FFmpegService
imageService *ffmpeg.FFmpegService
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
}
// 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
// get round up half value of concurrencyLimit for FFmpeg operations
ffmpegConcurrencyLimit := (concurrencyLimit + 1) / 2
actualCacheDir := cacheDir
@@ -73,49 +82,81 @@ func NewPreviewGenerator(concurrencyLimit int, cacheDir string) *Service {
logger.Error(err)
}
videoService := ffmpeg.NewFFmpegService(ffmpegConcurrencyLimit, settings.Config.Integrations.Media.Debug, "")
imageService := ffmpeg.NewFFmpegService(concurrencyLimit, settings.Config.Integrations.Media.Debug, filepath.Join(actualCacheDir, "heic"))
// Single FFmpeg service shared by video preview and HEIC/JPEG fallback
ffmpegService := ffmpeg.NewFFmpegService(ffmpegConcurrencyLimit, settings.Config.Integrations.Media.Debug, filepath.Join(actualCacheDir, "heic"))
var imageSem, imageLargeSem chan struct{}
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()
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
return &Service{
fileCache: fileCache,
cacheDir: actualCacheDir,
debug: settings.Config.Integrations.Media.Debug,
docSemaphore: make(chan struct{}, 1), // must be 1 because cgo thread limit
officeSem: make(chan struct{}, concurrencyLimit),
videoService: videoService,
imageService: imageService,
fileCache: fileCache,
cacheDir: actualCacheDir,
debug: settings.Config.Integrations.Media.Debug,
ffmpegService: ffmpegService,
imageSem: imageSem,
imageLargeSem: imageLargeSem,
}
}
// Document semaphore methods
func (s *Service) acquireDoc(ctx context.Context) error {
// 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 {
select {
case s.docSemaphore <- struct{}{}:
case s.imageSem <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) releaseDoc() {
<-s.docSemaphore
func (s *Service) releaseImageSem() {
<-s.imageSem
}
// Office semaphore methods
func (s *Service) acquireOffice(ctx context.Context) error {
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)
}
select {
case s.officeSem <- struct{}{}:
case s.imageLargeSem <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) releaseOffice() {
<-s.officeSem
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 StartPreviewGenerator(concurrencyLimit int, cacheDir string) error {
@@ -123,9 +164,165 @@ func StartPreviewGenerator(concurrencyLimit int, cacheDir string) error {
logger.Errorf("WARNING: StartPreviewGenerator called multiple times! This will create multiple semaphores!")
}
service = NewPreviewGenerator(concurrencyLimit, cacheDir)
// Generate PWA icons after service is initialized
GeneratePWAIcons()
return nil
}
// GetService returns the preview service instance (can be nil if not started)
func GetService() *Service {
return service
}
// GeneratePWAIcons generates all icon sizes from favicon.svg or favicon.png
func GeneratePWAIcons() {
if service == nil {
logger.Warning("Preview service not initialized, skipping icon generation")
return
}
// Determine source icon - prefer custom, fallback to default
var sourceIcon string
var sourceData []byte
var err error
var isSVG bool
if settings.Env.FaviconIsCustom {
sourceIcon = settings.Env.FaviconPath
logger.Debugf("Generating icons from custom favicon: %s", sourceIcon)
isSVG = strings.ToLower(filepath.Ext(sourceIcon)) == ".svg"
// Read the source file
sourceData, err = os.ReadFile(sourceIcon)
if err != nil {
logger.Warningf("Failed to read custom favicon: %v", err)
return
}
// If it's not a raster format that imaging library can decode, handle separately
if isSVG {
// SVG: Set PWA icons to use SVG directly, look for PNG for raster generation
logger.Debug("Source is SVG - will use directly where supported")
settings.Env.PWAIcon192 = "pwa-icon.svg"
settings.Env.PWAIcon256 = "pwa-icon.svg"
settings.Env.PWAIcon512 = "pwa-icon.svg"
// Look for PNG in same directory with same base name
basePath := sourceIcon[:len(sourceIcon)-len(filepath.Ext(sourceIcon))]
pngPath := basePath + ".png"
sourceData, err = os.ReadFile(pngPath)
if err != nil {
logger.Warningf("SVG favicon provided without PNG version at %s. For best compatibility across all browsers and platforms, provide a PNG version alongside your SVG.", pngPath)
return
}
logger.Debugf("Using PNG version (%s) for generating raster icons", pngPath)
} else {
// For any raster format (JPEG, PNG, GIF, WebP, etc.), decode and convert to PNG
// This creates a standardized PNG source for all icon generation
img, decodeErr := imaging.Decode(bytes.NewReader(sourceData))
if decodeErr != nil {
logger.Warningf("Failed to decode favicon image: %v", decodeErr)
return
}
// Re-encode as PNG in memory for consistent processing
var buf bytes.Buffer
if encodeErr := imaging.Encode(&buf, img, imaging.PNG); encodeErr != nil {
logger.Warningf("Failed to convert favicon to PNG: %v", encodeErr)
return
}
sourceData = buf.Bytes()
logger.Debugf("Converted %s favicon to PNG for icon generation", filepath.Ext(sourceIcon))
}
} else {
// Use default embedded favicon - we have both SVG and PNG versions
logger.Debug("Generating icons from default favicon")
// For default, use the PNG version for raster generation
assetFs := fileutils.GetAssetFS()
if assetFs == nil {
logger.Warning("Asset filesystem not initialized, skipping default icon generation")
return
}
pngPath := "img/icons/favicon.png"
sourceData, err = fs.ReadFile(assetFs, pngPath)
if err != nil {
logger.Warningf("Failed to read default favicon PNG: %v", err)
return
}
// Set PWA icons to use SVG for modern browsers
settings.Env.PWAIcon192 = "pwa-icon.svg"
settings.Env.PWAIcon256 = "pwa-icon.svg"
settings.Env.PWAIcon512 = "pwa-icon.svg"
}
// Define all icon sizes we need to generate
// Format: {size, filename, envVar pointer (optional)}
iconSizes := []struct {
size int
name string
env *string
}{
// Browser favicon
{32, "favicon-32x32.png", nil},
// PWA icons
{192, "pwa-icon-192.png", &settings.Env.PWAIcon192},
{256, "pwa-icon-256.png", &settings.Env.PWAIcon256},
{512, "pwa-icon-512.png", &settings.Env.PWAIcon512},
// Platform-specific icons
{180, "apple-touch-icon.png", nil}, // iOS home screen
{256, "mstile-256x256.png", nil}, // Windows tile
}
allSuccess := true
generatedCount := 0
for _, iconSize := range iconSizes {
outputPath := filepath.Join(settings.Env.PWAIconsDir, iconSize.name)
// Create output file
outFile, err := os.Create(outputPath)
if err != nil {
logger.Warningf("Failed to create icon %s: %v", iconSize.name, err)
allSuccess = false
continue
}
// Resize image using the service's Resize method
err = service.Resize(
bytes.NewReader(sourceData),
outFile,
ResizeOptions{
Width: iconSize.size,
Height: iconSize.size,
ResizeMode: ResizeModeFill,
Quality: QualityHigh,
Format: FormatPng,
},
)
outFile.Close()
if err != nil {
logger.Warningf("Failed to generate icon %s: %v", iconSize.name, err)
os.Remove(outputPath)
allSuccess = false
continue
}
// Update environment variable if specified
if iconSize.env != nil {
*iconSize.env = filepath.Join("icons", iconSize.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))
}
}
func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, url string, seekPercentage int) ([]byte, error) {
if !file.HasPreview {
return nil, ErrUnsupportedMedia
@@ -174,6 +371,35 @@ 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 {
// Large file path - use imageLargeSem
if err := service.acquireImageLargeSem(ctx); err != nil {
return nil, err
}
defer service.releaseImageLargeSem()
} else {
// Small file path or fallback - use imageSem
if err := service.acquireImageSem(ctx); err != nil {
return nil, err
}
defer service.releaseImageSem()
}
// 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)
}
ext := strings.ToLower(filepath.Ext(file.Name))
var (
err error
@@ -184,7 +410,10 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
hasher := md5.New()
_, _ = hasher.Write([]byte(CacheKey(fileMD5, previewSize, seekPercentage)))
hash := hex.EncodeToString(hasher.Sum(nil))
convertHEIC := strings.HasPrefix(file.Type, "image/heic") && settings.Config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview]
// 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]
// Generate an image from office document
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
tempFilePath := filepath.Join(service.cacheDir, "thumbnails", "docs", hash) + ".txt"
@@ -210,12 +439,58 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
}
return imageBytes, nil
} 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)
// Stream from file instead of os.ReadFile so we don't load every image fully into memory.
// ResizeWithSize() holds the imaging semaphore and only reads in chunks / decodes one at a time.
f, openErr := os.Open(file.RealPath)
if openErr != nil {
logger.Errorf("Failed to open image file '%s' (path: %s): %v", file.Name, file.RealPath, openErr)
return nil, fmt.Errorf("failed to open image file: %w", openErr)
}
defer f.Close()
imageBytes, err = service.CreatePreviewFromReaderWithSize(f, previewSize, file.Size)
if err != nil {
// Check if this is an unsupported JPEG format and FFmpeg fallback is enabled
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")) {
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
if enableJPEGFallback && service.ffmpegService != nil {
logger.Debugf("JPEG decode failed for '%s', falling back to FFmpeg: %v", file.Name, err)
imageBytes, err = service.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to resize preview image with FFmpeg fallback: %w", err)
}
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache FFmpeg-converted image: %v", err)
}
return imageBytes, nil
}
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 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))
}
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache image: %v", 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
@@ -258,6 +533,37 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
// resize image
resizedBytes, err := service.CreatePreview(imageBytes, previewSize)
if err != nil {
// 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")) {
// 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.ffmpegService != 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
}
// 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
@@ -273,7 +579,6 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
}
return imageBytes, nil
}
}
func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int) ([]byte, error) {
@@ -287,30 +592,44 @@ func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previe
}
func (s *Service) CreatePreview(data []byte, previewSize string) ([]byte, error) {
var (
width int
height int
options []Option
)
return s.CreatePreviewFromReader(bytes.NewReader(data), previewSize)
}
// CreatePreviewFromReader resizes an image from a reader (e.g. *os.File) without loading it fully into memory.
// Used for the image preview path so only concurrencyLimit decodes run at once.
func (s *Service) CreatePreviewFromReader(reader io.Reader, previewSize string) ([]byte, error) {
return s.CreatePreviewFromReaderWithSize(reader, previewSize, 0)
}
// CreatePreviewFromReaderWithSize resizes an image with file size information for semaphore selection.
func (s *Service) CreatePreviewFromReaderWithSize(reader io.Reader, previewSize string, fileSize int64) ([]byte, error) {
var options ResizeOptions
switch previewSize {
case "large":
width, height = 640, 640
options = []Option{WithMode(ResizeModeFit), WithQuality(QualityHigh), WithFormat(FormatJpeg)}
options = ResizeOptions{
Width: 640,
Height: 640,
ResizeMode: ResizeModeFit,
Quality: QualityHigh,
Format: FormatJpeg,
}
case "small":
width, height = 256, 256
options = []Option{WithMode(ResizeModeFit), WithQuality(QualityMedium), WithFormat(FormatJpeg)}
options = ResizeOptions{
Width: 256,
Height: 256,
ResizeMode: ResizeModeFit,
Quality: QualityMedium,
Format: FormatJpeg,
}
default:
return nil, ErrUnsupportedFormat
}
input := bytes.NewReader(data)
output := &bytes.Buffer{}
if err := s.Resize(input, width, height, output, options...); err != nil {
if err := s.ResizeWithSize(reader, output, fileSize, 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.videoService == nil {
logger.Errorf("Video service not available for file '%s'", videoPath)
return nil, fmt.Errorf("video service not available")
if s.ffmpegService == nil {
logger.Errorf("FFmpeg service not available for file '%s'", videoPath)
return nil, fmt.Errorf("FFmpeg 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.videoService.GenerateVideoPreviewStreaming(ctx, videoPath, percentageSeek, &buf)
err := s.ffmpegService.GenerateVideoPreviewStreaming(ctx, videoPath, percentageSeek, &buf)
if err != nil {
return nil, err
}
previewBytes := buf.Bytes()
if len(previewBytes) == 0 {
logger.Errorf("Video service returned empty result for '%s'", videoPath)
logger.Errorf("FFmpeg service returned empty result for '%s'", videoPath)
return nil, fmt.Errorf("video preview generation returned empty result")
}
+21 -17
View File
@@ -1278,7 +1278,7 @@ const docTemplate = `{
},
"/api/raw": {
"get": {
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.\n\n**Filename Encoding:**\n- The Content-Disposition header will always include both:\n1. ` + "`" + `filename=\"...\"` + "`" + `: An ASCII-safe version of the filename for compatibility.\n2. ` + "`" + `filename*=utf-8\"...` + "`" + `: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.",
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.\n\n**Filename Encoding:**\n- The Content-Disposition header will always include both:\n1. ` + "`" + `filename=\"...\"` + "`" + `: An ASCII-safe version of the filename for compatibility.\n2. ` + "`" + `filename*=utf-8\"...` + "`" + `: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.\n\n**Multiple Files:**\n- Use repeated query parameters: ` + "`" + `?file=file1.txt\u0026file=file2.txt\u0026file=file3.txt` + "`" + `\n- This supports filenames containing commas and special characters",
"consumes": [
"application/json"
],
@@ -1295,9 +1295,13 @@ const docTemplate = `{
"required": true
},
{
"type": "string",
"description": "Comma-separated list of file paths (required)",
"name": "files",
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "File path (can be repeated for multiple files)",
"name": "file",
"in": "query",
"required": true
},
@@ -2769,7 +2773,7 @@ const docTemplate = `{
},
"/public/api/raw": {
"get": {
"description": "Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.",
"description": "Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.\n\n**Multiple Files:**\n- Use repeated query parameters: ` + "`" + `?file=file1.txt\u0026file=file2.txt\u0026file=file3.txt` + "`" + `\n- This supports filenames containing commas and special characters",
"consumes": [
"application/json"
],
@@ -2789,9 +2793,13 @@ const docTemplate = `{
"required": true
},
{
"type": "string",
"description": "Comma-separated list of file paths. Example: 'file1.txt,folder/file2.txt'",
"name": "files",
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "File path (can be repeated for multiple files)",
"name": "file",
"in": "query",
"required": true
},
@@ -4255,7 +4263,7 @@ const docTemplate = `{
"type": "object",
"properties": {
"imagePreview": {
"description": "supported image preview formats. defaults to false for all types unless explicitly enabled.",
"description": "supported image preview formats. defaults vary by type (see individual type docs)",
"type": "object",
"additionalProperties": {
"type": "boolean"
@@ -4324,6 +4332,10 @@ const docTemplate = `{
"description": "display name",
"type": "string"
},
"oidcLoginButtonText": {
"description": "text to display on the OIDC login button",
"type": "string"
},
"styling": {
"$ref": "#/definitions/settings.StylingConfig"
}
@@ -4824,10 +4836,6 @@ const docTemplate = `{
"description": "debug onlyoffice editor",
"type": "boolean"
},
"defaultLandingPage": {
"description": "deprecated: determined by sidebar link order since 1.1.0",
"type": "string"
},
"deleteWithoutConfirming": {
"description": "delete files without confirmation",
"type": "boolean"
@@ -5670,10 +5678,6 @@ const docTemplate = `{
"description": "debug onlyoffice editor",
"type": "boolean"
},
"defaultLandingPage": {
"description": "deprecated: determined by sidebar link order instead",
"type": "string"
},
"deleteWithoutConfirming": {
"description": "delete files without confirmation",
"type": "boolean"
+21 -17
View File
@@ -1267,7 +1267,7 @@
},
"/api/raw": {
"get": {
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.\n\n**Filename Encoding:**\n- The Content-Disposition header will always include both:\n1. `filename=\"...\"`: An ASCII-safe version of the filename for compatibility.\n2. `filename*=utf-8\"...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.",
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.\n\n**Filename Encoding:**\n- The Content-Disposition header will always include both:\n1. `filename=\"...\"`: An ASCII-safe version of the filename for compatibility.\n2. `filename*=utf-8\"...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.\n\n**Multiple Files:**\n- Use repeated query parameters: `?file=file1.txt\u0026file=file2.txt\u0026file=file3.txt`\n- This supports filenames containing commas and special characters",
"consumes": [
"application/json"
],
@@ -1284,9 +1284,13 @@
"required": true
},
{
"type": "string",
"description": "Comma-separated list of file paths (required)",
"name": "files",
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "File path (can be repeated for multiple files)",
"name": "file",
"in": "query",
"required": true
},
@@ -2758,7 +2762,7 @@
},
"/public/api/raw": {
"get": {
"description": "Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.",
"description": "Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.\n\n**Multiple Files:**\n- Use repeated query parameters: `?file=file1.txt\u0026file=file2.txt\u0026file=file3.txt`\n- This supports filenames containing commas and special characters",
"consumes": [
"application/json"
],
@@ -2778,9 +2782,13 @@
"required": true
},
{
"type": "string",
"description": "Comma-separated list of file paths. Example: 'file1.txt,folder/file2.txt'",
"name": "files",
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "File path (can be repeated for multiple files)",
"name": "file",
"in": "query",
"required": true
},
@@ -4244,7 +4252,7 @@
"type": "object",
"properties": {
"imagePreview": {
"description": "supported image preview formats. defaults to false for all types unless explicitly enabled.",
"description": "supported image preview formats. defaults vary by type (see individual type docs)",
"type": "object",
"additionalProperties": {
"type": "boolean"
@@ -4313,6 +4321,10 @@
"description": "display name",
"type": "string"
},
"oidcLoginButtonText": {
"description": "text to display on the OIDC login button",
"type": "string"
},
"styling": {
"$ref": "#/definitions/settings.StylingConfig"
}
@@ -4813,10 +4825,6 @@
"description": "debug onlyoffice editor",
"type": "boolean"
},
"defaultLandingPage": {
"description": "deprecated: determined by sidebar link order since 1.1.0",
"type": "string"
},
"deleteWithoutConfirming": {
"description": "delete files without confirmation",
"type": "boolean"
@@ -5659,10 +5667,6 @@
"description": "debug onlyoffice editor",
"type": "boolean"
},
"defaultLandingPage": {
"description": "deprecated: determined by sidebar link order instead",
"type": "string"
},
"deleteWithoutConfirming": {
"description": "delete files without confirmation",
"type": "boolean"
+27 -17
View File
@@ -606,8 +606,8 @@ definitions:
imagePreview:
additionalProperties:
type: boolean
description: supported image preview formats. defaults to false for all types
unless explicitly enabled.
description: supported image preview formats. defaults vary by type (see individual
type docs)
type: object
videoPreview:
additionalProperties:
@@ -655,6 +655,9 @@ 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
@@ -1035,9 +1038,6 @@ definitions:
debugOffice:
description: debug onlyoffice editor
type: boolean
defaultLandingPage:
description: 'deprecated: determined by sidebar link order since 1.1.0'
type: string
deleteWithoutConfirming:
description: delete files without confirmation
type: boolean
@@ -1641,9 +1641,6 @@ definitions:
debugOffice:
description: debug onlyoffice editor
type: boolean
defaultLandingPage:
description: 'deprecated: determined by sidebar link order instead'
type: string
deleteWithoutConfirming:
description: delete files without confirmation
type: boolean
@@ -2624,17 +2621,24 @@ paths:
- The Content-Disposition header will always include both:
1. `filename="..."`: An ASCII-safe version of the filename for compatibility.
2. `filename*=utf-8"...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.
**Multiple Files:**
- Use repeated query parameters: `?file=file1.txt&file=file2.txt&file=file3.txt`
- This supports filenames containing commas and special characters
parameters:
- description: Source name for the files (required)
in: query
name: source
required: true
type: string
- description: Comma-separated list of file paths (required)
- collectionFormat: csv
description: File path (can be repeated for multiple files)
in: query
name: files
items:
type: string
name: file
required: true
type: string
type: array
- description: If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults
to 'attachment'.
in: query
@@ -3641,20 +3645,26 @@ paths:
get:
consumes:
- application/json
description: Downloads raw content from a public share. Supports single files,
multiple files, or directories as archives. Enforces download limits (global
or per-user) and blocks anonymous users when per-user limits are enabled.
description: |-
Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.
**Multiple Files:**
- Use repeated query parameters: `?file=file1.txt&file=file2.txt&file=file3.txt`
- This supports filenames containing commas and special characters
parameters:
- description: Share hash for authentication
in: query
name: hash
required: true
type: string
- description: 'Comma-separated list of file paths. Example: ''file1.txt,folder/file2.txt'''
- collectionFormat: csv
description: File path (can be repeated for multiple files)
in: query
name: files
items:
type: string
name: file
required: true
type: string
type: array
- description: If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults
to 'attachment'.
in: query
+17 -1
View File
@@ -27,6 +27,20 @@ server:
private: false # designate as source as private -- currently just means no sharing permitted.
disabled: false # disable the source, this is useful so you don't need to remove it from the config file
rules: # list of item rules to apply to specific paths
- neverWatchPath: "" # index the folder in the first pass to get included in search, but never re-indexed.
includeRootItem: "" # include only these items at root folder level
fileStartsWith: "" # (global) exclude files that start with these prefixes. Eg. "archive-" or "backup-"
folderStartsWith: "" # (global) exclude folders that start with these prefixes. Eg. "archive-" or "backup-"
fileEndsWith: "" # (global) exclude files that end with these suffixes. Eg. ".jpg" or ".txt"
folderEndsWith: "" # (global) exclude folders that end with these suffixes. Eg. ".thumbnails" or ".git"
folderPath: "" # (global) exclude folders that match this path. Eg. "/path/to/folder" or "/path/to/folder/subfolder"
filePath: "" # (global) exclude files that match this path. Eg. "/path/to/file.txt" or "/path/to/file.txt/subfile.txt"
fileName: "" # (global) exclude files that match these names. Eg. "file.txt" or "test.csv"
folderName: "" # (global) exclude folders that match these names. Eg. "folder" or "subfolder"
viewable: false # Enable viewing in UI but exclude from indexing
ignoreHidden: false # Excludes only hidden files and folders
ignoreZeroSizeFolders: false # Excludes only folders with 0 size
ignoreSymlinks: false # Excludes symbolic links
defaultUserScope: "/" # defaults to root of index "/" should match folders under path
defaultEnabled: true # should be added as a default source for new users?
createUserDir: false # create a user directory for each user under defaultUserScope + username
@@ -105,6 +119,7 @@ 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
@@ -165,8 +180,9 @@ integrations:
media: # validate:omitempty
ffmpegPath: "" # path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
convert: # config for ffmpeg conversion settings
imagePreview: # supported image preview formats. defaults to false for all types unless explicitly enabled.
imagePreview: # supported image preview formats. defaults vary by type (see individual type docs)
heic: false
jpeg: true
videoPreview: # supported video preview formats. defaults to true for all types unless explicitly disabled.
3g2: true
3gp: true
File renamed without changes.

Before

Width:  |  Height:  |  Size: 949 B

After

Width:  |  Height:  |  Size: 949 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 969 B

@@ -1,13 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 527 B

File renamed without changes.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

-10
View File
@@ -1,10 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

@@ -1,24 +0,0 @@
{
"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
@@ -0,0 +1,96 @@
<?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>

After

Width:  |  Height:  |  Size: 5.9 KiB

+12 -10
View File
@@ -57,20 +57,22 @@
"short_name": window.globalVars.name || 'FileBrowser',
"icons": [
{
"src": window.location.origin + "{{ .htmlVars.staticURL }}/img/icons/android-chrome-512x512.png",
"sizes": "512x512",
"src": "{{ .htmlVars.pwaIcon192 }}",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "{{ .htmlVars.pwaIcon256 }}",
"sizes": "256x256",
"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"
"src": "{{ .htmlVars.pwaIcon512 }}",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"start_url": window.location.origin + "{{ .htmlVars.baseURL }}",
+14 -15
View File
@@ -1,5 +1,5 @@
import { fetchURL, adjustedData } from './utils'
import { getApiPath, doubleEncode, getPublicApiPath } from '@/utils/url.js'
import { getApiPath, 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: doubleEncode(path),
source: doubleEncode(source),
path: encodeURIComponent(path),
source: encodeURIComponent(source),
...(content && { content: 'true' }),
...(metadata && { metadata: 'true' })
})
@@ -32,8 +32,8 @@ async function resourceAction(source, path, method, content) {
throw new Error('no source provided')
}
try {
source = doubleEncode(source)
path = doubleEncode(path)
source = encodeURIComponent(source)
path = encodeURIComponent(path)
const apiPath = getApiPath('api/resources', { path, source })
let opts = { method }
if (content) {
@@ -151,11 +151,11 @@ export async function download(format, files, shareHash = "") {
}
const params = {
files: filePaths.join(','),
file: filePaths, // Array of file paths - getApiPath will create repeated parameters
algo: format,
...(shareHash && { hash: shareHash }),
...(!shareHash && source && { source: encodeURIComponent(source) }),
...(state.share.token && { token: state.share.token }),
...(state.shareInfo.token && { token: state.shareInfo.token }),
sessionId: state.sessionId
}
@@ -203,12 +203,11 @@ async function downloadChunked(file, shareHash = "") {
mutations.showHover({ name: 'download' })
}
// Build the download URL with new query format
const params = {
files: encodeURIComponent(file.path),
file: encodeURIComponent(file.path),
...(shareHash && { hash: shareHash }),
...(!shareHash && file.source && { source: encodeURIComponent(file.source) }),
...(state.share.token && { token: state.share.token }),
...(state.shareInfo.token && { token: state.shareInfo.token }),
sessionId: state.sessionId
}
@@ -361,8 +360,8 @@ export function post(
}
try {
const apiPath = getApiPath("api/resources", {
path: doubleEncode(path),
source: doubleEncode(source),
path: encodeURIComponent(path),
source: encodeURIComponent(source),
override: overwrite,
...(isDir && { isDir: 'true' })
});
@@ -497,8 +496,8 @@ export async function checksum(source, path, algo) {
}
try {
const params = {
path: doubleEncode(path),
source: doubleEncode(source),
path: encodeURIComponent(path),
source: encodeURIComponent(source),
checksum: algo
}
const apiPath = getApiPath('api/resources', params)
@@ -518,7 +517,7 @@ export function getDownloadURL(source, path, inline, useExternal) {
try {
const params = {
source: encodeURIComponent(source),
files: encodeURIComponent(path),
file: encodeURIComponent(path),
...(inline && { inline: 'true' })
}
const apiPath = getApiPath('api/raw', params)
+18 -14
View File
@@ -1,6 +1,6 @@
import { adjustedData } from "./utils";
import { notify } from "@/notify";
import { getPublicApiPath, encodedPath, doubleEncode } from "@/utils/url.js";
import { getPublicApiPath, encodedPath } from "@/utils/url.js";
import { state } from "@/store";
// ============================================================================
@@ -23,7 +23,7 @@ export async function fetchPub(path, hash, password = "", content = false, metad
hash,
...(content && { content: 'true' }),
...(metadata && { metadata: 'true' }),
...(state.share.token && { token: state.share.token })
...(state.shareInfo.token && { token: state.shareInfo.token })
}
const apiPath = getPublicApiPath("resources", params);
const response = await fetch(apiPath, {
@@ -59,31 +59,35 @@ export async function fetchPub(path, hash, password = "", content = false, metad
* @returns {string}
*/
export function getDownloadURL(share, files, inline=false) {
// Join files array with || delimiter and then URL encode
const filesParam = Array.isArray(files) ? files.join('||') : files;
// Handle array of files for repeated 'file' parameters
const fileArray = Array.isArray(files) ? files : [files]
const filePaths = fileArray.map(file => encodeURIComponent(file))
const params = {
files: encodeURIComponent(filesParam),
file: filePaths, // Array of file paths - getPublicApiPath will create repeated parameters
hash: share.hash,
token: share.token,
...(inline && { inline: 'true' })
}
const apiPath = getPublicApiPath("raw", params);
const apiPath = getPublicApiPath("raw", params)
return window.origin + apiPath
}
// 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="small") {
export function getPreviewURL(path, size) {
try {
const params = {
path: encodeURIComponent(path),
size: size,
hash: state.share.hash,
hash: state.shareInfo.hash,
inline: 'true',
...(state.share.token && { token: state.share.token })
// Only add size parameter if specified and not the default
...(size && size !== 'small' && { size: size }),
...(state.shareInfo.token && { token: state.shareInfo.token })
}
const apiPath = getPublicApiPath('preview', params)
return window.origin + apiPath
@@ -111,7 +115,7 @@ export function post(
}
try {
const apiPath = getPublicApiPath("resources", {
targetPath: doubleEncode(path),
targetPath: encodeURIComponent(path),
hash: hash,
override: overwrite,
...(isDir && { isDir: 'true' })
@@ -203,7 +207,7 @@ async function resourceAction(hash, path, method, content, token = "") {
if (sharePassword) {
headers["X-SHARE-PASSWORD"] = sharePassword;
}
path = doubleEncode(path)
path = encodeURIComponent(path)
const apiPath = getPublicApiPath('resources', { path, hash: hash, token: token })
const response = await fetch(apiPath, {
method,
@@ -244,7 +248,7 @@ export async function bulkDelete(items) {
const params = {
hash: hash,
...(state.share.token && { token: state.share.token }),
...(state.shareInfo.token && { token: state.shareInfo.token }),
sessionId: state.sessionId
}
const apiPath = getPublicApiPath("resources/bulk/delete", params)
@@ -324,7 +328,7 @@ export async function moveCopy(
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(state.share.token && { 'X-Auth-Token': state.share.token })
...(state.shareInfo.token && { 'X-Auth-Token': state.shareInfo.token })
},
body: JSON.stringify(requestBody),
})
+84 -21
View File
@@ -17,7 +17,7 @@
<div class="context-menu-header">
<div
class="action button clickable"
v-if="!isSearchActive && permissions.create && !isShare"
v-if="showCreateButton"
@click="toggleShowCreate"
>
<i v-if="!showCreate" class="material-icons">add</i>
@@ -32,71 +32,77 @@
<span>{{ selectedCount }}</span>
</div>
</div>
<hr class="divider">
<hr v-if="!isDuplicateFinder" class="divider">
<action
v-if="showCreate && !isSearchActive"
v-if="showCreateActions"
icon="create_new_folder"
:label="$t('files.newFolder')"
@action="showHover('newDir')"
/>
<action
v-if="showCreate && !isSearchActive"
v-if="showCreateActions"
icon="note_add"
:label="$t('files.newFile')"
@action="showHover('newFile')"
/>
<action
v-if="showCreate && !isSearchActive"
v-if="showCreateActions"
icon="file_upload"
:label="$t('general.upload')"
@action="uploadFunc"
/>
<action
v-if="!showCreate && selectedCount == 1"
v-if="showInfo"
icon="info"
:label="$t('general.info')"
show="info"
@action="showInfoHover"
/>
<action
v-if="(!showCreate && permissions.download && selectedCount > 0)"
v-if="showDownload"
icon="file_download"
:label="$t('general.download')"
@action="startDownload"
:counter="selectedCount"
/>
<action
v-if="(showCreate || selectedCount == 1) && showShare"
v-if="showShareAction"
icon="share"
:label="$t('general.share')"
@action="showShareHover"
/>
<action
v-if="!showCreate && selectedCount == 1 && permissions.modify && !isSearchActive"
v-if="showRename"
icon="mode_edit"
:label="$t('general.rename')"
@action="showRenameHover"
/>
<action
v-if="!showCreate && selectedCount > 0 && permissions.modify"
v-if="showCopy"
icon="content_copy"
:label="$t('buttons.copyFile')"
show="copy"
/>
<action
v-if="!showCreate && selectedCount == 1 && isSearchActive"
v-if="showOpenParentFolder"
icon="folder"
:label="$t('buttons.openParentFolder')"
@action="openParentFolder"
/>
<action
v-if="!showCreate && selectedCount > 0 && permissions.modify"
v-if="showGoToItem"
icon="folder"
:label="$t('buttons.goToItem')"
@action="goToItem"
/>
<action
v-if="showMove"
icon="forward"
:label="$t('buttons.moveFile')"
show="move"
/>
<action
v-if="!showCreate && !isSearchActive && req?.items?.length > 0"
v-if="showSelectAll"
icon="select_all"
:label="$t('buttons.selectAll')"
@action="selectAllItems"
@@ -138,7 +144,7 @@
class="button no-select fb-shadow"
:class="{ 'dark-mode': isDarkMode }"
>
<action icon="info" :label="$t('general.info')" show="info" />
<action icon="info" :label="$t('general.info')" @action="showInfoHover"/>
<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" />
@@ -181,6 +187,12 @@ export default {
},
},
computed: {
showGoToItem() {
return this.isDuplicateFinder && this.selectedCount == 1;
},
isDuplicateFinder() {
return getters.currentView() === "duplicateFinder";
},
permissions() {
return getters.permissions();
},
@@ -190,7 +202,47 @@ 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;
}
@@ -217,6 +269,7 @@ export default {
return cv == "markdownViewer" && this.permissions.modify;
},
showDelete() {
if (this.isDuplicateFinder) return false;
if (this.selectedCount == 0) {
return false;
}
@@ -246,6 +299,7 @@ export default {
return getters.currentPromptName() == "OverflowMenu";
},
showAccess() {
if (this.isDuplicateFinder) return false;
if (getters.isShare()) {
return false;
}
@@ -316,6 +370,18 @@ 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();
},
@@ -525,12 +591,9 @@ export default {
});
},
openParentFolder() {
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, {});
const item = getters.getFirstSelected();
const parentPath = url.removeLastDir(item.path) || "/";
url.goToItem(item.source, parentPath, {}, this.isDuplicateFinder);
},
selectAllItems() {
if (state.req && state.req.items && state.req.items.length > 0) {
+6 -3
View File
@@ -114,8 +114,7 @@ export default {
if (!this.isValNumeric) return 100;
if (this.max <= 0) return 0;
var pct = (this.val / this.max) * 100;
var finalPct = Math.max(0, Math.min(pct.toFixed(2), 100));
return finalPct > 0 && finalPct < 7 ? 6 : finalPct;
return Math.max(0, Math.min(pct.toFixed(2), 100));
},
displayed_text() {
if (!this.isValNumeric) return this.val;
@@ -208,8 +207,12 @@ export default {
barColor = '#ff9800';
}
let percentage = this.pct >= 1 && this.pct < 7 ? 6 : this.pct;
if (percentage < 1) {
percentage = 0;
}
var style = {
width: this.pct + "%",
width: percentage + "%",
height: this.size_px + "px",
background: barColor,
transition: this.barTransition,
+19 -7
View File
@@ -304,8 +304,8 @@ export default {
return this.selectedSource === "__all__" || (this.selectedSource === "" && this.multipleSources);
},
getContext() {
// If searching all sources, don't use context
if (this.selectedSource === "__all__") {
// If searching all sources (explicitly or by default), always use root context
if (this.selectedSource === "__all__" || (this.selectedSource === "" && this.multipleSources)) {
return "/";
}
let result = url.extractSourceFromPath(decodeURIComponent(state.route.path));
@@ -375,7 +375,12 @@ export default {
getItemUrl(s) {
// Use source from result if available, otherwise fall back to selectedSource
const source = s.source || this.selectedSource || state.sources.current;
return url.buildItemUrl(source, s.path, true);
// Combine context (scope) with the result path - needed when searching within a folder
// Ensure exactly one slash between context and path
const context = url.removeTrailingSlash(this.getContext);
const path = url.removeLeadingSlash(url.removeTrailingSlash(s.path));
const fullPath = context + '/' + path;
return url.buildItemUrl(source, fullPath, true);
},
getIcon(mimetype) {
return getMaterialIconForType(mimetype);
@@ -513,9 +518,13 @@ export default {
try {
// Use source from result if available, otherwise fall back to selectedSource
const source = s.source || this.selectedSource || state.sources.current;
const path = s.path;
// Combine context (scope) with the result path - needed when searching within a folder
// Ensure exactly one slash between context and path
const context = url.removeTrailingSlash(this.getContext);
const path = url.removeLeadingSlash(url.removeTrailingSlash(s.path));
const fullPath = context + '/' + path;
const modified = s.modified || "";
return filesApi.getPreviewURL(source, path, modified);
return filesApi.getPreviewURL(source, fullPath, modified);
} catch (err) {
return "";
}
@@ -524,7 +533,10 @@ export default {
const pathParts = url.removeTrailingSlash(s.path).split("/");
// Use source from result if available, otherwise fall back to selectedSource
const source = s.source || this.selectedSource || state.sources.current;
let path = this.getContext + url.removeTrailingSlash(s.path);
// Combine context (scope) with the result path - ensure exactly one slash between
const context = url.removeTrailingSlash(this.getContext);
const pathStr = url.removeLeadingSlash(url.removeTrailingSlash(s.path));
let path = context + '/' + pathStr;
const modifiedItem = {
name: pathParts.pop(),
path: path,
@@ -917,7 +929,7 @@ body.rtl #search .boxes h3 {
overflow: hidden;
}
@media (max-width: 800px) {
@media (max-width: 768px) {
#search {
min-width: unset;
max-width: 60%;
+14 -5
View File
@@ -4,6 +4,7 @@
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" />
@@ -48,6 +49,9 @@ export default {
return getters.isStickySidebar() || getters.isShare();
},
showShelf() {
if (state.loading.length > 0) {
return false;
}
return this.showBreadcrumbs || this.showListingHeader || this.showDuplicateFinderActions;
},
showBreadcrumbs() {
@@ -74,6 +78,14 @@ 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(() => {
@@ -192,7 +204,7 @@ export default {
z-index: 1000;
right: 0;
left: 0;
transition: 0.3s ease;
transition: 0.2s ease;
box-sizing: border-box;
height: auto;
min-height: 0;
@@ -201,7 +213,7 @@ export default {
.shelf-slide-enter-active,
.shelf-slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
transition: transform 0.3s ease, opacity 0.3s ease !important;
}
.shelf-slide-enter-from,
@@ -224,7 +236,4 @@ export default {
}
}
#main.moveWithSidebar #shelf {
padding-left: 20.5em;
}
</style>
+14 -8
View File
@@ -1,5 +1,5 @@
<template>
<div id="status-bar" :class="{ 'dark-mode-header': isDarkMode, 'active': showStatusBar }" @contextmenu.prevent.stop @touchstart.stop @touchend.stop>
<div id="status-bar" :style="moveWithSidebar" :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,6 +42,9 @@ export default {
},
computed: {
showStatusBar() {
if (getters.isShare() && state.shareInfo.shareType === "upload") {
return false;
}
return getters.currentView() === "listingView";
},
isDarkMode() {
@@ -108,6 +111,14 @@ 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) {
@@ -167,7 +178,7 @@ export default {
overflow: hidden;
margin: 0;
padding: 0;
transition: bottom 0.5s ease, margin-left 0.5s ease;
transition: bottom 0.5s ease, left 0.2s ease, width 0.2s ease;
pointer-events: none;
}
@@ -176,10 +187,6 @@ export default {
pointer-events: auto;
}
#status-bar.moveWithSidebar {
margin-left: 20em;
}
.status-content {
display: flex;
align-items: center;
@@ -238,7 +245,7 @@ input[type="range"] {
}
/* Mobile styles */
@media (max-width: 800px) {
@media (max-width: 768px) {
#status-bar {
height: 3em;
bottom: -3em;
@@ -261,7 +268,6 @@ input[type="range"] {
input[type="range"] {
width: 7em;
height: 1.5em; /* For better touch */
}
.status-info {
@@ -190,14 +190,6 @@ 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 "/";
@@ -209,7 +201,7 @@ export default {
if (normalizedTarget === normalizedCurrent) {
notify.showErrorToast(this.$t("files.sameFolder"));
console.log("Cannot move to same folder");
console.error("Cannot move to same folder");
return;
}
+220 -109
View File
@@ -1,35 +1,62 @@
<template>
<div class="image-ex-container" ref="container" @touchstart="touchStart" @touchmove="touchMove" @touchend="touchEnd" @dblclick="zoomAuto"
@mousedown="mousedownStart" @mousemove="mouseMove" @mouseup="mouseUp" @wheel="wheelMove">
<div v-if="!isLoaded">{{ $t('general.loading', { suffix: "..." }) }}</div>
<!-- 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>
<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>
<!-- 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>
</div>
</template>
<script>
import { state, mutations } from "@/store";
import { state, mutations, getters } 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: String,
moveDisabledTime: {
type: Number,
default: () => 200,
},
classList: {
type: Array,
default: () => [],
},
zoomStep: {
type: Number,
default: () => 0.25,
src: {
type: String,
default: null,
},
},
data() {
return {
moveDisabledTime: 200,
classList: [],
zoomStep: 0.25,
scale: 1,
lastX: null,
lastY: null,
@@ -39,13 +66,15 @@ 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, // Minimum scale is 1 (full frame view)
isTiff: false, // Determine if the image is a TIFF
minScale: 1,
isTiff: false,
// Swipe navigation properties
swipeStartTime: null,
swipeStartX: 0,
@@ -54,19 +83,55 @@ export default {
swipeCurrentY: 0,
isSwipeGesture: false,
hasStartedSwipe: false,
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
gestureDecided: false,
swipeMinDistance: 150,
swipeMaxTime: 500,
swipeMaxVerticalDistance: 50,
};
},
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 {
this.$refs.imgex.src = this.src;
// Use nextTick to ensure element exists
this.$nextTick(() => {
this.loadFullImage();
});
}
let container = this.$refs.container;
this.classList.forEach((className) => container.classList.add(className));
if (getComputedStyle(container).width === "0px") {
@@ -79,39 +144,61 @@ 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.setCenter(); // Center the image after loading
this.showSpinner = false;
this.fullImageLoaded = true;
// Clear the timeout if image loaded successfully
if (this.loadTimeout) {
clearTimeout(this.loadTimeout);
this.loadTimeout = null;
}
this.setCenter();
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();
@@ -123,13 +210,11 @@ export default {
if (!response.ok) {
throw new Error("Network response was not ok");
}
const blob = await response.blob(); // Convert response to a blob
const blob = await response.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); // Clean up URL object after loading
imgex.onload = () => URL.revokeObjectURL(imgex.src);
}
} catch (error) {
notify.showError("Error decoding TIFF");
@@ -147,16 +232,20 @@ export default {
setCenter() {
const container = this.$refs.container;
const img = this.$refs.imgex;
if (!container || !img || !img.clientWidth || !img.clientHeight) {
return; // Exit if dimensions are unavailable
return;
}
// 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);
img.style.left = `${this.position.center.x}px`;
img.style.top = `${this.position.center.y}px`;
// 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})`;
}
},
mousedownStart(event) {
this.lastX = null;
@@ -177,8 +266,6 @@ 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();
@@ -188,13 +275,10 @@ export default {
this.swipeCurrentY = touch.pageY;
this.isSwipeGesture = false;
this.hasStartedSwipe = false;
this.gestureDecided = false; // Reset decision for new touch
this.gestureDecided = false;
} else {
// Reset swipe tracking for multi-touch (zoom gestures) or when zoomed in
this.resetSwipeTracking();
}
if (event.targetTouches.length < 2) {
setTimeout(() => {
this.touches = 0;
@@ -205,9 +289,6 @@ 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();
}
@@ -230,45 +311,31 @@ 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; // Mark that we've made a decision
if (deltaX > deltaY * 2) {
// Horizontal movement is significantly more than vertical - it's a swipe
this.gestureDecided = true;
if (deltaX > deltaY * 2) {
this.isSwipeGesture = true;
event.preventDefault(); // Prevent scrolling during swipe
event.preventDefault();
} 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; // Block normal pan behavior for swipes
return;
}
}
// 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;
@@ -282,7 +349,6 @@ export default {
() => (this.moveDisabled = false),
this.moveDisabledTime
);
let p1 = event.targetTouches[0];
let p2 = event.targetTouches[1];
let touchDistance = Math.sqrt(
@@ -296,7 +362,6 @@ 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;
@@ -309,8 +374,11 @@ export default {
doMove(x, y) {
this.position.relative.x += x;
this.position.relative.y += y;
// 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})`;
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})`;
}
},
wheelMove(event) {
event.preventDefault()
@@ -319,30 +387,26 @@ 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 };
}
// 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})`;
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})`;
}
},
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 &&
@@ -357,13 +421,9 @@ 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() {
@@ -374,7 +434,49 @@ export default {
this.swipeCurrentY = 0;
this.isSwipeGesture = false;
this.hasStartedSwipe = false;
this.gestureDecided = false; // Reset decision state
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();
},
},
};
@@ -383,22 +485,31 @@ 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>
+54 -5
View File
@@ -33,10 +33,11 @@
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 + "static/img/placeholder.png"; // A generic loading placeholder
const ERROR_URL = globalVars.baseURL + "static/img/placeholder.png";
const PLACEHOLDER_URL = globalVars.baseURL + "public/static/img/placeholder.png"; // A generic loading placeholder
const ERROR_URL = globalVars.baseURL + "public/static/img/placeholder.png";
export default {
name: "Icon",
@@ -60,6 +61,18 @@ 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,
@@ -188,6 +201,15 @@ 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);
}
}
};
@@ -201,9 +223,19 @@ 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;
@@ -218,7 +250,7 @@ export default {
let index = 0;
const updateThumbnail = () => {
if (state.popupPreviewSource === "") {
if (state.popupPreviewSourceInfo?.url === "") {
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
return;
@@ -233,6 +265,17 @@ 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
@@ -247,6 +290,8 @@ 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();
},
@@ -255,9 +300,13 @@ export default {
},
updateImageTargetSrc() {
let newSrc = this.thumbnailUrl || PLACEHOLDER_URL;
if (this.showLargeIcon) {
newSrc = (this.thumbnailUrl ? this.thumbnailUrl + "&size=large" : "") || 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.imageTargetSrc !== newSrc) {
this.imageTargetSrc = newSrc;
}
+75 -46
View File
@@ -38,6 +38,9 @@
:thumbnailUrl="isThumbnailInView ? thumbnailUrl : ''"
:filename="name"
:hasPreview="hasPreview"
:modified="modified"
:path="path"
:source="source"
/>
</div>
@@ -46,14 +49,14 @@
<p
class="size"
:class="{ adjustment: quickDownloadEnabled }"
:data-order="humanSize()"
:data-order="humanSize"
>
{{ humanSize() }}
{{ humanSize }}
</p>
<p class="modified">
<time :datetime="modified">{{ getTime() }}</time>
<time :datetime="modified">{{ formattedTime }}</time>
</p>
<p v-if="hasDuration" class="duration">{{ getDuration() }}</p>
<p v-if="hasDuration" class="duration">{{ formattedDuration }}</p>
</div>
<Icon
@click.stop="downloadFile"
@@ -101,6 +104,9 @@
:thumbnailUrl="isThumbnailInView ? thumbnailUrl : ''"
:filename="name"
:hasPreview="hasPreview"
:modified="modified"
:path="path"
:source="source"
/>
</div>
@@ -109,14 +115,14 @@
<p
class="size"
:class="{ adjustment: quickDownloadEnabled }"
:data-order="humanSize()"
:data-order="humanSize"
>
{{ humanSize() }}
{{ humanSize }}
</p>
<p class="modified">
<time :datetime="modified">{{ getTime() }}</time>
<time :datetime="modified">{{ formattedTime }}</time>
</p>
<p v-if="hasDuration" class="duration">{{ getDuration() }}</p>
<p v-if="hasDuration" class="duration">{{ formattedDuration }}</p>
</div>
</div>
</template>
@@ -141,7 +147,7 @@ export default {
data() {
return {
isThumbnailInView: false,
isInView: false, // Track if item is in viewport
isInView: false,
touches: 0,
touchStartX: 0,
touchStartY: 0,
@@ -149,8 +155,8 @@ export default {
isSwipe: false,
isDraggedOver: false,
contextTimeout: null,
observer: null, // Store observer reference
localSelected: false, // Track local selection when updateGlobalState is false
observer: null,
localSelected: false,
};
},
props: {
@@ -237,7 +243,8 @@ export default {
return this.readOnly == undefined && state.user.permissions?.modify || state.shareInfo.allowCreate;
},
canDrop() {
if (!this.isDir || this.readOnly !== undefined) return false;
if (!this.isDir) return false;
if (this.readOnly === true) return false;
for (const i of this.selected) {
if (
@@ -267,7 +274,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 "";
@@ -276,7 +283,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()) {
@@ -291,24 +298,50 @@ 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 with larger margins for smoother rendering
// Set up IntersectionObserver for lazy-loading thumbnails
this.observer = new IntersectionObserver(this.handleIntersect, {
root: null,
rootMargin: "1500px", // Start rendering 1500px before entering viewport for smooth scrolling
rootMargin: "500px", // Reduced from 1500px for better performance
threshold: 0,
});
this.observer.observe(this.$el);
// Use $nextTick to ensure $el is available and is an Element
this.$nextTick(() => {
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;
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;
}
}
});
// Note: dragend listener moved to parent ListingView for better performance
},
beforeUnmount() {
// Clean up observer
@@ -316,6 +349,7 @@ export default {
this.observer.disconnect();
this.observer = null;
}
// Note: dragend listener removed - handled by parent ListingView
},
methods: {
/** @param {MouseEvent} event */
@@ -381,8 +415,22 @@ export default {
mutations.addSelected(this.index);
}
} else {
// Emit selection event for local handling
this.$emit('select', { index: this.index, selected: true });
// 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);
}
mutations.showHover({
name: "ContextMenu",
@@ -405,28 +453,6 @@ 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
@@ -472,6 +498,9 @@ export default {
event.preventDefault();
this.isDraggedOver = true;
},
dragEnd() {
this.isDraggedOver = false;
},
/** @param {DragEvent} event */
async drop(event) {
this.isDraggedOver = false;
+14 -6
View File
@@ -1,11 +1,12 @@
<template>
<div class="popup-preview" v-show="source" ref="popup" :style="popupStyle">
<img :src="source" alt="Popup image" />
<div class="popup-preview" v-show="sourceInfo" ref="popup" :style="popupStyle">
<img v-if="sourceInfo" :src="sourceInfo.url" alt="Popup image" @load="onImageLoad" />
</div>
</template>
<script>
import { state, getters } from "@/store";
import { setImageLoaded } from "@/utils/imageCache";
export default {
name: "PopupPreview",
@@ -20,7 +21,7 @@ export default {
};
},
watch: {
source(newVal) {
sourceInfo(newVal) {
if (newVal) {
this.$nextTick(() => {
this.positionPopup();
@@ -29,8 +30,8 @@ export default {
},
},
computed: {
source() {
return state.popupPreviewSource;
sourceInfo() {
return state.popupPreviewSourceInfo;
},
},
mounted() {
@@ -40,13 +41,20 @@ 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.source) return;
if (!this.sourceInfo) return;
const popup = this.$refs.popup;
if (!popup) return;
+14 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="scroll-wrapper" ref="wrapper">
<div class="scroll-wrapper" :class="{ 'halloween-theme': eventTheme === 'halloween' }" :style="halloweenBackgroundStyle" ref="wrapper">
<slot />
<div
class="custom-scrollbar"
@@ -35,6 +35,7 @@
<script>
import { state, mutations, getters } from "@/store";
import { globalVars } from "@/utils/constants";
const offsetFromBottomListing = 110;
const offsetFromBottomFull = 75;
@@ -63,6 +64,18 @@ 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.share.passwordValid
return state.shareInfo.passwordValid
},
shareInfo() {
return state.shareInfo;
+64 -40
View File
@@ -3,7 +3,7 @@
<div
v-if="enabled && hasPrevious"
class="nav-zone nav-zone-left"
:class="{ moveWithSidebar: moveWithSidebar }"
:style="leftZoneStyle"
@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="dragState.type === 'previous' ? { transform: `translateY(-50%) translate(${dragState.deltaX}px, 0)` } : {}"
:style="previousButtonStyle"
: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="dragState.type === 'next' ? { transform: `translateY(-50%) translate(${dragState.deltaX}px, 0)` } : {}"
:style="nextButtonStyle"
:aria-label="$t('general.next')"
:title="$t('general.next')"
>
@@ -108,6 +108,7 @@ export default {
tapTimeout: null,
triggered: false
},
isSidebarResizing: false, // Track if sidebar is being resized
};
},
computed: {
@@ -115,6 +116,9 @@ export default {
moveWithSidebar() {
return getters.isSidebarVisible() && getters.isStickySidebar();
},
sidebarWidth() {
return state.sidebar?.width || 20;
},
enabled() {
return state.navigation.enabled && getters.currentPrompt() == null;
},
@@ -157,6 +161,45 @@ 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: {
@@ -207,6 +250,10 @@ 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);
@@ -336,7 +383,7 @@ export default {
try {
let res;
if (getters.isShare()) {
res = await publicApi.fetchPub(directoryPath, state.share.hash);
res = await publicApi.fetchPub(directoryPath, state.shareInfo.hash);
} else {
res = await filesApi.fetchFiles(state.req.source, directoryPath);
}
@@ -595,14 +642,16 @@ export default {
}
},
isClickInLeftZone(event) {
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
const emSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
const zoneWidth = 3 * emSize; // 3em in pixels
const sidebarOffset = this.moveWithSidebar ? (this.sidebarWidth * emSize) : 0;
return event.clientX >= sidebarOffset && event.clientX <= (sidebarOffset + zoneWidth);
},
isClickInRightZone(event) {
const viewportWidth = window.innerWidth;
const zoneWidth = 3 * parseFloat(getComputedStyle(document.documentElement).fontSize); // 3em in pixels
const emSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
const zoneWidth = 3 * emSize; // 3em in pixels
return event.clientX >= (viewportWidth - zoneWidth) && event.clientX <= viewportWidth;
},
@@ -918,7 +967,6 @@ export default {
name: "file-list",
props: {
fileList: currentItems,
mode: "quick-jump",
title: this.$t("prompts.quickJump")
}
});
@@ -945,7 +993,7 @@ export default {
const zoneWidth = 5 * emSize; // 5em in pixels
// Check left zone
const sidebarOffset = this.moveWithSidebar ? 20 * emSize : 0; // Account for sidebar
const sidebarOffset = this.moveWithSidebar ? (this.sidebarWidth * 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
@@ -983,11 +1031,6 @@ export default {
background: transparent; /* Invisible zones for mouse/touch detection */
}
.nav-zone-left {
left: 0;
}
.nav-zone-right {
right: 0;
}
@@ -1005,7 +1048,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;
transition: opacity 0.4s ease, transform 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease, left 0.2s ease;
pointer-events: auto;
z-index: 1001;
display: flex;
@@ -1038,13 +1081,9 @@ export default {
opacity: 1;
}
.nav-previous {
left: 20px;
}
.nav-next {
right: 20px;
/* 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-button.hidden {
@@ -1055,7 +1094,7 @@ export default {
}
/* Smooth show animation for better UX */
.nav-button:not(.hidden) {
.nav-button:not(.hidden):not(.sidebar-resizing) {
animation: nav-button-show 0.4s ease-out;
}
@@ -1073,7 +1112,7 @@ export default {
.nav-button.dragging {
z-index: 1002;
cursor: grabbing;
transition: none; /* Disable transitions during drag for immediate response */
transition: none !important; /* Disable transitions during drag */
}
.nav-button i.material-icons {
@@ -1094,14 +1133,6 @@ export default {
height: 44px;
}
.nav-previous {
left: 10px;
}
.nav-next {
right: 10px;
}
.nav-button i.material-icons {
font-size: 20px;
}
@@ -1114,15 +1145,8 @@ 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>
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more