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
94 changed files with 1800 additions and 3253 deletions

No files matched your search

-53
View File
@@ -2,58 +2,6 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v1.2.7-beta
**BugFixes**:
- head > title > infinitely duplicating titles (#2016)
- Proxy auth not working with Nginx, stuck at logo (#2013)
## v1.2.6-beta
**Security**:
- resolves https://github.com/gtsteffaniak/filebrowser/security/advisories/GHSA-8vrh-3pm2-v4v6
## v1.2.5-beta
**Notes**:
- Dependency Updates for frontend and backend packages
- Upgrade to go 1.26
- file list prompt interaction behavior, single-click always selects, double-click navigates. (#1911)
**BugFixes**:
- .ssa and .ass subtitle files get their styling removed during conversion (#1933)
- custom icons issue
- new database message for noauth (#1935)
## v1.2.4-beta
**Notes**:
- Changed `/api/media/subtitles` api endpoint to better support subtitles.
- Correcting some errors in French language (#1947)
- add Dutch `nl.json` for frontend i18n -- thanks @Stephan-P (#1957)
- share banner and icon images automatically serve a scaled down 1024x1024
**BugFixes**:
- Can't access directories with space (and possibly other special characters) when browsing a public link share (#1956)
- External ASS subtitles are not usable in web preview (#1933)
- Filename blocking rename/move (#1950)
- enforcedOtp: true blocks all user settings updates with HTTP 400 (#1962)
- Share favicon URL not showing up and Share description causing endless loading (#1911)
- folder sizes not updated for changes to filesystem outside of filebrowser (#1974)
## v1.2.3-beta
**Notes**:
- Removed upload api behavior to assume paths ending in "/" are folders, strictly uses isDir query param
- renamed public upload api query param from `targetPath` to `path`. see swagger docs.
**BugFixes**:
- fix stuck motion preview popup window issue
- Chunked uploads ignore User Scope and are saved to Source Root (#1894)
- share delete with user scope issue
- fix favicon pwa icon, having the PWA respect the favicon/loginIcon (#1899)
- delete prompt icons show image previews
## v1.2.2-beta
**New Features**:
@@ -87,7 +35,6 @@ All notable changes to this project will be documented in this file. For commit
- added 75MB filesize limit for image previews
- optimized concurrency for large vs small images
## v1.2.1-beta
**New Features**:
+1
View File
@@ -6,6 +6,7 @@ WORKDIR /app
COPY ./backend ./
RUN apk update && apk add --no-cache gcc musl-dev upx
ENV CGO_ENABLED=1
ENV GOEXPERIMENT=greenteagc
RUN go build -tags mupdf,musl -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
+1
View File
@@ -4,6 +4,7 @@ ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add upx
ENV GOEXPERIMENT=greenteagc
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
+19 -27
View File
@@ -2,6 +2,7 @@ package files
import (
"context"
"encoding/base64"
"fmt"
"io"
"os"
@@ -36,18 +37,6 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
metadataCount++
}
}
// Process files concurrently using goroutines
var wg sync.WaitGroup
var mu sync.Mutex // Protects processedCount
// Refresh directory to update folder sizes
wg.Go(func() {
indexPath := idx.MakeIndexPath(response.Path, true)
err := idx.RefreshDirectory(indexPath, false)
if err != nil {
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
}
})
// Set hasMetadata flag if there are files with potential metadata
if metadataCount > 0 {
@@ -61,6 +50,9 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
// Create a single shared FFmpegService instance for all files to coordinate concurrency
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
if sharedFFmpegService != nil {
// Process files concurrently using goroutines
var wg sync.WaitGroup
var mu sync.Mutex // Protects processedCount
for i := range response.Files {
fileItem := &response.Files[i]
@@ -94,10 +86,10 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
}
}
// Wait for all goroutines to complete
wg.Wait()
}
}
// Wait for all goroutines to complete
wg.Wait()
}
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping)
@@ -219,16 +211,17 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
}
// Handle subtitles if requested
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
if exists {
info.GetSubtitles(parentInfo)
}
if opts.ExtractEmbeddedSubtitles {
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
info.Subtitles = append(info.Subtitles, subtitles...)
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetReducedMetadata(parentPath, true)
if exists {
info.DetectSubtitles(parentInfo)
err := info.LoadSubtitleContent()
if err != nil {
logger.Debug("failed to load subtitle content: " + err.Error())
}
}
}
return
}
@@ -243,8 +236,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
} else {
// Copy metadata to ExtendedFileInfo
info.Metadata = extItem.Metadata
info.HasMetadata = true
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
info.HasPreview = extItem.Metadata != nil && extItem.Metadata.AlbumArt != ""
}
return
}
@@ -331,11 +323,11 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
return nil
}
// Extract album art if available (stored as raw bytes, auto-encoded to base64 in JSON)
// Extract album art and encode as base64 with strict size limits
if picture := m.Picture(); picture != nil && picture.Data != nil {
// Size limit to prevent memory issues (max 5MB)
// More aggressive size limit to prevent memory issues (max 5MB)
if len(picture.Data) <= 5*1024*1024 {
item.Metadata.AlbumArt = picture.Data
item.Metadata.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
} else {
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, len(picture.Data))
}
+1 -1
View File
@@ -189,7 +189,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
logger.Warningf("Invalid TOTP code for user %s", user.Username)
return fmt.Errorf("invalid OTP token")
}
if totpSecret != "" || !user.OtpEnabled {
if totpSecret != "" {
user.TOTPSecret = totpSecret // The encrypted or plaintext secret
user.TOTPNonce = totpNonce // The nonce if encrypted, or empty if plaintext
user.OtpEnabled = true // Enable OTP for the user
+1 -8
View File
@@ -19,7 +19,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/icons"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
@@ -195,19 +194,13 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
fileutils.InitAssetFS(nil, false)
}
// Start preview service
// setup disk cache
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
if err != nil {
logger.Fatalf("Error starting preview service: %v", err)
}
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
// Generate PWA icons after preview service is initialized
if err := icons.GeneratePWAIcons(); err != nil {
logger.Warningf("Failed to generate PWA icons: %v", err)
}
fbhttp.StartHttp(ctx, store, shutdownComplete)
return nil
}
+51 -94
View File
@@ -14,7 +14,6 @@ import (
"sort"
"strings"
"golang.org/x/tools/go/packages"
"gopkg.in/yaml.v3"
)
@@ -112,93 +111,74 @@ func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, De
return comments, secrets, deprecated, nil
}
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields.
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields
func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
absDir, err := filepath.Abs(dir)
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
if err != nil {
return nil, nil, nil, err
}
var files []*ast.File
cfg := &packages.Config{
Mode: packages.NeedSyntax | packages.NeedFiles,
Dir: absDir,
ParseFile: func(fset *token.FileSet, filename string, src []byte) (*ast.File, error) {
return parser.ParseFile(fset, filename, src, parser.ParseComments)
},
}
pkgs, err := packages.Load(cfg, ".")
if err == nil && len(pkgs) > 0 {
for _, pkg := range pkgs {
files = append(files, pkg.Syntax...)
}
}
if len(files) == 0 {
// Fallback for directories outside a module (e.g. test temp dirs): parse .go files directly
files, err = parseDirGoFiles(absDir)
if err != nil || len(files) == 0 {
return nil, nil, nil, err
}
}
comments := make(CommentsMap)
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
for _, file := range files {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
for _, pkg := range pkgs {
for _, file := range pkg.Files {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
}
typeName := ts.Name.Name
commentMap := make(map[string]string)
secretMap := make(map[string]bool)
deprecatedMap := make(map[string]bool)
comments[typeName] = commentMap
secrets[typeName] = secretMap
deprecated[typeName] = deprecatedMap
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
name := field.Names[0].Name
var parts []string
var fullComment string
if field.Doc != nil {
docText := strings.TrimSpace(field.Doc.Text())
parts = append(parts, docText)
fullComment += docText + " "
}
if field.Comment != nil {
commentText := strings.TrimSpace(field.Comment.Text())
parts = append(parts, commentText)
fullComment += commentText
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
}
typeName := ts.Name.Name
commentMap := make(map[string]string)
secretMap := make(map[string]bool)
deprecatedMap := make(map[string]bool)
comments[typeName] = commentMap
secrets[typeName] = secretMap
deprecated[typeName] = deprecatedMap
if len(parts) > 0 {
commentMap[name] = strings.Join(parts, " : ")
}
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
continue
}
name := field.Names[0].Name
var parts []string
var fullComment string
// Check if field should be treated as secret
if strings.Contains(strings.ToLower(fullComment), "secret:") {
secretMap[name] = true
}
if field.Doc != nil {
docText := strings.TrimSpace(field.Doc.Text())
parts = append(parts, docText)
fullComment += docText + " "
}
if field.Comment != nil {
commentText := strings.TrimSpace(field.Comment.Text())
parts = append(parts, commentText)
fullComment += commentText
}
// Check if field should be treated as deprecated
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
deprecatedMap[name] = true
if len(parts) > 0 {
commentMap[name] = strings.Join(parts, " : ")
}
// Check if field should be treated as secret
if strings.Contains(strings.ToLower(fullComment), "secret:") {
secretMap[name] = true
}
// Check if field should be treated as deprecated
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
deprecatedMap[name] = true
}
}
}
}
@@ -207,29 +187,6 @@ func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, Deprecate
return comments, secrets, deprecated, nil
}
// parseDirGoFiles parses all .go files in dir and returns their ASTs. Used when the directory
// is not inside a Go module (e.g. test temp dirs) so packages.Load would fail.
func parseDirGoFiles(dir string) ([]*ast.File, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
fset := token.NewFileSet()
var files []*ast.File
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
continue
}
path := filepath.Join(dir, e.Name())
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil, err
}
files = append(files, f)
}
return files, nil
}
// PathToTypeField represents a mapping from YAML path to Go type and field info
type PathToTypeField struct {
TypeName string
+4 -10
View File
@@ -63,18 +63,12 @@ type FileOptions struct {
// SanitizeUserPath prevents path traversal attacks by cleaning and validating user input.
// Rule 1: Do Not Use User Input in File Paths (without validation)
func SanitizeUserPath(userPath string) (string, error) {
// Clean resolves . and .. segments
clean := filepath.Clean(userPath)
// Split the path into segments to check for path traversal attempts
// We check if ".." appears as a complete path segment (not just in a filename)
segments := strings.Split(clean, string(filepath.Separator))
for _, segment := range segments {
// Check if any segment is exactly ".." (path traversal attempt)
// This catches any ".." that filepath.Clean couldn't resolve (i.e., escape attempts)
if segment == ".." {
return "", fmt.Errorf("invalid path: path traversal detected")
}
// Reject absolute paths or attempts to escape scope
if strings.HasPrefix(clean, "..") {
return "", fmt.Errorf("invalid path")
}
return clean, nil
-43
View File
@@ -1,43 +0,0 @@
package utils
import (
"errors"
"os"
)
type SubtitleTrack struct {
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language"` // language code
Title string `json:"title,omitempty"` // title/description
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
Embedded bool `json:"embedded"` // true for embedded subtitles, false for external files
}
func GetSubtitleSidecarContent(subtitlePath string) (string, error) {
// validate the size is not too large
info, err := os.Stat(subtitlePath)
if err != nil {
return "", err
}
if info.Size() > 1024*1024*50 { // 50MB
return "", errors.New("subtitle file is too large")
}
// First check if it's a text file using the consolidated validation logic
isText, err := IsTextFile(subtitlePath)
if err != nil {
return "", err
}
if !isText {
// Not a text file, return empty string (no error, just not text)
return "", nil
}
// read the file content
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
return string(content), nil
}
+7
View File
@@ -247,6 +247,7 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
continue
}
oldFullPath := l.Path
l.Source = newSource
l.Path = newPath
@@ -263,8 +264,14 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
s.shareCache[l.Hash] = l
}
s.mu.Unlock()
logger.Info("share updated", "hash", l.Hash, "fromPath", oldFullPath, "toPath", l.Path)
updated++
}
if updated == 0 {
logger.Warning("no matching shares to update for provided source/path")
}
return updated, nil
}
+1 -1
View File
@@ -541,7 +541,7 @@ func (db *IndexDB) ExplainQueryPlan(query string, args ...interface{}) (string,
if err := rows.Scan(&id, &parent, &notused, &detail); err != nil {
return "", err
}
fmt.Fprintf(&plan, "%d|%d|%s\n", id, parent, detail)
plan.WriteString(fmt.Sprintf("%d|%d|%s\n", id, parent, detail))
}
return plan.String(), rows.Err()
}
+1 -1
View File
@@ -77,7 +77,7 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if passwordUser && enforcedOtp && !existingUser.OtpEnabled {
if passwordUser && enforcedOtp && !user.OtpEnabled {
return errors.ErrNoTotpConfigured
}
if user.LoginMethod == "" {
+3 -4
View File
@@ -3,12 +3,11 @@ package ffmpeg
import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/go-cache/cache"
)
var (
MediaCache = cache.NewCache[[]utils.SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration gets cached for 24 hours
MediaCache = cache.NewCache[[]SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration gets cached for 24 hours
)
+136 -33
View File
@@ -10,10 +10,20 @@ import (
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
// SubtitleTrack represents a subtitle track (embedded or external file)
type SubtitleTrack struct {
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language,omitempty"` // language code
Title string `json:"title,omitempty"` // title/description
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
Content string `json:"content,omitempty"` // subtitle content
IsFile bool `json:"isFile"` // true for external files, false for embedded streams
}
// FFProbeOutput represents the JSON output from ffprobe
type FFProbeOutput struct {
Streams []struct {
@@ -26,7 +36,7 @@ type FFProbeOutput struct {
}
// DetectAllSubtitles finds both embedded and external subtitle tracks
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []utils.SubtitleTrack {
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []SubtitleTrack {
key := "all_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
// Check cache first
@@ -34,7 +44,7 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
return cached
}
var allSubtitles []utils.SubtitleTrack
var allSubtitles []SubtitleTrack
// First, get embedded subtitles
embeddedSubs := detectEmbeddedSubtitles(videoPath)
@@ -50,25 +60,9 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
return allSubtitles
}
// DetectEmbeddedSubtitles detects embedded subtitle streams using ffprobe.
// This is the public API that can be called from other packages.
// Returns empty array if ffprobe is not available or fails.
func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.SubtitleTrack {
// Check cache first
key := "embedded_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
if cached, ok := MediaCache.Get(key); ok {
return cached
}
// Detect embedded subtitles
subtitles := detectEmbeddedSubtitles(videoPath)
// Cache the results
MediaCache.Set(key, subtitles)
return subtitles
}
// detectEmbeddedSubtitles uses ffprobe to find embedded subtitle tracks
// Always runs ffprobe - results are cached for performance
func detectEmbeddedSubtitles(realPath string) []utils.SubtitleTrack {
func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
cmd := exec.Command("ffprobe",
"-v", "quiet",
"-print_format", "json",
@@ -88,13 +82,13 @@ func detectEmbeddedSubtitles(realPath string) []utils.SubtitleTrack {
return nil
}
var subtitles []utils.SubtitleTrack
var subtitles []SubtitleTrack
for _, stream := range probeOutput.Streams {
if stream.CodecType == "subtitle" {
track := utils.SubtitleTrack{
Index: &stream.Index,
Codec: stream.CodecName,
Embedded: true, // This is an embedded subtitle
track := SubtitleTrack{
Index: &stream.Index,
Codec: stream.CodecName,
IsFile: false, // This is an embedded subtitle
}
// Extract language and title from tags
@@ -123,8 +117,8 @@ func detectEmbeddedSubtitles(realPath string) []utils.SubtitleTrack {
}
// detectExternalSubtitles finds external subtitle files in the same directory
func detectExternalSubtitles(videoPath string, parentDir string) []utils.SubtitleTrack {
var subtitles []utils.SubtitleTrack
func detectExternalSubtitles(videoPath string, parentDir string) []SubtitleTrack {
var subtitles []SubtitleTrack
// Get the base name of the video (without extension)
videoBaseName := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
@@ -137,9 +131,9 @@ func detectExternalSubtitles(videoPath string, parentDir string) []utils.Subtitl
subtitlePath := filepath.Join(parentDir, videoBaseName+ext)
if _, err := os.Stat(subtitlePath); err == nil {
// File exists
track := utils.SubtitleTrack{
Name: filepath.Base(subtitlePath),
Embedded: false, // This is an external file
track := SubtitleTrack{
Name: filepath.Base(subtitlePath),
IsFile: true, // This is an external file
}
// Try to infer language from filename patterns like "video.en.srt"
@@ -176,11 +170,120 @@ func ExtractSubtitleContent(videoPath string, streamIndex int) (string, error) {
return string(output), nil
}
// LoadSubtitleFile loads a subtitle file and returns its raw content
func LoadSubtitleFile(subtitlePath string) (string, error) {
// LoadAndConvertSubtitleFile loads a subtitle file and converts it to WebVTT format
func LoadAndConvertSubtitleFile(subtitlePath string) (string, error) {
ext := strings.ToLower(filepath.Ext(subtitlePath))
if ext == ".vtt" {
// Already WebVTT, just read it
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
return string(content), nil
}
if ext == ".srt" {
// Convert SRT to WebVTT using ffmpeg
cmd := exec.Command("ffmpeg",
"-i", subtitlePath,
"-c:s", "webvtt",
"-f", "webvtt",
"-") // output to stdout
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to convert SRT to WebVTT: %v", err)
}
return string(output), nil
}
// For other formats, try to read as plain text and hope for the best
// This is a fallback - ideally you'd want format-specific converters
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
return string(content), nil
// Basic conversion wrapper for non-VTT formats
vttHeader := "WEBVTT\n\n"
return vttHeader + string(content), nil
}
// ExtractSingleSubtitle extracts content for a specific subtitle track by array index
func ExtractSingleSubtitle(videoPath string, parentDir string, trackIndex int, modtime time.Time) (SubtitleTrack, error) {
// Get all subtitle tracks
allTracks := DetectAllSubtitles(videoPath, parentDir, modtime)
if trackIndex >= len(allTracks) {
return SubtitleTrack{}, fmt.Errorf("subtitle track %d not found (only %d tracks available)", trackIndex, len(allTracks))
}
track := allTracks[trackIndex]
// Load content based on type
if track.IsFile {
// Load external subtitle file
subtitlePath := filepath.Join(parentDir, track.Name)
content, err := LoadAndConvertSubtitleFile(subtitlePath)
if err != nil {
return SubtitleTrack{}, fmt.Errorf("failed to load external subtitle: %v", err)
}
track.Content = content
} else {
// Extract embedded subtitle content
if track.Index == nil {
return SubtitleTrack{}, fmt.Errorf("embedded subtitle track has no stream index")
}
content, err := ExtractSubtitleContent(videoPath, *track.Index)
if err != nil {
return SubtitleTrack{}, fmt.Errorf("failed to extract embedded subtitle: %v", err)
}
track.Content = content
}
return track, nil
}
// LoadAllSubtitleContent loads the actual content for all detected subtitle tracks
func LoadAllSubtitleContent(videoPath string, subtitles []SubtitleTrack, modtime time.Time) error {
for idx := range subtitles {
subtitle := &subtitles[idx]
// Check if content is already cached
contentKey := fmt.Sprintf("subtitle_content:%s:%d:%s", videoPath, idx, modtime.Format(time.RFC3339))
if cached, ok := SubtitleContentCache.Get(contentKey); ok {
subtitle.Content = cached
continue
}
var content string
var err error
if subtitle.IsFile {
// Load external subtitle file content and convert to WebVTT
subtitlePath := filepath.Join(filepath.Dir(videoPath), subtitle.Name)
content, err = LoadAndConvertSubtitleFile(subtitlePath)
if err != nil {
logger.Debug("failed to read/convert subtitle file " + subtitlePath + ": " + err.Error())
continue
}
} else {
// Load embedded subtitle content
if subtitle.Index == nil {
logger.Debug("embedded subtitle has no stream index")
continue
}
content, err = ExtractSubtitleContent(videoPath, *subtitle.Index)
if err != nil {
logger.Debug("failed to extract embedded subtitle: " + err.Error())
continue
}
}
subtitle.Content = content
// Cache the content for future requests
SubtitleContentCache.Set(contentKey, content)
}
return nil
}
+34 -34
View File
@@ -16,22 +16,21 @@ require (
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-logger v1.0.2
github.com/kovidgoyal/imaging v1.8.19
github.com/mattn/go-sqlite3 v1.14.34
github.com/mattn/go-sqlite3 v1.14.33
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.48.0
golang.org/x/image v0.36.0
golang.org/x/mod v0.33.0
golang.org/x/net v0.50.0
golang.org/x/oauth2 v0.35.0
golang.org/x/sys v0.41.0
golang.org/x/crypto v0.47.0
golang.org/x/image v0.35.0
golang.org/x/mod v0.32.0
golang.org/x/net v0.49.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sys v0.40.0
golang.org/x/time v0.14.0
golang.org/x/tools v0.42.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.46.1
modernc.org/sqlite v1.44.3
)
require (
@@ -53,17 +52,16 @@ require (
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.3 // indirect
github.com/MirrexOne/unqueryvet v1.4.0 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.64.5 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/air-verse/air v1.64.2 // indirect
github.com/alecthomas/chroma/v2 v2.21.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.0.2 // indirect
github.com/alexkohler/prealloc v1.0.1 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect
github.com/ashanbrown/makezero/v2 v2.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -73,7 +71,7 @@ require (
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/bombsimon/wsl/v5 v5.6.0 // indirect
github.com/bombsimon/wsl/v5 v5.3.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
@@ -85,7 +83,7 @@ require (
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
@@ -99,7 +97,7 @@ require (
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.0-alpha.4 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
@@ -107,7 +105,7 @@ require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/ghostiam/protogetter v0.3.18 // indirect
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
@@ -130,10 +128,10 @@ require (
github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/godoc-lint/godoc-lint v0.11.1 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/golang/geo v0.0.0-20260129164528-943061e2742c // indirect
@@ -142,9 +140,9 @@ require (
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.10.1 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/golangci-lint/v2 v2.8.0 // indirect
github.com/golangci/golines v0.14.0 // indirect
github.com/golangci/misspell v0.7.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
@@ -166,7 +164,7 @@ require (
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/jupiterrider/ffi v0.6.0 // indirect
github.com/jupiterrider/ffi v0.5.1 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
@@ -196,7 +194,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.14.0 // indirect
github.com/mgechev/revive v1.13.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/moricho/tparallel v0.3.2 // indirect
@@ -205,7 +203,7 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
github.com/nunnatsa/ginkgolinter v0.21.2 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -230,8 +228,8 @@ require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.23.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/securego/gosec/v2 v2.22.11 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.4.0 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
@@ -264,21 +262,23 @@ require (
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.11.1 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/arangolint v0.3.1 // indirect
go.augendre.info/fatcontext v0.9.0 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.68.0 // indirect
honnef.co/go/tools v0.6.1 // indirect
modernc.org/libc v1.67.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+83 -80
View File
@@ -73,18 +73,18 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/MirrexOne/unqueryvet v1.5.3 h1:LpT3rsH+IY3cQddWF9bg4C7jsbASdGnrOSofY8IPEiw=
github.com/MirrexOne/unqueryvet v1.5.3/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
github.com/MirrexOne/unqueryvet v1.4.0 h1:6KAkqqW2KUnkl9Z0VuTphC3IXRPoFqEkJEtyxxHj5eQ=
github.com/MirrexOne/unqueryvet v1.4.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4=
github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/air-verse/air v1.64.2 h1:H49xKRa4r3uFUycmCCA+JectVuZLNMPGdypLFpoHVcY=
github.com/air-verse/air v1.64.2/go.mod h1:SOwv08NTvKextanAY819AK0FK6XzQO5/tC6s1R+rk8o=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
@@ -96,16 +96,14 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ=
github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
github.com/alexkohler/prealloc v1.0.2 h1:MPo8cIkGkZytq7WNH9UHv3DIX1mPz1RatPXnZb0zHWQ=
github.com/alexkohler/prealloc v1.0.2/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
github.com/alexkohler/prealloc v1.0.1 h1:A9P1haqowqUxWvU9nk6tQ7YktXIHf+LQM9wPRhuteEE=
github.com/alexkohler/prealloc v1.0.1/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc=
github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus=
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
@@ -156,8 +154,8 @@ github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
github.com/bombsimon/wsl/v5 v5.6.0 h1:4z+/sBqC5vUmSp1O0mS+czxwH9+LKXtCWtHH9rZGQL8=
github.com/bombsimon/wsl/v5 v5.6.0/go.mod h1:Uqt2EfrMj2NV8UGoN1f1Y3m0NpUVCsUdrNCdet+8LvU=
github.com/bombsimon/wsl/v5 v5.3.0 h1:nZWREJFL6U3vgW/B1lfDOigl+tEF6qgs6dGGbFeR0UM=
github.com/bombsimon/wsl/v5 v5.3.0/go.mod h1:Gp8lD04z27wm3FANIUPZycXp+8huVsn0oxc+n4qfV9I=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -184,8 +182,8 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
@@ -244,8 +242,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.0-alpha.4 h1:JzPbdf+cqbyT9sZtP4xnqelwUXwf7LvD8xKS6+ofTds=
github.com/ebitengine/purego v0.10.0-alpha.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -275,8 +273,8 @@ github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0=
github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
github.com/ghostiam/protogetter v0.3.18 h1:yEpghRGtP9PjKvVXtEzGpYfQj1Wl/ZehAfU6fr62Lfo=
github.com/ghostiam/protogetter v0.3.18/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
@@ -357,8 +355,8 @@ github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQi
github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ=
github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus=
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
@@ -367,8 +365,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM=
github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo=
github.com/godoc-lint/godoc-lint v0.11.1 h1:z9as8Qjiy6miRIa3VRymTa+Gt2RLnGICVikcvlUVOaA=
github.com/godoc-lint/godoc-lint v0.11.1/go.mod h1:BAqayheFSuZrEAqCRxgw9MyvsM+S/hZwJbU1s/ejRj8=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -434,12 +432,12 @@ github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarog
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
github.com/golangci/golangci-lint/v2 v2.10.1 h1:flhw5Px6ojbLyEFzXvJn5B2HEdkkRlkhE1SnmCbQBiE=
github.com/golangci/golangci-lint/v2 v2.10.1/go.mod h1:dBsrOk6zj0vDhlTv+IiJGqkDokR24IVTS7W3EVfPTQY=
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
github.com/golangci/golangci-lint/v2 v2.8.0 h1:wJnr3hJWY3eVzOUcfwbDc2qbi2RDEpvLmQeNFaPSNYA=
github.com/golangci/golangci-lint/v2 v2.8.0/go.mod h1:xl+HafQ9xoP8rzw0z5AwnO5kynxtb80e8u02Ej/47RI=
github.com/golangci/golines v0.14.0 h1:xt9d3RKBjhasA3qpoXs99J2xN2t6eBlpLHt0TrgyyXc=
github.com/golangci/golines v0.14.0/go.mod h1:gf555vPG2Ia7mmy2mzmhVQbVjuK8Orw0maR1G4vVAAQ=
github.com/golangci/misspell v0.7.0 h1:4GOHr/T1lTW0hhR4tgaaV1WS/lJ+ncvYCoFKmqJsj0c=
github.com/golangci/misspell v0.7.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
@@ -473,8 +471,8 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -545,8 +543,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
github.com/jupiterrider/ffi v0.6.0 h1:UX378KcZvH5c8qgLi9KL/bL82SZTHdRspZ+jj7bvBng=
github.com/jupiterrider/ffi v0.6.0/go.mod h1:PqZ5Go6X9by8CIXgfprxfMPYmn8oT5m2O7AA56s64bY=
github.com/jupiterrider/ffi v0.5.1 h1:l7ANXU+Ex33LilVa283HNaf/sTzCrrht7D05k6T6nlc=
github.com/jupiterrider/ffi v0.5.1/go.mod h1:x7xdNKo8h0AmLuXfswDUBxUsd2OqUP4ekC8sCnsmbvo=
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
@@ -624,12 +622,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgechev/revive v1.14.0 h1:CC2Ulb3kV7JFYt+izwORoS3VT/+Plb8BvslI/l1yZsc=
github.com/mgechev/revive v1.14.0/go.mod h1:MvnujelCZBZCaoDv5B3foPo6WWgULSSFxvfxp7GsPfo=
github.com/mgechev/revive v1.13.0 h1:yFbEVliCVKRXY8UgwEO7EOYNopvjb1BFbmYqm9hZjBM=
github.com/mgechev/revive v1.13.0/go.mod h1:efJfeBVCX2JUumNQ7dtOLDja+QKj9mYGgEZA7rt5u+0=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -661,8 +659,8 @@ github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhK
github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8=
github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4=
github.com/nunnatsa/ginkgolinter v0.21.2 h1:khzWfm2/Br8ZemX8QM1pl72LwM+rMeW6VUbQ4rzh0Po=
github.com/nunnatsa/ginkgolinter v0.21.2/go.mod h1:GItSI5fw7mCGLPmkvGYrr1kEetZe7B593jcyOpyabsY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
@@ -673,10 +671,10 @@ github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
@@ -700,6 +698,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
@@ -759,8 +759,8 @@ github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tM
github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
github.com/securego/gosec/v2 v2.23.0 h1:h4TtF64qFzvnkqvsHC/knT7YC5fqyOCItlVR8+ptEBo=
github.com/securego/gosec/v2 v2.23.0/go.mod h1:qRHEgXLFuYUDkI2T7W7NJAmOkxVhkR0x9xyHOIcMNZ0=
github.com/securego/gosec/v2 v2.22.11 h1:tW+weM/hCM/GX3iaCV91d5I6hqaRT2TPsFM1+USPXwg=
github.com/securego/gosec/v2 v2.22.11/go.mod h1:KE4MW/eH0GLWztkbt4/7XpyH0zJBBnu7sYB4l6Wn7Mw=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
@@ -769,8 +769,8 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
@@ -802,6 +802,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
@@ -852,8 +853,6 @@ github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pH
github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM=
github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk=
github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs=
@@ -879,8 +878,8 @@ go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo=
go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE=
go-simpler.org/sloglint v0.11.1 h1:xRbPepLT/MHPTCA6TS/wNfZrDzkGvCCqUv4Bdwc3H7s=
go-simpler.org/sloglint v0.11.1/go.mod h1:2PowwiCOK8mjiF+0KGifVOT8ZsCNiFzvfyJeJOIt8MQ=
go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50=
go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA=
go.augendre.info/arangolint v0.3.1 h1:n2E6p8f+zfXSFLa2e2WqFPp4bfvcuRdd50y6cT65pSo=
go.augendre.info/arangolint v0.3.1/go.mod h1:6ZKzEzIZuBQwoSvlKT+qpUfIbBfFCE5gbAoTg0/117g=
go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE=
go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
@@ -891,6 +890,8 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
@@ -908,8 +909,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -920,16 +921,16 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk=
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 h1:HDjDiATsGqvuqvkDvgJjD1IgPrVekcSXVVE21JwvzGE=
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -955,8 +956,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -999,16 +1000,16 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1070,6 +1071,7 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1078,8 +1080,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -1096,8 +1098,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1153,8 +1155,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -1260,6 +1262,7 @@ gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -1269,22 +1272,22 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/libc v1.67.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=
@@ -1293,8 +1296,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+5
View File
@@ -8,6 +8,7 @@ import (
libError "errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
@@ -397,6 +398,10 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
}
password := r.Header.Get("X-SHARE-PASSWORD")
password, err := url.QueryUnescape(password)
if err != nil {
return http.StatusUnauthorized, err
}
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return http.StatusUnauthorized, nil
+9 -6
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -555,10 +556,7 @@ func mergeGroupsByChecksum(groups []duplicateGroupWithChecksums) []duplicateGrou
func prepDuplicatesOptions(r *http.Request, d *requestContext) (*duplicatesOptions, error) {
source := r.URL.Query().Get("source")
scope, err := utils.SanitizeUserPath(r.URL.Query().Get("scope"))
if err != nil {
return nil, fmt.Errorf("invalid scope: %v", err)
}
scope := r.URL.Query().Get("scope")
minSizeMbStr := r.URL.Query().Get("minSizeMb")
// First pass uses filename matching for speed, then checksums verify final groups
@@ -567,14 +565,19 @@ func prepDuplicatesOptions(r *http.Request, d *requestContext) (*duplicatesOptio
// Default minimum size: 1MB
minSizeMb := int64(1)
if minSizeMbStr != "" {
if _, err = fmt.Sscanf(minSizeMbStr, "%d", &minSizeMb); err != nil {
if _, err := fmt.Sscanf(minSizeMbStr, "%d", &minSizeMb); err != nil {
return nil, fmt.Errorf("invalid minSizeMb parameter: %w", err)
}
}
// Convert MB to bytes
minSize := minSizeMb * 1024 * 1024
searchScope := strings.TrimPrefix(scope, ".")
unencodedScope, err := url.PathUnescape(scope)
if err != nil {
return nil, fmt.Errorf("invalid path encoding: %v", err)
}
searchScope := strings.TrimPrefix(unencodedScope, ".")
index := indexing.GetIndex(source)
if index == nil {
+1 -12
View File
@@ -137,12 +137,8 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
var err error
path, err = utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
var err error
// Parse lines parameter
lines := 10 // default
if linesStr != "" {
@@ -286,13 +282,6 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
path = cleanPath
// Parse lines parameter
lines := 10 // default
if linesStr != "" {
-3
View File
@@ -74,9 +74,6 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
devMode: settings.Env.IsDevMode,
}
// Initialize cached PWA manifest
InitializePWAManifest()
router := http.NewServeMux()
// API group routing
api := http.NewServeMux()
+25 -54
View File
@@ -4,26 +4,24 @@ import (
"bytes"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
// subtitlesHandler handles subtitle requests for both external files and embedded streams
// @Summary Get subtitle content
// @Description Returns raw subtitle content from external files or embedded streams
// subtitlesHandler handles subtitle extraction requests
// @Summary Extract embedded subtitles
// @Description Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content
// @Tags Subtitles
// @Accept json
// @Produce text/plain
// @Produce text/vtt
// @Param path query string true "Index path to the video file"
// @Param source query string true "Source name for the desired source"
// @Param name query string true "Subtitle track name (filename for external, descriptive name for embedded)"
// @Param embedded query bool false "Whether this is an embedded stream (true) or external file (false), defaults to false"
// @Success 200 {string} string "Raw subtitle content in original format"
// @Param index query int false "Stream index for embedded subtitle extraction, defaults to 0"
// @Success 200 {string} string "Raw WebVTT subtitle content"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Resource not found"
@@ -32,11 +30,10 @@ import (
func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
name := r.URL.Query().Get("name")
embedded := r.URL.Query().Get("embedded") == "true"
indexParam := r.URL.Query().Get("index")
if name == "" {
return http.StatusBadRequest, fmt.Errorf("name parameter is required")
if indexParam == "" {
indexParam = "0" // default to first subtitle stream
}
userscope, err := d.user.GetScopeForSourceName(source)
@@ -48,54 +45,28 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, err := idx.GetRealPath(userscope, path)
if err != nil {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
parentDir := filepath.Dir(realPath)
var content string
if !embedded {
content, err = utils.GetSubtitleSidecarContent(filepath.Join(parentDir, name))
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to get subtitle sidecar content: %v", err)
}
} else {
// For embedded subtitles, we need to find the stream index by name
// Get file modification time for caching
fileInfo, err := os.Stat(realPath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to stat file: %v", err)
}
// Detect embedded subtitles
embeddedSubs := ffmpeg.DetectEmbeddedSubtitles(realPath, fileInfo.ModTime())
// Find the subtitle track by name
var streamIndex *int
for _, sub := range embeddedSubs {
if sub.Name == name {
streamIndex = sub.Index
break
}
}
if streamIndex == nil {
return http.StatusNotFound, fmt.Errorf("embedded subtitle track '%s' not found", name)
}
content, err = ffmpeg.ExtractSubtitleContent(realPath, *streamIndex)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to extract embedded subtitle: %v", err)
}
metadata, exists := idx.GetMetadataInfo(userscope, true, false)
if !exists {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
// Return raw content with appropriate content type
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
index, err := strconv.Atoi(indexParam)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid index parameter: %v", err)
}
parentDir := filepath.Dir(realPath)
subtitle, err := ffmpeg.ExtractSingleSubtitle(realPath, parentDir, index, metadata.ModTime)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to extract subtitle: %v", err)
}
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
w.Header().Set("Content-Disposition", "inline")
w.Header().Set("Cache-Control", "private")
http.ServeContent(w, r, name, time.Now(), bytes.NewReader([]byte(content)))
http.ServeContent(w, r, fmt.Sprintf("%s-%d.vtt", subtitle.Name, index),
time.Now(), bytes.NewReader([]byte(subtitle.Content)))
return http.StatusOK, nil
}
+15 -14
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"runtime"
"slices"
"strings"
@@ -25,7 +26,6 @@ import (
type requestContext struct {
user *users.User
shareUser *users.User
fileInfo iteminfo.ExtendedFileInfo
token string
share *share.Link
@@ -51,7 +51,12 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
func withHashFileHelper(fn handleFunc) handleFunc {
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
path := r.URL.Query().Get("path")
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
path, err := url.PathUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
@@ -98,27 +103,23 @@ func withHashFileHelper(fn handleFunc) handleFunc {
if link.DisableFileViewer || reachedDownloadsLimit {
getContent = false
}
data.shareUser, err = store.Users.Get(link.UserID)
shareCreatedByUser, err := store.Users.Get(link.UserID)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
}
// get user scope path from share
userScope, err := data.shareUser.GetScopeForSourceName(source.Name)
userScope, err := shareCreatedByUser.GetScopeForSourceName(source.Name)
if err != nil {
return http.StatusForbidden, err
}
// so trim user scope from link.Path
pathWithoutUserScope := utils.JoinPathAsUnix("/", strings.TrimPrefix(link.Path, userScope), path)
if !strings.HasSuffix(pathWithoutUserScope, "/") {
pathWithoutUserScope = pathWithoutUserScope + "/"
}
data.IndexPath = pathWithoutUserScope
if r.Method == "POST" && strings.Contains(r.URL.Path, "/resources") {
// no need to fetch file info for uploads
return fn(w, r, data)
userScopedPath := utils.JoinPathAsUnix("/", strings.TrimPrefix(link.Path, userScope), path)
if !strings.HasSuffix(userScopedPath, "/") {
userScopedPath = userScopedPath + "/"
}
data.IndexPath = userScopedPath
file, err := FileInfoFasterFunc(utils.FileOptions{
Path: pathWithoutUserScope,
Path: userScopedPath,
Source: source.Name,
Expand: true,
Content: getContent,
@@ -127,7 +128,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
ShowHidden: link.ShowHidden,
FollowSymlinks: true,
}, store.Access, data.shareUser)
}, store.Access, shareCreatedByUser)
if err != nil {
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
return errToStatus(err), fmt.Errorf("error fetching share from server")
+1 -19
View File
@@ -80,14 +80,6 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, providedPath)
return http.StatusBadRequest, errors.New("missing required parameters: path + source/hash are required")
}
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(providedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
providedPath = cleanPath
themeMode := utils.Ternary(d.user.DarkMode, "dark", "light")
var sourceInfo *settings.Source
var ok bool
@@ -110,8 +102,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
// Build file info based on whether this is a share or regular request
// Regular user request
logger.Debugf("OnlyOffice user request: request path=%s", path)
var fileInfo *iteminfo.ExtendedFileInfo
fileInfo, err = files.FileInfoFaster(utils.FileOptions{
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: path,
Source: source,
Expand: false,
@@ -345,15 +336,6 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, path)
return returnOnlyOfficeError(w, r, 400, "missing required parameters: path + source/hash are required")
}
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
logger.Errorf("OnlyOffice callback: invalid path: %v", err)
return returnOnlyOfficeError(w, r, 400, "invalid path")
}
path = cleanPath
var sourceInfo *settings.Source
var ok bool
if d.fileInfo.Hash != "" {
+7 -6
View File
@@ -16,6 +16,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-logger/logger"
@@ -86,6 +87,10 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.ExtendedFileInfo) (int, error) {
idx := indexing.GetIndex(file.Source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source not found: %s", file.Source)
}
fd, err := os.Open(file.RealPath)
if err != nil {
return http.StatusInternalServerError, err
@@ -157,7 +162,7 @@ func getDirectoryPreview(r *http.Request, d *requestContext) (*iteminfo.Extended
func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
previewSize := r.URL.Query().Get("size")
if !(previewSize == "large" || previewSize == "original" || previewSize == "xlarge") {
if !(previewSize == "large" || previewSize == "original") {
previewSize = "small"
}
if !d.fileInfo.HasPreview {
@@ -178,13 +183,9 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
isImage := strings.HasPrefix(d.fileInfo.Type, "image")
ext := strings.ToLower(filepath.Ext(d.fileInfo.Name))
resizable := iteminfo.ResizableImageTypes[ext]
// If image is under 500KB, serve the original instead of generating a preview
const maxSizeForOriginal = 500 * 1024 // 500KB
if (!resizable || config.Server.DisableResize || d.fileInfo.Size < maxSizeForOriginal) && isImage {
if (!resizable || config.Server.DisableResize) && isImage {
return rawFileHandler(w, r, d.fileInfo)
}
seekPercentage := 0
percentage := r.URL.Query().Get("atPercentage")
if percentage != "" {
+44 -53
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
@@ -76,8 +77,8 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
// Get all "file" parameter values (supports repeated params)
files := r.URL.Query()["file"]
if len(files) == 0 {
encodedFiles := r.URL.Query()["file"]
if len(encodedFiles) == 0 {
return http.StatusBadRequest, fmt.Errorf("no files specified")
}
@@ -88,17 +89,16 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
actualSourceName := sourceInfo.Name
// Process each file path
// Decode and process each file path
fileList := []string{}
for _, file := range files {
// Rule 1: Validate each file path to prevent path traversal
cleanFile, err := utils.SanitizeUserPath(file)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid file path: %v", err)
for _, encodedFile := range encodedFiles {
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
file, decodeErr := url.PathUnescape(encodedFile)
if decodeErr != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", decodeErr)
}
// Join the share path with the requested path
filePath := utils.JoinPathAsUnix(d.share.Path, cleanFile)
filePath := utils.JoinPathAsUnix(d.share.Path, file)
fileList = append(fileList, filePath)
}
@@ -107,8 +107,8 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if err == errors.ErrDownloadNotAllowed {
return http.StatusForbidden, errors.ErrDownloadNotAllowed
}
logger.Errorf("public share handler: error processing filelist: %v with error %v", files, err)
return status, fmt.Errorf("error processing filelist: %v", files)
logger.Errorf("public share handler: error processing filelist: %v with error %v", encodedFiles, err)
return status, fmt.Errorf("error processing filelist: %v", encodedFiles)
}
return status, nil
}
@@ -143,7 +143,7 @@ func publicGetResourceHandler(w http.ResponseWriter, r *http.Request, d *request
// @Accept multipart/form-data
// @Produce json
// @Param hash query string true "Share hash for authentication"
// @Param path query string true "path within the share to upload to. Must be relative to share root."
// @Param targetPath query string true "Target path within the share to upload to. Must be relative to share root."
// @Param override query bool false "If true, overwrite existing files/folders. Defaults to false."
// @Param action query string false "Upload action: 'override' to replace files, 'rename' to auto-rename"
// @Param file formData file true "File to upload"
@@ -159,15 +159,18 @@ func publicUploadHandler(w http.ResponseWriter, r *http.Request, d *requestConte
if d.share.ShareType != "upload" && !d.share.AllowCreate {
return http.StatusForbidden, fmt.Errorf("uploading is disabled for this share")
}
if !d.share.AllowReplacements && r.URL.Query().Get("action") == "override" {
return http.StatusForbidden, fmt.Errorf("cannot overwrite files for this share")
}
// Go automatically decodes query params
path := 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
q := r.URL.Query()
q.Set("source", source)
q.Set("path", d.IndexPath)
q.Set("path", fullPath)
r.URL.RawQuery = q.Encode()
status, err := resourcePostHandler(w, r, d)
if err != nil {
@@ -256,14 +259,7 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
// Go automatically decodes query params
path := r.URL.Query().Get("path")
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
resolvedPath := utils.JoinPathAsUnix(d.share.Path, cleanPath)
resolvedPath := utils.JoinPathAsUnix(d.share.Path, path)
err = files.WriteFile(source, resolvedPath, r.Body)
// hide the error
if err != nil {
@@ -273,26 +269,36 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusOK, nil
}
// deprecated -- see publicBulkDeleteHandler
func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.share.AllowDelete {
return http.StatusForbidden, fmt.Errorf("delete is not allowed for this share")
}
path := r.URL.Query().Get("path")
indexPath := utils.JoinPathAsUnix(d.share.Path, path)
source, err := d.share.GetSourceName()
if err != nil {
return http.StatusNotFound, fmt.Errorf("source not available")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: d.IndexPath,
Source: d.share.Source,
}, store.Access, d.shareUser)
Path: indexPath,
Source: source,
Expand: false,
ShowHidden: d.share.ShowHidden,
}, store.Access, d.user)
if err != nil {
return http.StatusNotFound, fmt.Errorf("resource not available")
}
err = files.DeleteFiles(d.share.Source, fileInfo.RealPath, fileInfo.Type == "directory")
// delete thumbnails
preview.DelThumbs(r.Context(), *fileInfo)
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
if err != nil {
logger.Errorf("public delete handler: error deleting resource with error %v", err)
return http.StatusInternalServerError, fmt.Errorf("an error occured while deleting the resource")
}
// delete thumbnails
preview.DelThumbs(r.Context(), *fileInfo)
return http.StatusOK, nil
}
@@ -314,10 +320,10 @@ func publicBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestC
if !d.share.AllowDelete {
return http.StatusForbidden, fmt.Errorf("delete is not allowed for this share")
}
// hide the error
status, err := resourceBulkDeleteHandler(w, r, d)
if err != nil {
logger.Errorf("public bulk delete handler: error deleting resources with error %v", err)
return http.StatusInternalServerError, fmt.Errorf("an error occurred while processing the request")
}
return status, nil
@@ -392,15 +398,15 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return status, err
}
// getShareImage serves banner or favicon files for shares as resizable previews
// @Summary Get share image (banner or favicon) as preview
// @Description Returns a resizable preview (large size) for the banner or favicon file of a share
// getShareImage serves banner or favicon files for shares
// @Summary Get share image (banner or favicon)
// @Description Serves the banner or favicon file for a share
// @Tags Public Shares
// @Produce image/jpeg
// @Produce octet-stream
// @Param hash query string true "Share hash"
// @Param banner query bool false "Request banner file"
// @Param favicon query bool false "Request favicon file"
// @Success 200 {file} file "Preview image content (JPEG)"
// @Success 200 {file} binary "Image file content"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 403 {object} map[string]string "Permission denied"
// @Failure 404 {object} map[string]string "Asset not found"
@@ -445,24 +451,9 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
return http.StatusBadRequest, fmt.Errorf("invalid file type, must be image")
}
// Set file info in request context for preview generation
d.fileInfo = *fileInfo
q := r.URL.Query()
if isBanner {
q.Set("size", "xlarge")
} else {
q.Set("size", "small")
}
r.URL.RawQuery = q.Encode()
// Use the preview helper to generate and serve a resized preview
status, err := previewHelperFunc(w, r, d)
if err != nil {
logger.Errorf("error generating preview for share asset: source=%v path=%v error=%v", sourceName, assetPath, err)
return http.StatusNotFound, fmt.Errorf("preview not available for this asset")
}
return status, err
// Serve the file
http.ServeFile(w, r, fileInfo.RealPath)
return 0, nil
}
func getShareImagePartsHelper(share *share.Link, isBanner bool) (string, string, error) {
-10
View File
@@ -113,16 +113,6 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
source := r.URL.Query().Get("source")
fileList := r.URL.Query()["file"]
// Rule 1: Validate all user-provided file paths to prevent path traversal
for i, filePath := range fileList {
cleanPath, err := utils.SanitizeUserPath(filePath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid file path: %v", err)
}
fileList[i] = cleanPath
}
return rawFilesHandler(w, r, d, source, fileList)
}
+57 -74
View File
@@ -203,10 +203,6 @@ type MoveCopyResponse struct {
// @Failure 500 {object} map[string]string "Internal server error - all deletions failed"
// @Router /api/resources/bulk/delete [post]
func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
filePermUser := d.user
if d.share != nil {
filePermUser = d.shareUser
}
// Check permissions - either user delete permission or share delete permission
if d.share == nil {
if !d.user.Permissions.Delete {
@@ -252,29 +248,18 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
}
if d.share != nil {
indexPath := utils.JoinPathAsUnix(d.share.Path, item.Path)
source, err := d.share.GetSourceName()
if err != nil {
return http.StatusNotFound, fmt.Errorf("source not available")
}
// get user scope path from share
userScope, err := d.shareUser.GetScopeForSourceName(source)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: item.Path,
Message: "user does not have access",
})
continue
}
withoutUserScope := strings.TrimPrefix(d.share.Path, userScope)
indexPath := utils.JoinPathAsUnix(withoutUserScope, item.Path)
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: indexPath,
Source: source,
ShowHidden: true,
}, store.Access, filePermUser)
}, store.Access, d.user)
if err != nil {
return http.StatusNotFound, fmt.Errorf("resource not available")
}
@@ -304,7 +289,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
}
// Check user scope for this source
_, err := filePermUser.GetScopeForSourceName(item.Source)
_, err := d.user.GetScopeForSourceName(item.Source)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
@@ -330,7 +315,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
Path: idx.MakeIndexPath(item.Path, false),
Source: item.Source,
ShowHidden: true,
}, store.Access, filePermUser)
}, store.Access, d.user)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
@@ -369,7 +354,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "url encoded destination path where to place the files inside the destination source"
// @Param path query string true "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory"
// @Param source query string true "Name for the desired filebrowser destination source name, default is used if not provided"
// @Param override query bool false "Override existing file if true"
// @Param isDir query bool false "Explicitly specify if the resource is a directory"
@@ -382,22 +367,22 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
path = cleanPath
if d.share == nil && !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
filePermUser := d.user
var err error
accessStore := store.Access
// if share is not nil, then set accessStore to nil
if d.share != nil {
filePermUser = d.shareUser
accessStore = nil
} else {
// 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
}
isDir := r.URL.Query().Get("isDir") == "true"
// Determine if this is a directory based on isDir query param or trailing slash (for backwards compatibility)
isDirParam := r.URL.Query().Get("isDir")
isDir := isDirParam == "true" || strings.HasSuffix(path, "/")
fileOpts := utils.FileOptions{
Path: path,
Source: source,
@@ -409,21 +394,10 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
logger.Debugf("source %s not found", source)
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
userscope, err := filePermUser.GetScopeForSourceName(source)
if err != nil {
logger.Debugf("error getting scope from source name: %v", err)
return http.StatusForbidden, err
}
userscope = strings.TrimRight(userscope, "/")
fullIndexPath := utils.JoinPathAsUnix(userscope, path)
// get scoped path
realPath, _, _ := idx.GetRealPath(fullIndexPath)
realPath, _, _ := idx.GetRealPath(path)
// Check access control for the target path
if !store.Access.Permitted(idx.Path, path, filePermUser.Username) {
if accessStore != nil && !accessStore.Permitted(idx.Path, path, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
@@ -442,6 +416,14 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Directories creation on POST.
if isDir {
// Get user scope to resolve full index path for directory creation
var userScope string
userScope, err = d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
// Create a new FileOptions with the full index path
dirOpts := fileOpts
dirOpts.Path = fullIndexPath
@@ -487,7 +469,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
var fileInfo *iteminfo.ExtendedFileInfo
fileInfo, err = files.FileInfoFaster(fileOpts, store.Access, filePermUser)
fileInfo, err = files.FileInfoFaster(fileOpts, accessStore, d.user)
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
@@ -537,16 +519,23 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// close file before moving
outFile.Close()
// Move the completed file from the temp location to the final destination
err = files.MoveResource(false, source, source, tempFilePath, realPath, store.Share, store.Access)
err = fileutils.MoveFile(tempFilePath, realPath)
if err != nil {
logger.Debugf("could not move file from %v to %v: %v", tempFilePath, realPath, err)
return http.StatusInternalServerError, fmt.Errorf("could not move file from chunked folder to destination: %v", err)
}
// Refresh index with user scope
userScope, scopeErr := d.user.GetScopeForSourceName(source)
if scopeErr == nil {
fullIndexPath := utils.JoinPathAsUnix(userScope, fileOpts.Path)
go files.RefreshIndex(source, fullIndexPath, false, false) //nolint:errcheck
}
}
return http.StatusOK, nil
}
fileInfo, err := files.FileInfoFaster(fileOpts, store.Access, filePermUser)
fileInfo, err := files.FileInfoFaster(fileOpts, accessStore, d.user)
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
@@ -556,6 +545,13 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
preview.DelThumbs(r.Context(), *fileInfo)
}
// Get user scope to resolve full index path for write operation
userScope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
err = files.WriteFile(fileOpts.Source, fullIndexPath, r.Body)
if err != nil {
logger.Debugf("error writing file: %v", err)
@@ -582,13 +578,6 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
source := r.URL.Query().Get("source")
path := r.URL.Query().Get("path")
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
path = cleanPath
// Only allow PUT for files.
if strings.HasSuffix(path, "/") {
return http.StatusMethodNotAllowed, nil
@@ -656,7 +645,6 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
// Process each item
for _, item := range req.Items {
// Validate all fields are provided
if item.FromSource == "" || item.FromPath == "" || item.ToSource == "" || item.ToPath == "" {
item.Message = "fromSource, fromPath, toSource, and toPath are required"
@@ -669,26 +657,26 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
response.Failed = append(response.Failed, item)
continue
}
cleanFromPath, err := utils.SanitizeUserPath(item.FromPath)
if err != nil {
item.Message = fmt.Sprintf("invalid fromPath: %v", err)
// Check for root path modifications
if item.ToPath == "/" || item.FromPath == "/" {
item.Message = "cannot modify root directory"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
response.Failed = append(response.Failed, item)
continue
}
cleanToPath, err := utils.SanitizeUserPath(item.ToPath)
if err != nil {
item.Message = fmt.Sprintf("invalid toPath: %v", err)
response.Failed = append(response.Failed, item)
continue
}
item.FromPath = cleanFromPath
item.ToPath = cleanToPath
// Get user scopes for both sources
// For shares, paths are already absolute, so use empty scope
userscopeSrc := ""
userscopeDst := ""
if d.share == nil {
var err error
userscopeSrc, err = d.user.GetScopeForSourceName(item.FromSource)
if err != nil {
item.Message = "source not available"
@@ -734,11 +722,6 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
// Build full index paths for access control
fullSrcIndexPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
fullDstIndexPath := utils.JoinPathAsUnix(userscopeDst, item.ToPath)
if fullDstIndexPath == "/" || fullSrcIndexPath == "/" {
item.Message = "source or destination is the root or unautharized directory"
response.Failed = append(response.Failed, item)
continue
}
// Check access control for both source and destination paths
if !store.Access.Permitted(srcIdx.Path, fullSrcIndexPath, d.user.Username) {
-5
View File
@@ -122,11 +122,6 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
sourceParam := r.URL.Query().Get("source") // deprecated, but still supported
scope := r.URL.Query().Get("scope") // Go automatically decodes query params
largest := r.URL.Query().Get("largest") == "true"
cleanScope, err := utils.SanitizeUserPath(scope)
if err != nil {
return nil, fmt.Errorf("invalid scope: %v", err)
}
scope = cleanScope
var sources []string
if sourcesParam != "" {
+9 -10
View File
@@ -116,8 +116,13 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/share [get]
func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
encodedPath := r.URL.Query().Get("path")
sourceName := r.URL.Query().Get("source")
// Decode the URL-encoded path
path, err := url.PathUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
sourceInfo, ok := config.Server.NameToSource[sourceName] // backend source is path
if !ok {
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", sourceName)
@@ -358,14 +363,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusForbidden, err
}
providedPath := body.Path
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(providedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
body.Path = utils.JoinPathAsUnix(userscope, cleanPath)
body.Path = utils.JoinPathAsUnix(userscope, providedPath)
body.Path = utils.AddTrailingSlashIfNotExists(body.Path)
// validate path exists as file or folder
_, _, err = idx.GetRealPath(body.Path)
@@ -610,7 +608,8 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusNotFound, fmt.Errorf("share hash not found")
}
commonShare := shareLink.CommonShare
commonShare.ShareURL = getShareURL(r, hash, false, "")
commonShare.DownloadURL = getShareURL(r, hash, true, shareLink.Token)
commonShare.ShareURL = getShareURL(r, hash, false, shareLink.Token)
_, _, err = getShareImagePartsHelper(shareLink, true)
if err == nil {
commonShare.BannerUrl = fmt.Sprintf("%spublic/api/share/image?banner=true&hash=%s", config.Server.BaseURL, hash)
+16 -131
View File
@@ -16,7 +16,6 @@ import (
)
var templateRenderer *TemplateRenderer
var cachedManifest PWAManifest // generated at startup
type TemplateRenderer struct {
templates *template.Template
@@ -79,12 +78,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
disableSidebar := false
// Use custom favicon if configured and validated, otherwise fall back to default
// Determine the correct favicon extension based on type
faviconExt := ".svg" // Default to SVG
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
faviconExt = ".png"
}
favicon := staticURL + "/favicon" + faviconExt
favicon := staticURL + "/favicon"
shareHash := ""
data := make(map[string]interface{})
disableNavButtons := settings.Config.Frontend.DisableNavButtons
@@ -111,7 +105,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
if d.share.DisableSidebar {
disableSidebar = true
}
if strings.HasPrefix(strings.ToLower(d.share.Banner), "http") {
if strings.HasPrefix(d.share.Banner, "http") {
banner = d.share.Banner
} else {
_, _, err := getShareImagePartsHelper(d.share, true)
@@ -119,7 +113,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
banner = fmt.Sprintf("%s%spublic/api/share/image?banner=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
}
}
if strings.HasPrefix(strings.ToLower(d.share.Favicon), "http") {
if strings.HasPrefix(d.share.Favicon, "http") {
favicon = d.share.Favicon
} else {
_, _, err := getShareImagePartsHelper(d.share, false)
@@ -199,7 +193,6 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"pwaIcon192": pwaIcon192,
"pwaIcon256": pwaIcon256,
"pwaIcon512": pwaIcon512,
"manifestURL": staticURL + "/site.webmanifest",
}
// variables consumed by frontend as json
data["globalVars"] = map[string]interface{}{
@@ -277,16 +270,6 @@ func setContentType(w http.ResponseWriter, path string) {
}
}
// manifestHandler serves the cached PWA manifest
func manifestHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/manifest+json")
w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
if err := json.NewEncoder(w).Encode(cachedManifest); err != nil {
http.Error(w, "Failed to serve manifest", http.StatusInternalServerError)
}
}
// staticAssetHandler serves static assets exactly as the frontend build produces them
func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
const maxAge = 86400 // 1 day
@@ -302,30 +285,25 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
// Handle special routes that need path mapping
var assetPath string
switch path {
case "site.webmanifest":
manifestHandler(w, r)
return
case "favicon.svg":
// For custom SVG favicons, serve the SVG directly
case "favicon.svg", "favicon":
// Handle custom favicon from filesystem
if settings.Env.FaviconIsCustom {
http.ServeFile(w, r, settings.Env.FaviconPath)
return
}
// Use embedded default
assetPath = settings.Env.FaviconEmbeddedPath
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
}
// Use embedded default SVG
// Fall back to default favicon SVG
assetPath = settings.Env.FaviconEmbeddedPath
case "favicon.png":
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-512.png")
if _, err := os.Stat(iconPath); err == nil {
http.ServeFile(w, r, iconPath)
return
}
}
// Fall back to embedded default favicon.png
assetPath = "img/icons/favicon.png"
case "favicon-32x32.png",
case "favicon-16x16.png", "favicon-32x32.png",
"pwa-icon-192.png", "pwa-icon-256.png", "pwa-icon-512.png",
"apple-touch-icon.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 {
@@ -334,15 +312,6 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
}
// Fall back to embedded favicon.png if generation failed
assetPath = "img/icons/favicon.png"
case "mstile-256x256.png":
// Windows tile - redirect to pwa-icon-256.png (they're identical)
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-256.png")
if _, err := os.Stat(iconPath); err == nil {
http.ServeFile(w, r, iconPath)
return
}
// Fall back to embedded favicon.png if generation failed
assetPath = "img/icons/favicon.png"
case "loginIcon":
// Handle custom login icon from filesystem
if settings.Env.LoginIconIsCustom {
@@ -395,87 +364,3 @@ func indexHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
}
return handleWithStaticData(w, r, d, "index.html", "text/html")
}
// PWAManifest represents the web app manifest structure
type PWAManifest struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
Icons []PWAIcon `json:"icons"`
StartURL string `json:"start_url"`
Display string `json:"display"`
BackgroundColor string `json:"background_color"`
ThemeColor string `json:"theme_color"`
Description string `json:"description"`
}
// PWAIcon represents an icon in the web app manifest
type PWAIcon struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
Purpose string `json:"purpose"`
}
// generatePWAManifest creates the PWA manifest structure
func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwaIcon256, pwaIcon512 string) PWAManifest {
shortName := name
if len(name) > 12 {
shortName = name[:12]
}
return PWAManifest{
Name: name,
ShortName: shortName,
StartURL: baseURL,
Display: "standalone",
BackgroundColor: "#ffffff",
ThemeColor: themeColor,
Description: description,
Icons: []PWAIcon{
{
Src: pwaIcon192,
Sizes: "192x192",
Type: "image/png",
Purpose: "any",
},
{
Src: pwaIcon256,
Sizes: "256x256",
Type: "image/png",
Purpose: "any maskable",
},
{
Src: pwaIcon512,
Sizes: "512x512",
Type: "image/png",
Purpose: "any",
},
},
}
}
// InitializePWAManifest generates and caches the PWA manifest at startup
func InitializePWAManifest() {
staticURL := config.Server.BaseURL + "public/static"
title := config.Frontend.Name
description := config.Frontend.Description
defaultThemeColor := "#455a64"
// Determine PWA icon URLs based on custom favicon settings
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
favicon := staticURL + "/favicon"
pwaIcon192 = favicon
pwaIcon256 = favicon
pwaIcon512 = favicon
} else if settings.Env.FaviconIsCustom {
pwaIcon192 = staticURL + "/pwa-icon-192.png"
pwaIcon256 = staticURL + "/pwa-icon-256.png"
pwaIcon512 = staticURL + "/pwa-icon-512.png"
}
cachedManifest = generatePWAManifest(title, description, config.Server.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
}
-249
View File
@@ -1,249 +0,0 @@
package icons
import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/kovidgoyal/imaging"
)
// sourceType represents the type of favicon source
type sourceType int
const (
sourceCustomSVG sourceType = iota
sourceCustomRaster
sourceDefault
)
// iconSize defines an icon size to generate
type iconSize struct {
size int
name string
env *string
}
// determineSourceType determines which type of favicon source we have
func determineSourceType(faviconPath string) sourceType {
if !settings.Env.FaviconIsCustom {
return sourceDefault
}
ext := strings.ToLower(filepath.Ext(faviconPath))
switch ext {
case ".svg":
return sourceCustomSVG
default:
return sourceCustomRaster
}
}
// loadDefaultFavicon loads the embedded default favicon PNG
func loadDefaultFavicon() ([]byte, error) {
assetFs := fileutils.GetAssetFS()
if assetFs == nil {
return nil, errors.New("asset filesystem not initialized")
}
pngPath := "img/icons/favicon.png"
sourceData, err := fs.ReadFile(assetFs, pngPath)
if err != nil {
return nil, fmt.Errorf("failed to read default favicon PNG: %w", err)
}
logger.Debug("Loaded default embedded favicon")
return sourceData, nil
}
// findRasterCompanion looks for a raster companion file for an SVG favicon
// Returns the companion data and path, or error if not found
func findRasterCompanion(svgPath string) ([]byte, string, error) {
basePath := svgPath[:len(svgPath)-len(filepath.Ext(svgPath))]
rasterExts := []string{".png", ".jpg", ".jpeg", ".webp", ".gif"}
for _, ext := range rasterExts {
companionPath := basePath + ext
if _, err := os.Stat(companionPath); err == nil {
data, readErr := os.ReadFile(companionPath)
if readErr != nil {
continue
}
return data, companionPath, nil
}
}
return nil, "", fmt.Errorf("no raster companion found at %s.{png,jpg,webp,gif}", basePath)
}
// convertRasterToPNG decodes any raster format and re-encodes as PNG
func convertRasterToPNG(rasterData []byte, sourceExt string) ([]byte, error) {
img, err := imaging.Decode(bytes.NewReader(rasterData))
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
var buf bytes.Buffer
if err := imaging.Encode(&buf, img, imaging.PNG); err != nil {
return nil, fmt.Errorf("failed to convert to PNG: %w", err)
}
logger.Debugf("Converted %s to PNG for icon generation", sourceExt)
return buf.Bytes(), nil
}
// loadFaviconSource loads and prepares the favicon source data as PNG
// Returns PNG data ready for resizing into icon sizes
func loadFaviconSource() ([]byte, error) {
sourceType := determineSourceType(settings.Env.FaviconPath)
switch sourceType {
case sourceCustomSVG:
logger.Debugf("Processing custom SVG favicon: %s", settings.Env.FaviconPath)
return handleSVGFavicon(settings.Env.FaviconPath)
case sourceCustomRaster:
logger.Debugf("Processing custom favicon: %s", settings.Env.FaviconPath)
return handleRasterFavicon(settings.Env.FaviconPath)
case sourceDefault:
logger.Debug("Using default embedded favicon")
return loadDefaultFavicon()
default:
return nil, errors.New("unknown icon source type")
}
}
// handleSVGFavicon processes an SVG favicon by finding its raster companion
func handleSVGFavicon(svgPath string) ([]byte, error) {
logger.Debug("Looking for companion for SVG favicon")
companionData, companionPath, err := findRasterCompanion(svgPath)
if err != nil {
logger.Warningf("SVG favicon has no companion: %v", err)
logger.Warning("Falling back to default embedded favicon")
logger.Warning("For best Apple/iOS compatibility, provide a PNG/JPG companion alongside your SVG")
return loadDefaultFavicon()
}
logger.Debugf("Found companion: %s", companionPath)
return convertRasterToPNG(companionData, filepath.Ext(companionPath))
}
// handleRasterFavicon processes a raster favicon (PNG, JPEG, GIF, WebP, etc.)
func handleRasterFavicon(faviconPath string) ([]byte, error) {
sourceData, err := os.ReadFile(faviconPath)
if err != nil {
return nil, fmt.Errorf("failed to read favicon: %w", err)
}
return convertRasterToPNG(sourceData, filepath.Ext(faviconPath))
}
// getIconSizesToGenerate returns all icon sizes that need to be generated
func getIconSizesToGenerate() []iconSize {
return []iconSize{
// Browser favicon (32x32 PNG for Safari/older browsers)
{32, "favicon-32x32.png", nil},
// PWA icons (PNG required for Apple PWA compatibility)
{192, "pwa-icon-192.png", &settings.Env.PWAIcon192},
{256, "pwa-icon-256.png", &settings.Env.PWAIcon256}, // Also serves as Windows tile
{512, "pwa-icon-512.png", &settings.Env.PWAIcon512},
// Platform-specific icons
{180, "apple-touch-icon.png", nil}, // iOS home screen (required for Apple)
}
}
// generateIconSizes generates all required icon sizes from source PNG data
func generateIconSizes(sourceData []byte) error {
previewService := preview.GetService()
if previewService == nil {
return errors.New("preview service not initialized")
}
iconSizes := getIconSizesToGenerate()
allSuccess := true
generatedCount := 0
for _, icon := range iconSizes {
if err := generateSingleIcon(previewService, sourceData, icon); err != nil {
logger.Warningf("Failed to generate %s: %v", icon.name, err)
allSuccess = false
continue
}
// Update environment variable if specified
if icon.env != nil {
*icon.env = filepath.Join("icons", icon.name)
}
generatedCount++
}
if allSuccess {
logger.Debugf("Successfully generated %d icon sizes", generatedCount)
} else {
logger.Warningf("Generated %d/%d icon sizes (some failed)", generatedCount, len(iconSizes))
}
return nil
}
// generateSingleIcon generates a single icon file at the specified size
func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon iconSize) error {
outputPath := filepath.Join(settings.Env.PWAIconsDir, icon.name)
// Create output file
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer outFile.Close()
// Resize image using the preview service's Resize method
err = previewService.Resize(
bytes.NewReader(sourceData),
outFile,
preview.ResizeOptions{
Width: icon.size,
Height: icon.size,
ResizeMode: preview.ResizeModeFill,
Quality: preview.QualityHigh,
Format: preview.FormatPng,
},
)
if err != nil {
os.Remove(outputPath)
return fmt.Errorf("resize failed: %w", err)
}
return nil
}
// GeneratePWAIcons generates all PWA and platform icon sizes from favicon source
// All icons are generated as PNG for maximum compatibility (Apple devices, PWA, older browsers)
func GeneratePWAIcons() error {
logger.Info("Generating PWA icons...")
// Load and prepare source data as PNG
sourceData, err := loadFaviconSource()
if err != nil {
return fmt.Errorf("failed to load favicon source: %w", err)
}
// Generate all icon sizes
if err := generateIconSizes(sourceData); err != nil {
return fmt.Errorf("failed to generate icon sizes: %w", err)
}
return nil
}
+12 -12
View File
@@ -3,7 +3,7 @@ package iteminfo
import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
)
type ItemInfo struct {
@@ -40,21 +40,21 @@ type MediaMetadata struct {
Genre string `json:"genre,omitempty"` // music/video genre
Track int `json:"track,omitempty"` // track number
Duration int `json:"duration,omitempty"` // duration in seconds
AlbumArt []byte `json:"albumArt,omitempty"` // album art image data (automatically base64-encoded in JSON)
AlbumArt string `json:"albumArt,omitempty"` // base64 encoded album art / video thumbnail
}
// for efficiency, a response will be a pointer to the data
// extra calculated fields can be added here
type ExtendedFileInfo struct {
FileInfo
Content string `json:"content,omitempty"` // text content of a file, if requested
Subtitles []utils.SubtitleTrack `json:"subtitles,omitempty"` // subtitles for video files
Metadata *MediaMetadata `json:"metadata,omitempty"` // media metadata for audio/video files (includes duration)
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
Token string `json:"token,omitempty"` // token for the file -- used for sharing
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source,omitempty"` // associated index source for the file
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
HasMetadata bool `json:"hasMetadata"` // whether the file or folder has metadata
RealPath string `json:"-"`
Content string `json:"content,omitempty"` // text content of a file, if requested
Subtitles []ffmpeg.SubtitleTrack `json:"subtitles,omitempty"` // subtitles for video files
Metadata *MediaMetadata `json:"metadata,omitempty"` // media metadata for audio/video files (includes duration)
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
Token string `json:"token,omitempty"` // token for the file -- used for sharing
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source,omitempty"` // associated index source for the file
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
HasMetadata bool `json:"hasMetadata"` // whether the file or folder has metadata
RealPath string `json:"-"`
}
+21 -46
View File
@@ -8,9 +8,19 @@ import (
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/go-logger/logger"
)
type SubtitleTrack struct {
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language,omitempty"` // language code
Title string `json:"title,omitempty"` // title/description
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
IsFile bool `json:"isFile"` // true for external files, false for embedded
}
type FFProbeOutput struct {
Streams []struct {
Index int `json:"index"`
@@ -21,55 +31,20 @@ type FFProbeOutput struct {
} `json:"streams"`
}
// GetSubtitles detects external subtitle files for video files.
// Embedded subtitles should be detected by ffmpeg and passed as embeddedSubs parameter.
func (i *ExtendedFileInfo) GetSubtitles(parentInfo *FileInfo) {
// detects subtitles for video files.
func (i *ExtendedFileInfo) DetectSubtitles(parentInfo *FileInfo) {
if !strings.HasPrefix(i.Type, "video") {
logger.Debug("subtitles are not supported for this file : " + i.Name)
return
}
ext := filepath.Ext(i.Name)
baseWithoutExt := strings.TrimSuffix(i.Name, ext)
// Use unified subtitle detection that finds both embedded and external
parentDir := filepath.Dir(i.RealPath)
i.Subtitles = ffmpeg.DetectAllSubtitles(i.RealPath, parentDir, i.ModTime)
}
// Collect external subtitle files
var externalSubs []utils.SubtitleTrack
if parentInfo != nil && parentInfo.Files != nil {
for _, f := range parentInfo.Files {
fileExt := filepath.Ext(f.Name)
fileBase := strings.TrimSuffix(f.Name, fileExt)
// Check if this file has the same base name and a subtitle extension
if fileBase == baseWithoutExt && SubtitleExts[strings.ToLower(fileExt)] {
track := utils.SubtitleTrack{
Name: f.Name,
Embedded: false,
}
// Try to infer language from filename patterns like "video.en.srt"
parts := strings.Split(fileBase, ".")
if len(parts) > 1 {
lastPart := parts[len(parts)-1]
if len(lastPart) == 2 || len(lastPart) == 3 {
track.Language = lastPart
}
}
externalSubs = append(externalSubs, track)
}
}
}
// Sort external subtitles alphabetically for consistent ordering
sort.Slice(externalSubs, func(i, j int) bool {
return externalSubs[i].Name < externalSubs[j].Name
})
i.Subtitles = append(i.Subtitles, externalSubs...)
// Sort all subtitles for consistent ordering
sort.Slice(i.Subtitles, func(a, b int) bool {
return strings.ToLower(i.Subtitles[a].Name) < strings.ToLower(i.Subtitles[b].Name)
})
// Note: Content is NOT loaded here. Frontend will call /api/media/subtitles for each track
// LoadSubtitleContent loads the actual content for all detected subtitle tracks
func (i *ExtendedFileInfo) LoadSubtitleContent() error {
return ffmpeg.LoadAllSubtitleContent(i.RealPath, i.Subtitles, i.ModTime)
}
func (info *FileInfo) SortItems() {
-4
View File
@@ -174,10 +174,6 @@ func (s *Service) ResizeWithSize(in io.Reader, out io.Writer, fileSize int64, op
if opts.Format == FormatHeic {
opts.Format = FormatJpeg
}
// Ensure JPEG quality is set based on quality setting
if opts.JpegQuality == 0 {
opts.JpegQuality = opts.Quality.jpegQuality()
}
// Try to use embedded EXIF thumbnail for JPEGs (only for low quality to keep it simple)
if opts.Format == FormatJpeg && opts.Quality == QualityLow {
+3 -8
View File
@@ -489,17 +489,12 @@ func TestCreatePreviewFromReader_UsesConcurrencyLimit(t *testing.T) {
data, err := io.ReadAll(source)
require.NoError(t, err)
// Get options for small preview
options, err := getPreviewOptions("small")
require.NoError(t, err)
// Create preview from reader
out, err := svc.CreatePreview(bytes.NewReader(data), int64(len(data)), options)
out, err := svc.CreatePreviewFromReader(bytes.NewReader(data), "small")
require.NoError(t, err)
require.NotEmpty(t, out)
// Same via another call for parity
out2, err := svc.CreatePreview(bytes.NewReader(data), int64(len(data)), options)
// Same via CreatePreview (bytes) for parity
out2, err := svc.CreatePreview(data, "small")
require.NoError(t, err)
require.Equal(t, out, out2)
}
-72
View File
@@ -1,72 +0,0 @@
package preview
import (
"context"
"sync/atomic"
)
// MemoryTracker wraps the existing FFmpegService to add memory tracking
type MemoryTracker struct {
maxMemoryBytes int64
usedMemoryBytes int64
semaphore chan struct{}
}
// NewMemoryTracker creates a memory-aware concurrency limiter
func NewMemoryTracker(maxConcurrent int, maxMemoryMB int) *MemoryTracker {
if maxConcurrent < 1 {
maxConcurrent = 1
}
if maxMemoryMB < 50 {
maxMemoryMB = 500 // Default 500MB
}
return &MemoryTracker{
maxMemoryBytes: int64(maxMemoryMB) * 1024 * 1024,
semaphore: make(chan struct{}, maxConcurrent),
}
}
// TryAcquire attempts to acquire resources for processing an image
// Returns false if memory limit would be exceeded
func (mt *MemoryTracker) TryAcquire(ctx context.Context, estimatedBytes int64) bool {
// Check if we have memory headroom
currentUsed := atomic.LoadInt64(&mt.usedMemoryBytes)
if currentUsed+estimatedBytes > mt.maxMemoryBytes {
return false
}
// Try to acquire semaphore slot (non-blocking check first)
select {
case mt.semaphore <- struct{}{}:
// Successfully acquired slot, now add memory
atomic.AddInt64(&mt.usedMemoryBytes, estimatedBytes)
return true
case <-ctx.Done():
return false
default:
return false
}
}
// Acquire waits to acquire resources
func (mt *MemoryTracker) Acquire(ctx context.Context, estimatedBytes int64) error {
// Wait for semaphore slot
select {
case mt.semaphore <- struct{}{}:
atomic.AddInt64(&mt.usedMemoryBytes, estimatedBytes)
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Release frees up resources
func (mt *MemoryTracker) Release(estimatedBytes int64) {
<-mt.semaphore
atomic.AddInt64(&mt.usedMemoryBytes, -estimatedBytes)
}
// GetMemoryUsageMB returns current memory usage in MB
func (mt *MemoryTracker) GetMemoryUsageMB() int64 {
return atomic.LoadInt64(&mt.usedMemoryBytes) / (1024 * 1024)
}
+316 -241
View File
@@ -4,10 +4,12 @@ import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"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 (
@@ -161,6 +164,10 @@ 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
}
@@ -169,6 +176,153 @@ 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
@@ -181,10 +335,10 @@ func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, prev
// Generate fast cache key based on file metadata
var cacheHash string
if file.Metadata != nil && len(file.Metadata.AlbumArt) > 0 {
if file.Metadata != nil && file.Metadata.AlbumArt != "" {
// For audio with album art, hash the album art content
hasher := md5.New()
_, _ = hasher.Write(file.Metadata.AlbumArt)
_, _ = hasher.Write([]byte(file.Metadata.AlbumArt))
cacheHash = hex.EncodeToString(hasher.Sum(nil))
} else {
// For all other files, use fast metadata-based hash
@@ -203,191 +357,6 @@ func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, prev
return GeneratePreviewWithMD5(ctx, file, previewSize, url, seekPercentage, cacheHash)
}
// filePreviewType represents the type of preview generation needed
type filePreviewType int
const (
previewTypeDocument filePreviewType = iota
previewTypeOffice
previewTypeHEIC
previewTypeImage
previewTypeVideo
previewTypeAudio
previewTypeUnsupported
)
// determinePreviewType determines what type of preview generation is needed
func determinePreviewType(file iteminfo.ExtendedFileInfo) filePreviewType {
// Check document conversion first
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
return previewTypeDocument
}
// Check office files
if file.OnlyOfficeId != "" {
return previewTypeOffice
}
// Check HEIC with conversion enabled
if strings.HasPrefix(file.Type, "image/heic") &&
*settings.Config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview] {
return previewTypeHEIC
}
// Check by MIME type prefix
switch {
case strings.HasPrefix(file.Type, "image"):
return previewTypeImage
case strings.HasPrefix(file.Type, "video"):
return previewTypeVideo
case strings.HasPrefix(file.Type, "audio") && file.Metadata != nil && len(file.Metadata.AlbumArt) > 0:
return previewTypeAudio
default:
return previewTypeUnsupported
}
}
// generateRawPreview generates the initial preview image bytes based on file type
func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int, hash string) ([]byte, error) {
previewType := determinePreviewType(file)
switch previewType {
case previewTypeDocument:
return s.generateDocumentPreview(ctx, file, hash)
case previewTypeOffice:
return s.generateOfficeFilePreview(ctx, file, officeUrl)
case previewTypeHEIC:
return s.generateHEICPreview(ctx, file, previewSize)
case previewTypeImage:
return s.generateImagePreview(ctx, file, previewSize)
case previewTypeVideo:
return s.generateVideoPreviewBytes(ctx, file, seekPercentage)
case previewTypeAudio:
if file.Metadata != nil && len(file.Metadata.AlbumArt) > 0 {
return file.Metadata.AlbumArt, nil
}
return nil, nil
case previewTypeUnsupported:
ext := strings.ToLower(filepath.Ext(file.Name))
return nil, fmt.Errorf("unsupported media type: %s", ext)
default:
return nil, errors.New("unknown preview type")
}
}
// generateDocumentPreview generates preview for PDF and document files
func (s *Service) generateDocumentPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, hash string) ([]byte, error) {
tempFilePath := filepath.Join(s.cacheDir, "thumbnails", "docs", hash) + ".txt"
imageBytes, err := s.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page
if err != nil {
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
}
return imageBytes, nil
}
// generateOfficeFilePreview generates preview for Office files via OnlyOffice
func (s *Service) generateOfficeFilePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, officeUrl string) ([]byte, error) {
imageBytes, err := s.GenerateOfficePreview(ctx, filepath.Ext(file.Name), file.OnlyOfficeId, file.Name, officeUrl)
if err != nil {
return nil, fmt.Errorf("failed to create image for office file: %w", err)
}
return imageBytes, nil
}
// generateHEICPreview generates preview for HEIC files using FFmpeg
func (s *Service) generateHEICPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize string) ([]byte, error) {
imageBytes, err := s.convertHEICToJPEGWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
}
return imageBytes, nil
}
// generateVideoPreviewBytes generates preview frame from video file
func (s *Service) generateVideoPreviewBytes(ctx context.Context, file iteminfo.ExtendedFileInfo, seekPercentage int) ([]byte, error) {
// Check if this video format is enabled for preview generation
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
if !settings.CanConvertVideo(ext) {
return nil, fmt.Errorf("video preview generation is disabled for .%s files in settings", ext)
}
videoSeekPercentage := seekPercentage
if videoSeekPercentage == 0 {
videoSeekPercentage = 10
}
imageBytes, err := s.GenerateVideoPreview(ctx, file.RealPath, videoSeekPercentage)
if err != nil {
// Don't log client cancellations as errors
if ctx.Err() != context.Canceled {
logger.Errorf("Video preview generation failed for '%s' (path: %s, seek: %d%%): %v",
file.Name, file.RealPath, videoSeekPercentage, err)
}
return nil, fmt.Errorf("failed to create image for video file: %w", err)
}
return imageBytes, nil
}
// generateImagePreview generates preview for regular image files
func (s *Service) generateImagePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize string) ([]byte, error) {
// Stream from file instead of os.ReadFile so we don't load every image fully into memory
f, err := os.Open(file.RealPath)
if err != nil {
logger.Errorf("Failed to open image file '%s' (path: %s): %v", file.Name, file.RealPath, err)
return nil, fmt.Errorf("failed to open image file: %w", err)
}
defer f.Close()
options, err := getPreviewOptions(previewSize)
if err != nil {
return nil, err
}
imageBytes, err := s.CreatePreview(f, file.Size, options)
if err != nil {
// For JPEG files, try FFmpeg fallback if initial decode failed
if strings.HasPrefix(file.Type, "image/jpeg") {
return handleJPEGFallback(ctx, s, file, previewSize, err)
}
return nil, fmt.Errorf("failed to create image preview: %w", err)
}
if len(imageBytes) < 100 {
logger.Errorf("Generated image too small for '%s' (type: %s): %d bytes", file.Name, file.Type, len(imageBytes))
return nil, fmt.Errorf("generated image is too small, likely an error occurred: %d bytes", len(imageBytes))
}
return imageBytes, nil
}
// handleJPEGFallback attempts to use FFmpeg for problematic JPEG files
// This is preview-specific logic for user files, not used for icon generation
func handleJPEGFallback(ctx context.Context, s *Service, file iteminfo.ExtendedFileInfo, previewSize string, originalErr error) ([]byte, error) {
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
if !enableJPEGFallback {
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg conversion disabled in settings): %w", originalErr)
}
if s.ffmpegService == nil {
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg not available): %w", originalErr)
}
logger.Debugf("JPEG decode failed for '%s', falling back to FFmpeg: %v", file.Name, originalErr)
imageBytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to resize preview image with FFmpeg fallback: %w", err)
}
return imageBytes, nil
}
func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int, fileMD5 string) ([]byte, error) {
// Note: fileMD5 is actually a cache hash (metadata-based), not a true file content MD5
// Validate that cache hash is not empty to prevent cache corruption
@@ -405,11 +374,13 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
// 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
}
@@ -429,34 +400,122 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
return nil, errors.New(message)
}
// Generate hash for temp file paths
ext := strings.ToLower(filepath.Ext(file.Name))
var (
err error
imageBytes []byte
)
// Generate thumbnail image from video
hasher := md5.New()
_, _ = hasher.Write([]byte(CacheKey(fileMD5, previewSize, seekPercentage)))
hash := hex.EncodeToString(hasher.Sum(nil))
// Generate raw preview based on file type
imageBytes, err := service.generateRawPreview(ctx, file, previewSize, officeUrl, seekPercentage, hash)
if err != nil {
return nil, err
}
// Check if HEIC conversion is enabled (guaranteed non-nil after defaults)
convertHEIC := strings.HasPrefix(file.Type, "image/heic") && *settings.Config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview]
// For HEIC files, we've already done the resize/conversion, cache and return directly
previewType := determinePreviewType(file)
if previewType == previewTypeHEIC {
// Generate an image from office document
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
tempFilePath := filepath.Join(service.cacheDir, "thumbnails", "docs", hash) + ".txt"
imageBytes, err = service.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page
if err != nil {
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
}
} else if file.OnlyOfficeId != "" {
imageBytes, err = service.GenerateOfficePreview(ctx, filepath.Ext(file.Name), file.OnlyOfficeId, file.Name, officeUrl)
if err != nil {
return nil, fmt.Errorf("failed to create image for office file: %w", err)
}
} else if convertHEIC {
// HEIC files need FFmpeg conversion to JPEG with proper size/quality handling
imageBytes, err = service.convertHEICToJPEGWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
}
// For HEIC files, we've already done the resize/conversion, so cache and return directly
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache HEIC image: %v", err)
}
return imageBytes, nil
}
// For regular images that were already resized, cache and return
if previewType == previewTypeImage {
} else if strings.HasPrefix(file.Type, "image") {
// 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
}
imageBytes, err = service.GenerateVideoPreview(ctx, file.RealPath, videoSeekPercentage)
if err != nil {
// Don't log client cancellations as errors
if ctx.Err() != context.Canceled {
logger.Errorf("Video preview generation failed for '%s' (path: %s, seek: %d%%): %v",
file.Name, file.RealPath, videoSeekPercentage, err)
}
return nil, fmt.Errorf("failed to create image for video file: %w", err)
}
} else if strings.HasPrefix(file.Type, "audio") {
// Extract album artwork from audio files
if file.Metadata != nil && file.Metadata.AlbumArt != "" {
imageBytes, err = base64.StdEncoding.DecodeString(file.Metadata.AlbumArt)
if err != nil {
return nil, fmt.Errorf("failed to decode album artwork: %w", err)
}
} else {
return nil, nil
}
} else {
return nil, fmt.Errorf("unsupported media type: %s", ext)
}
// Check if context was cancelled during processing
@@ -464,47 +523,62 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
return nil, ctx.Err()
}
// Validate generated image size
if len(imageBytes) < 100 {
logger.Errorf("Generated image too small for '%s' (type: %s): %d bytes - likely an error occurred",
file.Name, file.Type, len(imageBytes))
return nil, fmt.Errorf("generated image is too small, likely an error occurred: %d bytes", len(imageBytes))
}
// Resize if needed (for videos, audio, documents, office files)
if previewSize != "original" {
options, err := getPreviewOptions(previewSize)
// resize image
resizedBytes, err := service.CreatePreview(imageBytes, previewSize)
if err != nil {
return nil, err
}
// Check if this is an unsupported JPEG format (extended sequential, etc.)
errMsg := err.Error()
if strings.HasPrefix(file.Type, "image/jpeg") &&
(strings.Contains(errMsg, "unrecognised marker") ||
strings.Contains(errMsg, "invalid JPEG format") ||
strings.Contains(errMsg, "Huffman")) {
resizedBytes, err := service.CreatePreview(bytes.NewReader(imageBytes), 0, options)
if err != nil {
// For JPEG files, try FFmpeg fallback if resize failed
if strings.HasPrefix(file.Type, "image/jpeg") {
resizedBytes, err = handleJPEGFallback(ctx, service, file, previewSize, err)
if err != nil {
return nil, err
// Check if JPEG FFmpeg conversion is enabled (guaranteed non-nil after defaults)
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
// Only attempt FFmpeg fallback if it's enabled and FFmpeg service is available
if enableJPEGFallback && service.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
}
} else {
return nil, fmt.Errorf("failed to resize preview image: %w", err)
// If FFmpeg fallback is disabled or not available, return the original error
if !enableJPEGFallback {
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg conversion disabled in settings): %w", err)
}
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg not available): %w", err)
}
return nil, fmt.Errorf("failed to resize preview image: %w", err)
}
// Cache and return resized image
// Cache and return
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err := service.fileCache.Store(ctx, cacheKey, resizedBytes); err != nil {
logger.Errorf("failed to cache resized image: %v", err)
}
return resizedBytes, nil
} else {
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache original image: %v", err)
}
return imageBytes, nil
}
// Cache and return original size
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache original image: %v", err)
}
return imageBytes, nil
}
func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int) ([]byte, error) {
@@ -517,40 +591,41 @@ func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previe
return GeneratePreviewWithMD5(ctx, file, previewSize, officeUrl, seekPercentage, cacheHash)
}
// getPreviewOptions returns resize options for the given preview size
func getPreviewOptions(previewSize string) (ResizeOptions, error) {
func (s *Service) CreatePreview(data []byte, previewSize string) ([]byte, error) {
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 "xlarge":
return ResizeOptions{
Width: 1024,
Height: 1024,
ResizeMode: ResizeModeFit,
Quality: QualityHigh,
Format: FormatJpeg,
}, nil
case "large":
return ResizeOptions{
options = ResizeOptions{
Width: 640,
Height: 640,
ResizeMode: ResizeModeFit,
Quality: QualityHigh,
Format: FormatJpeg,
}, nil
}
case "small":
return ResizeOptions{
options = ResizeOptions{
Width: 256,
Height: 256,
ResizeMode: ResizeModeFit,
Quality: QualityMedium,
Format: FormatJpeg,
}, nil
}
default:
return ResizeOptions{}, ErrUnsupportedFormat
return nil, ErrUnsupportedFormat
}
}
// CreatePreview resizes an image from a reader with the given options
func (s *Service) CreatePreview(reader io.Reader, fileSize int64, options ResizeOptions) ([]byte, error) {
output := &bytes.Buffer{}
if err := s.ResizeWithSize(reader, output, fileSize, options); err != nil {
return nil, err
+16 -26
View File
@@ -1105,17 +1105,17 @@ const docTemplate = `{
},
"/api/media/subtitles": {
"get": {
"description": "Returns raw subtitle content from external files or embedded streams",
"description": "Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content",
"consumes": [
"application/json"
],
"produces": [
"text/plain"
"text/vtt"
],
"tags": [
"Subtitles"
],
"summary": "Get subtitle content",
"summary": "Extract embedded subtitles",
"parameters": [
{
"type": "string",
@@ -1132,22 +1132,15 @@ const docTemplate = `{
"required": true
},
{
"type": "string",
"description": "Subtitle track name (filename for external, descriptive name for embedded)",
"name": "name",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Whether this is an embedded stream (true) or external file (false), defaults to false",
"name": "embedded",
"type": "integer",
"description": "Stream index for embedded subtitle extraction, defaults to 0",
"name": "index",
"in": "query"
}
],
"responses": {
"200": {
"description": "Raw subtitle content in original format",
"description": "Raw WebVTT subtitle content",
"schema": {
"type": "string"
}
@@ -1571,7 +1564,7 @@ const docTemplate = `{
"parameters": [
{
"type": "string",
"description": "url encoded destination path where to place the files inside the destination source",
"description": "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory",
"name": "path",
"in": "query",
"required": true
@@ -3070,8 +3063,8 @@ const docTemplate = `{
},
{
"type": "string",
"description": "path within the share to upload to. Must be relative to share root.",
"name": "path",
"description": "Target path within the share to upload to. Must be relative to share root.",
"name": "targetPath",
"in": "query",
"required": true
},
@@ -3319,14 +3312,14 @@ const docTemplate = `{
},
"/public/api/share/image": {
"get": {
"description": "Returns a resizable preview (large size) for the banner or favicon file of a share",
"description": "Serves the banner or favicon file for a share",
"produces": [
"image/jpeg"
"application/octet-stream"
],
"tags": [
"Public Shares"
],
"summary": "Get share image (banner or favicon) as preview",
"summary": "Get share image (banner or favicon)",
"parameters": [
{
"type": "string",
@@ -3350,7 +3343,7 @@ const docTemplate = `{
],
"responses": {
"200": {
"description": "Preview image content (JPEG)",
"description": "Image file content",
"schema": {
"type": "file"
}
@@ -4082,11 +4075,8 @@ const docTemplate = `{
"type": "string"
},
"albumArt": {
"description": "album art image data (automatically base64-encoded in JSON)",
"type": "array",
"items": {
"type": "integer"
}
"description": "base64 encoded album art / video thumbnail",
"type": "string"
},
"artist": {
"description": "track artist",
+16 -26
View File
@@ -1094,17 +1094,17 @@
},
"/api/media/subtitles": {
"get": {
"description": "Returns raw subtitle content from external files or embedded streams",
"description": "Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content",
"consumes": [
"application/json"
],
"produces": [
"text/plain"
"text/vtt"
],
"tags": [
"Subtitles"
],
"summary": "Get subtitle content",
"summary": "Extract embedded subtitles",
"parameters": [
{
"type": "string",
@@ -1121,22 +1121,15 @@
"required": true
},
{
"type": "string",
"description": "Subtitle track name (filename for external, descriptive name for embedded)",
"name": "name",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Whether this is an embedded stream (true) or external file (false), defaults to false",
"name": "embedded",
"type": "integer",
"description": "Stream index for embedded subtitle extraction, defaults to 0",
"name": "index",
"in": "query"
}
],
"responses": {
"200": {
"description": "Raw subtitle content in original format",
"description": "Raw WebVTT subtitle content",
"schema": {
"type": "string"
}
@@ -1560,7 +1553,7 @@
"parameters": [
{
"type": "string",
"description": "url encoded destination path where to place the files inside the destination source",
"description": "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory",
"name": "path",
"in": "query",
"required": true
@@ -3059,8 +3052,8 @@
},
{
"type": "string",
"description": "path within the share to upload to. Must be relative to share root.",
"name": "path",
"description": "Target path within the share to upload to. Must be relative to share root.",
"name": "targetPath",
"in": "query",
"required": true
},
@@ -3308,14 +3301,14 @@
},
"/public/api/share/image": {
"get": {
"description": "Returns a resizable preview (large size) for the banner or favicon file of a share",
"description": "Serves the banner or favicon file for a share",
"produces": [
"image/jpeg"
"application/octet-stream"
],
"tags": [
"Public Shares"
],
"summary": "Get share image (banner or favicon) as preview",
"summary": "Get share image (banner or favicon)",
"parameters": [
{
"type": "string",
@@ -3339,7 +3332,7 @@
],
"responses": {
"200": {
"description": "Preview image content (JPEG)",
"description": "Image file content",
"schema": {
"type": "file"
}
@@ -4071,11 +4064,8 @@
"type": "string"
},
"albumArt": {
"description": "album art image data (automatically base64-encoded in JSON)",
"type": "array",
"items": {
"type": "integer"
}
"description": "base64 encoded album art / video thumbnail",
"type": "string"
},
"artist": {
"description": "track artist",
+18 -27
View File
@@ -447,10 +447,8 @@ definitions:
description: album name
type: string
albumArt:
description: album art image data (automatically base64-encoded in JSON)
items:
type: integer
type: array
description: base64 encoded album art / video thumbnail
type: string
artist:
description: track artist
type: string
@@ -2499,7 +2497,8 @@ paths:
get:
consumes:
- application/json
description: Returns raw subtitle content from external files or embedded streams
description: Extracts embedded subtitle content from video files by stream index
and returns raw WebVTT content
parameters:
- description: Index path to the video file
in: query
@@ -2511,22 +2510,15 @@ paths:
name: source
required: true
type: string
- description: Subtitle track name (filename for external, descriptive name
for embedded)
- description: Stream index for embedded subtitle extraction, defaults to 0
in: query
name: name
required: true
type: string
- description: Whether this is an embedded stream (true) or external file (false),
defaults to false
in: query
name: embedded
type: boolean
name: index
type: integer
produces:
- text/plain
- text/vtt
responses:
"200":
description: Raw subtitle content in original format
description: Raw WebVTT subtitle content
schema:
type: string
"400":
@@ -2553,7 +2545,7 @@ paths:
additionalProperties:
type: string
type: object
summary: Get subtitle content
summary: Extract embedded subtitles
tags:
- Subtitles
/api/preview:
@@ -2863,7 +2855,7 @@ paths:
Supports file uploads and directory creation.
parameters:
- description: url encoded destination path where to place the files inside
the destination source
the destination source, a directory must end in / to create a directory
in: query
name: path
required: true
@@ -3847,10 +3839,10 @@ paths:
name: hash
required: true
type: string
- description: path within the share to upload to. Must be relative to share
root.
- description: Target path within the share to upload to. Must be relative to
share root.
in: query
name: path
name: targetPath
required: true
type: string
- description: If true, overwrite existing files/folders. Defaults to false.
@@ -4026,8 +4018,7 @@ paths:
- Public Shares
/public/api/share/image:
get:
description: Returns a resizable preview (large size) for the banner or favicon
file of a share
description: Serves the banner or favicon file for a share
parameters:
- description: Share hash
in: query
@@ -4043,10 +4034,10 @@ paths:
name: favicon
type: boolean
produces:
- image/jpeg
- application/octet-stream
responses:
"200":
description: Preview image content (JPEG)
description: Image file content
schema:
type: file
"400":
@@ -4067,7 +4058,7 @@ paths:
additionalProperties:
type: string
type: object
summary: Get share image (banner or favicon) as preview
summary: Get share image (banner or favicon)
tags:
- Public Shares
/public/api/shareinfo:
+435 -401
View File
File diff suppressed because it is too large. Load diff
+1 -4
View File
@@ -22,7 +22,7 @@
"dependencies": {
"@onlyoffice/document-editor-vue": "^1.4.0",
"ace-builds": "^1.24.2",
"axios": "^1.13.5",
"axios": "^1.7.9",
"clipboard": "^2.0.4",
"css-vars-ponyfill": "^2.4.3",
"dompurify": "^3.2.4",
@@ -41,9 +41,6 @@
"vue-lazyload": "^3.0.0",
"vue-router": "^4.3.0"
},
"overrides": {
"minimatch": "^10.2.1"
},
"devDependencies": {
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@intlify/unplugin-vue-i18n": "^4.0.0",
+35 -1
View File
@@ -17,7 +17,7 @@
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-symbols-outlined.woff2" as="font" type="font/woff2" crossorigin>
<!-- Add to home screen for Android and modern mobile browsers -->
<link rel="manifest" href="{{ .htmlVars.manifestURL }}" crossorigin="use-credentials">
<link rel="manifest" id="manifestPlaceholder" crossorigin="use-credentials">
<meta name="theme-color" content="{{ .htmlVars.color }}">
<!-- Add to home screen for Safari on iOS/iPadOS -->
@@ -52,6 +52,40 @@
<!-- Injected variables come from backend server -->
<script>
window.globalVars = {{ .globalVars }};
var dynamicManifest = {
"name": window.globalVars.name || 'FileBrowser Quantum',
"short_name": window.globalVars.name || 'FileBrowser',
"icons": [
{
"src": "{{ .htmlVars.pwaIcon192 }}",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "{{ .htmlVars.pwaIcon256 }}",
"sizes": "256x256",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "{{ .htmlVars.pwaIcon512 }}",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"start_url": window.location.origin + "{{ .htmlVars.baseURL }}",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "{{ .htmlVars.color }}",
"description": "{{ .htmlVars.description }}"
}
const stringManifest = JSON.stringify(dynamicManifest);
const blob = new Blob([stringManifest], { type: 'application/json' });
const manifestURL = URL.createObjectURL(blob);
document.querySelector('#manifestPlaceholder').setAttribute('href', manifestURL);
</script>
<style>
+12
View File
@@ -0,0 +1,12 @@
import { globalVars } from "@/utils/constants";
const ssl = window.location.protocol === "https:";
const protocol = ssl ? "wss:" : "ws:";
export default function command(url, command, onmessage, onclose) {
url = `${protocol}//${window.location.host}${globalVars.baseURL}api/command${url}`;
let conn = new window.WebSocket(url);
conn.onopen = () => conn.send(command);
conn.onmessage = onmessage;
conn.onclose = onclose;
}
+52 -64
View File
@@ -12,8 +12,8 @@ export async function fetchFiles(source, path, content = false, metadata = false
}
try {
const apiPath = getApiPath('api/resources', {
path: path,
source: source,
path: encodeURIComponent(path),
source: encodeURIComponent(source),
...(content && { content: 'true' }),
...(metadata && { metadata: 'true' })
})
@@ -32,6 +32,8 @@ async function resourceAction(source, path, method, content) {
throw new Error('no source provided')
}
try {
source = encodeURIComponent(source)
path = encodeURIComponent(path)
const apiPath = getApiPath('api/resources', { path, source })
let opts = { method }
if (content) {
@@ -74,12 +76,15 @@ export async function bulkDelete(items) {
},
body: JSON.stringify(items),
})
const data = await response.json()
// 200 = all succeeded, 207 = partial success (some succeeded, some failed)
// Both are valid responses that should be returned, not thrown as errors
if (response.status === 200 || response.status === 207) {
return data
}
// For other error status codes, throw an error
const error = new Error(data.message || response.statusText)
error.status = response.status
@@ -105,12 +110,12 @@ export async function download(format, files, shareHash = "") {
// Check if chunked download should be used (single file only)
const downloadChunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
const sizeThreshold = downloadChunkSizeMb * 1024 * 1024;
const useChunkedDownload =
downloadChunkSizeMb > 0 &&
files.length === 1 &&
!files[0].isDir &&
files[0].size &&
const useChunkedDownload =
downloadChunkSizeMb > 0 &&
files.length === 1 &&
!files[0].isDir &&
files[0].size &&
files[0].size >= sizeThreshold
if (useChunkedDownload) {
@@ -122,14 +127,14 @@ export async function download(format, files, shareHash = "") {
if (format !== 'zip') {
format = 'tar.gz'
}
// For non-share downloads, validate single source and build file list
let source = null
let filePaths = []
if (shareHash) {
// For shares, no source parameter needed, just paths
filePaths = files.map(file => file.path)
filePaths = files.map(file => encodeURIComponent(file.path))
} else {
// Validate all files are from the same source
for (let file of files) {
@@ -141,19 +146,19 @@ export async function download(format, files, shareHash = "") {
} else if (source !== file.source) {
throw new Error('All files must be from the same source for downloads')
}
filePaths.push(file.path)
filePaths.push(encodeURIComponent(file.path))
}
}
const params = {
file: filePaths, // Array of file paths - getApiPath will create repeated parameters
algo: format,
...(shareHash && { hash: shareHash }),
...(!shareHash && source && { source: source }),
...(!shareHash && source && { source: encodeURIComponent(source) }),
...(state.shareInfo.token && { token: state.shareInfo.token }),
sessionId: state.sessionId
}
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'public/api/raw', params)
const url = window.origin + apiPath
@@ -167,7 +172,7 @@ export async function download(format, files, shareHash = "") {
link.style.display = 'none'
document.body.appendChild(link)
link.click()
// Clean up after a short delay
setTimeout(() => {
document.body.removeChild(link)
@@ -176,36 +181,36 @@ export async function download(format, files, shareHash = "") {
async function downloadChunked(file, shareHash = "") {
const chunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
if (chunkSizeMb === 0) {
throw new Error("Chunked download is disabled (chunk size is 0)")
}
const chunkSize = chunkSizeMb * 1024 * 1024 // Convert MB to bytes
const fileSize = file.size
// Extract filename from path if name is not available
const fileName = file.name || (file.path ? file.path.split('/').pop() : 'download')
// Add to download manager
const downloadId = downloadManager.add(file, shareHash)
downloadManager.setStatus(downloadId, "downloading")
// Show download prompt if not already shown (it should already be shown by downloadFiles, but check to be safe)
const hasDownloadPrompt = state.hovers && state.hovers.some(h => h.name === 'download');
if (!hasDownloadPrompt) {
mutations.showHover({ name: 'download' })
}
const params = {
file: file.path,
file: encodeURIComponent(file.path),
...(shareHash && { hash: shareHash }),
...(!shareHash && file.source && { source: file.source }),
...(!shareHash && file.source && { source: encodeURIComponent(file.source) }),
...(state.shareInfo.token && { token: state.shareInfo.token }),
sessionId: state.sessionId
}
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'public/api/raw', params)
const baseUrl = window.origin + apiPath
@@ -220,7 +225,7 @@ async function downloadChunked(file, shareHash = "") {
let loaded = 0
while (offset < fileSize) {
const download = downloadManager.findById(downloadId);
if (download && download.status === "cancelled") {
// Silently handle cancellation - don't throw error
@@ -244,7 +249,7 @@ async function downloadChunked(file, shareHash = "") {
// Track progress within the chunk using ReadableStream
const expectedChunkSize = end - offset + 1;
const reader = response.body.getReader();
const chunkParts = [];
let chunkLoaded = 0;
@@ -259,14 +264,14 @@ async function downloadChunked(file, shareHash = "") {
reading = false;
break;
}
chunkParts.push(value);
chunkLoaded += value.length;
// Calculate progress: only count up to expected chunk size to avoid over-counting
const chunkProgress = Math.min(chunkLoaded, expectedChunkSize);
const totalLoaded = offset + chunkProgress;
// Update progress in real-time, but throttle updates for performance
if (chunkLoaded - lastProgressUpdate >= progressUpdateInterval || chunkLoaded >= expectedChunkSize) {
downloadManager.updateProgress(downloadId, totalLoaded, fileSize);
@@ -290,19 +295,19 @@ async function downloadChunked(file, shareHash = "") {
chunk.set(part, position);
position += part.length;
}
// Only use the expected chunk size portion if server returned more (handles Range header issues)
const chunkToUse = chunk.byteLength > expectedChunkSize
? chunk.slice(0, expectedChunkSize).buffer
const chunkToUse = chunk.byteLength > expectedChunkSize
? chunk.slice(0, expectedChunkSize).buffer
: chunk.buffer;
chunks.push(chunkToUse)
// Always use expected chunk size for progress to avoid double-counting
loaded += expectedChunkSize
// Final progress update for this chunk
downloadManager.updateProgress(downloadId, loaded, fileSize)
offset = end + 1
}
@@ -355,8 +360,8 @@ export function post(
}
try {
const apiPath = getApiPath("api/resources", {
path: path,
source: source,
path: encodeURIComponent(path),
source: encodeURIComponent(source),
override: overwrite,
...(isDir && { isDir: 'true' })
});
@@ -448,7 +453,7 @@ export async function moveCopy(
'Content-Type': 'application/json',
'sessionId': state.sessionId
}
const response = await fetch(apiPath, {
method: 'PATCH',
headers: headers,
@@ -491,8 +496,8 @@ export async function checksum(source, path, algo) {
}
try {
const params = {
path: path,
source: source,
path: encodeURIComponent(path),
source: encodeURIComponent(source),
checksum: algo
}
const apiPath = getApiPath('api/resources', params)
@@ -511,8 +516,8 @@ export function getDownloadURL(source, path, inline, useExternal) {
}
try {
const params = {
source: source,
file: path,
source: encodeURIComponent(source),
file: encodeURIComponent(path),
...(inline && { inline: 'true' })
}
const apiPath = getApiPath('api/raw', params)
@@ -532,9 +537,9 @@ export function getPreviewURL(source, path, modified) {
}
try {
const params = {
path: path,
path: encodeURIComponent(path),
key: Date.parse(modified), // Use modified date as cache key
source: source,
source: encodeURIComponent(source),
inline: 'true'
}
const apiPath = getApiPath('api/preview', params)
@@ -563,9 +568,9 @@ export async function sources() {
export async function GetOfficeConfig(req) {
const params = {
path: req.path,
...(req.hash && { hash: req.hash }),
...(req.source && { source: req.source })
path: encodeURIComponent(req.path),
...(req.hash && { hash: encodeURIComponent(req.hash) }),
...(req.source && { source: encodeURIComponent(req.source) })
}
let apiPath = getApiPath('api/onlyoffice/config', params)
if (req.hash) {
@@ -573,21 +578,4 @@ export async function GetOfficeConfig(req) {
}
const res = await fetchURL(apiPath)
return await res.json()
}
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
try {
const apiPath = getApiPath('api/media/subtitles', {
source: source,
path: path,
name: subtitleName,
embedded: embedded.toString()
})
const res = await fetchURL(apiPath)
const content = await res.text()
return content
} catch (err) {
notify.showError(err.message || `Error fetching subtitle ${subtitleName}`)
throw err
}
}
+15 -11
View File
@@ -1,6 +1,6 @@
import { adjustedData } from "./utils";
import { notify } from "@/notify";
import { getPublicApiPath } from "@/utils/url.js";
import { getPublicApiPath, encodedPath } from "@/utils/url.js";
import { state } from "@/store";
// ============================================================================
@@ -17,6 +17,7 @@ import { state } from "@/store";
* @returns {Promise<any>}
*/
export async function fetchPub(path, hash, password = "", content = false, metadata = false) {
path = encodedPath(path);
const params = {
path: path,
hash,
@@ -27,7 +28,7 @@ export async function fetchPub(path, hash, password = "", content = false, metad
const apiPath = getPublicApiPath("resources", params);
const response = await fetch(apiPath, {
headers: {
"X-SHARE-PASSWORD": password || "",
"X-SHARE-PASSWORD": password ? encodeURIComponent(password) : "",
},
});
@@ -60,8 +61,10 @@ export async function fetchPub(path, hash, password = "", content = false, metad
export function getDownloadURL(share, files, inline=false) {
// Handle array of files for repeated 'file' parameters
const fileArray = Array.isArray(files) ? files : [files]
const filePaths = fileArray.map(file => encodeURIComponent(file))
const params = {
file: fileArray, // Array of file paths - getPublicApiPath will create repeated parameters
file: filePaths, // Array of file paths - getPublicApiPath will create repeated parameters
hash: share.hash,
token: share.token,
...(inline && { inline: 'true' })
@@ -79,7 +82,7 @@ export function getDownloadURL(share, files, inline=false) {
export function getPreviewURL(path, size) {
try {
const params = {
path: path,
path: encodeURIComponent(path),
hash: state.shareInfo.hash,
inline: 'true',
// Only add size parameter if specified and not the default
@@ -112,7 +115,7 @@ export function post(
}
try {
const apiPath = getPublicApiPath("resources", {
path: path,
targetPath: encodeURIComponent(path),
hash: hash,
override: overwrite,
...(isDir && { isDir: 'true' })
@@ -153,13 +156,13 @@ export function post(
// If parsing fails, use responseText or default message
errorMessage = request.responseText || errorMessage;
}
const error = new Error(errorMessage);
error.status = request.status;
// Show notification for upload errors
notify.showError(errorMessage);
reject(error);
}
};
@@ -169,7 +172,7 @@ export function post(
notify.showError("Network error during upload");
reject(error);
};
request.onabort = () => {
const error = new Error("Upload aborted");
notify.showError("Upload was aborted");
@@ -204,6 +207,7 @@ async function resourceAction(hash, path, method, content, token = "") {
if (sharePassword) {
headers["X-SHARE-PASSWORD"] = sharePassword;
}
path = encodeURIComponent(path)
const apiPath = getPublicApiPath('resources', { path, hash: hash, token: token })
const response = await fetch(apiPath, {
method,
@@ -236,12 +240,12 @@ export async function bulkDelete(items) {
if (!items || !Array.isArray(items) || items.length === 0) {
throw new Error('items array is required and must not be empty')
}
const hash = state.shareInfo?.hash;
if (!hash) {
throw new Error('share hash is required')
}
const params = {
hash: hash,
...(state.shareInfo.token && { token: state.shareInfo.token }),
+6 -4
View File
@@ -4,12 +4,14 @@ import { getApiPath } from "@/utils/url.js";
export default async function search(base, sources, query, largest = false) {
try {
query = encodeURIComponent(query);
// Ensure sources is an array
const sourcesArray = Array.isArray(sources) ? sources : [sources];
const params = {
query: query,
sources: sourcesArray.join(",")
sources: encodeURIComponent(sourcesArray.join(","))
};
// Only include scope if searching a single source
@@ -18,7 +20,7 @@ export default async function search(base, sources, query, largest = false) {
if (!base.endsWith("/")) {
base += "/";
}
params.scope = base;
params.scope = encodeURIComponent(base);
}
if (largest) {
@@ -42,8 +44,8 @@ export async function findDuplicates(base, source, minSizeMb, useChecksum = fals
base += "/";
}
const params = {
scope: base,
source: source,
scope: encodeURIComponent(base),
source: encodeURIComponent(source),
minSizeMb: minSizeMb.toString()
};
+1 -1
View File
@@ -26,7 +26,7 @@ export async function list() {
*/
export async function get(path, source) {
try {
const params = { path, source };
const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
const apiPath = getApiPath("api/share", params);
let data = await fetchJSON(apiPath);
return adjustedData(data);
-1
View File
@@ -1,7 +1,6 @@
import { fetchURL, fetchJSON } from '@/api/utils'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { notify } from '@/notify' // Import notify for error handling
export async function getAllUsers() {
try {
const apiPath = getApiPath('api/users')
+1 -6
View File
@@ -250,7 +250,7 @@ export default {
let index = 0;
const updateThumbnail = () => {
if (!state.popupPreviewSourceInfo || state.popupPreviewSourceInfo.path !== this.path) {
if (state.popupPreviewSourceInfo?.url === "") {
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
return;
@@ -361,11 +361,6 @@ export default {
this.svgPath = result.svgPath || "";
this.updateImageTargetSrc();
},
beforeUnmount() {
// Clean up any pending animation timeouts
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
},
};
</script>
@@ -406,10 +406,6 @@ export default {
},
/** @param {MouseEvent} event */
onRightClick(event) {
if (!this.updateGlobalState) {
event.preventDefault();
return;
}
event.preventDefault(); // Prevent default context menu
// If one or fewer items are selected, reset the selection
if (this.updateGlobalState) {
+3 -6
View File
@@ -17,9 +17,9 @@
:name="getItemName(item.path)"
:isDir="item.type === 'directory'"
:source="item.source"
:type="item.type"
:size="item.size"
:modified="item.modified"
:type="item.type || 'file'"
:size="item.size || 0"
:modified="item.modified || ''"
:index="index"
:path="item.path"
:hasPreview="item.hasPreview"
@@ -107,7 +107,6 @@ export default {
size: item.size,
modified: item.modified,
previewUrl: previewUrl,
hasPreview: item.hasPreview,
});
} else if (!this.isListing) {
const item = state.req.items[state.selected[0]];
@@ -119,7 +118,6 @@ export default {
size: item.size,
modified: item.modified,
previewUrl: previewUrl,
hasPreview: item.hasPreview,
});
} else {
for (let index of state.selected) {
@@ -132,7 +130,6 @@ export default {
size: item.size,
modified: item.modified,
previewUrl: previewUrl,
hasPreview: item.hasPreview,
});
}
}
+12 -18
View File
@@ -38,7 +38,7 @@
:modified="item.originalItem?.modified || new Date().toISOString()"
:index="index"
:path="item.path"
:hasPreview="item.originalItem?.hasPreview && item.type !== 'directory' || false"
:hasPreview="item.originalItem?.hasPreview || false"
:metadata="item.originalItem?.metadata"
:hasDuration="item.originalItem?.hasDuration || false"
:updateGlobalState="false"
@@ -412,14 +412,13 @@ export default {
handleItemClick(item, _index, event) {
event.preventDefault();
event.stopPropagation();
// Display mode: single click navigates directly to the file
if (this.isDisplayMode) {
this.navigateToItem(item);
return;
}
// Browse mode: single click selects only, emit selected, do not navigate
// Create a synthetic event-like object for compatibility with existing methods
const syntheticEvent = {
currentTarget: {
dataset: {
@@ -429,17 +428,18 @@ export default {
preventDefault: () => {},
stopPropagation: () => {},
};
this.select(syntheticEvent);
if (state.user.singleClick) {
this.next(syntheticEvent);
} else {
this.select(syntheticEvent);
}
},
handleItemDblClick(item, _index, event) {
event.preventDefault();
event.stopPropagation();
// Double click: navigate (into folder or open file)
if (this.isDisplayMode) {
this.navigateToItem(item);
return;
}
// Create a synthetic event for double-click
const syntheticEvent = {
currentTarget: {
dataset: {
@@ -457,12 +457,9 @@ export default {
if (this.selected === path) {
this.selected = null;
this.selectedSource = null;
this.selectedType = null;
this.$emit("update:selected", {
path: this.current,
source: this.source,
type: 'directory',
isValid: !this.requireFileSelection,
source: this.source
});
return;
}
@@ -471,12 +468,9 @@ export default {
this.selected = path;
let clickedItem = this.items.find(item => item.path === path);
this.selectedSource = clickedItem ? clickedItem.source : this.source;
this.selectedType = clickedItem ? clickedItem.type : null;
this.$emit("update:selected", {
path: this.selected,
source: this.selectedSource,
type: this.selectedType,
isValid: this.selectedType === 'directory' ? !this.requireFileSelection : true,
source: this.selectedSource
});
},
createDir: async function () {
+43 -41
View File
@@ -27,10 +27,26 @@
</div>
<div v-if="isPickingFile">
<file-list @update:selected="updateTempFilePath" :browse-source="displaySource" :browse-path="displayPath"
:show-files="true" :show-folders="true" :allowed-file-types="filePickerAllowedTypes"
:require-file-selection="true" :title="filePickerTitle">
<file-list
@update:selected="updateTempFilePath"
:browse-source="displaySource"
:browse-path="displayPath"
:show-files="true"
:show-folders="true"
:allowed-file-types="filePickerAllowedTypes"
:require-file-selection="true"
:title="filePickerTitle">
</file-list>
<div class="card-action">
<button class="button button--flat" @click="cancelFilePicker" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
<button class="button button--flat button--blue" @click="confirmFilePicker" :disabled="!filePickerSelectionValid" :aria-label="$t('general.ok')"
:title="$t('general.ok')">
{{ $t("general.ok") }}
</button>
</div>
</div>
<div v-else-if="!isPickingFile">
@@ -94,8 +110,8 @@
</i>
</p>
<div class="form-flex-group">
<input class="form-grow input flat-right" v-focus type="number" max="2147483647" min="0"
@keyup.enter="submit" v-model.trim="time" />
<input class="form-grow input flat-right" v-focus type="number" max="2147483647" min="0" @keyup.enter="submit"
v-model.trim="time" />
<select class="flat-left input form-dropdown" v-model="unit" :aria-label="$t('time.unit')">
<option value="minutes">{{ $t("time.minutes") }}</option>
<option value="hours">{{ $t("time.hours") }}</option>
@@ -132,19 +148,16 @@
{{ $t('share.customizeSidebarLinksButton') }}
</button>
<div class="settings-items" style="margin-top: 0.5em;">
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowModify"
:name="$t('share.allowModify')" :description="$t('share.allowModifyDescription')"
aria-label="allow editing files toggle" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowCreate"
:name="$t('share.allowCreate')" :description="$t('share.allowCreateDescription')"
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowModify" :name="$t('share.allowModify')"
:description="$t('share.allowModifyDescription')" aria-label="allow editing files toggle" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowCreate" :name="$t('share.allowCreate')"
:description="$t('share.allowCreateDescription')"
aria-label="allow creating and uploading files and folders toggle" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowDelete"
:name="$t('share.allowDelete')" :description="$t('share.allowDeleteDescription')"
aria-label="allow deleting files toggle" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowDelete" :name="$t('share.allowDelete')"
:description="$t('share.allowDeleteDescription')" aria-label="allow deleting files toggle" />
</div>
</div>
<SettingsItem :title="showMoreExpanded ? $t('buttons.showLess') : $t('buttons.showMore')" :collapsable="true"
:start-collapsed="!showMoreExpanded" @toggle="showMoreExpanded = $event">
<SettingsItem :title="showMoreExpanded ? $t('buttons.showLess') : $t('buttons.showMore')" :collapsable="true" :start-collapsed="!showMoreExpanded" @toggle="showMoreExpanded = $event">
<div class="settings-items">
<p>
{{ $t("prompts.shareTheme") }}
@@ -200,8 +213,7 @@
<p>
{{ $t("share.enforceDarkLightMode") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.enforceDarkLightModeDescription'))"
@mouseleave="hideTooltip">
@mouseenter="showTooltip($event, $t('share.enforceDarkLightModeDescription'))" @mouseleave="hideTooltip">
help
</i>
</p>
@@ -215,22 +227,22 @@
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="disableThumbnails"
:name="$t('share.disableThumbnails')" :description="$t('share.disableThumbnailsDescription')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="showHidden"
:name="$t('profileSettings.showHiddenFiles')"
:description="$t('profileSettings.showHiddenFilesDescription')" />
<ToggleSwitch v-if="shareType !== 'upload'" class="item" v-model="disableNavButtons"
:name="$t('share.hideNavButtons')" :description="$t('share.hideNavButtonsDescription')" />
:name="$t('profileSettings.showHiddenFiles')" :description="$t('profileSettings.showHiddenFilesDescription')" />
<ToggleSwitch v-if="shareType !== 'upload'" class="item" v-model="disableNavButtons" :name="$t('share.hideNavButtons')"
:description="$t('share.hideNavButtonsDescription')" />
<ToggleSwitch class="item" v-model="disableShareCard" :name="$t('share.disableShareCard')"
:description="$t('share.disableShareCardDescription')" />
<ToggleSwitch class="item" v-model="disableSidebar" :name="$t('share.disableSidebar')"
:description="$t('share.disableSidebarDescription')" />
<ToggleSwitch class="item" v-model="disableSidebar"
:name="$t('share.disableSidebar')" :description="$t('share.disableSidebarDescription')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="perUserDownloadLimit"
:name="$t('share.perUserDownloadLimit')" :description="$t('share.perUserDownloadLimitDescription')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="extractEmbeddedSubtitles"
:name="$t('share.extractEmbeddedSubtitles')"
:description="$t('share.extractEmbeddedSubtitlesDescription')" />
<ToggleSwitch class="item" v-model="disableLoginOption" :name="$t('share.disableLoginOption')"
:description="$t('share.disableLoginOptionDescription')" />
</div>
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="extractEmbeddedSubtitles"
:name="$t('share.extractEmbeddedSubtitles')"
:description="$t('share.extractEmbeddedSubtitlesDescription')" />
<ToggleSwitch class="item" v-model="disableLoginOption"
:name="$t('share.disableLoginOption')"
:description="$t('share.disableLoginOptionDescription')" />
</div>
<div v-if="shareType === 'normal'">
<p>
@@ -330,16 +342,6 @@
{{ $t("general.share") }}
</button>
</div>
<div v-if="isPickingFile" class="card-action">
<button class="button button--flat" @click="cancelFilePicker" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
<button class="button button--flat button--blue" @click="confirmFilePicker" :disabled="!filePickerSelectionValid"
:aria-label="$t('general.ok')" :title="$t('general.ok')">
{{ $t("general.ok") }}
</button>
</div>
</template>
<script>
import { notify } from "@/notify";
@@ -862,7 +864,7 @@ export default {
icon: "qr_code"
}
];
// Only add Download link for normal shares, not upload shares
if (this.shareType !== 'upload') {
this.sidebarLinks.push({
@@ -962,7 +964,7 @@ export default {
if (this.tempFilePath && this.tempFileSource) {
// Build query params format: source=sourcename&path=indexpath
const queryParams = `source=${encodeURIComponent(this.tempFileSource)}&path=${encodeURIComponent(this.tempFilePath)}`;
if (this.filePickerField === 'banner') {
this.banner = queryParams;
} else if (this.filePickerField === 'favicon') {
+26 -2
View File
@@ -8,7 +8,6 @@
<script>
import { defineComponent } from "vue";
import { availableLocales } from "@/i18n/index.ts";
export default defineComponent({
name: "Languages",
@@ -20,7 +19,32 @@ export default defineComponent({
},
data() {
return {
locales: availableLocales,
locales: {
he: "he",
hu: "hu",
ar: "ar",
de: "de",
en: "en",
es: "es",
fr: "fr",
is: "is",
it: "it",
ja: "ja",
ko: "ko",
nlBE: "nl-be",
pl: "pl",
ptBR: "pt-br",
pt: "pt",
ro: "ro",
ru: "ru",
sk: "sk",
svSE: "sv-se",
tr: "tr",
ua: "ua",
zhCN: "zh-cn",
zhTW: "zh-tw",
cz: "cz",
},
};
},
methods: {
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Create an account",
@@ -321,6 +320,7 @@
"adminOptions": "خيارات المستخدم المسؤول",
"searchOptions": "خيارات البحث",
"systemAdmin": "النظام والإدارة",
"configViewer": "عارض التكوين",
"configViewerShowFull": "إظهار التكوين الكامل",
"configViewerShowComments": "إظهار التعليقات",
"configViewerLoadConfig": "تكوين التحميل",
@@ -332,8 +332,7 @@
"proxy": "الوكيل"
},
"newUserHintSubFolder": "على سبيل المثال '/مجلد فرعي' - فارغ للافتراضي",
"permissionsHelp": "تتحكم أذونات المستخدم في الوصول إلى نوع الإجراءات التي يمكن للمستخدم تنفيذها. يمنح الوصول إلى المسؤول التحكم الكامل لتحديث وتغيير إعدادات أي مستخدم بالإضافة إلى العديد من إعدادات النظام.",
"configViewer": "عارض التكوين"
"permissionsHelp": "تتحكم أذونات المستخدم في الوصول إلى نوع الإجراءات التي يمكن للمستخدم تنفيذها. يمنح الوصول إلى المسؤول التحكم الكامل لتحديث وتغيير إعدادات أي مستخدم بالإضافة إلى العديد من إعدادات النظام."
},
"sidebar": {
"siteSettings": "إعدادات الموقع",
@@ -626,8 +625,7 @@
"lines": "الخطوط{suffix}",
"lastUpdate": "آخر تحديث{suffix}",
"latency": "الكمون{suffix}",
"all": "الكل{suffix}",
"expiration": "انتهاء الصلاحية{suffix}"
"all": "الكل{suffix}"
},
"access": {
"rules": "قواعد الوصول",
+4 -6
View File
@@ -117,8 +117,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Vytvořit účet",
@@ -458,6 +457,7 @@
"userUpdated": "Uživatel aktualizován!",
"adminOptions": "Možnosti administrátora",
"systemAdmin": "Systém a správce",
"configViewer": "Prohlížeč konfigurace",
"configViewerShowFull": "Zobrazit úplnou konfiguraci",
"configViewerShowComments": "Zobrazit komentáře",
"configViewerLoadConfig": "Konfigurace načítání",
@@ -469,8 +469,7 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "např. '/subfolder' - prázdný pro výchozí nastavení",
"permissionsHelp": "Oprávnění uživatele řídí přístup k typům akcí, které může uživatel provádět. Přístup administrátora poskytuje plnou kontrolu nad aktualizací a změnou nastavení libovolného uživatele i nad mnoha nastaveními systému.",
"configViewer": "Prohlížeč konfigurace"
"permissionsHelp": "Oprávnění uživatele řídí přístup k typům akcí, které může uživatel provádět. Přístup administrátora poskytuje plnou kontrolu nad aktualizací a změnou nastavení libovolného uživatele i nad mnoha nastaveními systému."
},
"sidebar": {
"siteSettings": "Nastavení webu",
@@ -706,8 +705,7 @@
"lines": "Řádky{suffix}",
"lastUpdate": "Poslední aktualizace{suffix}",
"latency": "Zpoždění{suffix}",
"all": "Všechny{suffix}",
"expiration": "Expirace{suffix}"
"all": "Všechny{suffix}"
},
"access": {
"rules": "Pravidla přístupu",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Konto erstellen",
@@ -321,6 +320,7 @@
"adminOptions": "Optionen für Verwaltungsbenutzer",
"searchOptions": "Suchoptionen",
"systemAdmin": "System & Verwaltung",
"configViewer": "Config Viewer",
"configViewerShowFull": "Vollständige Konfiguration anzeigen",
"configViewerShowComments": "Kommentare anzeigen",
"configViewerLoadConfig": "Konfiguration laden",
@@ -332,8 +332,7 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "z.B. '/subfolder' - leer für Standard",
"permissionsHelp": "Die Benutzerrechte steuern den Zugriff auf die Art der Aktionen, die der Benutzer durchführen kann. Der Administrator-Zugang gibt Ihnen die volle Kontrolle über die Aktualisierung und Änderung der Benutzereinstellungen sowie vieler Systemeinstellungen.",
"configViewer": "Config Viewer"
"permissionsHelp": "Die Benutzerrechte steuern den Zugriff auf die Art der Aktionen, die der Benutzer durchführen kann. Der Administrator-Zugang gibt Ihnen die volle Kontrolle über die Aktualisierung und Änderung der Benutzereinstellungen sowie vieler Systemeinstellungen."
},
"sidebar": {
"siteSettings": "Seiteneinstellungen",
@@ -626,8 +625,7 @@
"lines": "Zeilen{suffix}",
"lastUpdate": "Letzte Aktualisierung{suffix}",
"latency": "Latenzzeit{suffix}",
"all": "Alle{suffix}",
"expiration": "Verfallsdatum{suffix}"
"all": "Alle{suffix}"
},
"access": {
"rules": "Zugangsregeln",
+4 -6
View File
@@ -117,8 +117,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Δημιουργία λογαριασμού",
@@ -324,6 +323,7 @@
"adminOptions": "Επιλογές χρήστη διαχειριστή",
"searchOptions": "Επιλογές αναζήτησης",
"systemAdmin": "Σύστημα & Διαχείριση",
"configViewer": "Config Viewer",
"configViewerShowFull": "Εμφάνιση πλήρων ρυθμίσεων",
"configViewerShowComments": "Εμφάνιση σχολίων",
"configViewerLoadConfig": "Φόρτωση Config",
@@ -335,8 +335,7 @@
"proxy": "Πληρεξούσιος"
},
"newUserHintSubFolder": "π.χ. '/subfolder' - κενό για προεπιλογή",
"permissionsHelp": "Τα δικαιώματα χρήστη ελέγχουν την πρόσβαση σε είδος ενεργειών που μπορεί να εκτελέσει ο χρήστης. Η πρόσβαση διαχειριστή παρέχει πλήρη έλεγχο για την ενημέρωση και την αλλαγή των ρυθμίσεων οποιουδήποτε χρήστη, καθώς και πολλών ρυθμίσεων του συστήματος.",
"configViewer": "Config Viewer"
"permissionsHelp": "Τα δικαιώματα χρήστη ελέγχουν την πρόσβαση σε είδος ενεργειών που μπορεί να εκτελέσει ο χρήστης. Η πρόσβαση διαχειριστή παρέχει πλήρη έλεγχο για την ενημέρωση και την αλλαγή των ρυθμίσεων οποιουδήποτε χρήστη, καθώς και πολλών ρυθμίσεων του συστήματος."
},
"sidebar": {
"siteSettings": "Ρυθμίσεις ιστότοπου",
@@ -626,8 +625,7 @@
"lines": "Γραμμές{suffix}",
"lastUpdate": "Τελευταία ενημέρωση{suffix}",
"latency": "Καθυστέρηση{suffix}",
"all": "Όλα{suffix}",
"expiration": "Λήξη{suffix}"
"all": "Όλα{suffix}"
},
"access": {
"rules": "Κανόνες πρόσβασης",
+1 -3
View File
@@ -8,7 +8,6 @@
"incompleteResultsDetails": "Showing max results ({max}). Try narrowing your search to get more results under {max}."
},
"general": {
"expiration": "Expiration{suffix}",
"all": "All{suffix}",
"name": "Name{suffix}",
"path": "Path{suffix}",
@@ -236,8 +235,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Create an account",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Crear cuenta",
@@ -327,13 +326,13 @@
"adminOptions": "Opciones de administrador",
"searchOptions": "Opciones de busqueda",
"systemAdmin": "Sistema y administración",
"configViewer": "Visor de configuración",
"configViewerShowFull": "Mostrar configuración completa",
"configViewerShowComments": "Mostrar comentarios",
"configViewerLoadConfig": "Cargar configuración",
"brandingHelp": "Puedes personalizar la apariencia de FileBrowser Quantum cambiando el nombre, el logo, puedes añadir tus propios estilos personalizados, desactivar los enlaces externos a GitHub, etc. \nSi quieres saber más, visita {0}.",
"commandRunnerHelp": "Aquí puede establecer los comandos que se ejecutan en los eventos nombrados. Debe escribir uno por línea. Las variables de entorno {0} y {1} estarán disponibles, siendo {0} relativa a {1}. Para más información sobre esta característica y las variables de entorno disponibles, por favor lea el {2}.",
"permissionsHelp": "Los permisos de usuario controlan el acceso al tipo de acciones que el usuario puede realizar. El acceso de administrador proporciona un control total para actualizar y cambiar la configuración de cualquier usuario, así como muchos ajustes del sistema.",
"configViewer": "Visor de configuración"
"permissionsHelp": "Los permisos de usuario controlan el acceso al tipo de acciones que el usuario puede realizar. El acceso de administrador proporciona un control total para actualizar y cambiar la configuración de cualquier usuario, así como muchos ajustes del sistema."
},
"sidebar": {
"siteSettings": "Ajustes del sitio",
@@ -626,8 +625,7 @@
"lines": "Líneas{suffix}",
"lastUpdate": "Última actualización{suffix}",
"latency": "Latencia{suffix}",
"all": "Todos{suffix}",
"expiration": "Caducidad{suffix}"
"all": "Todos{suffix}"
},
"access": {
"rules": "Reglas de acceso",
+17 -19
View File
@@ -58,9 +58,9 @@
"itemSelected": "élément sélectionné",
"itemsSelected": "éléments sélectionnés",
"myFiles": "Mes dossiers{suffix}",
"newFile": "Nouveau fichier{suffix}",
"newFile": "Nouveau Fichier{suffix}",
"newFolder": "Nouveau dossier{suffix}",
"duration": "Durée{suffix}",
"duration": "Durée de l'accord{suffix}",
"sortByDuration": "Trier par durée",
"sameFolder": "Impossible de déplacer vers le même dossier",
"cannotAccesDir": "Impossible d'accéder au répertoire",
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Créer un compte",
@@ -310,7 +309,7 @@
"userHomeBasePath": "Chemin de base pour les répertoires personnels des utilisateurs",
"userScopeGenerationPlaceholder": "Le champ d'application sera généré automatiquement",
"createUserHomeDirectory": "Créer le répertoire personnel de l'utilisateur",
"api": "API",
"api": "Clés API",
"loginMethodDescription": "méthode de connexion autorisée pour l'utilisateur.",
"userNotAdmin": "L'utilisateur n'est pas un administrateur",
"enterPassword": "Saisir le mot de passe",
@@ -321,7 +320,7 @@
"adminOptions": "Options de l'utilisateur administrateur",
"searchOptions": "Options de recherche",
"systemAdmin": "Système et administration",
"configViewer": "Visualiseur de configuration",
"configViewer": "Config Viewer",
"configViewerShowFull": "Afficher la configuration complète",
"configViewerShowComments": "Afficher les commentaires",
"configViewerLoadConfig": "Configuration de la charge",
@@ -477,6 +476,7 @@
"description": "Les clés API sont basées sur l'utilisateur qui les a créées. Voir les",
"swaggerLinkText": "page swagger",
"created": "Créée",
"expires": "Expiration",
"actions": "Actions",
"enabled": "Activée",
"disabled": "Désactivée",
@@ -484,8 +484,7 @@
"tokenType": "Type de jeton :",
"clickToCopyKey": "Cliquez pour copier la clé (cachée)",
"minimalDescription": "Jeton minimal",
"minimalInfo": "Exclut les personnalisations du jeton afin de réduire la longueur du jeton. Les autorisations correspondent exactement à celles de l'utilisateur associé.",
"expires": "Expiration"
"minimalInfo": "Exclut les personnalisations du jeton afin de réduire la longueur du jeton. Les autorisations correspondent exactement à celles de l'utilisateur associé."
},
"index": {
"assessment": "Évaluation",
@@ -495,7 +494,7 @@
"settingsHover": "Paramètres pour l'utilisateur",
"toggleClick": "Activation du mode simple clic",
"toggleDark": "Mode sombre/clair",
"toggleSticky": "Activer/Désactiver le mode persistant",
"toggleSticky": "Activer/Désactiver le mode persistent",
"noSources": "Aucune source n'est disponible pour cet utilisateur. Ajoutez une source pour cet utilisateur ou configurez une source avec defaultEnabled : true",
"unknown": "Inconnu",
"simple": "Simple",
@@ -526,11 +525,11 @@
"signup": "S'inscrire",
"password": "Mot de passe",
"username": "Nom d'utilisateur",
"files": "Fichiers",
"files": "Dossiers",
"folders": "Dossiers",
"exit": "Sortie",
"permanent": "Permanent",
"hash": "Hachage",
"hash": "Hachures",
"of": "de",
"source": "Source{suffix}",
"percentage": "{percentage}%",
@@ -570,7 +569,7 @@
"documentation": "la documentation{suffix}",
"examples": "Exemples{suffix}",
"dark": "Sombre{suffix}",
"light": "Clair{suffix}",
"light": "Lumière{suffix}",
"title": "Thème{suffix}",
"expires": "Expiration{suffix}",
"scope": "Champ d'application{suffix}",
@@ -605,12 +604,12 @@
"select": "Sélectionner{suffix}",
"share": "Partager{suffix}",
"update": "Mise à jour{suffix}",
"upload": "Importer{suffix}",
"upload": "Télécharger{suffix}",
"openFile": "Ouvrir un fichier{suffix}",
"continue": "Continuer{suffix}",
"edit": "Éditer{suffix}",
"edit": "Editer{suffix}",
"pause": "Pause{suffix}",
"resume": "Reprendre{suffix}",
"resume": "Curriculum vitae{suffix}",
"retry": "Réessayer{suffix}",
"disable": "Désactiver{suffix}",
"confirm": "Confirmer{suffix}",
@@ -626,8 +625,7 @@
"lines": "Lignes{suffix}",
"lastUpdate": "Dernière mise à jour{suffix}",
"latency": "Temps de latence{suffix}",
"all": "Tous{suffix}",
"expiration": "Expiration{suffix}"
"all": "Tous{suffix}"
},
"access": {
"rules": "Règles d'accès",
@@ -772,11 +770,11 @@
"video": "Vidéo",
"image": "Image",
"audio": "Audio",
"archive": "Archive",
"archive": "Archives",
"document": "Document",
"binary": "Binaire",
"directory": "Annuaire",
"other": "Autre"
"other": "Autres"
},
"tools": {
"description": "Utilitaires et outils d'analyse pour vos fichiers",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "צור חשבון",
@@ -321,6 +320,7 @@
"adminOptions": "אפשרויות משתמש מנהל",
"searchOptions": "אפשרויות חיפוש",
"systemAdmin": "מערכת וניהול",
"configViewer": "מציג תצורה",
"configViewerShowFull": "הצג את התצורה המלאה",
"configViewerShowComments": "הצג תגובות",
"configViewerLoadConfig": "טען תצורה",
@@ -332,8 +332,7 @@
"proxy": "פרוקסי"
},
"newUserHintSubFolder": "לדוגמה, '/subfolder' - ריק עבור ברירת המחדל",
"permissionsHelp": "הרשאות המשתמש שולטות בגישה לסוגי הפעולות שהמשתמש יכול לבצע. גישה לניהול מעניקה שליטה מלאה לעדכון ולשינוי הגדרות המשתמש, כמו גם הגדרות רבות של המערכת.",
"configViewer": "מציג תצורה"
"permissionsHelp": "הרשאות המשתמש שולטות בגישה לסוגי הפעולות שהמשתמש יכול לבצע. גישה לניהול מעניקה שליטה מלאה לעדכון ולשינוי הגדרות המשתמש, כמו גם הגדרות רבות של המערכת."
},
"sidebar": {
"siteSettings": "הגדרות",
@@ -626,8 +625,7 @@
"lines": "קווים{suffix}",
"lastUpdate": "עדכון אחרון{suffix}",
"latency": "חביון{suffix}",
"all": "כל{suffix}",
"expiration": "תוקף{suffix}"
"all": "כל{suffix}"
},
"access": {
"rules": "כללי גישה",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Fiók létrehozása",
@@ -321,6 +320,7 @@
"adminOptions": "Admin felhasználói beállítások",
"searchOptions": "Keresési lehetőségek",
"systemAdmin": "Rendszer és adminisztráció",
"configViewer": "Konfiguráció megtekintő",
"configViewerShowFull": "Teljes konfiguráció megjelenítése",
"configViewerShowComments": "Hozzászólások megjelenítése",
"configViewerLoadConfig": "Konfiguráció betöltése",
@@ -332,8 +332,7 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "pl. '/subfolder' - üres az alapértelmezéshez",
"permissionsHelp": "A felhasználói engedélyek szabályozzák, hogy a felhasználó milyen típusú műveleteket hajthat végre. A rendszergazdai hozzáférés teljes körű ellenőrzést biztosít bármely felhasználó beállításainak, valamint számos rendszerbeállításnak a frissítéséhez és módosításához.",
"configViewer": "Konfiguráció megtekintő"
"permissionsHelp": "A felhasználói engedélyek szabályozzák, hogy a felhasználó milyen típusú műveleteket hajthat végre. A rendszergazdai hozzáférés teljes körű ellenőrzést biztosít bármely felhasználó beállításainak, valamint számos rendszerbeállításnak a frissítéséhez és módosításához."
},
"sidebar": {
"siteSettings": "Oldalbeállítások",
@@ -626,8 +625,7 @@
"lines": "Vonalak{suffix}",
"lastUpdate": "Utolsó frissítés{suffix}",
"latency": "Késleltetés{suffix}",
"all": "Minden{suffix}",
"expiration": "Lejárat{suffix}"
"all": "Minden{suffix}"
},
"access": {
"rules": "Hozzáférési szabályok",
+59 -52
View File
@@ -1,21 +1,19 @@
// i18n.js
import { createI18n } from 'vue-i18n';
// Import translations (alphabetical order)
// Import translations
import he from './he.json';
import hu from './hu.json';
import ar from './ar.json';
import cz from './cz.json';
import de from './de.json';
import el from './el.json';
import en from './en.json';
import es from './es.json';
import fr from './fr.json';
import he from './he.json';
import hu from './hu.json';
import is from './is.json';
import it from './it.json';
import ja from './ja.json';
import ko from './ko.json';
import nl from './nl.json';
import nlBE from './nl-be.json';
import pl from './pl.json';
import pt from './pt.json';
@@ -23,35 +21,14 @@ import ptBR from './pt-br.json';
import ro from './ro.json';
import ru from './ru.json';
import sk from './sk.json';
import svSE from './sv-se.json';
import ua from './ua.json';
import svSE from './sv-se.json';
import zhCN from './zh-cn.json';
import zhTW from './zh-tw.json';
import cz from './cz.json';
type LocaleMap = { [key: string]: string };
// Map of all imported translation modules (alphabetical order)
// This is the single source for all translations
const translationModules: { [key: string]: any } = {
ar, cz, de, el, en, es, fr, he, hu, is, it, ja, ko, nl, nlBE, pl, pt, ptBR, ro, ru, sk, svSE, ua, zhCN, zhTW
};
// Shared list of available locales for the application
// Auto-generated from translation modules
export const availableLocales: LocaleMap = Object.keys(translationModules).reduce((acc, key) => {
// Convert internal keys to their display format
const displayMap: { [key: string]: string } = {
nlBE: 'nl-be',
ptBR: 'pt-br',
svSE: 'sv-se',
zhCN: 'zh-cn',
zhTW: 'zh-tw',
};
acc[key] = displayMap[key] || key;
return acc;
}, {} as LocaleMap);
// Maps internal locale names to standard BCP 47 codes (for navigator.language)
export const internalToStandardLocaleMap: { [key: string]: string } = {
nlBE: 'nl-be',
ptBR: 'pt-br',
@@ -67,38 +44,43 @@ export function toStandardLocale(locale: string): string {
}
export function detectLocale(): string {
const browserLocale = navigator.language.toLowerCase();
// Map of browser locale codes to internal locale keys
const browserToInternalMap: LocaleMap = {
const locale = navigator.language.toLowerCase();
const localeMap: LocaleMap = {
'he': 'he',
'hu': 'hu',
'ar': 'ar',
'el': 'el',
'es': 'es',
'en': 'en',
'is': 'is',
'it': 'it',
'fr': 'fr',
'pt-br': 'ptBR',
'pt': 'pt',
'ja': 'ja',
'zh-tw': 'zhTW',
'zh-cn': 'zhCN',
'zh': 'zhCN',
'de': 'de',
'ro': 'ro',
'ru': 'ru',
'pl': 'pl',
'ko': 'ko',
'cz': 'cz',
'sk': 'sk',
'tr': 'tr',
'uk': 'uk',
'sv-se': 'svSE',
'sv': 'svSE',
'nl-be': 'nlBE',
};
// Check for exact matches first (including variants like pt-br)
if (browserToInternalMap[browserLocale]) {
return browserToInternalMap[browserLocale];
for (const key in localeMap) {
if (locale.startsWith(key)) {
return localeMap[key];
}
}
// Check for language prefix matches (e.g., 'en-US' -> 'en')
const languagePrefix = browserLocale.split('-')[0];
// If the language prefix exists in our translations, use it
if (translationModules[languagePrefix]) {
return languagePrefix;
}
// Try browser-specific mappings for the prefix
if (browserToInternalMap[languagePrefix]) {
return browserToInternalMap[languagePrefix];
}
return 'en'; // Default fallback
return 'en-us'; // Default fallback
}
// List of RTL languages
@@ -118,13 +100,38 @@ export function setLocale(locale: string) {
}
// Create i18n instance with auto-generated messages from imported modules
// Create i18n instance
const i18n = createI18n({
locale: detectLocale(),
fallbackLocale: 'en',
// expose i18n.global for outside components
legacy: true,
messages: translationModules,
messages: {
he,
hu,
ar,
de,
el,
en,
es,
fr,
is,
it,
ja,
ko,
nlBE,
pl,
ptBR,
pt,
ru,
ro,
sk,
svSE,
ua,
zhCN,
zhTW,
cz,
},
});
export default i18n;
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Crea un account",
@@ -321,6 +320,7 @@
"adminOptions": "Opzioni dell'utente amministratore",
"searchOptions": "Opzioni di ricerca",
"systemAdmin": "Sistema e amministrazione",
"configViewer": "Visualizzatore di configurazione",
"configViewerShowFull": "Mostra la configurazione completa",
"configViewerShowComments": "Mostra i commenti",
"configViewerLoadConfig": "Configurazione del carico",
@@ -332,8 +332,7 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "es. '/sottocartella' - vuoto per l'impostazione predefinita",
"permissionsHelp": "I permessi utente controllano l'accesso al tipo di azioni che l'utente può eseguire. L'accesso dell'amministratore consente di aggiornare e modificare le impostazioni di qualsiasi utente e molte impostazioni del sistema.",
"configViewer": "Visualizzatore di configurazione"
"permissionsHelp": "I permessi utente controllano l'accesso al tipo di azioni che l'utente può eseguire. L'accesso dell'amministratore consente di aggiornare e modificare le impostazioni di qualsiasi utente e molte impostazioni del sistema."
},
"sidebar": {
"siteSettings": "Impostazioni del sito",
@@ -626,8 +625,7 @@
"lines": "Linee{suffix}",
"lastUpdate": "Ultimo aggiornamento{suffix}",
"latency": "Latenza{suffix}",
"all": "Tutti{suffix}",
"expiration": "Scadenza{suffix}"
"all": "Tutti{suffix}"
},
"access": {
"rules": "Regole di accesso",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "アカウントを作成",
@@ -321,6 +320,7 @@
"adminOptions": "管理者ユーザーオプション",
"searchOptions": "検索オプション",
"systemAdmin": "システム&管理",
"configViewer": "設定ビューア",
"configViewerShowFull": "全設定を表示",
"configViewerShowComments": "コメントを表示",
"configViewerLoadConfig": "設定を読み込み",
@@ -332,8 +332,7 @@
"proxy": "プロキシ"
},
"newUserHintSubFolder": "例 '/subfolder' - デフォルトは空白",
"permissionsHelp": "ユーザー権限は、ユーザーが実行できるアクションの種類へのアクセスを制御します。管理者アクセスは、多くのシステム設定だけでなく、任意のユーザーの設定を更新および変更するための完全なコントロールを提供します。",
"configViewer": "コンフィグビューア"
"permissionsHelp": "ユーザー権限は、ユーザーが実行できるアクションの種類へのアクセスを制御します。管理者アクセスは、多くのシステム設定だけでなく、任意のユーザーの設定を更新および変更するための完全なコントロールを提供します。"
},
"sidebar": {
"siteSettings": "サイト設定",
@@ -626,8 +625,7 @@
"lines": "ライン{suffix}",
"lastUpdate": "最終更新{suffix}",
"latency": "レイテンシー{suffix}",
"all": "すべて{suffix}",
"expiration": "有効期限{suffix}"
"all": "すべて{suffix}"
},
"access": {
"rules": "アクセスルール",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "계정 생성",
@@ -321,6 +320,7 @@
"adminOptions": "관리자 사용자 옵션",
"searchOptions": "검색 옵션",
"systemAdmin": "시스템 및 관리자",
"configViewer": "구성 뷰어",
"configViewerShowFull": "전체 구성 표시",
"configViewerShowComments": "댓글 표시",
"configViewerLoadConfig": "로드 구성",
@@ -332,8 +332,7 @@
"proxy": "프록시"
},
"newUserHintSubFolder": "예: '/subfolder' - 기본값은 비어 있습니다.",
"permissionsHelp": "사용자 권한은 사용자가 수행할 수 있는 작업 유형에 대한 액세스를 제어합니다. 관리자 권한은 모든 사용자 설정은 물론 여러 시스템 설정을 업데이트하고 변경할 수 있는 모든 권한을 부여합니다.",
"configViewer": "구성 뷰어"
"permissionsHelp": "사용자 권한은 사용자가 수행할 수 있는 작업 유형에 대한 액세스를 제어합니다. 관리자 권한은 모든 사용자 설정은 물론 여러 시스템 설정을 업데이트하고 변경할 수 있는 모든 권한을 부여합니다."
},
"sidebar": {
"siteSettings": "사이트 설정",
@@ -626,8 +625,7 @@
"lines": "라인{suffix}",
"lastUpdate": "최근 업데이트{suffix}",
"latency": "지연 시간{suffix}",
"all": "모두{suffix}",
"expiration": "만료{suffix}"
"all": "모두{suffix}"
},
"access": {
"rules": "액세스 규칙",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Account aanmaken",
@@ -321,6 +320,7 @@
"adminOptions": "Opties voor beheerders",
"searchOptions": "Zoekopties",
"systemAdmin": "Systeem & Beheer",
"configViewer": "Configuratiescherm",
"configViewerShowFull": "Volledige configuratie weergeven",
"configViewerShowComments": "Opmerkingen tonen",
"configViewerLoadConfig": "Configuratie laden",
@@ -332,8 +332,7 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "bijv. '/submap' - leeg voor standaard",
"permissionsHelp": "Gebruikersrechten bepalen welke acties de gebruiker kan uitvoeren. Administrar toegang geeft volledige controle om de instellingen van elke gebruiker bij te werken en te wijzigen, evenals vele systeeminstellingen.",
"configViewer": "Configuratiescherm"
"permissionsHelp": "Gebruikersrechten bepalen welke acties de gebruiker kan uitvoeren. Administrar toegang geeft volledige controle om de instellingen van elke gebruiker bij te werken en te wijzigen, evenals vele systeeminstellingen."
},
"sidebar": {
"siteSettings": "Site-instellingen",
@@ -706,8 +705,7 @@
"lines": "Lijnen{suffix}",
"lastUpdate": "Laatste update{suffix}",
"latency": "Latency{suffix}",
"all": "Alle{suffix}",
"expiration": "Vervaldatum{suffix}"
"all": "Alle{suffix}"
},
"access": {
"rules": "Toegangsregels",
-853
View File
@@ -1,853 +0,0 @@
{
"messages": {
"pathNotFound": "Pad bestaat niet op bestandssysteem",
"pathNotFoundMessage": "Het pad hiervoor bestaat niet meer op het bestandssysteem. Het kan verplaatst of verwijderd zijn.",
"reassignPath": "Pad opnieuw toewijzen",
"pathReassigned": "Pad succesvol bijgewerkt!",
"pathReassignFailed": "Het bijwerken van het pad is mislukt. Probeer het opnieuw.",
"incompleteResultsDetails": "Max resultaten ({max}). Probeer de zoekopdracht aan te passen voor meer resultaten onder {max}."
},
"general": {
"all": "Alle{suffix}",
"name": "Naam{suffix}",
"path": "Pad{suffix}",
"user": "Gebruiker{suffix}",
"group": "Groep{suffix}",
"signup": "Aanmelden{suffix}",
"password": "Wachtwoord{suffix}",
"username": "Gebruikersnaam{suffix}",
"files": "Bestanden{suffix}",
"folders": "Mappen{suffix}",
"exit": "Sluiten{suffix}",
"permanent": "Permanent{suffix}",
"hash": "Hash{suffix}",
"of": "van{suffix}",
"source": "Bron{suffix}",
"percentage": "{percentage}%",
"type": "Type{suffix}",
"size": "Grootte{suffix}",
"body": "Inhoud{suffix}",
"home": "Thuis{suffix}",
"clear": "Wissen{suffix}",
"help": "Hulp{suffix}",
"login": "Inloggen{suffix}",
"logout": "Afmelden{suffix}",
"preview": "Voorbeeld{suffix}",
"settings": "Instellingen{suffix}",
"sources": "Bronnen{suffix}",
"links": "Links{suffix}",
"tool": "Gereedschap{suffix}",
"used": "gebruikt{suffix}",
"status": "Status{suffix}",
"analyze": "Analyseren{suffix}",
"results": "Resultaten{suffix}",
"users": "Gebruikers{suffix}",
"scopes": "Bereiken{suffix}",
"commands": "Opdrachten{suffix}",
"photos": "Foto's",
"audio": "Audio{suffix}",
"videos": "Video's{suffix}",
"documents": "Documenten{suffix}",
"archives": "Archieven{suffix}",
"images": "Afbeeldingen{suffix}",
"music": "Muziek{suffix}",
"pdf": "PDF{suffix}",
"search": "Zoeken{suffix}",
"number": "Aantal{suffix}",
"types": "Typen{suffix}",
"video": "Video{suffix}",
"admin": "Admin{suffix}",
"branding": "Branding{suffix}",
"language": "Taal{suffix}",
"documentation": "documentatie{suffix}",
"examples": "Voorbeelden{suffix}",
"dark": "Donker{suffix}",
"light": "Licht{suffix}",
"title": "Thema{suffix}",
"expires": "Verloopt op{suffix}",
"scope": "Bereik{suffix}",
"rules": "Regels{suffix}",
"downloads": "Downloads{suffix}",
"submit": "Indienen{suffix}",
"navigation": "Navigatie{suffix}",
"metadata": "Metagegevens{suffix}",
"loading": "Laden{suffix}",
"add": "Toevoegen{suffix}",
"stay": "Blijven{suffix}",
"cancel": "Annuleren{suffix}",
"change": "Wijzigen{suffix}",
"verify": "Controleren{suffix}",
"close": "Sluiten{suffix}",
"copy": "Kopiëren{suffix}",
"create": "Aanmaken{suffix}",
"delete": "Verwijderen{suffix}",
"download": "Downloaden{suffix}",
"file": "Bestand{suffix}",
"folder": "Map{suffix}",
"info": "Info{suffix}",
"more": "Meer{suffix}",
"move": "Verplaatsen{suffix}",
"new": "Nieuw{suffix}",
"next": "Volgende{suffix}",
"ok": "OK{suffix}",
"previous": "Vorige{suffix}",
"publish": "Publiceren{suffix}",
"rename": "Hernoemen{suffix}",
"replace": "Vervangen{suffix}",
"save": "Opslaan{suffix}",
"schedule": "Schema{suffix}",
"select": "Selecteren{suffix}",
"share": "Delen{suffix}",
"update": "Bijwerken{suffix}",
"upload": "Uploaden{suffix}",
"openFile": "Bestand openen{suffix}",
"continue": "Doorgaan{suffix}",
"edit": "Bewerken{suffix}",
"pause": "Pauzeren{suffix}",
"resume": "Hervatten{suffix}",
"retry": "Opnieuw proberen{suffix}",
"disable": "Uitschakelen{suffix}",
"confirm": "Bevestigen{suffix}",
"uploading": "Uploaden{suffix}",
"paused": "Gepauzeerd{suffix}",
"conflict": "Conflict{suffix}",
"output": "Uitvoer{suffix}",
"watching": "Bekijken{suffix}",
"lines": "Regels{suffix}",
"lastUpdate": "Laatste update{suffix}",
"latency": "Vertraging{suffix}",
"expiration": "Vervaldatum{suffix}"
},
"buttons": {
"generateNewOtp": "Herstellen en nieuwe twee-factorcode genereren",
"copyFile": "Bestand kopiëren",
"copyToClipboard": "Kopiëren naar klembord",
"copyDownloadLinkToClipboard": "Downloadlink kopiëren naar klembord",
"moveFile": "Bestand verplaatsen",
"permalink": "Permanente link aanmaken",
"reportIssue": "Probleem melden",
"selectMultiple": "Meerdervoudige selectie",
"shell": "Opdrachtvenster schakelen",
"switchView": "Weergave wisselen",
"toggleSidebar": "Zijbalk schakelen",
"pauseAll": "Alles onderbreken",
"resumeAll": "Alles hervatten",
"clearCompleted": "Wissen voltooid",
"showMore": "Geavanceerde opties weergeven",
"showLess": "Geavanceerde opties verbergen",
"openParentFolder": "Bovenliggende map openen",
"watchFile": "Bestand bekijken",
"listView": "Lijstweergave",
"compactView": "Compacte weergave",
"normalView": "Normale weergave",
"galleryView": "Galerie",
"selectedCount": "Aantal items geselecteerd om een actie uit te voeren",
"selectAll": "Alles selecteren",
"uploadFiles": "Bestanden uploaden",
"uploadFolder": "Map uploaden",
"openUploadViewer": "Uploadmanager openen",
"discardAndExit": "Weggooien en afsluiten",
"saveAndExit": "Opslaan en afsluiten",
"keepEditing": "Blijven bewerken",
"closeAnyway": "Sluiten",
"goToItem": "Ga naar item"
},
"download": {
"downloadFile": "Bestand downloaden",
"downloadFolder": "Map downloaden",
"downloadSelected": "Selectie downloaden"
},
"upload": {
"abortUpload": "Weet u zeker dat u wilt afbreken?"
},
"errors": {
"forbidden": "U hebt hier geen toegangsrechten.",
"internal": "Er ging iets niet goed.",
"notFound": "Geen toegang tot deze locatie.",
"connection": "Geen toegang tot de server.",
"shareNotFound": "Ongeldige of verlopen gedeelde map.",
"failedToConnectToServer": "Kan geen verbinding maken met de server, draait deze nog?"
},
"files": {
"closePreview": "Voorbeeld sluiten",
"lastModified": "Laatst bewerkt{suffix}",
"duration": "Duur{suffix}",
"lonely": "Hier is niets te zien...",
"multipleSelectionEnabled": "Meervoudige selectie ingeschakeld",
"sortByLastModified": "Sorteren op laatst bewerkt",
"sortByName": "Sorteren op naam",
"sortBySize": "Sorteren op grootte",
"sortByDuration": "Sorteren op duur",
"noPreview": "Voorbeeld is niet beschikbaar voor dit bestand.",
"noDownloadAccess": "Download is niet beschikbaar.",
"itemSelected": "item geselecteerd",
"itemsSelected": "items geselecteerd",
"myFiles": "Mijn bestanden{suffix}",
"newFile": "Nieuw bestand{suffix}",
"newFolder": "Nieuwe map{suffix}",
"sameFolder": "Kan niet verplaatsen naar dezelfde map",
"cannotAccesDir": "Geen toegang tot map"
},
"help": {
"functionKeys": "Functietoetsen",
"mouseActions": "Muisacties",
"fileOperations": "Bestandsbewerkingen",
"click": "selecteer bestand of map",
"marqueeSelect": "selecteer meerdere bestanden of mappen door over lege ruimte te slepen",
"ctrl": {
"click": "meerdere bestanden of mappen selecteren",
"c": "geselecteerde items kopiëren",
"v": "gekopieerde/geknipte items plakken",
"x": "geselecteerde items knippen",
"d": "geselecteerde items downloaden",
"b": "zijbalk schakelen",
"comma": "ga naar instellingen"
},
"del": "geselecteerde items verwijderen",
"esc": "selectie wissen en/of opdrachtvenster sluiten",
"f2": "bestand herbenoemen",
"f1": "hulp-venster tonen",
"slash": "Zoeken inschakelen",
"description": "U kunt de basisnavigatieopties hieronder bekijken. Ga voor aanvullende informatie naar",
"wiki": "FileBrowser Quantum Officiële documenten"
},
"languages": {
"he": "עברית",
"hu": "Magyar",
"ar": "العربية",
"de": "Deutsch",
"el": "Ελληνικά",
"en": "English",
"es": "Español",
"fr": "Français",
"is": "Icelandic",
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nlBE": "Dutch (Belgium)",
"pl": "Polski",
"pt": "Português",
"ptBR": "Português (Brasil)",
"ro": "Romanian",
"ru": "Русский",
"sk": "Slovenčina",
"svSE": "Swedish (Sweden)",
"tr": "Türkçe",
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
},
"login": {
"createAnAccount": "Account aanmaken",
"loginInstead": "Ik heb al een account",
"passwordConfirm": "Wachtwoord bevestigen",
"passwordsDontMatch": "Wachtwoorden komen niet overeen",
"usernameTaken": "Gebruikersnaam al in gebruik",
"wrongCredentials": "Verkeerde inloggegevens",
"or": "OF",
"inProgress": "Inloggen...",
"failedLogin": "Inloggen is mislukt",
"invalidCredentials": "Ongeldige inloggegevens verstrekt"
},
"share": {
"notice": "Opmerking: wanneer een gedeelde map wordt aangemaakt, wordt deze meer blootgesteld aan het publiek en gelden er andere toegangsregels. Als uw gedeelde map niet veilig is geconfigureerd, kan iedereen er toegang tot krijgen.",
"disableAnonymous": "Anonieme toegang uitschakelen",
"disableThumbnails": "Miniaturen uitschakelen",
"enableAllowedUsernames": "Alleen delen met bepaalde gebruikers",
"allowedUsernamesPlaceholder": "Voer gebruikersnamen in, door komma's gescheiden",
"disableAnonymousDescription": "Alleen geauthenticeerde gebruikers krijgen toegang tot de gedeelde map. De gebruiker moet toegang hebben tot de bron van de gedeelde map.",
"disableThumbnailsDescription": "Er worden geen voorbeeldminiaturen weergegeven in de gedeelde map.",
"enableAllowedUsernamesDescription": "Alleen de opgegeven gebruikers krijgen toegang tot de gedeelde map. De gebruiker moet toegang hebben tot de bron van de gedeelde map.",
"downloadsLimitDescription": "Het maximum aantal keren dat een bestand/map van de gedeelde map kan worden gedownload. Leeg laten voor onbeperkt.",
"maxBandwidthDescription": "De maximale downloadbandbreedte in kilobytes per seconde. Leeg laten voor onbeperkt.",
"shareThemeDescription": "Het thema dat moet worden gebruikt voor de link delen.",
"keepAfterExpiration": "Gedeelde map niet verwijderen nadat deze is verlopen",
"keepAfterExpirationDescription": "De gedeelde map wordt niet verwijderd nadat deze verlopen is. Dit is handig als u de vervaldatum van een gedeelde map wilt verlengen of het verder wilt bewerken nadat het verlopen is.",
"saveDisabled": "Kan wijzigingen in gedeelde bestanden niet opslaan.",
"shareDurationDescription": "Duur voordat de gedeelde map verloopt. Leeg laten voor een permanente gedeelde map.",
"passwordDescription": "Optioneel wachtwoord dat vereist is om toegang te krijgen tot deze gedeelde map, inclusief geauthenticeerde gebruikers.",
"allowedUsernamesListDescription": "Door komma's gescheiden gebruikersnamen die toegang hebben tot de gedeelde map (indien ingeschakeld).",
"shareThemeColorDescription": "CSS kleurwaarde toegepast op het thema van de gedeelde map (bijv. rood, #0ea5e9, rgb(14,165,233)).",
"shareTitleDescription": "Aangepaste paginatitel die wordt weergegeven op de deelpagina.",
"shareDescriptionHelp": "Korte beschrijving die wordt weergegeven op de pagina van de gedeelde map (kan worden gebruikt in metatags).",
"shareLogoDescription": "URL van logoafbeelding of pad dat toegankelijk is voor de client -- zowel abosolute als index path worden geaccepteerd: bijv. 'https://domain.com/logo.png' of '/path/to/logo.png'.",
"shareBannerDescription": "URL van bannerafbeelding of pad dat toegankelijk is voor de client -- zowel abosolute als indexpath worden geaccepteerd: bijv. 'https://domain.com/banner.png' of '/path/to/banner.png'.",
"shareFaviconDescription": "Favicon URL of pad toegankelijk door de client -- zowel abosolute als index path worden geaccepteerd: bijv. 'https://domain.com/favicon.png' of '/path/to/favicon.png'.",
"hideNavButtons": "Navigatieknoppen verbergen",
"hideNavButtonsDescription": "Verberg de navigatieknoppen op de navigatiebalk in de gedeelde map om een minimalistische look te creëren.",
"viewModeDescription": "Standaard lay-out voor de gedeelde pagina.",
"titleDefault": "Gedeelde bestanden - {title}",
"descriptionDefault": "Er is een gedeelde map naar u verzonden om te bekijken of te downloaden.",
"descriptionUploadDefault": "U hebt een gedeelde map ontvangen om bestanden te uploaden.",
"disableShareCard": "Gedeelde map-kaart uitschakelen",
"disableShareCardDescription": "De gedeelde map-kaart uitschakelen op de gedeelde pagina in de zijbalk of bovenaan de pagina op mobiel.",
"disableSidebar": "Zijbalk uitschakelen",
"disableFileViewer": "Bestandsverkenner uitschakelen",
"disableSidebarDescription": "De zijbalk op de gedeelde pagina uitschakelen.",
"enforceDarkLightMode": "Themamodus afdwingen",
"enforceDarkLightModeDescription": "Een specifieke themamodus (donker of licht) afdwingen voor deze gedeelde map, waarbij gebruikersvoorkeuren worden overschreven.",
"defaultViewMode": "Standaard weergavemodus",
"defaultViewModeDescription": "Stel de standaard weergavemodus in voor anonieme gebruikers die deze gedeelde map bekijken. Aangemelde gebruikers kunnen nog steeds hun weergavevoorkeur wijzigen.",
"default": "Gebruikersvoorkeuren toepassen",
"dark": "Donker",
"light": "Licht",
"enableOnlyOffice": "OnlyOffice Viewer inschakelen",
"enableOnlyOfficeDescription": "Het bekijken van Office-bestanden toestaan met OnlyOffice in deze gedeelde map.",
"shareType": "Type gedeelde map",
"shareTypeDescription": "Het type gedeelde map dat moet worden aangemaakt. Normale gedeelde mappen staan het bekijken en downloaden van bestanden toe. Upload gedeelde mappen staan het uploaden van bestanden naar de gedeelde map toe.",
"normalShare": "Normaal",
"uploadShare": "Alleen uploaden",
"perUserDownloadLimit": "Downloadlimiet op elke gebruiker toepassen",
"perUserDownloadLimitDescription": "Indien ingeschakeld, wordt de downloadlimiet toegepast op elke gebruiker. Anonieme gebruikers kunnen geen bestanden downloaden van deze gedeelde map.",
"extractEmbeddedSubtitles": "Ingesloten ondertitels uitpakken",
"extractEmbeddedSubtitlesDescription": "Ingesloten ondertitels uit mediabestanden halen tijdens het laden -- dit kan traag zijn voor grote bestanden.",
"disableLoginOption": "Aanmeldingsoptie verbergen",
"disableLoginOptionDescription": "De aanmeldknop in de zijbalk verbergen voor anonieme gebruikers. Als deze optie is ingeschakeld, zien anonieme gebruikers geen optie om in te loggen.",
"customizeSidebarLinks": "Koppelingen in de zijbalk aanpassen",
"customizeSidebarLinksButton": "Koppelingen aanpassen",
"browseFiles": "Bladeren door bestanden",
"selectBannerImage": "Banner-afbeelding selecteren",
"selectFaviconImage": "Favicon-afbeelding selecteren",
"selectImage": "Afbeelding selecteren",
"disableDownload": "Het downloaden van bestanden uitschakelen",
"disableDownloadDescription": "Niemand kan bestanden downloaden of de inhoud van een bestand bekijken.",
"allowModify": "Bewerken van bestanden toestaan",
"allowModifyDescription": "Iedereen kan bestanden bewerken in deze gedeelde map.",
"allowDelete": "Bestanden verwijderen toestaan",
"allowDeleteDescription": "Iedereen kan bestanden en mappen van deze gedeelde map verwijderen.",
"allowCreate": "Bestanden en mappen aanmaken en uploaden",
"allowCreateDescription": "Iedereen kan nieuwe bestanden en mappen aanmaken en uploaden in deze gedeelde map.",
"allowReplacements": "Vervanging van bestanden toestaan",
"allowReplacementsDescription": "Iedereen kan bestaande bestanden vervangen door bestanden met dezelfde naam te uploaden.",
"shareInfo": "QR-code en info delen",
"shareInfoDescription": "Deze koppeling toont de informatie van de gedeelde map, inclusief titel, beschrijving, banner en QR-code in een popup.",
"downloadDescription": "Met deze koppeling kunt u de volledige inhoud van de gedeelde map downloaden als een gecomprimeerd bestand."
},
"prompts": {
"operationInProgress": "Werk in uitvoering, even geduld...",
"quickJump": "Snel navigeren naar andere bestanden",
"downloadsLimit": "Max. aantal downloads",
"maxBandwidth": "Max. downloadbandbreedte (kbps)",
"shareTheme": "Thema delen",
"viewMode": "Weergavemodus",
"shareThemeColor": "Themakleur delen (CSS-waarde)",
"shareTitle": "Titel delen",
"shareDescription": "Beschrijving delen",
"shareLogo": "URL van logo delen",
"shareBanner": "URL van banner delen",
"shareFavicon": "URL van favicon delen",
"destinationSource": "Bestemmingsbron:",
"selectPath": "Pad kiezen",
"copy": "Kopiëren",
"copyMessage": "Kies een locatie om uw bestanden te kopiëren: ",
"deleteMessageMultiple": "Weet u zeker dat u {count} bestand(en) wilt verwijderen?",
"deleteMessageSingle": "Weet u zeker dat u dit bestand/deze map wil verwijderen?",
"deleteMessageShare": "Weet u zeker dat u deze gedeelde map wilt verwijderen: {path} ?",
"deleteUserMessage": "Weet u zeker dat u deze gebruiker wilt verwijderen?",
"deleteTitle": "Bestanden verwijderen",
"displayName": "Weergavenaam: ",
"download": "Bestanden downloaden",
"downloadMessage": "Kies het formaat dat u wilt downloaden. ",
"dragAndDrop": "Sleep bestanden hier naartoe",
"error": "Er ging iets mis",
"completed": "Voltooid",
"fileInfo": "Bestandsinformatie",
"selected": "geselecteerd",
"filesSelected": "bestanden geselecteerd",
"lastModified": "Laatst gewijzigd{suffix}",
"move": "Verplaatsen",
"moveSuccess": "Bestanden succesvol verplaatst!",
"newFileSuccess": "Bestand succesvol aangemaakt!",
"newDirSuccess": "Map succesvol aangemaakt!",
"copySuccess": "Bestanden succesvol gekopieerd!",
"moveMessage": "Kies een nieuwe locatie voor de bestand(en)/map(pen)",
"newArchetype": "Maak een nieuw bericht op basis van een archetype. Uw bestand wordt aangemaakt in de inhoudsmap.",
"newDir": "Nieuwe map",
"newDirMessage": "Voer de naam van de nieuwe map in",
"newFile": "Nieuw bestand",
"newFileMessage": "Voer de naam van het nieuwe bestand in",
"numberDirs": "Aantal mappen{suffix}",
"numberFiles": "Aantal bestanden{suffix}",
"rename": "Hernoemen",
"renameSuccess": "Item met succes hernoemd!",
"renameMessageConflict": "Een item met de naam \"{filename}\" bestaat al!",
"renameMessage": "Voer een nieuwe naam in:",
"renameMessageInvalid": "Een bestand kan geen \"/\" of \"\\\" bevatten.",
"renameFailed": "Hernoemen van item mislukt",
"replace": "Vervangen",
"replaceMessage": "Een van de bestanden die u probeert te uploaden geeft conflicten i.v.m. de naamgeving. Wilt u de bestaande bestanden vervangen?\n",
"saveBeforeExit": "Niet-opgeslagen wijzigingen",
"saveBeforeExitMessage": "U hebt niet-opgeslagen wijzigingen in de editor. Wat wilt u doen?",
"schedule": "Plannen",
"scheduleMessage": "Kies een datum en tijd om de publicatie van dit bericht in te plannen.",
"show": "Tonen",
"size": "Grootte{suffix}",
"upload": "Uploaden",
"uploadSettingsChunked": "Max. aantal gelijktijdige uploads: {maxConcurrentUpload}, brokgrootte: {uploadChunkSizeMb} MB",
"uploadSettingsNoChunk": "Max. aantal gelijktijdige uploads: {maxConcurrentUpload}, upload in brokken uitgeschakeld",
"optionalPassword": "Optioneel wachtwoord",
"optionalPasswordHelp": "Indien ingesteld, moeten gebruikers dit wachtwoord invoeren om de gedeelde inhoud te bekijken.",
"typeName": "Type:",
"deleted": "Met succes verwijderd!",
"conflictsDetected": "Conflicten gedetecteerd",
"copyPasteConfirm": "Bevestiging kopiëren/plakken",
"copyItemQuestion": "Dit item kopiëren naar de nieuwe locatie?",
"moveItemQuestion": "Dit item verplaatsen naar de nieuwe locatie?",
"sourcePath": "Bron:",
"destinationPath": "Bestemming:",
"closeWithActiveUploads": "Actieve uploads",
"closeWithActiveUploadsMessage": "Waarschuwing: U hebt actieve uploads en het sluiten van het uploadvenster kan resulteren in onvoltooide uploads. Wat wilt u doen?",
"basicInfo": "Basisinformatie",
"directoryInfo": "Mapinformatie",
"additionalInfo": "Aanvullende informatie",
"hidden": "Verborgen",
"hasPreview": "Voorbeeld",
"checksums": "Controlesommen",
"hashAlgorithm": "Hash-algoritme",
"hashValue": "Hashwaarde",
"selectHashAlgorithm": "Selecteer een hash-algoritme",
"generate": "Genereren",
"errorGeneratingHash": "Fout bij het genereren van hash",
"hashCopied": "Hash gekopieerd naar klembord",
"copyToClipboardFailed": "Kan niet naar klembord kopiëren",
"errorCopyingHash": "Fout bij het kopiëren van hash",
"token": "Token",
"onlyOfficeId": "OnlyOffice-ID",
"firstLoadTitle": "Welkom bij FileBrowser Quantum!",
"firstLoadBody": "Er is een nieuwe database aangemaakt bij het opstarten. Als dit niet is wat u verwachtte, controleer dan het configuratiebestand voor het `server.database` pad en zorg ervoor dat het bestaat. Op docker kan het databasepad `./data/database.db` of `./database.db` zijn.",
"halloweenTitle": "Happy Halloween!",
"halloweenBody": "Het Halloween-thema is toegepast. U kunt het uitschakelen als u dit niet prettig vindt, of permanent uitschakelen in de profielinstellingen door een ander thema te selecteren.",
"moveFailed": "Verplaatsen mislukt",
"operationFailed": "Bewerking is mislukt",
"partialSuccess": "Enkele bewerkingen zijn mislukt: {succeeded} gelukt, {failed} mislukt."
},
"search": {
"searchContext": "Zoekcontext: {context}",
"onlyFolders": "Alleen mappen",
"onlyFiles": "Alleen bestanden",
"showOptions": "Opties weergeven",
"notEnoughCharacters": "Niet genoeg tekens om te zoeken (min {minSearchLength})",
"typeToSearch": "Begin met het typen van {minSearchLength} of meer tekens om te beginnen met zoeken.",
"noResults": "Geen resultaten gevonden in geïndexeerde zoekopdrachten.",
"smallerThan": "Kleiner dan:",
"largerThan": "Groter dan:",
"showPreviewImages": "Voorbeeldafbeeldingen tonen",
"showPreviewImagesDescription": "Toon, indien beschikbaar, miniatuurvoorbeelden voor zoekresultaten en toon grotere pictogrammen voor alle resultaten.",
"helpText1": "Er wordt gezocht op elk teken dat u typt (minimaal 3 tekens voor zoektermen).",
"helpText2": "De index: Zoeken maakt gebruik van de index die automatisch wordt bijgewerkt op het ingestelde interval (standaard: 5 minuten). Zoeken als het programma net is gestart, kan onvolledige resultaten opleveren."
},
"profileSettings": {
"previewFolder": "Toon miniaturen voor mappen indien beschikbaar",
"previewFolderDescription": "Toon miniatuurafbeeldingen voor mappen met het eerste item met miniatuurafbeelding als omslag.",
"debugOfficeEditor": "OnlyOffice foutopsporingsmodus inschakelen",
"debugOfficeEditorDescription": "Een foutopsporingspop-up weergeven met aanvullende informatie in de OnlyOffice-editor.",
"defaultMediaPlayer": "De ingebouwde mediaspeler van uw browser gebruiken",
"defaultMediaPlayerDescription": "Gebruik de eigen mediaspeler van uw browser voor video- en audiobestanden in plaats van de meegeleverde 'vue-plyr' mediaspeler van FileBrowser.",
"showSelectMultiple": "Meervoudige selectie tonen in contextmenu op bureaublad",
"showSelectMultipleDescription": "Standaard heeft het contextmenu op de desktop geen optie \"meerdere selecteren\" zoals mobiel. Deze optie wordt altijd weergegeven in het contextmenu.",
"customTheme": "Kies uw thema",
"defaultThemeDescription": "standaard - Het standaardthema",
"setDateFormat": "Exact datumformaat instellen",
"showHiddenFiles": "Verborgen bestanden weergeven",
"showHiddenFilesDescription": "Toon verborgen bestanden in de lijst (bijv. .thumbnails, ProgramData), maar ze zijn nog steeds handmatig toegankelijk als ze zijn geconfigureerd om te worden geïndexeerd.",
"showQuickDownload": "Downloadpictogram weergeven voor snelle toegang",
"showQuickDownloadDescription": "Toon een downloadpictogram voor snelle toegang tot elk bestandsitem waarop kan worden geklikt om het meteen te downloaden.",
"previewImages": "Miniaturen van afbeeldingen weergeven",
"previewImagesDescription": "Toon miniatuurvoorbeelden voor afbeeldingen, toont generiek pictogram indien uitgeschakeld.",
"previewVideos": "Miniaturen van video's weergeven",
"previewVideosDescription": "Toon miniatuurvoorbeelden voor video's, toont generiek pictogram indien uitgeschakeld.",
"previewMotionVideos": "Bewegingsvoorbeeld voor video's inschakelen",
"previewMotionVideosDescription": "Bewegingsvoorbeeld is het geanimeerde popup-voorbeeld dat door de frames van de video loopt.",
"highQualityPreview": "Miniaturen van hoge kwaliteit",
"highQualityPreviewDescription": "Heeft alleen invloed op de galerieweergave. Het laden kan langer duren bij een langzame verbinding.",
"editOffice": "Office-bestanden bekijken en bewerken met OnlyOffice",
"editOfficeDescription": "Als alleen office is ingeschakeld, is de bestandsviewer/editor uitgeschakeld voor alle bestandsextensies die gerelateerd zijn aan Office-bestanden.",
"popupPreview": "Pop-upvoorbeeld inschakelen wanneer u met de muis over bestanden beweegt",
"popupPreviewDescription": "Er verschijnt een overlay pop-upvenster over de UI wanneer u met de muis over bestanden beweegt die een groot voorbeeld ondersteunen.",
"filePreviewOptions": "Opties voor bestandsvoorbeeld",
"disableUpdateNotifications": "Het tonen van meldingen update uitschakelen",
"disableUpdateNotificationsDescription": "Schakel de banner met updatemeldingen uit die wordt weergegeven voor beheerders in de zijbalk.",
"disableHideSidebar": "Houd de zijbalk open bij het bekijken of bewerken van bestanden",
"disableHideSidebarDescription": "Als deze optie is ingeschakeld, wordt de zijbalk niet automatisch verborgen bij het bekijken of bewerken van bestanden.",
"autoplayMedia": "Mediabestanden automatisch afspelen bij het bekijken",
"autoplayMediaDescription": "Mediabestanden automatisch afspelen bij het bekijken. Dit werkt alleen als het bestand een mediabestand is.",
"sidebarOptions": "Zijbalk-opties",
"disableQuickToggles": "Snel wisselen uitschakelen",
"disableFileExtensions": "Voer bestandsextensies in om uit te schakelen",
"disableQuickTogglesDescription": "Schakel de sneltoetsen uit die op de zijbalk worden weergegeven.",
"disableSearchOptions": "Zoekopties uitschakelen",
"disableSearchOptionsDescription": "Schakel de zoekopties in de zijbalk uit.",
"hideSidebarFileActions": "Verberg de knop Bestandsacties in de zijbalk",
"deleteWithoutConfirming": "Bestanden verwijderen zonder bevestigingsvenster",
"deleteWithoutConfirmingDescription": "Bestanden verwijderen zonder bevestigingsvenster. Dit werkt alleen als het bestand een mediabestand is.",
"editorViewerOptions": "Opties voor editor en viewer",
"editorQuickSave": "Snel opslaan in editor",
"editorQuickSaveDescription": "De teksteditor toont altijd het pictogram voor opslaan en verbergt alle andere acties",
"previewOffice": "Miniaturen weergeven voor Office-bestanden",
"previewOfficeDescription": "Toon miniaturen voor kantoorbestandstypen in de listingweergave",
"disableThumbnailPreviews": "Miniaturen uitschakelen voor bepaalde bestandsextensies",
"disableThumbnailPreviewsDescription": "Een door spaties gescheiden lijst met bestandsextensies waarvoor miniatuurafbeeldingen uitgeschakeld moeten worden (bijv. '.txt .html').",
"disableViewingFiles": "Bestandsviewer/editor uitschakelen voor bepaalde bestandsextensies",
"disableViewingFilesDescription": "Een door spaties gescheiden lijst met bestandsextensies waarvoor de bestandsviewer/editor uitgeschakeld moet worden (bijv. '.txt .pdf').",
"disablePreviewExt": "Voorbeelden van Office-bestanden uitschakelen voor bepaalde bestandsextensies",
"disablePreviewExtDescription": "Een door spaties gescheiden lijst met bestandsextensies waarvoor Office-bestandsvoorvertoningen moeten worden uitgeschakeld (bijv. '.docx .pptx').",
"disableOfficeEditorDescription": "Een door spaties gescheiden lijst van bestandsextensies waarvoor de OnlyOffice editor en viewer uitgeschakeld moeten worden - of '*' om alle extensies uit te schakelen. (bijv. '.txt .html .pdf').",
"disableOfficeEditor": "Office Viewer uitschakelen voor bepaalde bestandsextensies",
"fileViewerOptions": "Opties voor bestandsviewer",
"themeAndLanguage": "Thema & Taal"
},
"settings": {
"systemAdmin": "Systeem & Beheer",
"configViewer": "Configuratiescherm",
"configViewerShowFull": "Volledige configuratie weergeven",
"configViewerShowComments": "Opmerkingen tonen",
"configViewerLoadConfig": "Configuratie laden",
"enterPassword": "Wachtwoord invoeren",
"enterPasswordAgain": "Wachtwoord opnieuw invoeren",
"passwordsDoNotMatch": "Wachtwoorden komen niet overeen",
"loginMethod": "Aanmeldingsmethode",
"loginMethods": {
"password": "Wachtwoord",
"oidc": "OIDC",
"proxy": "Proxy"
},
"loginMethodDescription": "toegestane aanmeldingsmethode voor de gebruiker.",
"modifyOtherUser": "Gebruiker wijzigen:",
"modifyCurrentUser": "Huidige gebruiker wijzigen",
"themeColor": "Themakleur",
"listingOptions": "Lijstopties",
"searchOptions": "Zoekopties",
"emptyGlobal": "Op dit moment zijn er geen globale configuratieopties. U kunt de configuratie yaml bewerken om systeeminstellingen aan te passen.",
"scopes": "Toegestane bronnen en het gebruikersbereik daarbinnen definiëren",
"UserManagement": "Gebruikersbeheer",
"userNotAdmin": "Gebruiker is geen beheerder",
"administrator": "Beheerder",
"allowCommands": "Opdrachten uitvoeren",
"allowEdit": "Bestanden of mappen bewerken, hernoemen of verwijderen",
"allowNew": "Nieuwe bestanden of mappen aanmaken",
"allowPublish": "Nieuwe berichten en pagina's publiceren",
"allowSignup": "Gebruikers toestaan om zich te registreren",
"avoidChanges": "(Leeg laten om wijzigingen te voorkomen)",
"brandingDirectoryPath": "Pad naar map met Branding",
"brandingHelp": "U kunt aanpassen hoe deze FileBrowser eruit ziet en aanvoelt door de naam te veranderen, het logo te vervangen, aangepaste stijlen toe te voegen en zelfs externe links naar GitHub uit te schakelen.\nMeer informatie over het aanpassen van de branding, kunt u lezen op {0}.",
"changePassword": "Wachtwoord wijzigen",
"commandRunner": "Opdrachten uitvoeren",
"commandRunnerHelp": "Hier kunt u opdrachten instellen die worden uitgevoerd in de genoemde gebeurtenissen. U moet er één per regel schrijven. De omgevingsvariabelen {0} en {1} zijn beschikbaar, waarbij {0} relatief is ten opzichte van {1}. Lees de {2} voor meer informatie over deze functie en de beschikbare omgevingsvariabelen.",
"commandsUpdated": "Opdrachten bijgewerkt!",
"createUserDir": "Maak automatisch een thuismap aan wanneer een nieuwe gebruiker wordt aangemaakt",
"tusUploads": "Verzamelde uploads",
"tusUploadsHelp": "FileBrowser ondersteunt bestandsuploads in brokken, waardoor efficiënte, betrouwbare, hervattbare bestandsuploads mogelijk zijn, zelfs op onbetrouwbare netwerken.",
"tusUploadsChunkSize": "Geeft de maximale grootte van een verzoek aan (directe uploads worden gebruikt voor kleinere uploads). U kunt een gewoon geheel getal invoeren dat staat voor een bytes-invoer of een tekenreeks zoals 10MB, 1GB enz.",
"tusUploadsRetryCount": "Aantal pogingen dat moet worden uitgevoerd als het uploaden van een brok mislukt.",
"userHomeBasePath": "Basispad voor thuismappen van gebruikers",
"userScopeGenerationPlaceholder": "Het bereik wordt automatisch gegenereerd",
"createUserHomeDirectory": "Thuismap voor gebruiker aanmaken",
"customStylesheet": "Aangepast Stylesheet",
"defaultUserDescription": "Dit zijn de standaardinstellingen voor nieuwe gebruikers.",
"disableExternalLinks": "Externe links uitschakelen (behalve documentatie)",
"disableUsedDiskPercentage": "Grafiek voor gebruikt schijfpercentage uitschakelen",
"executeOnShell": "Uitvoeren in het opdrachtvenster",
"executeOnShellDescription": "FileBrowser voert de opdrachten standaard uit door hun binaire bestanden rechtstreeks op te roepen. Als u ze in plaats daarvan wilt uitvoeren op een opdrachtvenster (zoals Bash of PowerShell), kunt u dit hier definiëren met de vereiste argumenten en vlaggen. Indien ingesteld, wordt de opdracht die u uitvoert, toegevoegd als een argument. Dit is van toepassing op zowel gebruikersopdrachten als event hooks.",
"globalRules": "Dit is een algemene reeks regels die al dan niet zijn toegestaan. Ze zijn van toepassing op elke gebruiker. U kunt specifieke regels voor de instellingen van elke gebruiker definiëren om deze te overschrijven.",
"globalSettings": "Algemene instellingen",
"shareSettings": "Instellingen voor Delen",
"api": "API-sleutels",
"insertPath": "Een pad invoeren",
"insertRegex": "Regex expressie invoeren",
"instanceName": "Naam van de instantie",
"lockPassword": "Voorkom dat de gebruiker het wachtwoord wijzigt",
"newPassword": "Uw nieuwe wachtwoord",
"newPasswordConfirm": "Uw nieuwe wachtwoord bevestigen",
"newUser": "Nieuwe gebruiker",
"newUserHintSubFolder": "bijv. '/submap' - leeg voor standaard",
"passwordUpdated": "Wachtwoord bijgewerkt!",
"permissions": {
"admin": "Beheerder",
"modify": "Bestanden bewerken",
"share": "Bestanden delen",
"api": "Langlevende API-sleutels aanmaken en beheren",
"realtime": "Real-time verbindingen en updates mogelijk maken",
"create": "Bestanden aanmaken",
"delete": "Bestanden verwijderen",
"download": "Bestanden downloaden"
},
"permissions-name": "Machtigingen",
"permissionsHelp": "Gebruikersrechten bepalen welke acties de gebruiker kan uitvoeren. Toegang als beheerder geeft volledige controle om de instellingen van elke gebruiker bij te werken en te wijzigen, evenals vele systeeminstellingen.",
"profileSettings": "Profielinstellingen",
"ruleExample1": "voorkomt de toegang tot elk puntbestand (zoals .git, .gitignore) in elke map.\n",
"ruleExample2": "blokkeert de toegang tot het bestand met de naam Caddyfile in de hoofdmap van het bereik.",
"rulesHelp": "Hier kunt u een reeks regels voor toestaan en niet toestaan voor deze specifieke gebruiker definiëren. De geblokkeerde bestanden verschijnen niet in de lijsten en zijn niet toegankelijk voor de gebruiker. We ondersteunen regex en paden relatief ten opzichte van het bereik van gebruikers. \n",
"settingsUpdated": "Instellingen bijgewerkt!",
"shareManagement": "Beheer van Gedeelde mappen",
"shareDeleted": "Gedeelde map verwijderd!",
"singleClick": "Gebruik één klik om bestanden en mappen te openen",
"userCommandsHelp": "Een lijst met beschikbare opdrachten voor de gebruiker gescheiden door spaties. Voorbeeld: \n",
"userCreated": "Gebruiker aangemaakt!",
"userDefaults": "Standaardinstellingen gebruiker",
"userDeleted": "Gebruiker verwijderd!",
"userManagement": "Gebruikersbeheer",
"userUpdated": "Gebruiker bijgewerkt!",
"adminOptions": "Opties voor beheerders"
},
"sidebar": {
"siteSettings": "Site-instellingen",
"updateIsAvailable": "Er is een update beschikbaar!",
"fileActions": "Bestandsacties",
"customizeLinks": "Zijbalk koppelingen aanpassen",
"customizeLinksDescription": "Pas de koppelingen in de zijbalk aan. Gebruik slepen en neerzetten om te ordenen.",
"customizeShareLinks": "Koppelingen in Zijbalk Delen aanpassen",
"customizeShareLinksDescription": "Pas de koppelingen aan die worden weergegeven in de zijbalk voor deze gedeelde map. Slepen en neerzetten om te herschikken.",
"currentLinks": "Huidige koppelingen",
"addNewLink": "Nieuwe koppeling toevoegen",
"editLink": "Koppeling bewerken",
"linkType": "Type koppeling",
"selectLinkType": "Selecteer een type koppeling",
"customLink": "Aangepaste koppeling",
"selectSource": "Bron selecteren",
"chooseSource": "Kies een bron",
"selectShare": "Selecteer gedeelde map",
"chooseShare": "Kies een gedeelde map",
"selectSharePath": "Pad binnen gedeelde map (optioneel)",
"sharePathPlaceholder": "bijv. /submap of leeg laten voor root",
"selectTool": "Selecteer gereedschap",
"chooseTool": "Kies een hulpmiddel",
"linkName": "Link Naam",
"linkNamePlaceholder": "Naam koppeling invoeren",
"linkUrl": "URL van koppeling",
"linkUrlPlaceholder": "Voer een URL of pad in (bijv. /files/path of https://example.com)",
"linkIcon": "Pictogram van koppeling",
"linkIconPlaceholder": "Geef optioneel een materiaalpictogramnaam op",
"browseIcons": "Bladeren door pictogrammen",
"pickIcon": "Kies een pictogram",
"pickIconDescription": "Zoek en selecteer een pictogram uit de bibliotheek met materiaalpictogrammen",
"linksUpdatedSuccess": "Koppelingen in zijbalk succesvol bijgewerkt!",
"shareLinksUpdatedSuccess": "Koppelingen in zijbalk Delen succesvol bijgewerkt!",
"linksUpdateFailed": "Bijwerken van koppelingen in zijbalk is mislukt",
"sourceNotAccessible": "Deze bron is niet langer toegankelijk voor jou",
"noLinksYet": "Er zijn nog geen links geconfigureerd. Voeg hieronder uw eerste link toe.",
"shareHome": "Thuis delen"
},
"success": {
"linkCopied": "Koppeling gekopieerd!"
},
"time": {
"days": "Dagen",
"hours": "Uren",
"minutes": "Minuten",
"seconds": "Seconden",
"unit": "Tijdseenheid"
},
"api": {
"title": "API-sleutels",
"createdAt": "Aangemaakt op:",
"expiresAt": "Verloopt op:",
"permissions": "Machtigingen:",
"createTitle": "API-sleutel aanmaken",
"keyNamePlaceholder": "Voer een unieke naam voor de API-sleutel in:",
"tokenDuration": "Duur van token:",
"durationNumberPlaceholder": "Aantal",
"days": "Dagen",
"months": "Maanden",
"permissionNote": "Kies minstens één machtiging voor de sleutel. Uw gebruiker moet deze rechten ook hebben.",
"createKeyFailed": "API-sleutel is niet aangemaakt.",
"createKeySuccess": "API-sleutel met succes aangemaakt.",
"apiKeyDeleted": "API-sleutel verwijderd!",
"description": "API-sleutels zijn gebaseerd op de gebruiker die ze aanmaakt. Zie",
"swaggerLinkText": "branie-pagina",
"created": "Aangemaakt op",
"expires": "Verloopt op",
"actions": "Acties",
"enabled": "Ingeschakeld",
"disabled": "Uitgeschakeld",
"tokenType": "Type token:",
"minimalDescription": "Minimaal token",
"minimalInfo": "Sluit tokenaanpassingen uit om een kleinere tokenlengte te creëren. Exacte overeenkomst met de rechten van de bijbehorende gebruiker.",
"clickToCopyKey": "Klik om sleutel te kopiëren (verborgen)"
},
"index": {
"unknown": "Onbekend",
"simple": "Eenvoudig",
"normal": "Normaal",
"complex": "Complex",
"highlyComplex": "Zeer complex",
"assessment": "Beoordeling",
"ready": "Klaar",
"indexing": "Indexering",
"unavailable": "Niet beschikbaar",
"error": "Fout",
"lastScanned": "Laatst gescand",
"quickScan": "Seconden voor Snelle scan",
"fullScan": "Seconden voor Volledige scan",
"settingsHover": "Instellingen voor gebruiker",
"toggleClick": "Enkele klik schakelen",
"toggleDark": "Donkere modus schakelen",
"toggleSticky": "Kleefmodus schakelen",
"noSources": "Geen bronnen beschikbaar voor deze gebruiker. Voeg een bron toe voor deze gebruiker of configureer een bron met defaultEnabled: true"
},
"otp": {
"name": "Twee-factorenauthenticatie",
"verifyInstructions": "Voer de code van uw authenticator-app in",
"generate": "Scan de QR-code met uw authenticator-app",
"codeInputPlaceholder": "6-cijferige code",
"invalidCodeType": "De verificatiecode moet uit 6 engelse cijfers bestaan",
"enabledSuccessfully": "OTP succesvol ingeschakeld",
"verificationFailed": "Verificatie is mislukt",
"verificationSucceed": "Verificatie is geslaagd",
"generationFailed": "Aanmaken is mislukt"
},
"access": {
"all": "Alles weigeren",
"rules": "Toegangsregels",
"deny": "Weigeren",
"deniedUsers": "Geweigerde gebruikers",
"deniedGroups": "Geweigerde groepen",
"allowedUsers": "Toegestane gebruikers",
"allowedGroups": "Toegestane groepen",
"totalDenied": "Geweigerd",
"totalAllowed": "Toegestaan",
"allow": "Toestaan",
"allowDeny": "Toestaan/weigeren",
"userGroup": "Gebruiker/Groep",
"enterName": "Voer de naam van de gebruiker of groep in",
"deleted": "Toegangsregel verwijderd!",
"deletedCascade": "Toegangsregels verwijderd van pad en alle subpaden!",
"added": "Toegangsregel toegevoegd!",
"accessManagement": "Toegangsbeheer",
"defaultBehavior": "Standaardactie{suffix}",
"defaultBehaviorDescription": "Dit laat zien wat er gebeurt als een gebruiker toegang probeert te krijgen tot een pad waarvoor geen specifieke toegangsregels bestaan. Toestaan' betekent dat gebruikers standaard toegang hebben, 'Weigeren' betekent dat gebruikers standaard worden geblokkeerd tenzij ze expliciet toestemming krijgen.",
"cascadeDelete": "Recursief verwijderen",
"cascadeDeleteDescription": "Indien ingeschakeld, zal het verwijderen van een toegangsregel deze ook verwijderen uit alle submappen. Hierdoor kunt u een gebruiker of groep volledig verwijderen uit de hele mappenstructuur."
},
"fileLoading": {
"title": "Uploaden & Downloads",
"maxConcurrentUpload": "Max. gelijktijdige uploads",
"maxConcurrentUploadHelp": "Het maximum aantal gelijktijdige uploads. Hogere waarden presteren meestal beter, maar dit hangt van veel factoren af.",
"uploadChunkSizeMb": "Upload brokgrootte in MB",
"uploadChunkSizeMbHelp": "Gewoonlijk 5 tot 50 MB, kleiner is beter voor onstabiele of beperkte verbindingen. Instellen op 0 schakelt uploads in brokken uit.",
"downloadChunkSizeMb": "Grootte downloadbrok in MB",
"downloadChunkSizeMbHelp": "Brok-grootte voor downloads in brokken. Bestanden die groter zijn dan deze grootte worden in brokken gedownload om time-outproblemen te voorkomen (bijv. Cloudflare 524 fouten). Instellen op 0 schakelt downloads in brokken uit.",
"clearAll": "Wissen voltooid moet fout, pauze en conflict verhelpen",
"clearAllDescription": "De knop Wissen voltooid wist alle uploads in een voltooide status, inclusief fout-, gepauzeerde en conflictstatussen.",
"uploadSettings": "Instellingen uploaden"
},
"colors": {
"red": "rood",
"green": "groen",
"blue": "blauw",
"yellow": "geel",
"violet": "violet",
"orange": "oranje"
},
"editor": {
"uninitialized": "Het is niet gelukt de editor te initialiseren. Opnieuw laden.",
"saveFailed": "Het is niet gelukt om het bestand op te slaan. Probeer het opnieuw.",
"saveAborted": "Opslaan afgebroken. De bestandsnaam komt niet overeen met het actieve bestand.",
"saveAbortedMessage": "Opslaan afgebroken. Het actieve bestand van de toepassing ({activeFile}) komt niet overeen met het bestand dat u probeert op te slaan ({tryingToSave}).",
"syncFailed": "Na 5 pogingen is het niet gelukt om de status te synchroniseren met de route voor \"{filename}\". Installatie van editor afgebroken om gegevensbeschadiging te voorkomen.",
"fileSaved": "Bestand opgeslagen!",
"errorSavingFile": "Fout bij het opslaan van bestand: {error}"
},
"player": {
"LoopEnabled": "Herhaling ingeschakeld",
"LoopDisabled": "Herhaling uitgeschakeld",
"PlayAllOncePlayback": "Alles eenmaal afspelen",
"ShuffleAllPlayback": "Alles willekeurig afspelen",
"PlayAllLoopedPlayback": "Alles herhaald afspelen",
"QueueButtonHint": "Afspeellijst weergeven",
"QueuePlayback": "Afspeellijst",
"changePlaybackMode": "Afspeelmodus wijzigen",
"changePlaybackModeHint": "Wijzig afspeelmodus om meer bestanden te zien",
"emptyQueue": "Wachtrij is leeg"
},
"fileSizeAnalyzer": {
"title": "Bestandsgrootte analyseren",
"searchLocation": "Zoeklocatie",
"selectPath": "Zoeklocatie selecteren",
"largerThan": "Groter dan (MB)",
"includeFolders": "Mappen opnemen",
"includeFoldersDescription": "mappen meenemen in de analyse",
"totalFiles": "Totaal aantal bestanden{suffix}",
"totalSize": "Totale grootte{suffix}",
"emptyState": "Voer zoekcriteria in en klik op Analyseren om bestandsgroottes te visualiseren",
"completeResults": "Volledige resultaten getoond!",
"incompleteResults": "Resultaten kunnen onvolledig zijn",
"relativePercentage": "Relatief percentage{suffix}",
"percentageOfResults": "{percentage}% resultaten"
},
"fileTypes": {
"video": "Video",
"image": "Afbeelding",
"audio": "Audio",
"archive": "Archief",
"document": "Document",
"binary": "Binair",
"directory": "Map",
"other": "Overig"
},
"tools": {
"title": "Gereedschap",
"description": "Hulpprogramma's en analysetools voor uw bestanden",
"sizeAnalyzer": {
"name": "Bestandsgrootte analyseren",
"description": "Visualiseer grote bestanden en mappen met een interactieve treemap"
},
"duplicateFinder": {
"name": "Duplicaatbestanden zoeken",
"description": "Dubbele bestanden vinden en beheren om opslagruimte terug te winnen"
},
"materialIconPicker": {
"name": "Material pictogrammenkiezer",
"description": "Bladeren en zoeken in Material pictogrammen met live voorbeeld",
"searchPlaceholder": "Pictogrammen zoeken...",
"browseFullLibrary": "Blader door de volledige pictogrammenbibliotheek",
"iconLibraryUrl": "fonts.google.com/icons",
"popularIcons": "{count} populaire pictogrammen weergeven",
"showingResults": "{count} resultaten weergeven",
"noResults": "Geen pictogrammen gevonden",
"tryDifferentSearch": "Probeer een andere zoekterm",
"copiedToClipboard": "'{name}' is gekopieerd naar klembord!",
"copyFailed": "Kopiëren naar klembord mislukt",
"custom": "Aangepast",
"useCustomIcon": "Gebruik een aangepast pictogram: {name}"
},
"fileWatcher": {
"name": "Bestandenbewaker",
"description": "Bestanden in realtime bewaken en volgen met latentiebewaking",
"file": "Bestand{suffix}",
"interval": "Update-interval",
"stopWatching": "Stoppen met bewaken",
"emptyState": "Kies een bestand en klik op afspelen om te beginnen met het controleren van bestandswijzigingen",
"chooseFile": "Kies een bestand"
},
"toolNotFound": "Gereedschap niet gevonden",
"backToTools": "Terug naar Gereedschap"
},
"notifications": {
"title": "Meldingen",
"description": "Alle meldingen van uw huidige sessie bekijken",
"empty": "Nog geen meldingen",
"clearAll": "Alles wissen",
"clearConfirm": "Weet u zeker dat u de gehele meldingsgeschiedenis wilt wissen?",
"total": "{count} meldingen"
},
"duplicateFinder": {
"title": "Duplicaatbestanden zoeken",
"minSize": "Minimale grootte (MB){suffix}",
"minSizeHint": "Alleen bestanden analyseren die groter zijn dan deze grootte (in megabytes)",
"useChecksums": "Bestandsinhoud vergelijken",
"useChecksumsDescription": "Neem ook de inhoud van bestanden op in de vergelijking. Zonder dit worden bestanden vergeleken op basis van grootte en bestandsnaam overeenkomst.",
"findDuplicates": "Duplicaten zoeken",
"groupsFound": "Dubbele groepen gevonden{suffix}",
"totalWastedSpace": "Totale verspilde ruimte{suffix}",
"wasted": "Verspild{suffix}",
"emptyState": "Voer zoekcriteria in en klik op Duplicaten zoeken om dubbele bestanden te identificeren"
},
"events": {
"serverShutdown": "De server is uitgeschakeld.",
"authenticationFailed": "Authenticatie is mislukt. Ververs de pagina om opnieuw in te loggen.",
"reconnected": "Opnieuw verbinding gemaakt met server.",
"connectionLost": "De verbinding met de server is verbroken. Probeer opnieuw verbinding te maken..."
}
}
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Utwórz konto",
@@ -321,6 +320,7 @@
"adminOptions": "Opcje użytkownika administratora",
"searchOptions": "Opcje wyszukiwania",
"systemAdmin": "System i administrator",
"configViewer": "Przeglądarka konfiguracji",
"configViewerShowFull": "Pokaż pełną konfigurację",
"configViewerShowComments": "Pokaż komentarze",
"configViewerLoadConfig": "Konfiguracja ładowania",
@@ -332,8 +332,7 @@
"proxy": "Pełnomocnik"
},
"newUserHintSubFolder": "np. '/subfolder' - domyślnie puste",
"permissionsHelp": "Uprawnienia użytkownika kontrolują dostęp do rodzaju działań, które użytkownik może wykonywać. Dostęp administratora daje pełną kontrolę nad aktualizacją i zmianą ustawień dowolnego użytkownika, a także wielu ustawień systemowych.",
"configViewer": "Przeglądarka konfiguracji"
"permissionsHelp": "Uprawnienia użytkownika kontrolują dostęp do rodzaju działań, które użytkownik może wykonywać. Dostęp administratora daje pełną kontrolę nad aktualizacją i zmianą ustawień dowolnego użytkownika, a także wielu ustawień systemowych."
},
"sidebar": {
"siteSettings": "Ustawienia Strony",
@@ -626,8 +625,7 @@
"lines": "Linie{suffix}",
"lastUpdate": "Ostatnia aktualizacja{suffix}",
"latency": "Opóźnienie{suffix}",
"all": "Wszystkie{suffix}",
"expiration": "Wygaśnięcie{suffix}"
"all": "Wszystkie{suffix}"
},
"access": {
"rules": "Zasady dostępu",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Criar uma conta",
@@ -321,6 +320,7 @@
"adminOptions": "Opções do usuário administrador",
"searchOptions": "Opções de pesquisa",
"systemAdmin": "Sistema e administração",
"configViewer": "Visualizador de configuração",
"configViewerShowFull": "Mostrar configuração completa",
"configViewerShowComments": "Mostrar comentários",
"configViewerLoadConfig": "Configuração de carga",
@@ -332,8 +332,7 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "Por exemplo, '/subfolder' - em branco para o padrão",
"permissionsHelp": "As permissões de usuário controlam o acesso aos tipos de ações que o usuário pode executar. O acesso de administrador dá controle total para atualizar e alterar as configurações de qualquer usuário, bem como muitas configurações do sistema.",
"configViewer": "Visualizador de configuração"
"permissionsHelp": "As permissões de usuário controlam o acesso aos tipos de ações que o usuário pode executar. O acesso de administrador dá controle total para atualizar e alterar as configurações de qualquer usuário, bem como muitas configurações do sistema."
},
"sidebar": {
"siteSettings": "Configurações do site",
@@ -626,8 +625,7 @@
"lines": "Linhas{suffix}",
"lastUpdate": "Última atualização{suffix}",
"latency": "Latência{suffix}",
"all": "Todos{suffix}",
"expiration": "Vencimento{suffix}"
"all": "Todos{suffix}"
},
"access": {
"rules": "Regras de acesso",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Criar uma conta",
@@ -321,6 +320,7 @@
"adminOptions": "Opções do utilizador administrador",
"searchOptions": "Opções de pesquisa",
"systemAdmin": "Sistema e administração",
"configViewer": "Visualizador de configuração",
"configViewerShowFull": "Mostrar configuração completa",
"configViewerShowComments": "Mostrar comentários",
"configViewerLoadConfig": "Configuração de carga",
@@ -332,8 +332,7 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "por exemplo, '/subpasta' - em branco por defeito",
"permissionsHelp": "As permissões de utilizador controlam o acesso ao tipo de acções que o utilizador pode executar. O acesso de administrador dá controlo total para atualizar e alterar as definições de qualquer utilizador, bem como muitas definições do sistema.",
"configViewer": "Visualizador de configuração"
"permissionsHelp": "As permissões de utilizador controlam o acesso ao tipo de acções que o utilizador pode executar. O acesso de administrador dá controlo total para atualizar e alterar as definições de qualquer utilizador, bem como muitas definições do sistema."
},
"sidebar": {
"siteSettings": "Configurações do site",
@@ -706,8 +705,7 @@
"lines": "Linhas{suffix}",
"lastUpdate": "Última atualização{suffix}",
"latency": "Latência{suffix}",
"all": "Todos{suffix}",
"expiration": "Prazo de validade{suffix}"
"all": "Todos{suffix}"
},
"access": {
"rules": "Regras de acesso",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Crează cont",
@@ -321,6 +320,7 @@
"adminOptions": "Opțiuni utilizator administrator",
"searchOptions": "Opțiuni de căutare",
"systemAdmin": "Sistem și administrare",
"configViewer": "Vizualizator de configurare",
"configViewerShowFull": "Afișați configurația completă",
"configViewerShowComments": "Afișați comentariile",
"configViewerLoadConfig": "Configurare încărcare",
@@ -332,8 +332,7 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "de exemplu \"/subfolder\" - gol pentru implicit",
"permissionsHelp": "Permisiunile utilizatorului controlează accesul la ce tip de acțiuni poate efectua utilizatorul. Accesul de administrator oferă control deplin pentru actualizarea și modificarea setărilor oricărui utilizator, precum și a multor setări de sistem.",
"configViewer": "Vizualizator de configurare"
"permissionsHelp": "Permisiunile utilizatorului controlează accesul la ce tip de acțiuni poate efectua utilizatorul. Accesul de administrator oferă control deplin pentru actualizarea și modificarea setărilor oricărui utilizator, precum și a multor setări de sistem."
},
"sidebar": {
"siteSettings": "Setări site",
@@ -626,8 +625,7 @@
"lines": "Linii{suffix}",
"lastUpdate": "Ultima actualizare{suffix}",
"latency": "Latență{suffix}",
"all": "Toate{suffix}",
"expiration": "Expirare{suffix}"
"all": "Toate{suffix}"
},
"access": {
"rules": "Reguli de acces",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Создать аккаунт",
@@ -321,6 +320,7 @@
"adminOptions": "Параметры пользователя администратора",
"searchOptions": "Параметры поиска",
"systemAdmin": "Система и администрирование",
"configViewer": "Программа просмотра конфигурации",
"configViewerShowFull": "Показать полную конфигурацию",
"configViewerShowComments": "Показать комментарии",
"configViewerLoadConfig": "Загрузить конфигурацию",
@@ -332,8 +332,7 @@
"proxy": "Прокси-сервер"
},
"newUserHintSubFolder": "например, '/subfolder' - пустое место по умолчанию",
"permissionsHelp": "Разрешения пользователя контролируют, какие действия он может выполнять. Доступ администратора дает полный контроль над обновлением и изменением настроек любого пользователя, а также многих системных настроек.",
"configViewer": "Программа просмотра конфигурации"
"permissionsHelp": "Разрешения пользователя контролируют, какие действия он может выполнять. Доступ администратора дает полный контроль над обновлением и изменением настроек любого пользователя, а также многих системных настроек."
},
"sidebar": {
"siteSettings": "Настройки сайта",
@@ -626,8 +625,7 @@
"lines": "Линии{suffix}",
"lastUpdate": "Последнее обновление{suffix}",
"latency": "Латентность{suffix}",
"all": "Все{suffix}",
"expiration": "Срок действия{suffix}"
"all": "Все{suffix}"
},
"access": {
"rules": "Правила доступа",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Vytvoriť účet",
@@ -321,6 +320,7 @@
"adminOptions": "Možnosti používateľa správcu",
"searchOptions": "Možnosti vyhľadávania",
"systemAdmin": "Systém a administrátor",
"configViewer": "Prehliadač konfigurácie",
"configViewerShowFull": "Zobraziť úplnú konfiguráciu",
"configViewerShowComments": "Zobraziť komentáre",
"configViewerLoadConfig": "Konfigurácia načítania",
@@ -332,8 +332,7 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "napr. '/subfolder' - prázdne pre predvolené nastavenie",
"permissionsHelp": "Oprávnenia používateľa riadia prístup k typom akcií, ktoré môže používateľ vykonávať. Prístup administrátora poskytuje úplnú kontrolu nad aktualizáciou a zmenou nastavení ktoréhokoľvek používateľa, ako aj nad mnohými systémovými nastaveniami.",
"configViewer": "Prehliadač konfigurácie"
"permissionsHelp": "Oprávnenia používateľa riadia prístup k typom akcií, ktoré môže používateľ vykonávať. Prístup administrátora poskytuje úplnú kontrolu nad aktualizáciou a zmenou nastavení ktoréhokoľvek používateľa, ako aj nad mnohými systémovými nastaveniami."
},
"sidebar": {
"siteSettings": "Nastavenia stránky",
@@ -626,8 +625,7 @@
"lines": "Riadky{suffix}",
"lastUpdate": "Posledná aktualizácia{suffix}",
"latency": "Latencia{suffix}",
"all": "Všetky{suffix}",
"expiration": "Uplynutie platnosti{suffix}"
"all": "Všetky{suffix}"
},
"access": {
"rules": "Pravidlá prístupu",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Skapa ett konto",
@@ -321,6 +320,7 @@
"adminOptions": "Alternativ för administratörsanvändare",
"searchOptions": "Sökalternativ",
"systemAdmin": "System & Administration",
"configViewer": "Konfig-visare",
"configViewerShowFull": "Visa fullständig konfiguration",
"configViewerShowComments": "Visa kommentarer",
"configViewerLoadConfig": "Ladda konfig",
@@ -332,8 +332,7 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "t.ex. \"/undermapp\" - tom för standard",
"permissionsHelp": "Användarbehörigheter styr vilka typer av åtgärder som användaren kan utföra. Administratörsbehörighet ger full kontroll över uppdatering och ändring av alla användarinställningar samt många systeminställningar.",
"configViewer": "Konfig-visare"
"permissionsHelp": "Användarbehörigheter styr vilka typer av åtgärder som användaren kan utföra. Administratörsbehörighet ger full kontroll över uppdatering och ändring av alla användarinställningar samt många systeminställningar."
},
"sidebar": {
"siteSettings": "System inställningar",
@@ -706,8 +705,7 @@
"lines": "Linjer{suffix}",
"lastUpdate": "Senaste uppdatering{suffix}",
"latency": "Fördröjning{suffix}",
"all": "Alla{suffix}",
"expiration": "Förfallodag{suffix}"
"all": "Alla{suffix}"
},
"access": {
"rules": "Regler för åtkomst",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Bir hesap oluşturun",
@@ -321,6 +320,7 @@
"adminOptions": "Yönetici kullanıcı seçenekleri",
"searchOptions": "Arama seçenekleri",
"systemAdmin": "Sistem ve Yönetici",
"configViewer": "Konfigürasyon Görüntüleyici",
"configViewerShowFull": "Tam yapılandırmayı göster",
"configViewerShowComments": "Yorumları göster",
"configViewerLoadConfig": "Yük Konfigürasyonu",
@@ -332,8 +332,7 @@
"proxy": "Proxy"
},
"newUserHintSubFolder": "örneğin '/subfolder' - varsayılan için boş",
"permissionsHelp": "Kullanıcı izinleri, kullanıcının ne tür eylemler gerçekleştirebileceğine erişimi kontrol eder. Yönetici erişimi, herhangi bir kullanıcının ayarlarının yanı sıra birçok sistem ayarını güncellemek ve değiştirmek için tam kontrol sağlar.",
"configViewer": "Yapılandırma Görüntüleyici"
"permissionsHelp": "Kullanıcı izinleri, kullanıcının ne tür eylemler gerçekleştirebileceğine erişimi kontrol eder. Yönetici erişimi, herhangi bir kullanıcının ayarlarının yanı sıra birçok sistem ayarını güncellemek ve değiştirmek için tam kontrol sağlar."
},
"sidebar": {
"siteSettings": "Site ayarları!",
@@ -626,8 +625,7 @@
"lines": "Çizgiler{suffix}",
"lastUpdate": "Son Güncelleme{suffix}",
"latency": "Gecikme{suffix}",
"all": "Tümü{suffix}",
"expiration": "Son kullanma tarihi{suffix}"
"all": "Tümü{suffix}"
},
"access": {
"rules": "Erişim kuralları",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "Створити обліковий запис",
@@ -321,6 +320,7 @@
"adminOptions": "Параметри користувача адміністрування",
"searchOptions": "Параметри пошуку",
"systemAdmin": "Система та адміністрування",
"configViewer": "Переглядач конфігурацій",
"configViewerShowFull": "Показати повну конфігурацію",
"configViewerShowComments": "Показати коментарі",
"configViewerLoadConfig": "Завантажити конфігурацію",
@@ -332,8 +332,7 @@
"proxy": "Довірена особа"
},
"newUserHintSubFolder": "наприклад, '/subfolder' - порожній за замовчуванням",
"permissionsHelp": "Дозволи користувача контролюють доступ до того, які дії може виконувати користувач. Доступ адміністратора дає повний контроль над оновленням і зміною будь-яких налаштувань користувача, а також багатьох системних налаштувань.",
"configViewer": "Переглядач конфігурацій"
"permissionsHelp": "Дозволи користувача контролюють доступ до того, які дії може виконувати користувач. Доступ адміністратора дає повний контроль над оновленням і зміною будь-яких налаштувань користувача, а також багатьох системних налаштувань."
},
"sidebar": {
"siteSettings": "Налаштування сайту",
@@ -706,8 +705,7 @@
"lines": "Лінії{suffix}",
"lastUpdate": "Останнє оновлення{suffix}",
"latency": "Затримка{suffix}",
"all": "Усе.{suffix}",
"expiration": "Закінчення терміну дії{suffix}"
"all": "Усе.{suffix}"
},
"access": {
"rules": "Правила доступу",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "创建用户",
@@ -321,6 +320,7 @@
"adminOptions": "管理员用户选项",
"searchOptions": "搜索选项",
"systemAdmin": "系统与管理",
"configViewer": "配置查看器",
"configViewerShowFull": "显示完整配置",
"configViewerShowComments": "显示评论",
"configViewerLoadConfig": "加载配置",
@@ -332,8 +332,7 @@
"proxy": "代理"
},
"newUserHintSubFolder": "例如\"/子文件夹\"--默认为空",
"permissionsHelp": "用户权限控制用户可以执行哪些类型的操作。管理员权限可完全控制更新和更改任何用户的设置以及许多系统设置。",
"configViewer": "配置查看器"
"permissionsHelp": "用户权限控制用户可以执行哪些类型的操作。管理员权限可完全控制更新和更改任何用户的设置以及许多系统设置。"
},
"sidebar": {
"siteSettings": "网站设置",
@@ -706,8 +705,7 @@
"lines": "线路{suffix}",
"lastUpdate": "最新更新{suffix}",
"latency": "延迟{suffix}",
"all": "全部{suffix}",
"expiration": "到期{suffix}"
"all": "全部{suffix}"
},
"access": {
"rules": "访问规则",
+4 -6
View File
@@ -114,8 +114,7 @@
"ua": "Українська",
"zhCN": "中文 (简体)",
"zhTW": "中文 (繁體)",
"cz": "Čeština",
"nl": "Dutch"
"cz": "Čeština"
},
"login": {
"createAnAccount": "新建賬戶",
@@ -321,6 +320,7 @@
"shareDeleted": "分享已刪除!",
"adminOptions": "管理員使用者選項",
"systemAdmin": "系統與管理",
"configViewer": "組態檢視器",
"configViewerShowFull": "顯示完整設定",
"configViewerShowComments": "顯示評論",
"configViewerLoadConfig": "載入組態",
@@ -332,8 +332,7 @@
"proxy": "代理"
},
"newUserHintSubFolder": "例如 '/subfolder' - 預設為空白",
"permissionsHelp": "使用者權限可控制使用者可執行的動作類型。管理員存取權可完全控制更新和變更任何使用者的設定以及許多系統設定。",
"configViewer": "組態檢視器"
"permissionsHelp": "使用者權限可控制使用者可執行的動作類型。管理員存取權可完全控制更新和變更任何使用者的設定以及許多系統設定。"
},
"sidebar": {
"siteSettings": "網站設定",
@@ -706,8 +705,7 @@
"lines": "線路{suffix}",
"lastUpdate": "最後更新{suffix}",
"latency": "延遲{suffix}",
"all": "全部{suffix}",
"expiration": "到期日{suffix}"
"all": "全部{suffix}"
},
"access": {
"rules": "存取規則",
+2 -6
View File
@@ -11,10 +11,10 @@ import { mutations } from "@/store";
import { validateLogin } from "@/utils/auth";
import i18n from "@/i18n";
const titles = {
Login: i18n.global.t("general.login"),
Share: i18n.global.t("general.share"),
PublicShare: i18n.global.t("general.share"),
Files: i18n.global.t("general.files"),
Tools: i18n.global.t("general.tool"),
Settings: i18n.global.t("general.settings"),
@@ -169,9 +169,6 @@ router.beforeResolve(async (to, from, next) => {
return next(false);
}
// Clear any popup previews when navigating
mutations.setPreviewSource("");
const title = titles[to.name as keyof typeof titles] || String(to.name || '');
document.title = globalVars.name + " - " + title;
mutations.setRoute(to);
@@ -180,12 +177,11 @@ router.beforeResolve(async (to, from, next) => {
to.matched.some((record) => record.meta.requiresAuth) ||
to.matched.some((record) => record.meta.optionalAuth)
) {
const isPublicRoute = to.path.startsWith("/public");
if (state?.user?.username) {
// do nothing, user is already set
} else {
try {
await validateLogin(isPublicRoute);
await validateLogin();
} catch (error) {
mutations.setCurrentUser(getters.anonymous());
}
+2 -3
View File
@@ -270,9 +270,8 @@ export const getters = {
if (urlPath == "/" || urlPath == "") {
return "";
}
// remove hash from path and decode each part
let parts = urlPath.split('/').slice(2);
urlPath = parts.map(part => decodeURIComponent(part)).join('/')
// remove hash from path
urlPath = urlPath.split('/').slice(2).join('/')
if (subPath != "") {
urlPath = url.joinPath(urlPath, removeLeadingSlash(subPath))
}
+1
View File
@@ -76,6 +76,7 @@ export interface UserObject {
rules?: any[];
lockPassword?: boolean;
hideDotfiles?: boolean;
commands?: string[];
sorting?: {
by: string;
asc: boolean;
+2 -5
View File
@@ -2,12 +2,9 @@ import { mutations, getters,state } from "@/store";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { globalVars } from "@/utils/constants";
export async function validateLogin(isPublicRoute = false) {
export async function validateLogin() {
// Use direct fetch to avoid automatic logout on 401
// Public routes (e.g. /public/share/...) use the public API base path
const apiPath = isPublicRoute
? getPublicApiPath('users', { id: 'self' })
: getApiPath('/api/users', { id: 'self' });
let apiPath = getPublicApiPath('users', { id: 'self' });
const res = await fetch(apiPath, {
credentials: 'same-origin', // Ensure cookies are sent with the request
headers: {
+1 -13
View File
@@ -1,15 +1,3 @@
/**
* Strips ASS/SSA override blocks from dialogue text (e.g. {\fad(100,100)\blur3}).
* Used only when converting .ass / .ssa to VTT. Other formats keep their styling.
*/
export function stripAssOverrideTags (text) {
if (typeof text !== 'string') return text
return text
.replace(/\{[^}]*\}/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function convertToVTT (ext, text) {
if (ext == '.vtt') {
return text
@@ -85,7 +73,7 @@ export function convertToVTT (ext, text) {
let parts = line.split(',')
let startTime = formatAssTime(parts[1].trim())
let endTime = formatAssTime(parts[2].trim())
let dialogue = stripAssOverrideTags(parts.slice(9).join(',').trim())
let dialogue = parts.slice(9).join(',').trim()
return `${startTime} --> ${endTime}\n ${dialogue}` // Add leading space before dialogue
})
.join('\n\n')
+9 -7
View File
@@ -79,12 +79,12 @@ export function removePrefix(path, prefix = "") {
// get path with parameters
// Supports array values for repeated parameters (e.g., file=a&file=b&file=c)
export function getApiPath(path, params = {}, encode = true) {
export function getApiPath(path, params = {}, encode = false) {
if (path.startsWith("/")) {
path = path.slice(1);
}
path = `${globalVars.baseURL}${path}`;
const paramKeys = Object.keys(params);
if (paramKeys.length > 0) {
if (encode) {
@@ -92,7 +92,7 @@ export function getApiPath(path, params = {}, encode = true) {
for (const key of paramKeys) {
const value = params[key];
if (value === undefined) continue;
// Handle array values for repeated parameters
if (Array.isArray(value)) {
value.forEach(v => {
@@ -110,7 +110,7 @@ export function getApiPath(path, params = {}, encode = true) {
for (const key in params) {
const value = params[key];
if (value === undefined) continue;
// Handle array values for repeated parameters
if (Array.isArray(value)) {
value.forEach(v => {
@@ -125,13 +125,14 @@ export function getApiPath(path, params = {}, encode = true) {
}
}
}
return path;
}
// get path with parameters
// relative path so it can be used behind proxy
export function getPublicApiPath(path, params = {}, encode = true) {
return getApiPath(`/public/api/${path}`, params, encode);
export function getPublicApiPath(path, params = {}) {
return getApiPath(`/public/api/${path}`, params);
}
export function removeTrailingSlash(str) {
@@ -210,6 +211,7 @@ export function goToItem(source, path, previousHistoryItem, newTab = false) {
if (source == state.sources.current && path == state.req.path) {
return;
}
mutations.setPreviewSource("");
let newPath = encodedPath(path);
let fullPath = `/files/${encodeURIComponent(source)}${newPath}`;
if (newTab) {
@@ -228,7 +230,7 @@ export function goToItem(source, path, previousHistoryItem, newTab = false) {
router.push({ path: fullPath });
return;
}
if (previousHistoryItem === undefined) {
// When undefined will not create browser history
router.replace({ path: fullPath });
+4 -5
View File
@@ -191,9 +191,7 @@ export default {
// Parse share route to get subPath
let urlPath = getters.routePath('public/share')
let parts = urlPath.split("/");
// Decode each part since URL paths are encoded
let decodedParts = parts.slice(2).map(part => decodeURIComponent(part));
shareInfo.subPath = "/" + decodedParts.join("/");
shareInfo.subPath = "/" + parts.slice(2).join("/");
// Set shareInfo in state
mutations.setShareInfo(shareInfo);
@@ -356,7 +354,8 @@ export default {
// Display initial data immediately
mutations.replaceRequest(file);
document.title = globalVars.name + " - " + this.$t("general.share") + " - " + file.name;
document.title = `${document.title} - ${file.name}`;
// Second pass: If directory has metadata available, fetch again with metadata IN THE BACKGROUND
if (file.type === "directory" && file.hasMetadata) {
this.loadingProgress = 90;
@@ -475,7 +474,7 @@ export default {
if (state.sources.count > 1) {
mutations.setCurrentSource(data.source);
}
document.title = globalVars.name + " - " + this.$t("general.files") + " - " + res.name;
document.title = `${document.title} - ${res.name}`;
// Display initial data immediately and clear loading spinner
mutations.replaceRequest(data);
+1 -2
View File
@@ -44,7 +44,6 @@ import { filesApi } from "@/api";
import { state, getters, mutations } from "@/store";
import { events, notify } from "@/notify";
import { generateRandomCode } from "@/utils/auth";
import { globalVars } from "@/utils/constants";
export default {
name: "layout",
@@ -181,7 +180,7 @@ export default {
if (maxUploads > 10 || maxUploads < 1) {
mutations.setMaxConcurrentUpload(1);
}
if (state.user.showFirstLogin && !globalVars.noAuth) {
if ( state.user.showFirstLogin) {
mutations.showHover({
name: "generic",
props: {
+32 -41
View File
@@ -231,14 +231,12 @@ export default {
}
mutations.setDeletedItem(false);
},
async req() {
req() {
if (!getters.isLoggedIn()) {
return;
}
this.isDeleted = false;
// Reload subtitles when navigating to a new video
this.subtitlesList = await this.subtitles();
this.updatePreview();
mutations.resetSelected();
mutations.addSelected({
@@ -281,45 +279,38 @@ export default {
return [];
}
let subs = [];
// Fetch subtitle content for each track using the media API
for (let index = 0; index < state.req.subtitles.length; index++) {
const subtitleTrack = state.req.subtitles[index];
try {
// Fetch subtitle content from API using name and embedded
const content = await filesApi.getSubtitleContent(
state.req.source,
state.req.path,
for (const subtitleTrack of state.req.subtitles) {
// All subtitle content is now pre-loaded when content=true
// Simply use the content that's already available
if (
!subtitleTrack.content ||
subtitleTrack.content.length === 0
) {
console.warn(
"Subtitle track has no content:",
subtitleTrack.name,
);
continue;
}
let vttContent = subtitleTrack.content;
if (!subtitleTrack.content.startsWith("WEBVTT")) {
const ext = getFileExtension(subtitleTrack.name);
vttContent = convertToVTT(ext, subtitleTrack.content);
}
if (vttContent.startsWith("WEBVTT")) {
// Create a virtual file (Blob) and get a URL for it
const blob = new Blob([vttContent], { type: "text/vtt" });
const vttURL = URL.createObjectURL(blob);
subs.push({
name: subtitleTrack.name,
src: vttURL,
language: subtitleTrack.language || ''
});
} else {
console.warn(
"Skipping subtitle track because it has no WEBVTT header:",
subtitleTrack.name,
subtitleTrack.embedded
);
if (!content || content.length === 0) {
console.warn("Subtitle track has no content:", subtitleTrack.name);
continue;
}
// Convert to VTT if needed
let vttContent = content;
if (!content.startsWith("WEBVTT")) {
const ext = getFileExtension(subtitleTrack.name);
vttContent = convertToVTT(ext, content);
}
if (vttContent.startsWith("WEBVTT")) {
// Create a virtual file (Blob) and get a URL for it
const blob = new Blob([vttContent], { type: "text/vtt" });
const vttURL = URL.createObjectURL(blob);
subs.push({
name: subtitleTrack.name,
src: vttURL,
language: subtitleTrack.language
});
} else {
console.warn(
"Skipping subtitle track - no WEBVTT header after conversion:",
subtitleTrack.name
);
}
} catch (err) {
console.error("Failed to load subtitle:", subtitleTrack.name, err);
}
}
return subs;
@@ -422,7 +413,7 @@ export default {
},
showDeletePrompt() {
const item = state.req;
const previewUrl = item.hasPreview
const previewUrl = item.hasPreview
? filesApi.getPreviewURL(item.source, item.path, item.modified)
: null;
mutations.showHover({
+3 -24
View File
@@ -50,7 +50,7 @@
<div class="plyr-video-container" ref="plyrVideoContainer">
<video :src="raw" :type="req.type" :autoplay="shouldAutoplay" @play="handlePlay" playsinline ref="videoElement">
<track kind="captions" v-for="(sub, index) in subtitlesList" :key="index" :src="sub.src"
:label="sub.name" :srclang="sub.language" :default="index === 0" />
:label="'Subtitle ' + sub.name" :default="false" />
</video>
</div>
</div>
@@ -67,7 +67,7 @@
<video ref="defaultVideoPlayer" :src="raw"
controls :autoplay="shouldAutoplay" @play="handlePlay" playsinline >
<track kind="captions" v-for="(sub, index) in subtitlesList" :key="index" :src="sub.src"
:label="sub.name" :srclang="sub.language" :default="index === 0" />
:label="'Subtitle ' + sub.name" :default="index === 0" />
</video>
</div>
@@ -188,7 +188,7 @@ export default {
"settings",
"fullscreen",
],
settings: ["captions", "quality", "speed", "playback"],
settings: ["quality", "speed", "playback"],
speed: {
selected: 1,
options: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2],
@@ -233,16 +233,6 @@ export default {
this.setupPlaybackQueue(true);
}
},
subtitlesList(newSubs, oldSubs) {
// Initialize Plyr when subtitles are loaded (if we were waiting for them)
if (newSubs && newSubs.length > 0 && (!oldSubs || oldSubs.length === 0)) {
if (!this.useDefaultMediaPlayer && !this.player && this.previewType === 'video') {
this.$nextTick(() => {
this.initializePlyr();
});
}
}
},
},
computed: {
darkMode() {
@@ -602,17 +592,6 @@ export default {
this.setupDefaultPlayerEvents(this.mediaElement);
return;
}
// For videos with subtitle metadata, wait for subtitles to load before initializing Plyr
// This prevents Plyr from trying to access tracks before they have valid blob URLs
const hasSubtitleMetadata = this.req?.subtitles && this.req.subtitles.length > 0;
const subtitlesNotLoaded = !this.subtitlesList || this.subtitlesList.length === 0;
if (this.previewType === 'video' && hasSubtitleMetadata && subtitlesNotLoaded) {
// Wait for subtitles to be loaded (watcher will call initializePlyr)
return;
}
this.initializePlyr();
},
initializePlyr() {
+1 -1
View File
@@ -10,7 +10,7 @@
<tr>
<th>{{ $t("general.hash") }}</th>
<th>{{ $t("general.path") }}</th>
<th>{{ $t("general.expiration") }}</th>
<th>{{ $t("time.unit") }}</th>
<th>{{ $t("general.downloads") }}</th>
<th>{{ $t("general.username") }}</th>
<th></th>
@@ -50,27 +50,3 @@ test("navigate from search item", async({ page, checkForErrors, context }) => {
await expect(page.locator('.topTitle')).toHaveText('for testing.md');
checkForErrors()
});
test("use quick jump", async({ page, checkForErrors, context }) => {
await page.goto("/files/playwright%20%2B%20files/myfolder/testdata/gray-sample.jpg");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - gray-sample.jpg");
// drag next button to the left to open quick jump list
const nextButton = page.locator('button[aria-label="Next"]');
await nextButton.waitFor({ state: "visible" });
const box = await nextButton.boundingBox();
expect(box).toBeTruthy();
const startX = box!.x + box!.width / 2;
const startY = box!.y + box!.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX - 200, startY);
await page.mouse.up();
await expect(page.locator('div[aria-label="file-list-prompt"]')).toBeVisible();
await page.locator('div[aria-label="20130612_142406.jpg"]').click();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - 20130612_142406.jpg");
await expect(page.locator('.topTitle')).toHaveText('20130612_142406.jpg');
await expect(page.locator('div[aria-label="file-list-prompt"]')).toBeHidden();
checkForErrors();
})
@@ -31,10 +31,9 @@ test("Create first new file with basic auth", async ({ page, checkForErrors, co
// test public share route (no auth required)
await page.goto("/subpath/public/share/" + shareHash);
await expect(page).toHaveTitle("Graham's Filebrowser - Share - demo-127.0.0.1");
await expect(page.locator('.listing-items .file-items')).toHaveCount(1);
await page.locator('a[aria-label="test.txt"]').dblclick();
checkForErrors(0,1); // expect expect redirect error
checkForErrors(0,1);
});
test("Verify basic auth is required for protected route", async ({ page }) => {