Compare commits

...

178 Commits

Author SHA1 Message Date
61d76695bf Merge branch 'metadata-fix' into 'master'
fix: multivalue tags

See merge request joelilas/filebrowser!2
2026-02-08 15:55:13 +00:00
8993c8031a fix: multivalue tags 2026-02-08 16:50:29 +01:00
41ecbc5f73 Merge branch 'metadata-fix' into 'master'
Metadata fix

See merge request joelilas/filebrowser!1
2026-01-30 08:52:51 +00:00
b59d30784f fix,add: TXXX adding, delete option, dictionnary, M4A support 2026-01-30 09:50:55 +01:00
b91b7c431c fix: ffmpeg added to Dockerfile.full, id3v2 to go.mod 2026-01-29 18:57:17 +01:00
vcadoux
9c62bc990b chore(release): 2.57.0 - add changelog entry 2026-01-28 14:38:57 +01:00
vcadoux
b7e69dc2a3 Merge branch 'metadata-modifier' 2026-01-28 14:27:54 +01:00
vcadoux
4692ac2748 Add dependencies (ffmpeg) to Dockerfile 2026-01-28 14:25:55 +01:00
vcadoux
85c64667c1 feat(metadata): add top Apply & Add Field UI, beautify table; preserve existing tags; skip empty values; hide APIC; capture ffmpeg stderr; stable column widths; fix title capitalization; add EN/FR translations 2026-01-28 14:09:06 +01:00
vcadoux
a58f930d58 Add native MP3 metadata writer and prefer it over ffmpeg for .mp3 files 2026-01-27 15:37:27 +01:00
vcadoux
69b54f1ae8 feat: adding metadata button 2026-01-27 09:14:11 +01:00
vcadoux
ef70de2676 chore: start metadata modifier work 2026-01-26 14:52:46 +01:00
Henrique Dias
1053aace8c chore(release): 2.56.0 2026-01-24 14:51:03 +01:00
Ariel Leyva
004488c15b fix: avoid clearing selection when clicking elements outside the empty area (#5715) 2026-01-24 14:42:00 +01:00
Ariel Leyva
3032a1fade fix: adjust columns of the table from the "users ls" command (#5716) 2026-01-24 14:39:25 +01:00
renovate[bot]
87f1d00fd7 chore(deps): update all non-major dependencies (#5714)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-24 14:38:10 +01:00
transifex-integration[bot]
f0f2f1ff06 feat: Updates for project File Browser (#5698)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-01-24 14:36:45 +01:00
Henrique Dias
08c8ede587 chore(release): 2.55.0 2026-01-18 09:00:36 +01:00
Henrique Dias
1154b49974 chore: add openbsd, remove windows arm (#5695) 2026-01-18 08:59:57 +01:00
Ariel Leyva
785b7abb7b feat: added cut, copy, paste and show command palette functions in header (#5648) 2026-01-18 08:55:20 +01:00
renovate[bot]
550a73b6ba chore(deps): update all non-major dependencies (#5679)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-18 08:54:42 +01:00
transifex-integration[bot]
e7ea1ad27d feat: update translations (#5677)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-01-18 08:45:48 +01:00
GUCHI
24781badd4 Merge commit from fork
Added a dummy bcrypt hash to prevent user enumeration timing attacks in JSON authentication.
2026-01-18 08:44:16 +01:00
Ariel Leyva
4094fb359b fix: retain file selection when closing the editor (#5693) 2026-01-18 08:37:12 +01:00
Ariel Leyva
cfa6c5864e fix: request current password when deleting users (#5667) 2026-01-18 08:36:25 +01:00
Andy
59ca0c340a fix: prevent context menu clicks from clearing file selection (#5681) 2026-01-17 22:57:01 +01:00
Henrique Dias
037e29a708 chore(release): 2.54.0 2026-01-10 10:30:26 +01:00
Beckam White
fda8a99292 feat: add "redirect after copy/move" user setting (#5662) 2026-01-10 10:27:54 +01:00
Ariel Leyva
208535a8cc fix: clear selection by clicking on empty area (#5663) 2026-01-10 10:05:12 +01:00
transifex-integration[bot]
464b581953 feat: update translations (#5659)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2026-01-10 09:38:48 +01:00
renovate[bot]
20312ff18e chore(deps): update all non-major dependencies (#5676)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-10 09:37:56 +01:00
Ariel Leyva
219582c0b0 fix: hide "change password form" in noauth setting (#5652) 2026-01-10 09:31:27 +01:00
Jagadam Dinesh Reddy
4fd18a382c feat: force file sync while uploading file (#5668) 2026-01-10 09:30:08 +01:00
Henrique Dias
94ec786d34 chore(release): 2.53.1 2026-01-03 08:54:16 +01:00
renovate[bot]
25e9b85259 chore(deps): update all non-major dependencies (#5657)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-03 08:53:36 +01:00
Jagadam Dinesh Reddy
ffa893e9ac fix: download path encoding file paths (#5655) 2026-01-03 08:53:21 +01:00
Ariel Leyva
b8151a038a fix: request a password to change sensitive user data (#5629) 2026-01-03 08:44:03 +01:00
Henrique Dias
943e5340d0 chore(release): 2.53.0 2025-12-29 08:33:04 +01:00
Henrique Dias
88aa56f53b chore(docs): update CLI documentation 2025-12-29 08:32:46 +01:00
transifex-integration[bot]
a12a612970 feat: update translations
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-12-29 08:29:25 +01:00
Ariel Leyva
a2d80c62c1 feat: add "disable image resolution calculation" flag (#5638) 2025-12-29 08:25:49 +01:00
renovate[bot]
1faa21844f chore(deps): update all non-major dependencies (#5639)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 08:25:45 +01:00
Brendan Jackman
7a93b2a062 docs: clarify scope of config file and environment variables (#5643) 2025-12-29 08:20:48 +01:00
transifex-integration[bot]
032d6c7520 feat: update translations (#5631)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-12-28 22:14:08 +01:00
Dominik
9595f3939c fix: conversion of backslashes in file paths for archive creation (#5637)
Co-authored-by: FadedAtlas <fadedatlas.shield181@slmail.me>
2025-12-28 22:07:00 +01:00
Brendan Jackman
60b1ee8bb9 fix: fix nil deref in config set command (#5641)
Co-authored-by: Henrique Dias <mail@hacdias.com>
2025-12-28 22:04:59 +01:00
Brendan Jackman
79d1aa9229 fix: Don't crash on invalid config import (#5640) 2025-12-28 22:04:23 +01:00
Ramires Viana
20bfd131c6 feat: support streaming response for search results (#5630)
Co-authored-by: manx98 <1323517022@qq.com>
2025-12-28 21:57:25 +01:00
transifex-integration[bot]
f89975603e feat: update translations (#5626)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-12-21 17:41:19 +00:00
renovate[bot]
2d3b64ecf6 chore(deps): update all non-major dependencies (#5628)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-21 13:04:25 +00:00
renovate[bot]
579756dbb5 chore(deps): update all non-major dependencies (#5621)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-15 08:03:59 +01:00
Henrique Dias
c11c986b73 chore(release): 2.52.0 2025-12-13 09:13:37 +01:00
transifex-integration[bot]
3fdca6dfd9 feat: update translations (#5615)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-12-13 09:09:31 +01:00
Ariel Leyva
cf966578d8 fix: hide the context menu when changing the route (#5613) 2025-12-13 09:09:05 +01:00
Ariel Leyva
6d4c867672 fix: display the directory name in the shared folder view (#5617) 2025-12-13 09:08:44 +01:00
renovate[bot]
169e97e6f9 chore(deps): update all non-major dependencies (#5620)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-13 09:06:33 +01:00
transifex-integration[bot]
7fa3432f25 feat: sync translations with Transifex
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-12-09 21:45:34 +01:00
renovate[bot]
e9ce7fa5aa chore(deps): update all non-major dependencies (#5601) 2025-12-09 21:44:27 +01:00
Henrique Dias
849f5ad443 chore(release): 2.51.2 2025-12-07 08:12:35 +01:00
Henrique Dias
c1715992bd fix(frontend): add missing i18n strings 2025-12-07 07:47:43 +01:00
Henrique Dias
e4f2503298 chore(release): 2.51.1 2025-12-07 07:44:25 +01:00
Ariel Leyva
152f8302f7 fix: prevent the right-click from selecting multiple items when the "single-click" option is active (#5608) 2025-12-07 07:35:47 +01:00
Henrique Dias
4cbb4b73af fix(frontend): csv viewer i18n strings 2025-12-07 07:24:15 +01:00
Henrique Dias
58cc874828 chore(release): 2.51.0 2025-12-06 11:37:24 +01:00
Henrique Dias
124def5cd7 chore: dependency updates 2025-12-06 11:33:43 +01:00
transifex-integration[bot]
2d88c06761 feat: update translations
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-12-06 11:23:24 +01:00
Ariel Leyva
204a3f0eea fix: added column separator select (comma, semicolon and both) in CSV viewer (#5604)
Co-authored-by: Henrique Dias <mail@hacdias.com>
2025-12-06 11:08:50 +01:00
Henrique Dias
f029c3005e refactor: cleanup package names (#5605) 2025-12-06 10:52:11 +01:00
Henrique Dias
a6934e40ff ci: run renovate on weekends 2025-12-05 15:11:22 +01:00
renovate[bot]
98662ac5ec chore(deps): update all non-major dependencies (#5600)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-03 08:47:00 +01:00
renovate[bot]
5cf8ce8db5 chore(deps): update dependency vite to v7.2.6 (#5598)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 08:38:14 +01:00
renovate[bot]
062dc414f8 chore(deps): update module github.com/shirou/gopsutil/v4 to v4.25.11 (#5597)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 07:37:25 +01:00
Henrique Dias
63582b644c chore: run linter 2025-11-30 09:17:35 +01:00
Henrique Dias
4302ece49b chore(release): 2.50.0 2025-11-30 09:08:15 +01:00
Henrique Dias
e1ee14d827 chore(docs): update CLI documentation 2025-11-30 09:07:56 +01:00
Henrique Dias
84ca722261 ci: use docs on GitHub Pages 2025-11-30 08:49:24 +01:00
jake-dog
b9ac45d5da feat: configurable logout page URL for proxy/hook auth (#3884)
Co-authored-by: Henrique Dias <mail@hacdias.com>
2025-11-30 08:44:34 +01:00
Henrique Dias
701522a060 fix: do not close editor if save failed
Closes #5591
2025-11-30 07:58:37 +01:00
transifex-integration[bot]
78e0395960 feat: update translations
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-11-30 07:32:51 +01:00
renovate[bot]
f0680cf0f5 chore(deps): update dependency prettier to v3.7.3 (#5592)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 07:31:40 +01:00
Krishan Bhasin
982405ec94 feat: render CSVs as table (#5569)
Co-authored-by: Henrique Dias <mail@hacdias.com>
2025-11-29 10:45:11 +01:00
renovate[bot]
a78aaed214 chore(deps): update dependency prettier to v3.7.2 (#5589)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 10:25:22 +01:00
renovate[bot]
df11a7dd0e chore(deps): update all non-major dependencies (#5583)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-28 17:40:31 +01:00
renovate[bot]
79980bcf52 chore(deps): update all non-major dependencies to v14.1.0 (#5582)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-27 09:05:21 +01:00
renovate[bot]
3be134f23d chore(deps): update all non-major dependencies (#5578)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-26 13:56:59 +01:00
transifex-integration[bot]
279a5ccd1e feat: update frontend/src/i18n/hr.json
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-11-26 13:56:45 +01:00
renovate[bot]
87f73ac982 chore(deps): update dependency vue to v3.5.25 (#5577)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 09:39:33 +01:00
renovate[bot]
85cde140ba chore(deps): update dependency vue-tsc to v3.1.5 (#5575)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-23 08:10:22 +01:00
Henrique Dias
119609c834 chore(release): 2.49.0 2025-11-22 17:15:51 +01:00
Kosmos
d48f5665d6 feat: add "copy download link to clipboard" button to Share prompt (#5173)
Co-authored-by: Henrique Dias <mail@hacdias.com>
2025-11-22 17:07:10 +01:00
transifex-integration[bot]
54306bdc87 feat: Updates for project File Browser (#5566)
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-11-22 16:36:03 +01:00
renovate[bot]
33deedf559 chore(deps): update dependency vue-i18n to v11.2.1 (#5574)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-22 15:06:10 +01:00
renovate[bot]
88d1eecc4e chore(deps): update dependency eslint-plugin-vue to v10.6.0 (#5573)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-22 07:57:55 +01:00
renovate[bot]
43db19f8c8 chore(deps): update all non-major dependencies (#5571)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-20 18:57:20 +01:00
renovate[bot]
a360f26979 chore(deps): update actions/checkout action to v6 (#5572)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-20 18:54:40 +01:00
renovate[bot]
ab367a2740 chore(deps): update all non-major dependencies (#5567)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-20 09:58:37 +01:00
Henrique Dias
5df5508a85 chore: add govet, gocritic and revive 2025-11-20 07:56:56 +01:00
Brian Fromm
6d5aa355e4 fix: display friendly error message for password validation on signup (#5563)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-19 17:42:50 +01:00
renovate[bot]
a3b5584505 chore(deps): update dependency @vitejs/plugin-vue to v6.0.2 (#5564)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-19 08:14:22 +01:00
transifex-integration[bot]
8db2411cd4 feat: add Bulgarian language
Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
Co-authored-by: Henrique Dias <mail@hacdias.com>
2025-11-19 08:14:10 +01:00
Henrique Dias
c284de9d2c chore(release): 2.48.2 2025-11-18 11:32:24 +01:00
Henrique Dias
984ea7b569 fix: add transitionary support for FB_BASEURL 2025-11-18 11:30:43 +01:00
Henrique Dias
fd7b70cf38 refactor: rename python for clarification 2025-11-18 11:29:28 +01:00
renovate[bot]
13e3b46718 chore(deps): update all non-major dependencies (#5560)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 08:05:02 +01:00
Henrique Dias
d759ab0bd8 chore(release): 2.48.1 2025-11-17 10:02:54 +01:00
Henrique Dias
00323a8f37 chore: fix Taskfile commit when change 2025-11-17 10:02:29 +01:00
Henrique Dias
420adea7e6 fix: options should only override if set 2025-11-17 09:58:27 +01:00
Henrique Dias
f576d38a7e chore(release): 2.48.0 2025-11-17 09:39:04 +01:00
Henrique Dias
9bdc67c207 chore(docs): update CLI documentation 2025-11-17 09:38:45 +01:00
Henrique Dias
f41585f039 fix: use all available flags in quick setup 2025-11-17 09:17:30 +01:00
Henrique Dias
89be0b1873 refactor: reuse logic for config init and set 2025-11-17 09:16:54 +01:00
Brian Fromm
8c5dc7641e fix: add tokenExpirationTime to config init and troubleshoot docs (#5546)
Co-authored-by: Henrique Dias <mail@hacdias.com>
2025-11-17 08:57:02 +01:00
Henrique Dias
0a0cb8046f feat: consistent flags and environment variables (#5549)
- In the root command, all flags are now correctly available as environmental variables, except for `--config` flag. This was already supposed to be the case, but due to bugs in the implementation it didn't work properly.
- All configuration options (unless I missed something) that are available as flags should now properly update the configuration when using the `config init` and `config set` commands.
- Flag names are now consistently in the lowerCamelCase format. All flags that were in a different format have been updated in a backwards compatible way. For a transitionary period of at least 6 months, both will work:
  - `--dir-mode` --> `--dirMode`
  - `--hide-login-button` --> `--hideLoginButton`
  - `--create-user-dir` --> `--createUserDir`
  - `--minimum-password-length` --> `--minimumPasswordLength`
  - `--socket-perm` --> `--socketPerm`
  - `--disable-thumbnails` --> `--disableThumbnails`
  - `--disable-preview-resize` --> `--disablePreviewResize`
  - `--disable-exec` --> `--disableExec`
  - `--disable-type-detection-by-header` --> `--disableTypeDetectionByHeader`
  - `--img-processors` --> `--imageProcessors`
  - `--cache-dir` --> `--cacheDir`
  - `--token-expiration-time` --> `--tokenExpirationTime`
  - `--baseurl` --> `--baseURL`
2025-11-17 08:45:43 +01:00
Henrique Dias
f89435c068 chore: fix taskfile 2025-11-16 14:28:48 +01:00
Henrique Dias
fb8d41eb9a chore(release): 2.47.0 2025-11-16 14:28:15 +01:00
Henrique Dias
0fadaccaa2 chore(docs): update CLI documentation 2025-11-16 14:28:03 +01:00
Henrique Dias
e24e1f1aba feat: add TUS settings to the command line (#5556) 2025-11-16 14:13:58 +01:00
Henrique Dias
5de4099cba fix: exit 0 when gracefully shutting down (#5555) 2025-11-16 14:13:21 +01:00
Henrique Dias
d01493106d docs: improved config 2025-11-16 10:31:08 +01:00
Henrique Dias
2d9689dd6a docs: add CLI usage and integrate generation in release 2025-11-16 10:14:59 +01:00
Henrique Dias
c4c1cea230 docs: remove partially incorrect env variables info 2025-11-16 09:01:54 +01:00
Henrique Dias
ceb5e723f3 feat: remove importer of v1 config (#5550) 2025-11-16 07:52:53 +01:00
renovate[bot]
ebc7d2303d chore(deps): update dependency vue-tsc to v3.1.4 (#5551)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-15 20:14:10 +01:00
Henrique Dias
23c4e4565b chore: remove 'nolint' comments 2025-11-15 09:01:21 +01:00
Henrique Dias
17f1e08a58 chore(release): 2.46.1 2025-11-15 08:51:04 +01:00
Henrique Dias
ffc850454e fix: remove duplicated 'hide-defaults' flag (is 'hideDefaults') (#5548) 2025-11-15 08:50:22 +01:00
Henrique Dias
13814e1119 fix: env key replacer and remove unused function (#5547) 2025-11-15 08:40:37 +01:00
Henrique Dias
4e9e312984 chore(release): 2.46.0 2025-11-14 18:15:33 +01:00
Henrique Dias
ce3b407c51 docs: clarify status 2025-11-14 17:49:52 +01:00
transifex-integration[bot]
fb5d099f85 feat: Updates for project File Browser (#5544) 2025-11-14 17:47:21 +01:00
Omar Hussein
1ace579a55 feat: add context menu (#3343)
Co-authored-by: Henrique Dias <mail@hacdias.com>
2025-11-14 16:53:16 +01:00
Lucky Jain
ac7b49c148 feat: add option to hide the login button from public-facing pages (#3922)
Co-authored-by: Henrique Dias <mail@hacdias.com>
2025-11-14 16:21:08 +01:00
Henrique Dias
9d44932dba chore: use more standard golangci-lint options 2025-11-14 16:18:12 +01:00
Ahmad Hesam
0d973d3aad feat: add 'hide-dotfiles' as command line parameter (#3802)
Co-authored-by: Henrique Dias <mail@hacdias.com>
2025-11-14 08:19:03 +01:00
Henrique Dias
cacc0999e9 chore: let functions be longer 2025-11-14 08:11:10 +01:00
Henrique Dias
42d1b6f3ae docs: fix badge in readme 2025-11-13 18:03:51 +01:00
Henrique Dias
bb10c3dfa9 docs: clarify release 2025-11-13 17:39:51 +01:00
Henrique Dias
ce76aa23a6 chore(release): 2.45.3 2025-11-13 17:39:11 +01:00
Henrique Dias
94b635daf8 ci: fix workflow command 2025-11-13 17:37:50 +01:00
Henrique Dias
31871aaa4b docs: update project status (#5513) 2025-11-13 17:34:52 +01:00
Henrique Dias
9d465663db chore(release): 2.45.3 2025-11-13 17:32:46 +01:00
Henrique Dias
70081f2647 docs: add note about flags and env (#5542) 2025-11-13 17:31:12 +01:00
Henrique Dias
fa9d2f266f ci: simplify the workflows (#5541) 2025-11-13 17:25:59 +01:00
Henrique Dias
8fcfb502ca docs: improve contribution documentation (#5540) 2025-11-13 17:16:50 +01:00
Henrique Dias
0bab2aba9e chore: use Task, split workflows 2025-11-13 16:51:32 +01:00
renovate[bot]
38951d950f chore(deps): update module github.com/golang-jwt/jwt/v4 to v5 (#5535)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Henrique Dias <mail@hacdias.com>
2025-11-13 16:07:53 +01:00
renovate[bot]
dda8fdbcb2 chore(deps): update module golang.org/x/tools to v0.39.0 (#5522)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-13 14:28:28 +01:00
Henrique Dias
bf3ba65782 chore: bump @vue/tsconfig
Signed-off-by: Henrique Dias <mail@hacdias.com>
2025-11-13 14:21:30 +01:00
Henrique Dias
f35b7c9d9d chore: remove unused tests
Signed-off-by: Henrique Dias <mail@hacdias.com>
2025-11-13 14:21:11 +01:00
Henrique Dias
e9506c3eae chore: remove unused dependencies
Signed-off-by: Henrique Dias <mail@hacdias.com>
2025-11-13 14:19:08 +01:00
Henrique Dias
bb4465548b chore: use 'chore' instead of 'fix' in renovate 2025-11-13 14:10:06 +01:00
Henrique Dias
cf8b5ca768 docs: fix duplicated changelog entry 2025-11-13 14:05:50 +01:00
Henrique Dias
f93d760b1b chore(release): 2.45.2 2025-11-13 14:03:52 +01:00
Henrique Dias
1495ee8dd8 chore: replace release-please with commit-and-tag-version 2025-11-13 14:03:41 +01:00
FileBrowser Robot
8a7279e3ee chore(master): release 2.45.2 (#5538) 2025-11-13 13:51:22 +01:00
renovate[bot]
fdff7a38f4 fix(deps): update module github.com/shirou/gopsutil/v3 to v4 (#5536)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-13 13:48:34 +01:00
renovate[bot]
f26a68587d fix(deps): update module gopkg.in/yaml.v2 to v3 (#5537)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-13 13:47:39 +01:00
Henrique Dias
1c62038344 chore: delete commitlint 2025-11-13 09:41:33 +01:00
Henrique Dias
6eb7b4b8ec ci: replace standard-version with release please (#5533) 2025-11-13 09:34:59 +01:00
Henrique Dias
f11fc37409 chore: bump to Node 24, pnpm 10, multiple GH actions (#5532) 2025-11-13 07:50:00 +01:00
renovate[bot]
d12a3dc8a8 chore(deps): update amannn/action-semantic-pull-request action to v6 (#5523)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-12 22:28:24 +01:00
Henrique Dias
0cfab8770a chore: some dependency updates 2025-11-12 22:25:18 +01:00
renovate[bot]
3876ae8fe8 chore(deps): update actions/setup-go action to v6 2025-11-12 15:15:46 +01:00
renovate[bot]
77644e4425 chore(deps): update actions/setup-node action to v6 2025-11-12 15:15:36 +01:00
renovate[bot]
6592782dc0 chore: update minor and patch dependencies, go 1.25 2025-11-12 15:12:28 +01:00
renovate[bot]
d6dc250ed4 chore(deps): update actions/checkout action to v5 2025-11-12 14:54:45 +01:00
Henrique Dias
9579f14c34 chore: add renovate.json 2025-11-12 14:49:22 +01:00
Henrique Dias
c5acbffe3f docs: import logo and banner (#5514) 2025-11-11 18:32:41 +01:00
Henrique Dias
63142042bc docs: remove unmaintained badges 2025-11-11 18:10:55 +01:00
Henrique Dias
1ac0305ed0 docs: add notice about releases page 2025-11-11 17:40:33 +01:00
Henrique Dias
7860013aa9 chore: update CODEOWNERS to use team (#5512) 2025-11-11 17:39:24 +01:00
Henrique Dias
7a5b964611 chore(release): 2.45.1 2025-11-11 08:10:41 +01:00
Jagadam Dinesh Reddy
6950c2e4d2 fix: share page preview items to contain baseUrl (#5510)
Co-authored-by: jagadam97 <dineshjagadam@hmail.com>
2025-11-11 08:09:19 +01:00
Henrique Dias
291223b3ce Merge commit from fork 2025-11-11 08:06:16 +01:00
Henrique Dias
99aeb766c3 chore(release): 2.45.0 2025-11-01 09:57:04 +01:00
Henrique Dias
93fe31cc55 fix: support croatian (#5502) 2025-11-01 08:53:50 +01:00
transifex-integration[bot]
b9a03fabd9 feat: update translations (#5458) 2025-11-01 08:38:55 +01:00
jagadam97
d00b3ea8f8 fix(img):Prevent thumbnail generation for large images 2025-11-01 08:37:50 +01:00
248 changed files with 18760 additions and 9377 deletions

6
.github/CODEOWNERS vendored
View File

@@ -1,5 +1 @@
# These owners will be the default owners for everything in the repo. * @filebrowser/maintainers
# Unless a later match takes precedence, @o1egl will be requested for
# review when someone opens a pull request.
* @o1egl @hacdias

115
.github/workflows/ci.yaml vendored Normal file
View File

@@ -0,0 +1,115 @@
name: Continuous Integration
on:
push:
branches:
- "master"
tags:
- "v*"
pull_request:
jobs:
lint-frontend:
name: Lint Frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
with:
package_json_file: "frontend/package.json"
- uses: actions/setup-node@v6
with:
node-version: "24.x"
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"
- working-directory: frontend
run: |
pnpm install --frozen-lockfile
pnpm run lint
lint-backend:
name: Lint Backend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: "1.25.x"
- uses: golangci/golangci-lint-action@v9
with:
version: "latest"
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: "1.25.x"
- run: go test --race ./...
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: '1.25'
- uses: pnpm/action-setup@v4
with:
package_json_file: "frontend/package.json"
- uses: actions/setup-node@v6
with:
node-version: "24.x"
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"
- name: Install Task
uses: go-task/setup-task@v1
- run: task build
release:
name: Release
needs: ["lint-frontend", "lint-backend", "test", "build"]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: '1.25'
- uses: pnpm/action-setup@v4
with:
package_json_file: "frontend/package.json"
- uses: actions/setup-node@v6
with:
node-version: "24.x"
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Install Task
uses: go-task/setup-task@v1
- run: task build:frontend
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}

52
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Docs
on:
pull_request:
paths:
- 'www'
- '*.md'
push:
branches:
- master
jobs:
build:
name: Build Docs
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Install Task
uses: go-task/setup-task@v1
- name: Build site
run: task docs
build-and-release:
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
name: Build and Release Docs
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Install Task
uses: go-task/setup-task@v1
- name: Build site
run: task docs
- name: Upload static files as artifact
uses: actions/upload-pages-artifact@v4
with:
path: www/public
- name: Deploy to GitHub Pages
uses: actions/deploy-pages@v4

View File

@@ -13,10 +13,10 @@ permissions:
jobs: jobs:
main: main:
name: Validate PR title name: Validate Title
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: amannn/action-semantic-pull-request@v5 - uses: amannn/action-semantic-pull-request@v6
id: lint_pr_title id: lint_pr_title
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -43,4 +43,4 @@ jobs:
uses: marocchino/sticky-pull-request-comment@v2 uses: marocchino/sticky-pull-request-comment@v2
with: with:
header: pr-title-lint-error header: pr-title-lint-error
delete: true delete: true

View File

@@ -1,105 +0,0 @@
name: main
on:
push:
branches:
- "master"
tags:
- "v*"
pull_request:
jobs:
# linters
lint-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
package_json_file: "frontend/package.json"
- uses: actions/setup-node@v4
with:
node-version: "22.x"
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"
- run: make lint-frontend
lint-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- run: make lint-backend
lint:
runs-on: ubuntu-latest
needs: [lint-frontend, lint-backend]
steps:
- run: echo "done"
# tests
test-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
package_json_file: "frontend/package.json"
- uses: actions/setup-node@v4
with:
node-version: "22.x"
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"
- run: make test-frontend
test-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- run: make test-backend
test:
runs-on: ubuntu-latest
needs: [test-frontend, test-backend]
steps:
- run: echo "done"
# release
release:
runs-on: ubuntu-latest
needs: [lint, test]
if: startsWith(github.event.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- uses: pnpm/action-setup@v4
with:
package_json_file: "frontend/package.json"
- uses: actions/setup-node@v4
with:
node-version: "22.x"
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build frontend
run: make build-frontend
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}

View File

@@ -1,20 +0,0 @@
name: Build Site
on:
pull_request:
paths:
- 'www'
- '*.md'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build site
run: make site

View File

@@ -1,32 +0,0 @@
name: Build and Deploy Site
on:
push:
branches:
- master
jobs:
deploy:
permissions:
contents: read
deployments: write
pull-requests: write
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build site
run: make site
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy www/public --project-name=${{ secrets.CLOUDFLARE_PROJECT_NAME }}
gitHubToken: ${{ secrets.GITHUB_TOKEN }}

14
.gitignore vendored
View File

@@ -35,10 +35,14 @@ build/
/frontend/dist/* /frontend/dist/*
!/frontend/dist/.gitkeep !/frontend/dist/.gitkeep
# Playwright files
/frontend/test-results/
/frontend/playwright-report/
/frontend/playwright/.cache/
default.nix default.nix
Dockerfile.dev Dockerfile.dev
filebrowser.log
filebrowser.pid
frontend-dev.log
frontend.pid
SWAG.mp3
test/M5.flac
test/MALIBU.flac
test/SWAG.mp3
test

View File

@@ -1,132 +1,14 @@
version: "2" version: "2"
linters: linters:
# inverted configuration with `default: all` and `disable` is not scalable during updates of golangci-lint default: standard
default: none
enable: enable:
- bodyclose
- dogsled
- dupl
- errcheck
- errorlint
- exhaustive
- funlen
- gocheckcompilerdirectives
- gochecknoinits
- gocritic - gocritic
- gocyclo
- godox
- goprintffuncname
- gosec
- govet - govet
- ineffassign
- lll
- misspell
- mnd
- nakedret
- nolintlint
- prealloc
- revive - revive
- rowserrcheck
- staticcheck
- testifylint
- unconvert
- unparam
- unused
- whitespace
settings:
dupl:
threshold: 100
exhaustive:
default-signifies-exhaustive: false
funlen:
lines: 100
statements: 50
gocritic:
disabled-checks:
- dupImport # https://github.com/go-critic/go-critic/issues/845
- ifElseChain
- octalLiteral
- whyNoLint
- wrapperFunc
enabled-tags:
- diagnostic
- experimental
- opinionated
- performance
- style
gocyclo:
min-complexity: 15
govet:
enable:
- nilness
- shadow
lll:
line-length: 140
misspell:
locale: US
mnd:
# don't include the "operation" and "assign"
checks:
- argument
- case
- condition
- return
ignored-numbers:
- "0"
- "1"
- "2"
- "3"
- "0666"
- "0700"
- "0700"
ignored-functions:
- strings.SplitN
- make
nolintlint:
allow-unused: false # report any unused nolint directives
require-explanation: false # require an explanation for nolint directives
require-specific: true # require nolint directives to be specific about which linter is being skipped
staticcheck:
checks:
- "all"
- "-QF*"
exclusions: exclusions:
generated: lax
presets: presets:
- comments
- common-false-positives
- legacy
- std-error-handling - std-error-handling
rules: - comments
- linters:
- gochecknoinits
path: cmd/.*.go
- linters:
- dupl
- funlen
- gochecknoinits
- gocyclo
- lll
- scopelint
path: .*_test.go
- linters:
- misspell
text: "[aA]uther"
- linters:
- mnd
text: strconv.Parse
paths:
- frontend/
formatters:
enable:
- goimports
settings:
goimports:
local-prefixes:
- github.com/filebrowser/filebrowser
exclusions:
generated: lax
paths: paths:
- frontend/ - frontend/

View File

@@ -17,6 +17,7 @@ builds:
- linux - linux
- windows - windows
- freebsd - freebsd
- openbsd
goarch: goarch:
- amd64 - amd64
- "386" - "386"
@@ -30,6 +31,12 @@ builds:
ignore: ignore:
- goos: darwin - goos: darwin
goarch: "386" goarch: "386"
# Experimental, may not work properly
- goos: openbsd
goarch: riscv64
# Broken as of Go 1.24, deprecated as of Go 1.26
- goos: windows
goarch: arm
- goos: freebsd - goos: freebsd
goarch: arm goarch: arm

View File

@@ -1,6 +1,268 @@
# Changelog # Changelog
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
## [2.57.0] (2026-01-28)
### Features
* Add "Modify metadata" action and modal for MP3/FLAC with batch edit support
* Expose audio tags in API (GET /api/resources?metadata=1) and implement metadata updates (PATCH action=metadata)
* Update i18n (EN/FR) with metadata labels and modal texts
* Include ffmpeg in s6 Docker image to enable metadata writes in containers
### Bug Fixes
* Avoid 400/409 errors on metadata PATCH by skipping parent/stat checks when destination is empty
* Preserve existing tags on update using ffmpeg `-map_metadata 0`; apply only non-empty fields; no-op when no changes
* Use temp file with original extension for ffmpeg; capture stderr to improve error messages
* Exclude raw APIC frames from returned tags to prevent large payloads; keep preview-friendly picture key
* Modal polish: fix column alignment jitter and title capitalization
## [2.56.0](https://github.com/filebrowser/filebrowser/compare/v2.55.0...v2.56.0) (2026-01-24)
### Features
* Updates for project File Browser ([#5698](https://github.com/filebrowser/filebrowser/issues/5698)) ([f0f2f1f](https://github.com/filebrowser/filebrowser/commit/f0f2f1ff069aae566d8bf25ec275da59f29a96bc))
### Bug Fixes
* adjust columns of the table from the "users ls" command ([#5716](https://github.com/filebrowser/filebrowser/issues/5716)) ([3032a1f](https://github.com/filebrowser/filebrowser/commit/3032a1fade43737c51c49b5ccda34f336394c2ed))
* avoid clearing selection when clicking elements outside the empty area ([#5715](https://github.com/filebrowser/filebrowser/issues/5715)) ([004488c](https://github.com/filebrowser/filebrowser/commit/004488c15b3c30784e1ea564b3ca9feec7bcad08))
## [2.55.0](https://github.com/filebrowser/filebrowser/compare/v2.54.0...v2.55.0) (2026-01-18)
### Features
* added cut, copy, paste and show command palette functions in header ([#5648](https://github.com/filebrowser/filebrowser/issues/5648)) ([785b7ab](https://github.com/filebrowser/filebrowser/commit/785b7abb7ba7a86cc0deae1052c319ff714c222c))
* update translations ([#5677](https://github.com/filebrowser/filebrowser/issues/5677)) ([e7ea1ad](https://github.com/filebrowser/filebrowser/commit/e7ea1ad27d3d17e249489d3338be40bfea15e2a1))
### Bug Fixes
* prevent context menu clicks from clearing file selection ([#5681](https://github.com/filebrowser/filebrowser/issues/5681)) ([59ca0c3](https://github.com/filebrowser/filebrowser/commit/59ca0c340afc7774747c70ede9a5a5a3c9349d6b))
* request current password when deleting users ([#5667](https://github.com/filebrowser/filebrowser/issues/5667)) ([cfa6c58](https://github.com/filebrowser/filebrowser/commit/cfa6c5864e5e7673aa9f3180e4964e0db92cc4da))
* retain file selection when closing the editor ([#5693](https://github.com/filebrowser/filebrowser/issues/5693)) ([4094fb3](https://github.com/filebrowser/filebrowser/commit/4094fb359babac70e88d0ed4bfe3bd100744aad6))
## [2.54.0](https://github.com/filebrowser/filebrowser/compare/v2.53.1...v2.54.0) (2026-01-10)
### Features
* add "redirect after copy/move" user setting ([#5662](https://github.com/filebrowser/filebrowser/issues/5662)) ([fda8a99](https://github.com/filebrowser/filebrowser/commit/fda8a992929b1466e75fb2813f2c4e293c12d244))
* force file sync while uploading file ([#5668](https://github.com/filebrowser/filebrowser/issues/5668)) ([4fd18a3](https://github.com/filebrowser/filebrowser/commit/4fd18a382c31bbe7059d6733ffa371e70051865b))
* update translations ([#5659](https://github.com/filebrowser/filebrowser/issues/5659)) ([464b581](https://github.com/filebrowser/filebrowser/commit/464b581953139c17e3276b774e381e4052827125))
### Bug Fixes
* clear selection by clicking on empty area ([#5663](https://github.com/filebrowser/filebrowser/issues/5663)) ([208535a](https://github.com/filebrowser/filebrowser/commit/208535a8cc23254de0013dfab9008486707ee6c2))
* hide "change password form" in noauth setting ([#5652](https://github.com/filebrowser/filebrowser/issues/5652)) ([219582c](https://github.com/filebrowser/filebrowser/commit/219582c0b03fd90979b1d1398dba7919d086a23f))
## [2.53.1](https://github.com/filebrowser/filebrowser/compare/v2.53.0...v2.53.1) (2026-01-03)
### Bug Fixes
* download path encoding file paths ([#5655](https://github.com/filebrowser/filebrowser/issues/5655)) ([ffa893e](https://github.com/filebrowser/filebrowser/commit/ffa893e9ac387a49dba5917a41df7c3b7ce120fc))
* request a password to change sensitive user data ([#5629](https://github.com/filebrowser/filebrowser/issues/5629)) ([b8151a0](https://github.com/filebrowser/filebrowser/commit/b8151a038a1ea55afae8073b439b74e364cac12f))
## [2.53.0](https://github.com/filebrowser/filebrowser/compare/v2.52.0...v2.53.0) (2025-12-29)
### Features
* add "disable image resolution calculation" flag ([#5638](https://github.com/filebrowser/filebrowser/issues/5638)) ([a2d80c6](https://github.com/filebrowser/filebrowser/commit/a2d80c62c1c17962e566f68fb7cac6960ed3e4cb))
* support streaming response for search results ([#5630](https://github.com/filebrowser/filebrowser/issues/5630)) ([20bfd13](https://github.com/filebrowser/filebrowser/commit/20bfd131c6a4fca48a645b52171c2d1cc3ce92b7))
* update translations ([a12a612](https://github.com/filebrowser/filebrowser/commit/a12a612970d6cc3dfbca1b35ef3a60a887a4effb))
* update translations ([#5626](https://github.com/filebrowser/filebrowser/issues/5626)) ([f899756](https://github.com/filebrowser/filebrowser/commit/f89975603e29b9f1fc05aec58afb42bbd56ed696))
* update translations ([#5631](https://github.com/filebrowser/filebrowser/issues/5631)) ([032d6c7](https://github.com/filebrowser/filebrowser/commit/032d6c7520a64686c9d9b1218562256f629b4703))
### Bug Fixes
* conversion of backslashes in file paths for archive creation ([#5637](https://github.com/filebrowser/filebrowser/issues/5637)) ([9595f39](https://github.com/filebrowser/filebrowser/commit/9595f3939c1c129ed875a47adcc4fbcfad9a0e65))
* Don't crash on invalid config import ([#5640](https://github.com/filebrowser/filebrowser/issues/5640)) ([79d1aa9](https://github.com/filebrowser/filebrowser/commit/79d1aa9229b076ee8e3b71d6cf061fc90738f4da))
* fix nil deref in config set command ([#5641](https://github.com/filebrowser/filebrowser/issues/5641)) ([60b1ee8](https://github.com/filebrowser/filebrowser/commit/60b1ee8bb9e18b21d7f2c04cb1cc90046cecd3e1))
## [2.52.0](https://github.com/filebrowser/filebrowser/compare/v2.51.2...v2.52.0) (2025-12-13)
### Features
* sync translations with Transifex ([7fa3432](https://github.com/filebrowser/filebrowser/commit/7fa3432f25610bbb55a718bc709b9a7bf41d92f0))
* update translations ([#5615](https://github.com/filebrowser/filebrowser/issues/5615)) ([3fdca6d](https://github.com/filebrowser/filebrowser/commit/3fdca6dfd9a18c3f4895b4ef3cbd216824dbb57a))
### Bug Fixes
* display the directory name in the shared folder view ([#5617](https://github.com/filebrowser/filebrowser/issues/5617)) ([6d4c867](https://github.com/filebrowser/filebrowser/commit/6d4c86767239dad4f09f30f48678f2f3a716eb12))
* hide the context menu when changing the route ([#5613](https://github.com/filebrowser/filebrowser/issues/5613)) ([cf96657](https://github.com/filebrowser/filebrowser/commit/cf966578d8c6beab111b74f495bac6bdec173f41))
## [2.51.2](https://github.com/filebrowser/filebrowser/compare/v2.51.1...v2.51.2) (2025-12-07)
### Bug Fixes
* **frontend:** add missing i18n strings ([c171599](https://github.com/filebrowser/filebrowser/commit/c1715992bda46517f801c1aa496df8a3b42a4e4d))
## [2.51.1](https://github.com/filebrowser/filebrowser/compare/v2.51.0...v2.51.1) (2025-12-07)
### Bug Fixes
* **frontend:** csv viewer i18n strings ([4cbb4b7](https://github.com/filebrowser/filebrowser/commit/4cbb4b73af816104475f15c1d996640b56203602))
* prevent the right-click from selecting multiple items when the "single-click" option is active ([#5608](https://github.com/filebrowser/filebrowser/issues/5608)) ([152f830](https://github.com/filebrowser/filebrowser/commit/152f8302f7cda21bde37692b175c22c124233f45))
## [2.51.0](https://github.com/filebrowser/filebrowser/compare/v2.50.0...v2.51.0) (2025-12-06)
### Features
* update translations ([2d88c06](https://github.com/filebrowser/filebrowser/commit/2d88c067611e936056dbbf04247f1c1c709b2a09))
### Bug Fixes
* added column separator select (comma, semicolon and both) in CSV viewer ([#5604](https://github.com/filebrowser/filebrowser/issues/5604)) ([204a3f0](https://github.com/filebrowser/filebrowser/commit/204a3f0eeaa0c68781b60651bf27c4b27eac44e6))
### Refactorings
* cleanup package names ([#5605](https://github.com/filebrowser/filebrowser/issues/5605)) ([f029c30](https://github.com/filebrowser/filebrowser/commit/f029c3005e450cfbebb074c42dbdf65db9c8d56a))
## [2.50.0](https://github.com/filebrowser/filebrowser/compare/v2.49.0...v2.50.0) (2025-11-30)
### Features
* configurable logout page URL for proxy/hook auth ([#3884](https://github.com/filebrowser/filebrowser/issues/3884)) ([b9ac45d](https://github.com/filebrowser/filebrowser/commit/b9ac45d5dac4b4eb2ba364629090fbf306cffd2b))
* render CSVs as table ([#5569](https://github.com/filebrowser/filebrowser/issues/5569)) ([982405e](https://github.com/filebrowser/filebrowser/commit/982405ec944f94baf43594b0ed2f06329ff4e9ed))
* update frontend/src/i18n/hr.json ([279a5cc](https://github.com/filebrowser/filebrowser/commit/279a5ccd1e8d7bde4568b63cb3c506af48b6c618))
* update translations ([78e0395](https://github.com/filebrowser/filebrowser/commit/78e039596070a3a9e643a693cc99960c69dcfe92))
### Bug Fixes
* do not close editor if save failed ([701522a](https://github.com/filebrowser/filebrowser/commit/701522a0600cfa542469540ed764630c0ba1a732)), closes [#5591](https://github.com/filebrowser/filebrowser/issues/5591)
## [2.49.0](https://github.com/filebrowser/filebrowser/compare/v2.48.2...v2.49.0) (2025-11-22)
### Features
* add "copy download link to clipboard" button to Share prompt ([#5173](https://github.com/filebrowser/filebrowser/issues/5173)) ([d48f566](https://github.com/filebrowser/filebrowser/commit/d48f5665d6975c4cbbdf9be20dc2e0106db02f01))
* add Bulgarian language ([8db2411](https://github.com/filebrowser/filebrowser/commit/8db2411cd43a23ae3292a817e3524cfdb5ae9b86))
* Updates for project File Browser ([#5566](https://github.com/filebrowser/filebrowser/issues/5566)) ([54306bd](https://github.com/filebrowser/filebrowser/commit/54306bdc8700fac489326ae81e28ac5db0580d13))
### Bug Fixes
* display friendly error message for password validation on signup ([#5563](https://github.com/filebrowser/filebrowser/issues/5563)) ([6d5aa35](https://github.com/filebrowser/filebrowser/commit/6d5aa355e433d613e5a3ae137f410c63baeddf0f))
## [2.48.2](https://github.com/filebrowser/filebrowser/compare/v2.48.1...v2.48.2) (2025-11-18)
### Bug Fixes
* add transitionary support for FB_BASEURL ([984ea7b](https://github.com/filebrowser/filebrowser/commit/984ea7b569e3bd33b6f91ebdf63684a618d51e94))
### Refactorings
* rename python for clarification ([fd7b70c](https://github.com/filebrowser/filebrowser/commit/fd7b70cf38ac67c8c9ff79f2e7fde5e2ec45a1de))
## [2.48.1](https://github.com/filebrowser/filebrowser/compare/v2.48.0...v2.48.1) (2025-11-17)
### Bug Fixes
* options should only override if set ([420adea](https://github.com/filebrowser/filebrowser/commit/420adea7e61a1c182cddd6fb2544a0752e5709f7))
## [2.48.0](https://github.com/filebrowser/filebrowser/compare/v2.47.0...v2.48.0) (2025-11-17)
### Features
* consistent flags and environment variables ([#5549](https://github.com/filebrowser/filebrowser/issues/5549)) ([0a0cb80](https://github.com/filebrowser/filebrowser/commit/0a0cb8046fce52f1ff926171b34bcdb7cd39aab3))
### Bug Fixes
* add tokenExpirationTime to `config init` and troubleshoot docs ([#5546](https://github.com/filebrowser/filebrowser/issues/5546)) ([8c5dc76](https://github.com/filebrowser/filebrowser/commit/8c5dc7641e6f8aadd9e5d5d3b25a2ad9f1ec9a1e))
* use all available flags in quick setup ([f41585f](https://github.com/filebrowser/filebrowser/commit/f41585f0392d65c08c01ab65b62d3eeb04c03b7d))
### Refactorings
* reuse logic for config init and set ([89be0b1](https://github.com/filebrowser/filebrowser/commit/89be0b1873527987dd2dddac746e93b8bc684d46))
## [2.47.0](https://github.com/filebrowser/filebrowser/compare/v2.46.1...v2.47.0) (2025-11-16)
### Features
* add TUS settings to the command line ([#5556](https://github.com/filebrowser/filebrowser/issues/5556)) ([e24e1f1](https://github.com/filebrowser/filebrowser/commit/e24e1f1abae9e80add620c4ad65660ca1b575a49))
* remove importer of v1 config ([#5550](https://github.com/filebrowser/filebrowser/issues/5550)) ([ceb5e72](https://github.com/filebrowser/filebrowser/commit/ceb5e723f3ee2c966bb561a804015246450280ca))
### Bug Fixes
* exit 0 when gracefully shutting down ([#5555](https://github.com/filebrowser/filebrowser/issues/5555)) ([5de4099](https://github.com/filebrowser/filebrowser/commit/5de4099cba2cf012d4a213c8eb29c412fc72c151))
## [2.46.1](https://github.com/filebrowser/filebrowser/compare/v2.46.0...v2.46.1) (2025-11-15)
### Bug Fixes
* env key replacer and remove unused function ([#5547](https://github.com/filebrowser/filebrowser/issues/5547)) ([13814e1](https://github.com/filebrowser/filebrowser/commit/13814e11197ebd9101940883e3ca85998f86d442))
* remove duplicated 'hide-defaults' flag (is 'hideDefaults') ([#5548](https://github.com/filebrowser/filebrowser/issues/5548)) ([ffc8504](https://github.com/filebrowser/filebrowser/commit/ffc850454e4cb8f10b970511681d6c627340afc7))
## [2.46.0](https://github.com/filebrowser/filebrowser/compare/v2.45.3...v2.46.0) (2025-11-14)
### Features
* add 'hide-dotfiles' as command line parameter ([#3802](https://github.com/filebrowser/filebrowser/issues/3802)) ([0d973d3](https://github.com/filebrowser/filebrowser/commit/0d973d3aad70ceb88950f2cd9c297fc76e7955b1))
* add context menu ([#3343](https://github.com/filebrowser/filebrowser/issues/3343)) ([1ace579](https://github.com/filebrowser/filebrowser/commit/1ace579a553486bb15af2d11f537414156606434))
* add option to hide the login button from public-facing pages ([#3922](https://github.com/filebrowser/filebrowser/issues/3922)) ([ac7b49c](https://github.com/filebrowser/filebrowser/commit/ac7b49c1484b4e27a1149310542ccd1e90659ee2))
* Updates for project File Browser ([#5544](https://github.com/filebrowser/filebrowser/issues/5544)) ([fb5d099](https://github.com/filebrowser/filebrowser/commit/fb5d099f8514516216f407be012d2e3f25de2441))
## [2.45.3](https://github.com/filebrowser/filebrowser/compare/v2.45.2...v2.45.3) (2025-11-13)
This is a test release to ensure the updated workflow works.
## [2.45.2](https://github.com/filebrowser/filebrowser/compare/v2.45.1...v2.45.2) (2025-11-13)
### Bug Fixes
* **deps:** update module github.com/shirou/gopsutil/v3 to v4 ([#5536](https://github.com/filebrowser/filebrowser/issues/5536)) ([fdff7a3](https://github.com/filebrowser/filebrowser/commit/fdff7a38f4711f2b58dfdd60bebbb057bd3a478d))
* **deps:** update module gopkg.in/yaml.v2 to v3 ([#5537](https://github.com/filebrowser/filebrowser/issues/5537)) ([f26a685](https://github.com/filebrowser/filebrowser/commit/f26a68587d8432b536453093f42dc255d19d10fa))
### [2.45.1](https://github.com/filebrowser/filebrowser/compare/v2.45.0...v2.45.1) (2025-11-11)
### Bug Fixes
* share page preview items to contain baseUrl ([#5510](https://github.com/filebrowser/filebrowser/issues/5510)) ([6950c2e](https://github.com/filebrowser/filebrowser/commit/6950c2e4d2868f06235f93c0a18b303b4095ca0a))
## [2.45.0](https://github.com/filebrowser/filebrowser/compare/v2.44.2...v2.45.0) (2025-11-01)
### Features
* update translations ([#5458](https://github.com/filebrowser/filebrowser/issues/5458)) ([b9a03fa](https://github.com/filebrowser/filebrowser/commit/b9a03fabd98119d6588882f5ba2a7d29b012d729))
### Bug Fixes
* support croatian ([#5502](https://github.com/filebrowser/filebrowser/issues/5502)) ([93fe31c](https://github.com/filebrowser/filebrowser/commit/93fe31cc55c9d9d27c634993619a768fa700da1d))
### [2.44.2](https://github.com/filebrowser/filebrowser/compare/v2.44.1...v2.44.2) (2025-10-22) ### [2.44.2](https://github.com/filebrowser/filebrowser/compare/v2.44.1...v2.44.2) (2025-10-22)

View File

@@ -15,11 +15,23 @@ We encourage you to use git to manage your fork. To clone the main repository, j
git clone https://github.com/filebrowser/filebrowser git clone https://github.com/filebrowser/filebrowser
``` ```
We use [Taskfile](https://taskfile.dev/) to manage the different processes (building, releasing, etc) automatically.
## Build ## Build
You can fully build the project in order to produce a binary by running:
```bash
task build
```
## Development
For development, there are a few things to have in mind.
### Frontend ### Frontend
We are using [Node.js](https://nodejs.org/en/) on the frontend to manage the build process. The steps to build it are: We use [Node.js](https://nodejs.org/en/) on the frontend to manage the build process. Prepare the frontend environment:
```bash ```bash
# From the root of the repo, go to frontend/ # From the root of the repo, go to frontend/
@@ -27,37 +39,62 @@ cd frontend
# Install the dependencies # Install the dependencies
pnpm install pnpm install
```
# Build the frontend If you just want to develop the backend, you can create a static build of the frontend:
```bash
pnpm run build pnpm run build
``` ```
This will install the dependencies and build the frontend so you can then embed it into the Go app. Although, if you want to play with it, you'll get bored of building it after every change you do. So, you can run the command below to watch for changes: If you want to develop the frontend, start a development server which watches for changes:
```bash ```bash
pnpm run dev pnpm run dev
``` ```
Please note that you need to access File Browser's interface through the development server of the frontend.
### Backend ### Backend
First of all, you need to download the required dependencies. We are using the built-in `go mod` tool for dependency management. To get the modules, run: First prepare the backend environment by downloading all required dependencies:
```bash ```bash
go mod download go mod download
``` ```
The magic of File Browser is that the static assets are bundled into the final binary. For that, we use [Go embed.FS](https://golang.org/pkg/embed/). The files from `frontend/dist` will be embedded during the build process. You can now build or run File Browser as any other Go project:
To build File Browser is just like any other Go program:
```bash ```bash
# Build
go build go build
# Run
go run .
``` ```
To create a development build use the "dev" tag, this way the content inside the frontend folder will not be embedded in the binary but will be reloaded at every change: ## Documentation
We rely on Docker to abstract all the dependencies required for building the documentation.
To build the documentation to `www/public`:
```bash ```bash
go build -tags dev task docs
```
To start a local server on port `8000` to view the built documentation:
```bash
task docs:serve
```
## Release
To make a release, just run:
```bash
task release
``` ```
## Translations ## Translations

View File

@@ -1,5 +1,5 @@
## Multistage build: First stage fetches dependencies ## Multistage build: First stage fetches dependencies
FROM alpine:3.22 AS fetcher FROM alpine:3.23 AS fetcher
# install and copy ca-certificates, mailcap, and tini-static; download JSON.sh # install and copy ca-certificates, mailcap, and tini-static; download JSON.sh
RUN apk update && \ RUN apk update && \

86
Dockerfile.full Normal file
View File

@@ -0,0 +1,86 @@
# Multi-stage build: compile frontend + backend, then package
# This is a full build Dockerfile for filebrowser from source
##############################################################################
# Stage 1: Build frontend (Node.js / pnpm)
##############################################################################
FROM node:20-alpine AS frontend-builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app/frontend
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm run build
##############################################################################
# Stage 2: Build backend (Go)
##############################################################################
FROM golang:1.25-alpine AS backend-builder
RUN apk add --no-cache git
WORKDIR /app
COPY . .
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
RUN go mod tidy && go mod download
ARG VERSION=dev
ARG GIT_COMMIT=unknown
RUN CGO_ENABLED=0 go build \
-ldflags="-s -w -X 'github.com/filebrowser/filebrowser/v2/version.Version=${VERSION}' -X 'github.com/filebrowser/filebrowser/v2/version.CommitSHA=${GIT_COMMIT}'" \
-o filebrowser .
##############################################################################
# Stage 3: Fetch runtime dependencies
##############################################################################
FROM alpine:3.23 AS fetcher
RUN apk update && \
apk --no-cache add ca-certificates mailcap tini-static && \
wget -O /JSON.sh https://raw.githubusercontent.com/dominictarr/JSON.sh/0d5e5c77365f63809bf6e77ef44a1f34b0e05840/JSON.sh
##############################################################################
# Stage 4: Final runtime image (Alpine with ffmpeg)
##############################################################################
FROM alpine:3.23
ENV UID=1000
ENV GID=1000
RUN addgroup -g $GID user && \
adduser -D -u $UID -G user user && \
apk add --no-cache ffmpeg flac
# Copy compiled binary from backend-builder
COPY --from=backend-builder --chown=user:user /app/filebrowser /bin/filebrowser
# Copy scripts and configs from repo
COPY --chown=user:user docker/common/ /
COPY --chown=user:user docker/alpine/ /
# Copy runtime deps from fetcher
COPY --chown=user:user --from=fetcher /sbin/tini-static /bin/tini
COPY --from=fetcher /JSON.sh /JSON.sh
COPY --from=fetcher /etc/ca-certificates.conf /etc/ca-certificates.conf
COPY --from=fetcher /etc/ca-certificates /etc/ca-certificates
COPY --from=fetcher /etc/mime.types /etc/mime.types
COPY --from=fetcher /etc/ssl /etc/ssl
RUN mkdir -p /config /database /srv && \
chown -R user:user /config /database /srv && \
chmod +x /healthcheck.sh
HEALTHCHECK --start-period=2s --interval=5s --timeout=3s CMD /healthcheck.sh
USER user
VOLUME /srv /config /database
EXPOSE 80
ENTRYPOINT [ "tini", "--", "/init.sh" ]

View File

@@ -1,7 +1,7 @@
FROM ghcr.io/linuxserver/baseimage-alpine:3.22 FROM ghcr.io/linuxserver/baseimage-alpine:3.23
RUN apk update && \ RUN apk update && \
apk --no-cache add ca-certificates mailcap jq libcap apk --no-cache add ca-certificates mailcap jq libcap ffmpeg
# Make user and create necessary directories # Make user and create necessary directories
RUN mkdir -p /config /database /srv && \ RUN mkdir -p /config /database /srv && \

View File

@@ -1,88 +0,0 @@
include common.mk
include tools.mk
LDFLAGS += -X "$(MODULE)/version.Version=$(VERSION)" -X "$(MODULE)/version.CommitSHA=$(VERSION_HASH)"
SITE_DOCKER_FLAGS = \
-v $(CURDIR)/www:/docs \
-v $(CURDIR)/LICENSE:/docs/docs/LICENSE \
-v $(CURDIR)/SECURITY.md:/docs/docs/security.md \
-v $(CURDIR)/CHANGELOG.md:/docs/docs/changelog.md \
-v $(CURDIR)/CODE-OF-CONDUCT.md:/docs/docs/code-of-conduct.md \
-v $(CURDIR)/CONTRIBUTING.md:/docs/docs/contributing.md
## Build:
.PHONY: build
build: | build-frontend build-backend ## Build binary
.PHONY: build-frontend
build-frontend: ## Build frontend
$Q cd frontend && pnpm install --frozen-lockfile && pnpm run build
.PHONY: build-backend
build-backend: ## Build backend
$Q $(go) build -ldflags '$(LDFLAGS)' -o .
.PHONY: test
test: | test-frontend test-backend ## Run all tests
.PHONY: test-frontend
test-frontend: ## Run frontend tests
$Q cd frontend && pnpm install --frozen-lockfile && pnpm run typecheck
.PHONY: test-backend
test-backend: ## Run backend tests
$Q $(go) test -v ./...
.PHONY: lint
lint: lint-frontend lint-backend ## Run all linters
.PHONY: lint-frontend
lint-frontend: ## Run frontend linters
$Q cd frontend && pnpm install --frozen-lockfile && pnpm run lint
.PHONY: lint-backend
lint-backend: | $(golangci-lint) ## Run backend linters
$Q $(golangci-lint) run -v
.PHONY: lint-commits
lint-commits: $(commitlint) ## Run commit linters
$Q ./scripts/commitlint.sh
fmt: $(goimports) ## Format source files
$Q $(goimports) -local $(MODULE) -w $$(find . -type f -name '*.go' -not -path "./vendor/*")
clean: clean-tools ## Clean
## Release:
.PHONY: bump-version
bump-version: $(standard-version) ## Bump app version
$Q ./scripts/bump_version.sh
.PHONY: site
site: ## Build site
@rm -rf www/public
docker build -f www/Dockerfile --progress=plain -t filebrowser.site www
docker run --rm $(SITE_DOCKER_FLAGS) filebrowser.site build -d "public"
.PHONY: site-serve
site-serve: ## Serve site for development
docker build -f www/Dockerfile --progress=plain -t filebrowser.site www
docker run --rm -it -p 8000:8000 $(SITE_DOCKER_FLAGS) filebrowser.site
## Help:
help: ## Show this help
@echo ''
@echo 'Usage:'
@echo ' ${YELLOW}make${RESET} ${GREEN}<target> [options]${RESET}'
@echo ''
@echo 'Options:'
@$(call global_option, "V [0|1]", "enable verbose mode (default:0)")
@echo ''
@echo 'Targets:'
@awk 'BEGIN {FS = ":.*?## "} { \
if (/^[a-zA-Z_-]+:.*?##.*$$/) {printf " ${YELLOW}%-20s${GREEN}%s${RESET}\n", $$1, $$2} \
else if (/^## .*$$/) {printf " ${CYAN}%s${RESET}\n", substr($$1,4)} \
}' $(MAKEFILE_LIST)

View File

@@ -1,12 +1,10 @@
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/filebrowser/logo/master/banner.png" width="550"/> <img src="https://raw.githubusercontent.com/filebrowser/filebrowser/master/branding/banner.png" width="550"/>
</p> </p>
[![Build](https://github.com/filebrowser/filebrowser/actions/workflows/main.yaml/badge.svg)](https://github.com/filebrowser/filebrowser/actions/workflows/main.yaml) [![Build](https://github.com/filebrowser/filebrowser/actions/workflows/ci.yaml/badge.svg)](https://github.com/filebrowser/filebrowser/actions/workflows/ci.yaml)
[![Go Report Card](https://goreportcard.com/badge/github.com/filebrowser/filebrowser)](https://goreportcard.com/report/github.com/filebrowser/filebrowser) [![Go Report Card](https://goreportcard.com/badge/github.com/filebrowser/filebrowser/v2)](https://goreportcard.com/report/github.com/filebrowser/filebrowser/v2)
[![Documentation](https://img.shields.io/badge/godoc-reference-blue.svg)](http://godoc.org/github.com/filebrowser/filebrowser)
[![Version](https://img.shields.io/github/release/filebrowser/filebrowser.svg)](https://github.com/filebrowser/filebrowser/releases/latest) [![Version](https://img.shields.io/github/release/filebrowser/filebrowser.svg)](https://github.com/filebrowser/filebrowser/releases/latest)
[![Chat IRC](https://img.shields.io/badge/freenode-%23filebrowser-blue.svg)](http://webchat.freenode.net/?channels=%23filebrowser)
File Browser provides a file managing interface within a specified directory and it can be used to upload, delete, preview and edit your files. It is a **create-your-own-cloud**-kind of software where you can just install it on your server, direct it to a path and access your files through a nice web interface. File Browser provides a file managing interface within a specified directory and it can be used to upload, delete, preview and edit your files. It is a **create-your-own-cloud**-kind of software where you can just install it on your server, direct it to a path and access your files through a nice web interface.
@@ -16,18 +14,12 @@ Documentation on how to install, configure, and contribute to this project is ho
## Project Status ## Project Status
> [!WARNING] This project is a finished product which fulfills its goal: be a single binary web File Browser which can be run by anyone anywhere. That means that File Browser is currently on **maintenance-only** mode. Therefore, please note the following:
>
> This project is currently on **maintenance-only** mode, and is looking for new maintainers. For more information, please read the [discussion #4906](https://github.com/filebrowser/filebrowser/discussions/4906). Therefore, please note the following:
>
> - It can take a while until someone gets back to you. Please be patient.
> - [Issues][issues] are only being used to track bugs. Any unrelated issues will be converted into a [discussion][discussions].
> - No new features will be implemented until further notice. The priority is on triaging issues and merge bug fixes.
>
> If you're interested in maintaining this project, please reach out via the discussion above.
[issues]: https://github.com/filebrowser/filebrowser/issues - It can take a while until someone gets back to you. Please be patient.
[discussions]: https://github.com/filebrowser/filebrowser/discussions - [Issues](https://github.com/filebrowser/filebrowser/issues) are meant to track bugs. Unrelated issues will be converted into [discussions](https://github.com/filebrowser/filebrowser/discussions).
- No new features will be implemented by maintainers. Pull requests for new features will be reviewed on a case by case basis.
- The priority is triaging issues, addressing security issues and reviewing pull requests meant to solve bugs.
## Contributing ## Contributing
@@ -36,3 +28,4 @@ Contributions are always welcome. To start contributing to this project, read ou
## License ## License
[Apache License 2.0](LICENSE) © File Browser Contributors [Apache License 2.0](LICENSE) © File Browser Contributors
# Work in progress: metadata modifier

83
Taskfile.yml Normal file
View File

@@ -0,0 +1,83 @@
version: '3'
vars:
SITE_DOCKER_FLAGS: >-
-v ./www:/docs
-v ./LICENSE:/docs/docs/LICENSE
-v ./SECURITY.md:/docs/docs/security.md
-v ./CHANGELOG.md:/docs/docs/changelog.md
-v ./CODE-OF-CONDUCT.md:/docs/docs/code-of-conduct.md
-v ./CONTRIBUTING.md:/docs/docs/contributing.md
tasks:
build:frontend:
desc: Build frontend assets
dir: frontend
cmds:
- pnpm install --frozen-lockfile
- pnpm run build
build:backend:
desc: Build backend binary
cmds:
- go build -ldflags='-s -w -X "github.com/filebrowser/filebrowser/v2/version.Version={{.VERSION}}" -X "github.com/filebrowser/filebrowser/v2/version.CommitSHA={{.GIT_COMMIT}}"' -o filebrowser .
vars:
GIT_COMMIT:
sh: git log -n 1 --format=%h
VERSION:
sh: git describe --tags --abbrev=0 --match=v* | cut -c 2-
build:
desc: Build both frontend and backend
cmds:
- task: build:frontend
- task: build:backend
release:make:
internal: true
prompt: Do you wish to proceed?
cmds:
- pnpm dlx commit-and-tag-version -s
release:dry-run:
internal: true
cmds:
- pnpm dlx commit-and-tag-version --dry-run --skip
release:
desc: Create a new release
cmds:
- task: docs:cli:generate
- git add www/docs/cli
- |
if [[ `git status www/docs/cli --porcelain` ]]; then
git commit -m 'chore(docs): update CLI documentation'
fi
- task: release:dry-run
- task: release:make
docs:cli:generate:
cmds:
- rm -rf www/docs/cli
- mkdir -p www/docs/cli
- go run . docs
generates:
- www/docs/cli
docs:docker:generate:
internal: true
cmds:
- docker build -f www/Dockerfile --progress=plain -t filebrowser.site www
docs:
desc: Generate documentation
cmds:
- rm -rf www/public
- task: docs:docker:generate
- docker run --rm {{.SITE_DOCKER_FLAGS}} filebrowser.site build -d "public"
docs:serve:
desc: Serve documentation
cmds:
- task: docs:docker:generate
- docker run --rm -it -p 8000:8000 {{.SITE_DOCKER_FLAGS}} filebrowser.site

View File

@@ -11,7 +11,7 @@ import (
"slices" "slices"
"strings" "strings"
fbErrors "github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/files" "github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/settings" "github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/users" "github.com/filebrowser/filebrowser/v2/users"
@@ -103,7 +103,7 @@ func (a *HookAuth) RunCommand() (string, error) {
command[i] = os.Expand(arg, envMapping) command[i] = os.Expand(arg, envMapping)
} }
cmd := exec.Command(command[0], command[1:]...) //nolint:gosec cmd := exec.Command(command[0], command[1:]...)
cmd.Env = append(os.Environ(), fmt.Sprintf("USERNAME=%s", a.Cred.Username)) cmd.Env = append(os.Environ(), fmt.Sprintf("USERNAME=%s", a.Cred.Username))
cmd.Env = append(cmd.Env, fmt.Sprintf("PASSWORD=%s", a.Cred.Password)) cmd.Env = append(cmd.Env, fmt.Sprintf("PASSWORD=%s", a.Cred.Password))
out, err := cmd.Output() out, err := cmd.Output()
@@ -146,7 +146,7 @@ func (a *HookAuth) GetValues(s string) {
// SaveUser updates the existing user or creates a new one when not found // SaveUser updates the existing user or creates a new one when not found
func (a *HookAuth) SaveUser() (*users.User, error) { func (a *HookAuth) SaveUser() (*users.User, error) {
u, err := a.Users.Get(a.Server.Root, a.Cred.Username) u, err := a.Users.Get(a.Server.Root, a.Cred.Username)
if err != nil && !errors.Is(err, fbErrors.ErrNotExist) { if err != nil && !errors.Is(err, fberrors.ErrNotExist) {
return nil, err return nil, err
} }
@@ -158,16 +158,17 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
// create user with the provided credentials // create user with the provided credentials
d := &users.User{ d := &users.User{
Username: a.Cred.Username, Username: a.Cred.Username,
Password: pass, Password: pass,
Scope: a.Settings.Defaults.Scope, Scope: a.Settings.Defaults.Scope,
Locale: a.Settings.Defaults.Locale, Locale: a.Settings.Defaults.Locale,
ViewMode: a.Settings.Defaults.ViewMode, ViewMode: a.Settings.Defaults.ViewMode,
SingleClick: a.Settings.Defaults.SingleClick, SingleClick: a.Settings.Defaults.SingleClick,
Sorting: a.Settings.Defaults.Sorting, RedirectAfterCopyMove: a.Settings.Defaults.RedirectAfterCopyMove,
Perm: a.Settings.Defaults.Perm, Sorting: a.Settings.Defaults.Sorting,
Commands: a.Settings.Defaults.Commands, Perm: a.Settings.Defaults.Perm,
HideDotfiles: a.Settings.Defaults.HideDotfiles, Commands: a.Settings.Defaults.Commands,
HideDotfiles: a.Settings.Defaults.HideDotfiles,
} }
u = a.GetUser(d) u = a.GetUser(d)
@@ -219,13 +220,14 @@ func (a *HookAuth) GetUser(d *users.User) *users.User {
Download: isAdmin || a.Fields.GetBoolean("user.perm.download", d.Perm.Download), Download: isAdmin || a.Fields.GetBoolean("user.perm.download", d.Perm.Download),
} }
user := users.User{ user := users.User{
ID: d.ID, ID: d.ID,
Username: d.Username, Username: d.Username,
Password: d.Password, Password: d.Password,
Scope: a.Fields.GetString("user.scope", d.Scope), Scope: a.Fields.GetString("user.scope", d.Scope),
Locale: a.Fields.GetString("user.locale", d.Locale), Locale: a.Fields.GetString("user.locale", d.Locale),
ViewMode: users.ViewMode(a.Fields.GetString("user.viewMode", string(d.ViewMode))), ViewMode: users.ViewMode(a.Fields.GetString("user.viewMode", string(d.ViewMode))),
SingleClick: a.Fields.GetBoolean("user.singleClick", d.SingleClick), SingleClick: a.Fields.GetBoolean("user.singleClick", d.SingleClick),
RedirectAfterCopyMove: a.Fields.GetBoolean("user.redirectAfterCopyMove", d.RedirectAfterCopyMove),
Sorting: files.Sorting{ Sorting: files.Sorting{
Asc: a.Fields.GetBoolean("user.sorting.asc", d.Sorting.Asc), Asc: a.Fields.GetBoolean("user.sorting.asc", d.Sorting.Asc),
By: a.Fields.GetString("user.sorting.by", d.Sorting.By), By: a.Fields.GetString("user.sorting.by", d.Sorting.By),
@@ -251,6 +253,7 @@ var validHookFields = []string{
"user.locale", "user.locale",
"user.viewMode", "user.viewMode",
"user.singleClick", "user.singleClick",
"user.redirectAfterCopyMove",
"user.sorting.by", "user.sorting.by",
"user.sorting.asc", "user.sorting.asc",
"user.commands", "user.commands",

View File

@@ -14,6 +14,10 @@ import (
// MethodJSONAuth is used to identify json auth. // MethodJSONAuth is used to identify json auth.
const MethodJSONAuth settings.AuthMethod = "json" const MethodJSONAuth settings.AuthMethod = "json"
// dummyHash is used to prevent user enumeration timing attacks.
// It MUST be a valid bcrypt hash.
const dummyHash = "$2a$10$O4mEMeOL/nit6zqe.WQXauLRbRlzb3IgLHsa26Pf0N/GiU9b.wK1m"
type jsonCred struct { type jsonCred struct {
Password string `json:"password"` Password string `json:"password"`
Username string `json:"username"` Username string `json:"username"`
@@ -40,7 +44,7 @@ func (a JSONAuth) Auth(r *http.Request, usr users.Store, _ *settings.Settings, s
// If ReCaptcha is enabled, check the code. // If ReCaptcha is enabled, check the code.
if a.ReCaptcha != nil && a.ReCaptcha.Secret != "" { if a.ReCaptcha != nil && a.ReCaptcha.Secret != "" {
ok, err := a.ReCaptcha.Ok(cred.ReCaptcha) //nolint:govet ok, err := a.ReCaptcha.Ok(cred.ReCaptcha)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -52,7 +56,17 @@ func (a JSONAuth) Auth(r *http.Request, usr users.Store, _ *settings.Settings, s
} }
u, err := usr.Get(srv.Root, cred.Username) u, err := usr.Get(srv.Root, cred.Username)
if err != nil || !users.CheckPwd(cred.Password, u.Password) {
hash := dummyHash
if err == nil {
hash = u.Password
}
if !users.CheckPwd(cred.Password, hash) {
return nil, os.ErrPermission
}
if err != nil {
return nil, os.ErrPermission return nil, os.ErrPermission
} }

View File

@@ -4,7 +4,7 @@ import (
"errors" "errors"
"net/http" "net/http"
fbErrors "github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/settings" "github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/users" "github.com/filebrowser/filebrowser/v2/users"
) )
@@ -21,7 +21,7 @@ type ProxyAuth struct {
func (a ProxyAuth) Auth(r *http.Request, usr users.Store, setting *settings.Settings, srv *settings.Server) (*users.User, error) { func (a ProxyAuth) Auth(r *http.Request, usr users.Store, setting *settings.Settings, srv *settings.Server) (*users.User, error) {
username := r.Header.Get(a.Header) username := r.Header.Get(a.Header)
user, err := usr.Get(srv.Root, username) user, err := usr.Get(srv.Root, username)
if errors.Is(err, fbErrors.ErrNotExist) { if errors.Is(err, fberrors.ErrNotExist) {
return a.createUser(usr, setting, srv, username) return a.createUser(usr, setting, srv, username)
} }
return user, err return user, err

BIN
branding/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

1
branding/banner.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

BIN
branding/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

1
branding/icon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="700" height="700" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd"><defs><style>.prefix__fil1{fill:#fefefe}.prefix__fil6{fill:#006498}.prefix__fil5{fill:#bdeaff}</style></defs><g id="prefix__Layer_x0020_1"><path d="M80 0h540c44 0 80 36 80 80v540c0 44-36 80-80 80H80c-44 0-80-36-80-80V80C0 36 36 0 80 0z" fill="#455a64"/><path class="prefix__fil1" d="M350 71c154 0 279 125 279 279S504 629 350 629 71 504 71 350 196 71 350 71z"/><path d="M475 236l118 151c3 116-149 252-292 198l-76-99 114-156s138-95 136-94z" fill="#332c2b" fill-opacity=".149"/><path d="M231 211h208l38 24v246c0 5-3 8-8 8H231c-5 0-8-3-8-8V219c0-5 3-8 8-8z" fill="#2bbcff"/><path d="M231 211h208l38 24v2l-37-23H231c-4 0-7 3-7 7v263c-1-1-1-2-1-3V219c0-5 3-8 8-8z" fill="#53c6fc"/><path class="prefix__fil5" d="M305 212h113v98H305zM255 363h189c3 0 5 2 5 4v116H250V367c0-2 2-4 5-4z"/><path class="prefix__fil6" d="M250 470h199v13H250zM380 226h10c3 0 6 2 6 5v40c0 3-3 6-6 6h-10c-3 0-6-3-6-6v-40c0-3 3-5 6-5z"/><path class="prefix__fil1" d="M254 226c10 0 17 7 17 17 0 9-7 16-17 16-9 0-17-7-17-16 0-10 8-17 17-17z"/><path class="prefix__fil6" d="M267 448h165c2 0 3 1 3 3 0 1-1 3-3 3H267c-2 0-3-2-3-3 0-2 1-3 3-3zM267 415h165c2 0 3 1 3 3 0 1-1 2-3 2H267c-2 0-3-1-3-2 0-2 1-3 3-3zM267 381h165c2 0 3 2 3 3 0 2-1 3-3 3H267c-2 0-3-1-3-3 0-1 1-3 3-3z"/><path class="prefix__fil1" d="M236 472c3 0 5 2 5 5 0 2-2 4-5 4s-5-2-5-4c0-3 2-5 5-5zM463 472c3 0 5 2 5 5 0 2-2 4-5 4s-5-2-5-4c0-3 2-5 5-5z"/><path class="prefix__fil6" d="M305 212h-21v98h21z"/><path d="M477 479v2c0 5-3 8-8 8H231c-5 0-8-3-8-8v-2c0 4 3 8 8 8h238c5 0 8-4 8-8z" fill="#0ea5eb"/><path d="M350 70c155 0 280 125 280 280S505 630 350 630 70 505 70 350 195 70 350 70zm0 46c129 0 234 105 234 234S479 584 350 584 116 479 116 350s105-234 234-234z" fill="#2979ff"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
branding/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

1
branding/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="560" height="560" version="1.1" id="prefix__svg44" clip-rule="evenodd" fill-rule="evenodd" image-rendering="optimizeQuality" shape-rendering="geometricPrecision" text-rendering="geometricPrecision"><defs id="prefix__defs4"><style type="text/css" id="style2">.prefix__fil1{fill:#fefefe}.prefix__fil6{fill:#006498}.prefix__fil5{fill:#bdeaff}</style></defs><g id="prefix__g85" transform="translate(-70 -70)"><path class="prefix__fil1" d="M350 71c154 0 279 125 279 279S504 629 350 629 71 504 71 350 196 71 350 71z" id="prefix__path9" fill="#fefefe"/><path d="M475 236l118 151c3 116-149 252-292 198l-76-99 114-156s138-95 136-94z" id="prefix__path11" fill="#332c2b" fill-opacity=".149"/><path d="M231 211h208l38 24v246c0 5-3 8-8 8H231c-5 0-8-3-8-8V219c0-5 3-8 8-8z" id="prefix__path13" fill="#2bbcff"/><path d="M231 211h208l38 24v2l-37-23H231c-4 0-7 3-7 7v263c-1-1-1-2-1-3V219c0-5 3-8 8-8z" id="prefix__path15" fill="#53c6fc"/><path class="prefix__fil5" id="prefix__polygon17" fill="#bdeaff" d="M305 212h113v98H305z"/><path class="prefix__fil5" d="M255 363h189c3 0 5 2 5 4v116H250V367c0-2 2-4 5-4z" id="prefix__path19" fill="#bdeaff"/><path class="prefix__fil6" id="prefix__polygon21" fill="#006498" d="M250 470h199v13H250z"/><path class="prefix__fil6" d="M380 226h10c3 0 6 2 6 5v40c0 3-3 6-6 6h-10c-3 0-6-3-6-6v-40c0-3 3-5 6-5z" id="prefix__path23" fill="#006498"/><path class="prefix__fil1" d="M254 226c10 0 17 7 17 17 0 9-7 16-17 16-9 0-17-7-17-16 0-10 8-17 17-17z" id="prefix__path25" fill="#fefefe"/><path class="prefix__fil6" d="M267 448h165c2 0 3 1 3 3 0 1-1 3-3 3H267c-2 0-3-2-3-3 0-2 1-3 3-3z" id="prefix__path27" fill="#006498"/><path class="prefix__fil6" d="M267 415h165c2 0 3 1 3 3 0 1-1 2-3 2H267c-2 0-3-1-3-2 0-2 1-3 3-3z" id="prefix__path29" fill="#006498"/><path class="prefix__fil6" d="M267 381h165c2 0 3 2 3 3 0 2-1 3-3 3H267c-2 0-3-1-3-3 0-1 1-3 3-3z" id="prefix__path31" fill="#006498"/><path class="prefix__fil1" d="M236 472c3 0 5 2 5 5 0 2-2 4-5 4s-5-2-5-4c0-3 2-5 5-5z" id="prefix__path33" fill="#fefefe"/><path class="prefix__fil1" d="M463 472c3 0 5 2 5 5 0 2-2 4-5 4s-5-2-5-4c0-3 2-5 5-5z" id="prefix__path35" fill="#fefefe"/><path class="prefix__fil6" id="prefix__polygon37" fill="#006498" d="M305 212h-21v98h21z"/><path d="M477 479v2c0 5-3 8-8 8H231c-5 0-8-3-8-8v-2c0 4 3 8 8 8h238c5 0 8-4 8-8z" id="prefix__path39" fill="#0ea5eb"/><path d="M350 70c155 0 280 125 280 280S505 630 350 630 70 505 70 350 195 70 350 70zm0 46c129 0 234 105 234 234S479 584 350 584 116 479 116 350s105-234 234-234z" id="prefix__path41" fill="#2979ff"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

35
cmd/cmd_test.go Normal file
View File

@@ -0,0 +1,35 @@
package cmd
import (
"testing"
"github.com/samber/lo"
"github.com/spf13/cobra"
)
// TestEnvCollisions ensures that there are no collisions in the produced environment
// variable names for all commands and their flags.
func TestEnvCollisions(t *testing.T) {
testEnvCollisions(t, rootCmd)
}
func testEnvCollisions(t *testing.T, cmd *cobra.Command) {
for _, cmd := range cmd.Commands() {
testEnvCollisions(t, cmd)
}
replacements := generateEnvKeyReplacements(cmd)
envVariables := []string{}
for i := range replacements {
if i%2 != 0 {
envVariables = append(envVariables, replacements[i])
}
}
duplicates := lo.FindDuplicates(envVariables)
if len(duplicates) > 0 {
t.Errorf("Found duplicate environment variable keys for command %q: %v", cmd.Name(), duplicates)
}
}

View File

@@ -15,18 +15,18 @@ var cmdsAddCmd = &cobra.Command{
Short: "Add a command to run on a specific event", Short: "Add a command to run on a specific event",
Long: `Add a command to run on a specific event.`, Long: `Add a command to run on a specific event.`,
Args: cobra.MinimumNArgs(2), Args: cobra.MinimumNArgs(2),
RunE: python(func(_ *cobra.Command, args []string, d *pythonData) error { RunE: withStore(func(_ *cobra.Command, args []string, st *store) error {
s, err := d.store.Settings.Get() s, err := st.Settings.Get()
if err != nil { if err != nil {
return err return err
} }
command := strings.Join(args[1:], " ") command := strings.Join(args[1:], " ")
s.Commands[args[0]] = append(s.Commands[args[0]], command) s.Commands[args[0]] = append(s.Commands[args[0]], command)
err = d.store.Settings.Save(s) err = st.Settings.Save(s)
if err != nil { if err != nil {
return err return err
} }
printEvents(s.Commands) printEvents(s.Commands)
return nil return nil
}, pythonConfig{}), }, storeOptions{}),
} }

View File

@@ -14,12 +14,13 @@ var cmdsLsCmd = &cobra.Command{
Short: "List all commands for each event", Short: "List all commands for each event",
Long: `List all commands for each event.`, Long: `List all commands for each event.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: python(func(cmd *cobra.Command, _ []string, d *pythonData) error { RunE: withStore(func(cmd *cobra.Command, _ []string, st *store) error {
s, err := d.store.Settings.Get() s, err := st.Settings.Get()
if err != nil { if err != nil {
return err return err
} }
evt, err := getString(cmd.Flags(), "event")
evt, err := cmd.Flags().GetString("event")
if err != nil { if err != nil {
return err return err
} }
@@ -32,6 +33,7 @@ var cmdsLsCmd = &cobra.Command{
show["after_"+evt] = s.Commands["after_"+evt] show["after_"+evt] = s.Commands["after_"+evt]
printEvents(show) printEvents(show)
} }
return nil return nil
}, pythonConfig{}), }, storeOptions{}),
} }

View File

@@ -35,8 +35,8 @@ including 'index_end'.`,
return nil return nil
}, },
RunE: python(func(_ *cobra.Command, args []string, d *pythonData) error { RunE: withStore(func(_ *cobra.Command, args []string, st *store) error {
s, err := d.store.Settings.Get() s, err := st.Settings.Get()
if err != nil { if err != nil {
return err return err
} }
@@ -55,11 +55,11 @@ including 'index_end'.`,
} }
s.Commands[evt] = append(s.Commands[evt][:i], s.Commands[evt][f+1:]...) s.Commands[evt] = append(s.Commands[evt][:i], s.Commands[evt][f+1:]...)
err = d.store.Settings.Save(s) err = st.Settings.Save(s)
if err != nil { if err != nil {
return err return err
} }
printEvents(s.Commands) printEvents(s.Commands)
return nil return nil
}, pythonConfig{}), }, storeOptions{}),
} }

View File

@@ -2,7 +2,7 @@ package cmd
import ( import (
"encoding/json" "encoding/json"
nerrors "errors" "errors"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@@ -12,7 +12,7 @@ import (
"github.com/spf13/pflag" "github.com/spf13/pflag"
"github.com/filebrowser/filebrowser/v2/auth" "github.com/filebrowser/filebrowser/v2/auth"
"github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/settings" "github.com/filebrowser/filebrowser/v2/settings"
) )
@@ -30,14 +30,22 @@ var configCmd = &cobra.Command{
func addConfigFlags(flags *pflag.FlagSet) { func addConfigFlags(flags *pflag.FlagSet) {
addServerFlags(flags) addServerFlags(flags)
addUserFlags(flags) addUserFlags(flags)
flags.BoolP("signup", "s", false, "allow users to signup") flags.BoolP("signup", "s", false, "allow users to signup")
flags.Bool("create-user-dir", false, "generate user's home directory automatically") flags.Bool("hideLoginButton", false, "hide login button from public pages")
flags.Uint("minimum-password-length", settings.DefaultMinimumPasswordLength, "minimum password length for new users") flags.Bool("createUserDir", false, "generate user's home directory automatically")
flags.Uint("minimumPasswordLength", settings.DefaultMinimumPasswordLength, "minimum password length for new users")
flags.String("shell", "", "shell command to which other commands should be appended") flags.String("shell", "", "shell command to which other commands should be appended")
// NB: these are string so they can be presented as octal in the help text
// as that's the conventional representation for modes in Unix.
flags.String("fileMode", fmt.Sprintf("%O", settings.DefaultFileMode), "mode bits that new files are created with")
flags.String("dirMode", fmt.Sprintf("%O", settings.DefaultDirMode), "mode bits that new directories are created with")
flags.String("auth.method", string(auth.MethodJSONAuth), "authentication type") flags.String("auth.method", string(auth.MethodJSONAuth), "authentication type")
flags.String("auth.header", "", "HTTP header for auth.method=proxy") flags.String("auth.header", "", "HTTP header for auth.method=proxy")
flags.String("auth.command", "", "command for auth.method=hook") flags.String("auth.command", "", "command for auth.method=hook")
flags.String("auth.logoutPage", "", "url of custom logout page")
flags.String("recaptcha.host", "https://www.google.com", "use another host for ReCAPTCHA. recaptcha.net might be useful in China") flags.String("recaptcha.host", "https://www.google.com", "use another host for ReCAPTCHA. recaptcha.net might be useful in China")
flags.String("recaptcha.key", "", "ReCaptcha site key") flags.String("recaptcha.key", "", "ReCaptcha site key")
@@ -49,14 +57,13 @@ func addConfigFlags(flags *pflag.FlagSet) {
flags.String("branding.files", "", "path to directory with images and custom styles") flags.String("branding.files", "", "path to directory with images and custom styles")
flags.Bool("branding.disableExternal", false, "disable external links such as GitHub links") flags.Bool("branding.disableExternal", false, "disable external links such as GitHub links")
flags.Bool("branding.disableUsedPercentage", false, "disable used disk percentage graph") flags.Bool("branding.disableUsedPercentage", false, "disable used disk percentage graph")
// NB: these are string so they can be presented as octal in the help text
// as that's the conventional representation for modes in Unix. flags.Uint64("tus.chunkSize", settings.DefaultTusChunkSize, "the tus chunk size")
flags.String("file-mode", fmt.Sprintf("%O", settings.DefaultFileMode), "Mode bits that new files are created with") flags.Uint16("tus.retryCount", settings.DefaultTusRetryCount, "the tus retry count")
flags.String("dir-mode", fmt.Sprintf("%O", settings.DefaultDirMode), "Mode bits that new directories are created with")
} }
func getAuthMethod(flags *pflag.FlagSet, defaults ...interface{}) (settings.AuthMethod, map[string]interface{}, error) { func getAuthMethod(flags *pflag.FlagSet, defaults ...interface{}) (settings.AuthMethod, map[string]interface{}, error) {
methodStr, err := getString(flags, "auth.method") methodStr, err := flags.GetString("auth.method")
if err != nil { if err != nil {
return "", nil, err return "", nil, err
} }
@@ -87,17 +94,17 @@ func getAuthMethod(flags *pflag.FlagSet, defaults ...interface{}) (settings.Auth
} }
func getProxyAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (auth.Auther, error) { func getProxyAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (auth.Auther, error) {
header, err := getString(flags, "auth.header") header, err := flags.GetString("auth.header")
if err != nil { if err != nil {
return nil, err return nil, err
} }
if header == "" { if header == "" && defaultAuther != nil {
header = defaultAuther["header"].(string) header = defaultAuther["header"].(string)
} }
if header == "" { if header == "" {
return nil, nerrors.New("you must set the flag 'auth.header' for method 'proxy'") return nil, errors.New("you must set the flag 'auth.header' for method 'proxy'")
} }
return &auth.ProxyAuth{Header: header}, nil return &auth.ProxyAuth{Header: header}, nil
@@ -109,15 +116,17 @@ func getNoAuth() auth.Auther {
func getJSONAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (auth.Auther, error) { func getJSONAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (auth.Auther, error) {
jsonAuth := &auth.JSONAuth{} jsonAuth := &auth.JSONAuth{}
host, err := getString(flags, "recaptcha.host") host, err := flags.GetString("recaptcha.host")
if err != nil { if err != nil {
return nil, err return nil, err
} }
key, err := getString(flags, "recaptcha.key")
key, err := flags.GetString("recaptcha.key")
if err != nil { if err != nil {
return nil, err return nil, err
} }
secret, err := getString(flags, "recaptcha.secret")
secret, err := flags.GetString("recaptcha.secret")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -145,17 +154,16 @@ func getJSONAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (au
} }
func getHookAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (auth.Auther, error) { func getHookAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (auth.Auther, error) {
command, err := getString(flags, "auth.command") command, err := flags.GetString("auth.command")
if err != nil { if err != nil {
return nil, err return nil, err
} }
if command == "" { if command == "" {
command = defaultAuther["command"].(string) command = defaultAuther["command"].(string)
} }
if command == "" { if command == "" {
return nil, nerrors.New("you must set the flag 'auth.command' for method 'hook'") return nil, errors.New("you must set the flag 'auth.command' for method 'hook'")
} }
return &auth.HookAuth{Command: command}, nil return &auth.HookAuth{Command: command}, nil
@@ -178,7 +186,7 @@ func getAuthentication(flags *pflag.FlagSet, defaults ...interface{}) (settings.
case auth.MethodHookAuth: case auth.MethodHookAuth:
auther, err = getHookAuth(flags, defaultAuther) auther, err = getHookAuth(flags, defaultAuther)
default: default:
return "", nil, errors.ErrInvalidAuthMethod return "", nil, fberrors.ErrInvalidAuthMethod
} }
if err != nil { if err != nil {
@@ -192,10 +200,13 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "Sign up:\t%t\n", set.Signup) fmt.Fprintf(w, "Sign up:\t%t\n", set.Signup)
fmt.Fprintf(w, "Hide Login Button:\t%t\n", set.HideLoginButton)
fmt.Fprintf(w, "Create User Dir:\t%t\n", set.CreateUserDir) fmt.Fprintf(w, "Create User Dir:\t%t\n", set.CreateUserDir)
fmt.Fprintf(w, "Logout Page:\t%s\n", set.LogoutPage)
fmt.Fprintf(w, "Minimum Password Length:\t%d\n", set.MinimumPasswordLength) fmt.Fprintf(w, "Minimum Password Length:\t%d\n", set.MinimumPasswordLength)
fmt.Fprintf(w, "Auth method:\t%s\n", set.AuthMethod) fmt.Fprintf(w, "Auth Method:\t%s\n", set.AuthMethod)
fmt.Fprintf(w, "Shell:\t%s\t\n", strings.Join(set.Shell, " ")) fmt.Fprintf(w, "Shell:\t%s\t\n", strings.Join(set.Shell, " "))
fmt.Fprintln(w, "\nBranding:") fmt.Fprintln(w, "\nBranding:")
fmt.Fprintf(w, "\tName:\t%s\n", set.Branding.Name) fmt.Fprintf(w, "\tName:\t%s\n", set.Branding.Name)
fmt.Fprintf(w, "\tFiles override:\t%s\n", set.Branding.Files) fmt.Fprintf(w, "\tFiles override:\t%s\n", set.Branding.Files)
@@ -203,6 +214,7 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
fmt.Fprintf(w, "\tDisable used disk percentage graph:\t%t\n", set.Branding.DisableUsedPercentage) fmt.Fprintf(w, "\tDisable used disk percentage graph:\t%t\n", set.Branding.DisableUsedPercentage)
fmt.Fprintf(w, "\tColor:\t%s\n", set.Branding.Color) fmt.Fprintf(w, "\tColor:\t%s\n", set.Branding.Color)
fmt.Fprintf(w, "\tTheme:\t%s\n", set.Branding.Theme) fmt.Fprintf(w, "\tTheme:\t%s\n", set.Branding.Theme)
fmt.Fprintln(w, "\nServer:") fmt.Fprintln(w, "\nServer:")
fmt.Fprintf(w, "\tLog:\t%s\n", ser.Log) fmt.Fprintf(w, "\tLog:\t%s\n", ser.Log)
fmt.Fprintf(w, "\tPort:\t%s\n", ser.Port) fmt.Fprintf(w, "\tPort:\t%s\n", ser.Port)
@@ -212,19 +224,32 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
fmt.Fprintf(w, "\tAddress:\t%s\n", ser.Address) fmt.Fprintf(w, "\tAddress:\t%s\n", ser.Address)
fmt.Fprintf(w, "\tTLS Cert:\t%s\n", ser.TLSCert) fmt.Fprintf(w, "\tTLS Cert:\t%s\n", ser.TLSCert)
fmt.Fprintf(w, "\tTLS Key:\t%s\n", ser.TLSKey) fmt.Fprintf(w, "\tTLS Key:\t%s\n", ser.TLSKey)
fmt.Fprintf(w, "\tToken Expiration Time:\t%s\n", ser.TokenExpirationTime)
fmt.Fprintf(w, "\tExec Enabled:\t%t\n", ser.EnableExec) fmt.Fprintf(w, "\tExec Enabled:\t%t\n", ser.EnableExec)
fmt.Fprintf(w, "\tThumbnails Enabled:\t%t\n", ser.EnableThumbnails)
fmt.Fprintf(w, "\tResize Preview:\t%t\n", ser.ResizePreview)
fmt.Fprintf(w, "\tType Detection by Header:\t%t\n", ser.TypeDetectionByHeader)
fmt.Fprintln(w, "\nTUS:")
fmt.Fprintf(w, "\tChunk size:\t%d\n", set.Tus.ChunkSize)
fmt.Fprintf(w, "\tRetry count:\t%d\n", set.Tus.RetryCount)
fmt.Fprintln(w, "\nDefaults:") fmt.Fprintln(w, "\nDefaults:")
fmt.Fprintf(w, "\tScope:\t%s\n", set.Defaults.Scope) fmt.Fprintf(w, "\tScope:\t%s\n", set.Defaults.Scope)
fmt.Fprintf(w, "\tHideDotfiles:\t%t\n", set.Defaults.HideDotfiles)
fmt.Fprintf(w, "\tLocale:\t%s\n", set.Defaults.Locale) fmt.Fprintf(w, "\tLocale:\t%s\n", set.Defaults.Locale)
fmt.Fprintf(w, "\tView mode:\t%s\n", set.Defaults.ViewMode) fmt.Fprintf(w, "\tView mode:\t%s\n", set.Defaults.ViewMode)
fmt.Fprintf(w, "\tSingle Click:\t%t\n", set.Defaults.SingleClick) fmt.Fprintf(w, "\tSingle Click:\t%t\n", set.Defaults.SingleClick)
fmt.Fprintf(w, "\tRedirect after Copy/Move:\t%t\n", set.Defaults.RedirectAfterCopyMove)
fmt.Fprintf(w, "\tFile Creation Mode:\t%O\n", set.FileMode) fmt.Fprintf(w, "\tFile Creation Mode:\t%O\n", set.FileMode)
fmt.Fprintf(w, "\tDirectory Creation Mode:\t%O\n", set.DirMode) fmt.Fprintf(w, "\tDirectory Creation Mode:\t%O\n", set.DirMode)
fmt.Fprintf(w, "\tCommands:\t%s\n", strings.Join(set.Defaults.Commands, " ")) fmt.Fprintf(w, "\tCommands:\t%s\n", strings.Join(set.Defaults.Commands, " "))
fmt.Fprintf(w, "\tAce editor syntax highlighting theme:\t%s\n", set.Defaults.AceEditorTheme) fmt.Fprintf(w, "\tAce editor syntax highlighting theme:\t%s\n", set.Defaults.AceEditorTheme)
fmt.Fprintf(w, "\tSorting:\n") fmt.Fprintf(w, "\tSorting:\n")
fmt.Fprintf(w, "\t\tBy:\t%s\n", set.Defaults.Sorting.By) fmt.Fprintf(w, "\t\tBy:\t%s\n", set.Defaults.Sorting.By)
fmt.Fprintf(w, "\t\tAsc:\t%t\n", set.Defaults.Sorting.Asc) fmt.Fprintf(w, "\t\tAsc:\t%t\n", set.Defaults.Sorting.Asc)
fmt.Fprintf(w, "\tPermissions:\n") fmt.Fprintf(w, "\tPermissions:\n")
fmt.Fprintf(w, "\t\tAdmin:\t%t\n", set.Defaults.Perm.Admin) fmt.Fprintf(w, "\t\tAdmin:\t%t\n", set.Defaults.Perm.Admin)
fmt.Fprintf(w, "\t\tExecute:\t%t\n", set.Defaults.Perm.Execute) fmt.Fprintf(w, "\t\tExecute:\t%t\n", set.Defaults.Perm.Execute)
@@ -234,6 +259,7 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
fmt.Fprintf(w, "\t\tDelete:\t%t\n", set.Defaults.Perm.Delete) fmt.Fprintf(w, "\t\tDelete:\t%t\n", set.Defaults.Perm.Delete)
fmt.Fprintf(w, "\t\tShare:\t%t\n", set.Defaults.Perm.Share) fmt.Fprintf(w, "\t\tShare:\t%t\n", set.Defaults.Perm.Share)
fmt.Fprintf(w, "\t\tDownload:\t%t\n", set.Defaults.Perm.Download) fmt.Fprintf(w, "\t\tDownload:\t%t\n", set.Defaults.Perm.Download)
w.Flush() w.Flush()
b, err := json.MarshalIndent(auther, "", " ") b, err := json.MarshalIndent(auther, "", " ")
@@ -243,3 +269,123 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
fmt.Printf("\nAuther configuration (raw):\n\n%s\n\n", string(b)) fmt.Printf("\nAuther configuration (raw):\n\n%s\n\n", string(b))
return nil return nil
} }
func getSettings(flags *pflag.FlagSet, set *settings.Settings, ser *settings.Server, auther auth.Auther, all bool) (auth.Auther, error) {
errs := []error{}
hasAuth := false
visit := func(flag *pflag.Flag) {
var err error
switch flag.Name {
// Server flags from [addServerFlags]
case "address":
ser.Address, err = flags.GetString(flag.Name)
case "log":
ser.Log, err = flags.GetString(flag.Name)
case "port":
ser.Port, err = flags.GetString(flag.Name)
case "cert":
ser.TLSCert, err = flags.GetString(flag.Name)
case "key":
ser.TLSKey, err = flags.GetString(flag.Name)
case "root":
ser.Root, err = flags.GetString(flag.Name)
case "socket":
ser.Socket, err = flags.GetString(flag.Name)
case "baseURL":
ser.BaseURL, err = flags.GetString(flag.Name)
case "tokenExpirationTime":
ser.TokenExpirationTime, err = flags.GetString(flag.Name)
case "disableThumbnails":
ser.EnableThumbnails, err = flags.GetBool(flag.Name)
ser.EnableThumbnails = !ser.EnableThumbnails
case "disablePreviewResize":
ser.ResizePreview, err = flags.GetBool(flag.Name)
ser.ResizePreview = !ser.ResizePreview
case "disableExec":
ser.EnableExec, err = flags.GetBool(flag.Name)
ser.EnableExec = !ser.EnableExec
case "disableTypeDetectionByHeader":
ser.TypeDetectionByHeader, err = flags.GetBool(flag.Name)
ser.TypeDetectionByHeader = !ser.TypeDetectionByHeader
case "disableImageResolutionCalc":
ser.ImageResolutionCal, err = flags.GetBool(flag.Name)
ser.ImageResolutionCal = !ser.ImageResolutionCal
// Settings flags from [addConfigFlags]
case "signup":
set.Signup, err = flags.GetBool(flag.Name)
case "hideLoginButton":
set.HideLoginButton, err = flags.GetBool(flag.Name)
case "createUserDir":
set.CreateUserDir, err = flags.GetBool(flag.Name)
case "minimumPasswordLength":
set.MinimumPasswordLength, err = flags.GetUint(flag.Name)
case "shell":
var shell string
shell, err = flags.GetString(flag.Name)
if err == nil {
set.Shell = convertCmdStrToCmdArray(shell)
}
case "fileMode":
set.FileMode, err = getAndParseFileMode(flags, flag.Name)
case "dirMode":
set.DirMode, err = getAndParseFileMode(flags, flag.Name)
case "auth.method":
hasAuth = true
case "auth.logoutPage":
set.LogoutPage, err = flags.GetString(flag.Name)
case "branding.name":
set.Branding.Name, err = flags.GetString(flag.Name)
case "branding.theme":
set.Branding.Theme, err = flags.GetString(flag.Name)
case "branding.color":
set.Branding.Color, err = flags.GetString(flag.Name)
case "branding.files":
set.Branding.Files, err = flags.GetString(flag.Name)
case "branding.disableExternal":
set.Branding.DisableExternal, err = flags.GetBool(flag.Name)
case "branding.disableUsedPercentage":
set.Branding.DisableUsedPercentage, err = flags.GetBool(flag.Name)
case "tus.chunkSize":
set.Tus.ChunkSize, err = flags.GetUint64(flag.Name)
case "tus.retryCount":
set.Tus.RetryCount, err = flags.GetUint16(flag.Name)
}
if err != nil {
errs = append(errs, err)
}
}
if all {
flags.VisitAll(visit)
} else {
flags.Visit(visit)
}
err := errors.Join(errs...)
if err != nil {
return nil, err
}
err = getUserDefaults(flags, &set.Defaults, all)
if err != nil {
return nil, err
}
if all {
set.AuthMethod, auther, err = getAuthentication(flags)
if err != nil {
return nil, err
}
} else {
set.AuthMethod, auther, err = getAuthentication(flags, hasAuth, set, auther)
if err != nil {
return nil, err
}
}
return auther, nil
}

View File

@@ -13,19 +13,19 @@ var configCatCmd = &cobra.Command{
Short: "Prints the configuration", Short: "Prints the configuration",
Long: `Prints the configuration.`, Long: `Prints the configuration.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: python(func(_ *cobra.Command, _ []string, d *pythonData) error { RunE: withStore(func(_ *cobra.Command, _ []string, st *store) error {
set, err := d.store.Settings.Get() set, err := st.Settings.Get()
if err != nil { if err != nil {
return err return err
} }
ser, err := d.store.Settings.GetServer() ser, err := st.Settings.GetServer()
if err != nil { if err != nil {
return err return err
} }
auther, err := d.store.Auth.Get(set.AuthMethod) auther, err := st.Auth.Get(set.AuthMethod)
if err != nil { if err != nil {
return err return err
} }
return printSettings(ser, set, auther) return printSettings(ser, set, auther)
}, pythonConfig{}), }, storeOptions{}),
} }

View File

@@ -15,18 +15,18 @@ var configExportCmd = &cobra.Command{
json or yaml file. This exported configuration can be changed, json or yaml file. This exported configuration can be changed,
and imported again with 'config import' command.`, and imported again with 'config import' command.`,
Args: jsonYamlArg, Args: jsonYamlArg,
RunE: python(func(_ *cobra.Command, args []string, d *pythonData) error { RunE: withStore(func(_ *cobra.Command, args []string, st *store) error {
settings, err := d.store.Settings.Get() settings, err := st.Settings.Get()
if err != nil { if err != nil {
return err return err
} }
server, err := d.store.Settings.GetServer() server, err := st.Settings.GetServer()
if err != nil { if err != nil {
return err return err
} }
auther, err := d.store.Auth.Get(settings.AuthMethod) auther, err := st.Auth.Get(settings.AuthMethod)
if err != nil { if err != nil {
return err return err
} }
@@ -42,5 +42,5 @@ and imported again with 'config import' command.`,
return err return err
} }
return nil return nil
}, pythonConfig{}), }, storeOptions{}),
} }

View File

@@ -34,11 +34,11 @@ database.
The path must be for a json or yaml file.`, The path must be for a json or yaml file.`,
Args: jsonYamlArg, Args: jsonYamlArg,
RunE: python(func(_ *cobra.Command, args []string, d *pythonData) error { RunE: withStore(func(_ *cobra.Command, args []string, st *store) error {
var key []byte var key []byte
var err error var err error
if d.hadDB { if st.databaseExisted {
settings, settingErr := d.store.Settings.Get() settings, settingErr := st.Settings.Get()
if settingErr != nil { if settingErr != nil {
return settingErr return settingErr
} }
@@ -53,13 +53,17 @@ The path must be for a json or yaml file.`,
return err return err
} }
if file.Settings == nil || file.Server == nil {
return errors.New("invalid configuration file: 'settings' or 'server' fields are missing. Please ensure you are importing a file generated by the 'config export' command")
}
file.Settings.Key = key file.Settings.Key = key
err = d.store.Settings.Save(file.Settings) err = st.Settings.Save(file.Settings)
if err != nil { if err != nil {
return err return err
} }
err = d.store.Settings.SaveServer(file.Server) err = st.Settings.SaveServer(file.Server)
if err != nil { if err != nil {
return err return err
} }
@@ -98,13 +102,13 @@ The path must be for a json or yaml file.`,
return autherErr return autherErr
} }
err = d.store.Auth.Save(auther) err = st.Auth.Save(auther)
if err != nil { if err != nil {
return err return err
} }
return printSettings(file.Server, file.Settings, auther) return printSettings(file.Server, file.Settings, auther)
}, pythonConfig{allowNoDB: true}), }, storeOptions{allowsNoDatabase: true}),
} }
func getAuther(sample auth.Auther, data interface{}) (interface{}, error) { func getAuther(sample auth.Auther, data interface{}) (interface{}, error) {

View File

@@ -22,152 +22,31 @@ this options can be changed in the future with the command
to the defaults when creating new users and you don't to the defaults when creating new users and you don't
override the options.`, override the options.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: python(func(cmd *cobra.Command, _ []string, d *pythonData) error { RunE: withStore(func(cmd *cobra.Command, _ []string, st *store) error {
defaults := settings.UserDefaults{}
flags := cmd.Flags() flags := cmd.Flags()
err := getUserDefaults(flags, &defaults, true)
if err != nil { // Initialize config
return err s := &settings.Settings{Key: generateKey()}
} ser := &settings.Server{}
authMethod, auther, err := getAuthentication(flags)
// Fill config with options
auther, err := getSettings(flags, s, ser, nil, true)
if err != nil { if err != nil {
return err return err
} }
key := generateKey() // Save updated config
err = st.Settings.Save(s)
signup, err := getBool(flags, "signup")
if err != nil { if err != nil {
return err return err
} }
createUserDir, err := getBool(flags, "create-user-dir") err = st.Settings.SaveServer(ser)
if err != nil { if err != nil {
return err return err
} }
minLength, err := getUint(flags, "minimum-password-length") err = st.Auth.Save(auther)
if err != nil {
return err
}
shell, err := getString(flags, "shell")
if err != nil {
return err
}
brandingName, err := getString(flags, "branding.name")
if err != nil {
return err
}
brandingDisableExternal, err := getBool(flags, "branding.disableExternal")
if err != nil {
return err
}
brandingDisableUsedPercentage, err := getBool(flags, "branding.disableUsedPercentage")
if err != nil {
return err
}
brandingTheme, err := getString(flags, "branding.theme")
if err != nil {
return err
}
brandingFiles, err := getString(flags, "branding.files")
if err != nil {
return err
}
s := &settings.Settings{
Key: key,
Signup: signup,
CreateUserDir: createUserDir,
MinimumPasswordLength: minLength,
Shell: convertCmdStrToCmdArray(shell),
AuthMethod: authMethod,
Defaults: defaults,
Branding: settings.Branding{
Name: brandingName,
DisableExternal: brandingDisableExternal,
DisableUsedPercentage: brandingDisableUsedPercentage,
Theme: brandingTheme,
Files: brandingFiles,
},
}
s.FileMode, err = getMode(flags, "file-mode")
if err != nil {
return err
}
s.DirMode, err = getMode(flags, "dir-mode")
if err != nil {
return err
}
address, err := getString(flags, "address")
if err != nil {
return err
}
socket, err := getString(flags, "socket")
if err != nil {
return err
}
root, err := getString(flags, "root")
if err != nil {
return err
}
baseURL, err := getString(flags, "baseurl")
if err != nil {
return err
}
tlsKey, err := getString(flags, "key")
if err != nil {
return err
}
cert, err := getString(flags, "cert")
if err != nil {
return err
}
port, err := getString(flags, "port")
if err != nil {
return err
}
log, err := getString(flags, "log")
if err != nil {
return err
}
ser := &settings.Server{
Address: address,
Socket: socket,
Root: root,
BaseURL: baseURL,
TLSKey: tlsKey,
TLSCert: cert,
Port: port,
Log: log,
}
err = d.store.Settings.Save(s)
if err != nil {
return err
}
err = d.store.Settings.SaveServer(ser)
if err != nil {
return err
}
err = d.store.Auth.Save(auther)
if err != nil { if err != nil {
return err return err
} }
@@ -178,5 +57,5 @@ Now add your first user via 'filebrowser users add' and then you just
need to call the main command to boot up the server. need to call the main command to boot up the server.
`) `)
return printSettings(ser, s, auther) return printSettings(ser, s, auther)
}, pythonConfig{noDB: true}), }, storeOptions{expectsNoDatabase: true}),
} }

View File

@@ -2,7 +2,6 @@ package cmd
import ( import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
) )
func init() { func init() {
@@ -16,105 +15,47 @@ var configSetCmd = &cobra.Command{
Long: `Updates the configuration. Set the flags for the options Long: `Updates the configuration. Set the flags for the options
you want to change. Other options will remain unchanged.`, you want to change. Other options will remain unchanged.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: python(func(cmd *cobra.Command, _ []string, d *pythonData) error { RunE: withStore(func(cmd *cobra.Command, _ []string, st *store) error {
flags := cmd.Flags() flags := cmd.Flags()
set, err := d.store.Settings.Get()
// Read existing config
set, err := st.Settings.Get()
if err != nil { if err != nil {
return err return err
} }
ser, err := d.store.Settings.GetServer() ser, err := st.Settings.GetServer()
if err != nil { if err != nil {
return err return err
} }
hasAuth := false auther, err := st.Auth.Get(set.AuthMethod)
flags.Visit(func(flag *pflag.Flag) {
if err != nil {
return
}
switch flag.Name {
case "baseurl":
ser.BaseURL, err = getString(flags, flag.Name)
case "root":
ser.Root, err = getString(flags, flag.Name)
case "socket":
ser.Socket, err = getString(flags, flag.Name)
case "cert":
ser.TLSCert, err = getString(flags, flag.Name)
case "key":
ser.TLSKey, err = getString(flags, flag.Name)
case "address":
ser.Address, err = getString(flags, flag.Name)
case "port":
ser.Port, err = getString(flags, flag.Name)
case "log":
ser.Log, err = getString(flags, flag.Name)
case "signup":
set.Signup, err = getBool(flags, flag.Name)
case "auth.method":
hasAuth = true
case "shell":
var shell string
shell, err = getString(flags, flag.Name)
set.Shell = convertCmdStrToCmdArray(shell)
case "create-user-dir":
set.CreateUserDir, err = getBool(flags, flag.Name)
case "minimum-password-length":
set.MinimumPasswordLength, err = getUint(flags, flag.Name)
case "branding.name":
set.Branding.Name, err = getString(flags, flag.Name)
case "branding.color":
set.Branding.Color, err = getString(flags, flag.Name)
case "branding.theme":
set.Branding.Theme, err = getString(flags, flag.Name)
case "branding.disableExternal":
set.Branding.DisableExternal, err = getBool(flags, flag.Name)
case "branding.disableUsedPercentage":
set.Branding.DisableUsedPercentage, err = getBool(flags, flag.Name)
case "branding.files":
set.Branding.Files, err = getString(flags, flag.Name)
case "file-mode":
set.FileMode, err = getMode(flags, flag.Name)
case "dir-mode":
set.DirMode, err = getMode(flags, flag.Name)
}
})
if err != nil { if err != nil {
return err return err
} }
err = getUserDefaults(flags, &set.Defaults, false) // Get updated config
auther, err = getSettings(flags, set, ser, auther, false)
if err != nil { if err != nil {
return err return err
} }
// read the defaults // Save updated config
auther, err := d.store.Auth.Get(set.AuthMethod) err = st.Auth.Save(auther)
if err != nil { if err != nil {
return err return err
} }
// check if there are new flags for existing auth method err = st.Settings.Save(set)
set.AuthMethod, auther, err = getAuthentication(flags, hasAuth, set, auther)
if err != nil { if err != nil {
return err return err
} }
err = d.store.Auth.Save(auther) err = st.Settings.SaveServer(ser)
if err != nil {
return err
}
err = d.store.Settings.Save(set)
if err != nil {
return err
}
err = d.store.Settings.SaveServer(ser)
if err != nil { if err != nil {
return err return err
} }
return printSettings(ser, set, auther) return printSettings(ser, set, auther)
}, pythonConfig{}), }, storeOptions{}),
} }

View File

@@ -3,36 +3,18 @@ package cmd
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path"
"sort" "regexp"
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag" "github.com/spf13/cobra/doc"
) )
func init() { func init() {
rootCmd.AddCommand(docsCmd) rootCmd.AddCommand(docsCmd)
docsCmd.Flags().StringP("path", "p", "./docs", "path to save the docs") docsCmd.Flags().String("out", "www/docs/cli", "directory to write the docs to")
}
func printToc(names []string) {
for i, name := range names {
name = strings.TrimSuffix(name, filepath.Ext(name))
name = strings.Replace(name, "-", " ", -1)
names[i] = name
}
sort.Strings(names)
toc := ""
for _, name := range names {
toc += "* [" + name + "](cli/" + strings.Replace(name, " ", "-", -1) + ".md)\n"
}
fmt.Println(toc)
} }
var docsCmd = &cobra.Command{ var docsCmd = &cobra.Command{
@@ -40,115 +22,61 @@ var docsCmd = &cobra.Command{
Hidden: true, Hidden: true,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
dir, err := getString(cmd.Flags(), "path") outputDir, err := cmd.Flags().GetString("out")
if err != nil { if err != nil {
return err return err
} }
err = generateDocs(rootCmd, dir) tempDir, err := os.MkdirTemp(os.TempDir(), "filebrowser-docs-")
if err != nil { if err != nil {
return err return err
} }
names := []string{} defer os.RemoveAll(tempDir)
err = filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error { rootCmd.Root().DisableAutoGenTag = true
if err != nil || info.IsDir() {
return err
}
if !strings.HasPrefix(info.Name(), "filebrowser") { err = doc.GenMarkdownTreeCustom(cmd.Root(), tempDir, func(_ string) string {
return nil return ""
} }, func(s string) string {
return s
names = append(names, info.Name())
return nil
}) })
if err != nil { if err != nil {
return err return err
} }
printToc(names) entries, err := os.ReadDir(tempDir)
return nil
},
}
func generateDocs(cmd *cobra.Command, dir string) error {
for _, c := range cmd.Commands() {
if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() {
continue
}
err := generateDocs(c, dir)
if err != nil { if err != nil {
return err return err
} }
}
basename := strings.Replace(cmd.CommandPath(), " ", "-", -1) + ".md" headerRegex := regexp.MustCompile(`(?m)^(##)(.*)$`)
filename := filepath.Join(dir, basename) linkRegex := regexp.MustCompile(`\(filebrowser(.*)\.md\)`)
f, err := os.Create(filename)
if err != nil { fmt.Println("Generated Documents:")
return err
} for _, entry := range entries {
defer f.Close() srcPath := path.Join(tempDir, entry.Name())
return generateMarkdown(cmd, f) dstPath := path.Join(outputDir, strings.ReplaceAll(entry.Name(), "_", "-"))
}
data, err := os.ReadFile(srcPath)
func generateMarkdown(cmd *cobra.Command, w io.Writer) error { if err != nil {
cmd.InitDefaultHelpCmd() return err
cmd.InitDefaultHelpFlag() }
buf := new(bytes.Buffer) data = headerRegex.ReplaceAll(data, []byte("#$2"))
name := cmd.CommandPath() data = linkRegex.ReplaceAllFunc(data, func(b []byte) []byte {
return bytes.ReplaceAll(b, []byte("_"), []byte("-"))
short := cmd.Short })
long := cmd.Long data = bytes.ReplaceAll(data, []byte("## SEE ALSO"), []byte("## See Also"))
if long == "" {
long = short err = os.WriteFile(dstPath, data, 0666)
} if err != nil {
return err
buf.WriteString("---\ndescription: " + short + "\n---\n\n") }
buf.WriteString("# " + name + "\n\n")
buf.WriteString("## Synopsis\n\n") fmt.Println("- " + dstPath)
buf.WriteString(long + "\n\n") }
if cmd.Runnable() { return nil
_, _ = fmt.Fprintf(buf, "```\n%s\n```\n\n", cmd.UseLine()) },
}
if cmd.Example != "" {
buf.WriteString("## Examples\n\n")
_, _ = fmt.Fprintf(buf, "```\n%s\n```\n\n", cmd.Example)
}
printOptions(buf, cmd)
_, err := buf.WriteTo(w)
return err
}
func generateFlagsTable(fs *pflag.FlagSet, buf io.StringWriter) {
_, _ = buf.WriteString("| Name | Shorthand | Usage |\n")
_, _ = buf.WriteString("|------|-----------|-------|\n")
fs.VisitAll(func(f *pflag.Flag) {
_, _ = buf.WriteString("|" + f.Name + "|" + f.Shorthand + "|" + f.Usage + "|\n")
})
}
func printOptions(buf *bytes.Buffer, cmd *cobra.Command) {
flags := cmd.NonInheritedFlags()
flags.SetOutput(buf)
if flags.HasAvailableFlags() {
buf.WriteString("## Options\n\n")
generateFlagsTable(flags, buf)
buf.WriteString("\n")
}
parentFlags := cmd.InheritedFlags()
parentFlags.SetOutput(buf)
if parentFlags.HasAvailableFlags() {
buf.WriteString("### Inherited\n\n")
generateFlagsTable(parentFlags, buf)
buf.WriteString("\n")
}
} }

View File

@@ -13,20 +13,17 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strings"
"syscall" "syscall"
"time" "time"
homedir "github.com/mitchellh/go-homedir"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag" "github.com/spf13/pflag"
v "github.com/spf13/viper" "github.com/spf13/viper"
lumberjack "gopkg.in/natefinch/lumberjack.v2" lumberjack "gopkg.in/natefinch/lumberjack.v2"
"github.com/filebrowser/filebrowser/v2/auth" "github.com/filebrowser/filebrowser/v2/auth"
"github.com/filebrowser/filebrowser/v2/diskcache" "github.com/filebrowser/filebrowser/v2/diskcache"
fbErrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/frontend" "github.com/filebrowser/filebrowser/v2/frontend"
fbhttp "github.com/filebrowser/filebrowser/v2/http" fbhttp "github.com/filebrowser/filebrowser/v2/http"
"github.com/filebrowser/filebrowser/v2/img" "github.com/filebrowser/filebrowser/v2/img"
@@ -36,28 +33,67 @@ import (
) )
var ( var (
cfgFile string flagNamesMigrations = map[string]string{
"file-mode": "fileMode",
"dir-mode": "dirMode",
"hide-login-button": "hideLoginButton",
"create-user-dir": "createUserDir",
"minimum-password-length": "minimumPasswordLength",
"socket-perm": "socketPerm",
"disable-thumbnails": "disableThumbnails",
"disable-preview-resize": "disablePreviewResize",
"disable-exec": "disableExec",
"disable-type-detection-by-header": "disableTypeDetectionByHeader",
"img-processors": "imageProcessors",
"cache-dir": "cacheDir",
"token-expiration-time": "tokenExpirationTime",
"baseurl": "baseURL",
}
warnedFlags = map[string]bool{}
) )
// TODO(remove): remove after July 2026.
func migrateFlagNames(_ *pflag.FlagSet, name string) pflag.NormalizedName {
if newName, ok := flagNamesMigrations[name]; ok {
if !warnedFlags[name] {
warnedFlags[name] = true
log.Printf("DEPRECATION NOTICE: Flag --%s has been deprecated, use --%s instead\n", name, newName)
}
name = newName
}
return pflag.NormalizedName(name)
}
func init() { func init() {
cobra.OnInitialize(initConfig)
rootCmd.SilenceUsage = true rootCmd.SilenceUsage = true
rootCmd.SetGlobalNormalizationFunc(migrateFlagNames)
cobra.MousetrapHelpText = "" cobra.MousetrapHelpText = ""
rootCmd.SetVersionTemplate("File Browser version {{printf \"%s\" .Version}}\n") rootCmd.SetVersionTemplate("File Browser version {{printf \"%s\" .Version}}\n")
flags := rootCmd.Flags() // Flags available across the whole program
persistent := rootCmd.PersistentFlags() persistent := rootCmd.PersistentFlags()
persistent.StringP("config", "c", "", "config file path")
persistent.StringVarP(&cfgFile, "config", "c", "", "config file path")
persistent.StringP("database", "d", "./filebrowser.db", "database path") persistent.StringP("database", "d", "./filebrowser.db", "database path")
flags.Bool("noauth", false, "use the noauth auther when using quick setup")
flags.String("username", "admin", "username for the first user when using quick config")
flags.String("password", "", "hashed password for the first user when using quick config")
// Runtime flags for the root command
flags := rootCmd.Flags()
flags.Bool("noauth", false, "use the noauth auther when using quick setup")
flags.String("username", "admin", "username for the first user when using quick setup")
flags.String("password", "", "hashed password for the first user when using quick setup")
flags.Uint32("socketPerm", 0666, "unix socket file permissions")
flags.String("cacheDir", "", "file cache directory (disabled if empty)")
flags.Int("imageProcessors", 4, "image processors count")
addServerFlags(flags) addServerFlags(flags)
} }
// addServerFlags adds server related flags to the given FlagSet. These flags are available
// in both the root command, config set and config init commands.
func addServerFlags(flags *pflag.FlagSet) { func addServerFlags(flags *pflag.FlagSet) {
flags.StringP("address", "a", "127.0.0.1", "address to listen on") flags.StringP("address", "a", "127.0.0.1", "address to listen on")
flags.StringP("log", "l", "stdout", "log output") flags.StringP("log", "l", "stdout", "log output")
@@ -66,15 +102,13 @@ func addServerFlags(flags *pflag.FlagSet) {
flags.StringP("key", "k", "", "tls key") flags.StringP("key", "k", "", "tls key")
flags.StringP("root", "r", ".", "root to prepend to relative paths") flags.StringP("root", "r", ".", "root to prepend to relative paths")
flags.String("socket", "", "socket to listen to (cannot be used with address, port, cert nor key flags)") flags.String("socket", "", "socket to listen to (cannot be used with address, port, cert nor key flags)")
flags.Uint32("socket-perm", 0666, "unix socket file permissions") flags.StringP("baseURL", "b", "", "base url")
flags.StringP("baseurl", "b", "", "base url") flags.String("tokenExpirationTime", "2h", "user session timeout")
flags.String("cache-dir", "", "file cache directory (disabled if empty)") flags.Bool("disableThumbnails", false, "disable image thumbnails")
flags.String("token-expiration-time", "2h", "user session timeout") flags.Bool("disablePreviewResize", false, "disable resize of image previews")
flags.Int("img-processors", 4, "image processors count") //nolint:mnd flags.Bool("disableExec", true, "disables Command Runner feature")
flags.Bool("disable-thumbnails", false, "disable image thumbnails") flags.Bool("disableTypeDetectionByHeader", false, "disables type detection by reading file headers")
flags.Bool("disable-preview-resize", false, "disable resize of image previews") flags.Bool("disableImageResolutionCalc", false, "disables image resolution calculation by reading image files")
flags.Bool("disable-exec", true, "disables Command Runner feature")
flags.Bool("disable-type-detection-by-header", false, "disables type detection by reading file headers")
} }
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
@@ -89,65 +123,60 @@ it. Don't worry: you don't need to setup a separate database server.
We're using Bolt DB which is a single file database and all managed We're using Bolt DB which is a single file database and all managed
by ourselves. by ourselves.
For this specific command, all the flags you have available (except For this command, all flags are available as environmental variables,
"config" for the configuration file), can be given either through except for "--config", which specifies the configuration file to use.
environment variables or configuration files. The environment variables are prefixed by "FB_" followed by the flag name in
UPPER_SNAKE_CASE. For example, the flag "--disablePreviewResize" is available
as FB_DISABLE_PREVIEW_RESIZE.
If you don't set "config", it will look for a configuration file called If "--config" is not specified, File Browser will look for a configuration
.filebrowser.{json, toml, yaml, yml} in the following directories: file named .filebrowser.{json, toml, yaml, yml} in the following directories:
- ./ - ./
- $HOME/ - $HOME/
- /etc/filebrowser/ - /etc/filebrowser/
**Note:** Only the options listed below can be set via the config file or
environment variables. Other configuration options live exclusively in the
database and so they must be set by the "config set" or "config
import" commands.
The precedence of the configuration values are as follows: The precedence of the configuration values are as follows:
- flags - Flags
- environment variables - Environment variables
- configuration file - Configuration file
- database values - Database values
- defaults - Defaults
The environment variables are prefixed by "FB_" followed by the option
name in caps. So to set "database" via an env variable, you should
set FB_DATABASE.
Also, if the database path doesn't exist, File Browser will enter into Also, if the database path doesn't exist, File Browser will enter into
the quick setup mode and a new database will be bootstrapped and a new the quick setup mode and a new database will be bootstrapped and a new
user created with the credentials from options "username" and "password".`, user created with the credentials from options "username" and "password".`,
RunE: python(func(cmd *cobra.Command, _ []string, d *pythonData) error { RunE: withViperAndStore(func(_ *cobra.Command, _ []string, v *viper.Viper, st *store) error {
log.Println(cfgFile) if !st.databaseExisted {
err := quickSetup(v, st.Storage)
if !d.hadDB {
err := quickSetup(cmd.Flags(), *d)
if err != nil { if err != nil {
return err return err
} }
} }
// build img service // build img service
workersCount, err := cmd.Flags().GetInt("img-processors") imgWorkersCount := v.GetInt("imageProcessors")
if err != nil { if imgWorkersCount < 1 {
return err
}
if workersCount < 1 {
return errors.New("image resize workers count could not be < 1") return errors.New("image resize workers count could not be < 1")
} }
imgSvc := img.New(workersCount) imageService := img.New(imgWorkersCount)
var fileCache diskcache.Interface = diskcache.NewNoOp() var fileCache diskcache.Interface = diskcache.NewNoOp()
cacheDir, err := cmd.Flags().GetString("cache-dir") cacheDir := v.GetString("cacheDir")
if err != nil {
return err
}
if cacheDir != "" { if cacheDir != "" {
if err := os.MkdirAll(cacheDir, 0700); err != nil { //nolint:govet if err := os.MkdirAll(cacheDir, 0700); err != nil {
return fmt.Errorf("can't make directory %s: %w", cacheDir, err) return fmt.Errorf("can't make directory %s: %w", cacheDir, err)
} }
fileCache = diskcache.New(afero.NewOsFs(), cacheDir) fileCache = diskcache.New(afero.NewOsFs(), cacheDir)
} }
server, err := getRunParams(cmd.Flags(), d.store) server, err := getServerSettings(v, st.Storage)
if err != nil { if err != nil {
return err return err
} }
@@ -169,16 +198,13 @@ user created with the credentials from options "username" and "password".`,
if err != nil { if err != nil {
return err return err
} }
socketPerm, err := cmd.Flags().GetUint32("socket-perm") //nolint:govet socketPerm := v.GetUint32("socketPerm")
if err != nil {
return err
}
err = os.Chmod(server.Socket, os.FileMode(socketPerm)) err = os.Chmod(server.Socket, os.FileMode(socketPerm))
if err != nil { if err != nil {
return err return err
} }
case server.TLSKey != "" && server.TLSCert != "": case server.TLSKey != "" && server.TLSCert != "":
cer, err := tls.LoadX509KeyPair(server.TLSCert, server.TLSKey) //nolint:govet cer, err := tls.LoadX509KeyPair(server.TLSCert, server.TLSKey)
if err != nil { if err != nil {
return err return err
} }
@@ -201,7 +227,7 @@ user created with the credentials from options "username" and "password".`,
panic(err) panic(err)
} }
handler, err := fbhttp.NewHandler(imgSvc, fileCache, d.store, server, assetsFs) handler, err := fbhttp.NewHandler(imageService, fileCache, st.Storage, server, assetsFs)
if err != nil { if err != nil {
return err return err
} }
@@ -233,7 +259,7 @@ user created with the credentials from options "username" and "password".`,
sig := <-sigc sig := <-sigc
log.Println("Got signal:", sig) log.Println("Got signal:", sig)
shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) //nolint:mnd shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownRelease() defer shutdownRelease()
if err := srv.Shutdown(shutdownCtx); err != nil { if err := srv.Shutdown(shutdownCtx); err != nil {
@@ -241,66 +267,82 @@ user created with the credentials from options "username" and "password".`,
} }
log.Println("Graceful shutdown complete.") log.Println("Graceful shutdown complete.")
switch sig { return nil
case syscall.SIGHUP: }, storeOptions{allowsNoDatabase: true}),
d.err = fbErrors.ErrSighup
case syscall.SIGINT:
d.err = fbErrors.ErrSigint
case syscall.SIGQUIT:
d.err = fbErrors.ErrSigquit
case syscall.SIGTERM:
d.err = fbErrors.ErrSigTerm
}
return d.err
}, pythonConfig{allowNoDB: true}),
} }
//nolint:gocyclo func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, error) {
func getRunParams(flags *pflag.FlagSet, st *storage.Storage) (*settings.Server, error) {
server, err := st.Settings.GetServer() server, err := st.Settings.GetServer()
if err != nil { if err != nil {
return nil, err return nil, err
} }
if val, set := getStringParamB(flags, "root"); set {
server.Root = val
}
if val, set := getStringParamB(flags, "baseurl"); set {
server.BaseURL = val
}
if val, set := getStringParamB(flags, "log"); set {
server.Log = val
}
isSocketSet := false isSocketSet := false
isAddrSet := false isAddrSet := false
if val, set := getStringParamB(flags, "address"); set { if v.IsSet("address") {
server.Address = val server.Address = v.GetString("address")
isAddrSet = isAddrSet || set isAddrSet = true
} }
if val, set := getStringParamB(flags, "port"); set { if v.IsSet("log") {
server.Port = val server.Log = v.GetString("log")
isAddrSet = isAddrSet || set
} }
if val, set := getStringParamB(flags, "key"); set { if v.IsSet("port") {
server.TLSKey = val server.Port = v.GetString("port")
isAddrSet = isAddrSet || set isAddrSet = true
} }
if val, set := getStringParamB(flags, "cert"); set { if v.IsSet("cert") {
server.TLSCert = val server.TLSCert = v.GetString("cert")
isAddrSet = isAddrSet || set isAddrSet = true
} }
if val, set := getStringParamB(flags, "socket"); set { if v.IsSet("key") {
server.Socket = val server.TLSKey = v.GetString("key")
isSocketSet = isSocketSet || set isAddrSet = true
}
if v.IsSet("root") {
server.Root = v.GetString("root")
}
if v.IsSet("socket") {
server.Socket = v.GetString("socket")
isSocketSet = true
}
if v.IsSet("baseURL") {
server.BaseURL = v.GetString("baseURL")
// TODO(remove): remove after July 2026.
} else if v := os.Getenv("FB_BASEURL"); v != "" {
log.Println("DEPRECATION NOTICE: Environment variable FB_BASEURL has been deprecated, use FB_BASE_URL instead")
server.BaseURL = v
}
if v.IsSet("tokenExpirationTime") {
server.TokenExpirationTime = v.GetString("tokenExpirationTime")
}
if v.IsSet("disableThumbnails") {
server.EnableThumbnails = !v.GetBool("disableThumbnails")
}
if v.IsSet("disablePreviewResize") {
server.ResizePreview = !v.GetBool("disablePreviewResize")
}
if v.IsSet("disableTypeDetectionByHeader") {
server.TypeDetectionByHeader = !v.GetBool("disableTypeDetectionByHeader")
}
if v.IsSet("disableImageResolutionCalc") {
server.ImageResolutionCal = !v.GetBool("disableImageResolutionCalc")
}
if v.IsSet("disableExec") {
server.EnableExec = !v.GetBool("disableExec")
} }
if isAddrSet && isSocketSet { if isAddrSet && isSocketSet {
@@ -312,18 +354,6 @@ func getRunParams(flags *pflag.FlagSet, st *storage.Storage) (*settings.Server,
server.Socket = "" server.Socket = ""
} }
disableThumbnails := getBoolParam(flags, "disable-thumbnails")
server.EnableThumbnails = !disableThumbnails
disablePreviewResize := getBoolParam(flags, "disable-preview-resize")
server.ResizePreview = !disablePreviewResize
disableTypeDetectionByHeader := getBoolParam(flags, "disable-type-detection-by-header")
server.TypeDetectionByHeader = !disableTypeDetectionByHeader
disableExec := getBoolParam(flags, "disable-exec")
server.EnableExec = !disableExec
if server.EnableExec { if server.EnableExec {
log.Println("WARNING: Command Runner feature enabled!") log.Println("WARNING: Command Runner feature enabled!")
log.Println("WARNING: This feature has known security vulnerabilities and should not") log.Println("WARNING: This feature has known security vulnerabilities and should not")
@@ -331,71 +361,9 @@ func getRunParams(flags *pflag.FlagSet, st *storage.Storage) (*settings.Server,
log.Println("WARNING: read https://github.com/filebrowser/filebrowser/issues/5199") log.Println("WARNING: read https://github.com/filebrowser/filebrowser/issues/5199")
} }
if val, set := getStringParamB(flags, "token-expiration-time"); set {
server.TokenExpirationTime = val
}
return server, nil return server, nil
} }
// getBoolParamB returns a parameter as a string and a boolean to tell if it is different from the default
//
// NOTE: we could simply bind the flags to viper and use IsSet.
// Although there is a bug on Viper that always returns true on IsSet
// if a flag is binded. Our alternative way is to manually check
// the flag and then the value from env/config/gotten by viper.
// https://github.com/spf13/viper/pull/331
func getBoolParamB(flags *pflag.FlagSet, key string) (value, ok bool) {
value, _ = flags.GetBool(key)
// If set on Flags, use it.
if flags.Changed(key) {
return value, true
}
// If set through viper (env, config), return it.
if v.IsSet(key) {
return v.GetBool(key), true
}
// Otherwise use default value on flags.
return value, false
}
func getBoolParam(flags *pflag.FlagSet, key string) bool {
val, _ := getBoolParamB(flags, key)
return val
}
// getStringParamB returns a parameter as a string and a boolean to tell if it is different from the default
//
// NOTE: we could simply bind the flags to viper and use IsSet.
// Although there is a bug on Viper that always returns true on IsSet
// if a flag is binded. Our alternative way is to manually check
// the flag and then the value from env/config/gotten by viper.
// https://github.com/spf13/viper/pull/331
func getStringParamB(flags *pflag.FlagSet, key string) (string, bool) {
value, _ := flags.GetString(key)
// If set on Flags, use it.
if flags.Changed(key) {
return value, true
}
// If set through viper (env, config), return it.
if v.IsSet(key) {
return v.GetString(key), true
}
// Otherwise use default value on flags.
return value, false
}
func getStringParam(flags *pflag.FlagSet, key string) string {
val, _ := getStringParamB(flags, key)
return val
}
func setupLog(logMethod string) { func setupLog(logMethod string) {
switch logMethod { switch logMethod {
case "stdout": case "stdout":
@@ -414,20 +382,22 @@ func setupLog(logMethod string) {
} }
} }
func quickSetup(flags *pflag.FlagSet, d pythonData) error { func quickSetup(v *viper.Viper, s *storage.Storage) error {
log.Println("Performing quick setup") log.Println("Performing quick setup")
set := &settings.Settings{ set := &settings.Settings{
Key: generateKey(), Key: generateKey(),
Signup: false, Signup: false,
HideLoginButton: true,
CreateUserDir: false, CreateUserDir: false,
MinimumPasswordLength: settings.DefaultMinimumPasswordLength, MinimumPasswordLength: settings.DefaultMinimumPasswordLength,
UserHomeBasePath: settings.DefaultUsersHomeBasePath, UserHomeBasePath: settings.DefaultUsersHomeBasePath,
Defaults: settings.UserDefaults{ Defaults: settings.UserDefaults{
Scope: ".", Scope: ".",
Locale: "en", Locale: "en",
SingleClick: false, SingleClick: false,
AceEditorTheme: getStringParam(flags, "defaults.aceEditorTheme"), RedirectAfterCopyMove: true,
AceEditorTheme: v.GetString("defaults.aceEditorTheme"),
Perm: users.Permissions{ Perm: users.Permissions{
Admin: false, Admin: false,
Execute: true, Execute: true,
@@ -451,39 +421,45 @@ func quickSetup(flags *pflag.FlagSet, d pythonData) error {
} }
var err error var err error
if _, noauth := getStringParamB(flags, "noauth"); noauth { if v.GetBool("noauth") {
set.AuthMethod = auth.MethodNoAuth set.AuthMethod = auth.MethodNoAuth
err = d.store.Auth.Save(&auth.NoAuth{}) err = s.Auth.Save(&auth.NoAuth{})
} else { } else {
set.AuthMethod = auth.MethodJSONAuth set.AuthMethod = auth.MethodJSONAuth
err = d.store.Auth.Save(&auth.JSONAuth{}) err = s.Auth.Save(&auth.JSONAuth{})
} }
if err != nil { if err != nil {
return err return err
} }
err = d.store.Settings.Save(set) err = s.Settings.Save(set)
if err != nil { if err != nil {
return err return err
} }
ser := &settings.Server{ ser := &settings.Server{
BaseURL: getStringParam(flags, "baseurl"), BaseURL: v.GetString("baseURL"),
Port: getStringParam(flags, "port"), Port: v.GetString("port"),
Log: getStringParam(flags, "log"), Log: v.GetString("log"),
TLSKey: getStringParam(flags, "key"), TLSKey: v.GetString("key"),
TLSCert: getStringParam(flags, "cert"), TLSCert: v.GetString("cert"),
Address: getStringParam(flags, "address"), Address: v.GetString("address"),
Root: getStringParam(flags, "root"), Root: v.GetString("root"),
TokenExpirationTime: v.GetString("tokenExpirationTime"),
EnableThumbnails: !v.GetBool("disableThumbnails"),
ResizePreview: !v.GetBool("disablePreviewResize"),
EnableExec: !v.GetBool("disableExec"),
TypeDetectionByHeader: !v.GetBool("disableTypeDetectionByHeader"),
ImageResolutionCal: !v.GetBool("disableImageResolutionCalc"),
} }
err = d.store.Settings.SaveServer(ser) err = s.Settings.SaveServer(ser)
if err != nil { if err != nil {
return err return err
} }
username := getStringParam(flags, "username") username := v.GetString("username")
password := getStringParam(flags, "password") password := v.GetString("password")
if password == "" { if password == "" {
var pwd string var pwd string
@@ -514,35 +490,5 @@ func quickSetup(flags *pflag.FlagSet, d pythonData) error {
set.Defaults.Apply(user) set.Defaults.Apply(user)
user.Perm.Admin = true user.Perm.Admin = true
return d.store.Users.Save(user) return s.Users.Save(user)
}
func initConfig() {
if cfgFile == "" {
home, err := homedir.Dir()
if err != nil {
panic(err)
}
v.AddConfigPath(".")
v.AddConfigPath(home)
v.AddConfigPath("/etc/filebrowser/")
v.SetConfigName(".filebrowser")
} else {
v.SetConfigFile(cfgFile)
}
v.SetEnvPrefix("FB")
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
if err := v.ReadInConfig(); err != nil {
var configParseError v.ConfigParseError
if errors.As(err, &configParseError) {
panic(err)
}
cfgFile = "No config file used"
} else {
cfgFile = "Using config file: " + v.ConfigFileUsed()
}
} }

View File

@@ -40,7 +40,7 @@ including 'index_end'.`,
return nil return nil
}, },
RunE: python(func(cmd *cobra.Command, args []string, d *pythonData) error { RunE: withStore(func(cmd *cobra.Command, args []string, st *store) error {
i, err := strconv.Atoi(args[0]) i, err := strconv.Atoi(args[0])
if err != nil { if err != nil {
return err return err
@@ -55,14 +55,14 @@ including 'index_end'.`,
user := func(u *users.User) error { user := func(u *users.User) error {
u.Rules = append(u.Rules[:i], u.Rules[f+1:]...) u.Rules = append(u.Rules[:i], u.Rules[f+1:]...)
return d.store.Users.Save(u) return st.Users.Save(u)
} }
global := func(s *settings.Settings) error { global := func(s *settings.Settings) error {
s.Rules = append(s.Rules[:i], s.Rules[f+1:]...) s.Rules = append(s.Rules[:i], s.Rules[f+1:]...)
return d.store.Settings.Save(s) return st.Settings.Save(s)
} }
return runRules(d.store, cmd, user, global) return runRules(st.Storage, cmd, user, global)
}, pythonConfig{}), }, storeOptions{}),
} }

View File

@@ -69,11 +69,12 @@ func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User)
} }
func getUserIdentifier(flags *pflag.FlagSet) (interface{}, error) { func getUserIdentifier(flags *pflag.FlagSet) (interface{}, error) {
id, err := getUint(flags, "id") id, err := flags.GetUint("id")
if err != nil { if err != nil {
return nil, err return nil, err
} }
username, err := getString(flags, "username")
username, err := flags.GetString("username")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -21,15 +21,19 @@ var rulesAddCmd = &cobra.Command{
Short: "Add a global rule or user rule", Short: "Add a global rule or user rule",
Long: `Add a global rule or user rule.`, Long: `Add a global rule or user rule.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: python(func(cmd *cobra.Command, args []string, d *pythonData) error { RunE: withStore(func(cmd *cobra.Command, args []string, st *store) error {
allow, err := getBool(cmd.Flags(), "allow") flags := cmd.Flags()
allow, err := flags.GetBool("allow")
if err != nil { if err != nil {
return err return err
} }
regex, err := getBool(cmd.Flags(), "regex")
regex, err := flags.GetBool("regex")
if err != nil { if err != nil {
return err return err
} }
exp := args[0] exp := args[0]
if regex { if regex {
@@ -49,14 +53,14 @@ var rulesAddCmd = &cobra.Command{
user := func(u *users.User) error { user := func(u *users.User) error {
u.Rules = append(u.Rules, rule) u.Rules = append(u.Rules, rule)
return d.store.Users.Save(u) return st.Users.Save(u)
} }
global := func(s *settings.Settings) error { global := func(s *settings.Settings) error {
s.Rules = append(s.Rules, rule) s.Rules = append(s.Rules, rule)
return d.store.Settings.Save(s) return st.Settings.Save(s)
} }
return runRules(d.store, cmd, user, global) return runRules(st.Storage, cmd, user, global)
}, pythonConfig{}), }, storeOptions{}),
} }

View File

@@ -13,7 +13,7 @@ var rulesLsCommand = &cobra.Command{
Short: "List global rules or user specific rules", Short: "List global rules or user specific rules",
Long: `List global rules or user specific rules.`, Long: `List global rules or user specific rules.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: python(func(cmd *cobra.Command, _ []string, d *pythonData) error { RunE: withStore(func(cmd *cobra.Command, _ []string, st *store) error {
return runRules(d.store, cmd, nil, nil) return runRules(st.Storage, cmd, nil, nil)
}, pythonConfig{}), }, storeOptions{}),
} }

View File

@@ -1,40 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/filebrowser/filebrowser/v2/storage/bolt/importer"
)
func init() {
rootCmd.AddCommand(upgradeCmd)
upgradeCmd.Flags().String("old.database", "", "")
upgradeCmd.Flags().String("old.config", "", "")
_ = upgradeCmd.MarkFlagRequired("old.database")
}
var upgradeCmd = &cobra.Command{
Use: "upgrade",
Short: "Upgrades an old configuration",
Long: `Upgrades an old configuration. This command DOES NOT
import share links because they are incompatible with
this version.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
flags := cmd.Flags()
oldDB, err := getString(flags, "old.database")
if err != nil {
return err
}
oldConf, err := getString(flags, "old.config")
if err != nil {
return err
}
db, err := getString(flags, "database")
if err != nil {
return err
}
return importer.Import(oldDB, oldConf, db)
},
}

View File

@@ -27,16 +27,17 @@ var usersCmd = &cobra.Command{
func printUsers(usrs []*users.User) { func printUsers(usrs []*users.User) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tUsername\tScope\tLocale\tV. Mode\tS.Click\tAdmin\tExecute\tCreate\tRename\tModify\tDelete\tShare\tDownload\tPwd Lock") fmt.Fprintln(w, "ID\tUsername\tScope\tLocale\tV. Mode\tS.Click\tRed. After C/M\tAdmin\tExecute\tCreate\tRename\tModify\tDelete\tShare\tDownload\tPwd Lock")
for _, u := range usrs { for _, u := range usrs {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t\n", fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t\n",
u.ID, u.ID,
u.Username, u.Username,
u.Scope, u.Scope,
u.Locale, u.Locale,
u.ViewMode, u.ViewMode,
u.SingleClick, u.SingleClick,
u.RedirectAfterCopyMove,
u.Perm.Admin, u.Perm.Admin,
u.Perm.Execute, u.Perm.Execute,
u.Perm.Create, u.Perm.Create,
@@ -77,67 +78,72 @@ func addUserFlags(flags *pflag.FlagSet) {
flags.String("locale", "en", "locale for users") flags.String("locale", "en", "locale for users")
flags.String("viewMode", string(users.ListViewMode), "view mode for users") flags.String("viewMode", string(users.ListViewMode), "view mode for users")
flags.Bool("singleClick", false, "use single clicks only") flags.Bool("singleClick", false, "use single clicks only")
flags.Bool("redirectAfterCopyMove", false, "redirect to destination after copy/move")
flags.Bool("dateFormat", false, "use date format (true for absolute time, false for relative)") flags.Bool("dateFormat", false, "use date format (true for absolute time, false for relative)")
flags.Bool("hideDotfiles", false, "hide dotfiles") flags.Bool("hideDotfiles", false, "hide dotfiles")
flags.String("aceEditorTheme", "", "ace editor's syntax highlighting theme for users") flags.String("aceEditorTheme", "", "ace editor's syntax highlighting theme for users")
} }
func getViewMode(flags *pflag.FlagSet) (users.ViewMode, error) { func getAndParseViewMode(flags *pflag.FlagSet) (users.ViewMode, error) {
viewModeStr, err := getString(flags, "viewMode") viewModeStr, err := flags.GetString("viewMode")
if err != nil { if err != nil {
return "", err return "", err
} }
viewMode := users.ViewMode(viewModeStr) viewMode := users.ViewMode(viewModeStr)
if viewMode != users.ListViewMode && viewMode != users.MosaicViewMode { if viewMode != users.ListViewMode && viewMode != users.MosaicViewMode {
return "", errors.New("view mode must be \"" + string(users.ListViewMode) + "\" or \"" + string(users.MosaicViewMode) + "\"") return "", errors.New("view mode must be \"" + string(users.ListViewMode) + "\" or \"" + string(users.MosaicViewMode) + "\"")
} }
return viewMode, nil return viewMode, nil
} }
//nolint:gocyclo
func getUserDefaults(flags *pflag.FlagSet, defaults *settings.UserDefaults, all bool) error { func getUserDefaults(flags *pflag.FlagSet, defaults *settings.UserDefaults, all bool) error {
var visitErr error errs := []error{}
visit := func(flag *pflag.Flag) { visit := func(flag *pflag.Flag) {
if visitErr != nil {
return
}
var err error var err error
switch flag.Name { switch flag.Name {
case "scope": case "scope":
defaults.Scope, err = getString(flags, flag.Name) defaults.Scope, err = flags.GetString(flag.Name)
case "locale": case "locale":
defaults.Locale, err = getString(flags, flag.Name) defaults.Locale, err = flags.GetString(flag.Name)
case "viewMode": case "viewMode":
defaults.ViewMode, err = getViewMode(flags) defaults.ViewMode, err = getAndParseViewMode(flags)
case "singleClick": case "singleClick":
defaults.SingleClick, err = getBool(flags, flag.Name) defaults.SingleClick, err = flags.GetBool(flag.Name)
case "redirectAfterCopyMove":
defaults.RedirectAfterCopyMove, err = flags.GetBool(flag.Name)
case "aceEditorTheme": case "aceEditorTheme":
defaults.AceEditorTheme, err = getString(flags, flag.Name) defaults.AceEditorTheme, err = flags.GetString(flag.Name)
case "perm.admin": case "perm.admin":
defaults.Perm.Admin, err = getBool(flags, flag.Name) defaults.Perm.Admin, err = flags.GetBool(flag.Name)
case "perm.execute": case "perm.execute":
defaults.Perm.Execute, err = getBool(flags, flag.Name) defaults.Perm.Execute, err = flags.GetBool(flag.Name)
case "perm.create": case "perm.create":
defaults.Perm.Create, err = getBool(flags, flag.Name) defaults.Perm.Create, err = flags.GetBool(flag.Name)
case "perm.rename": case "perm.rename":
defaults.Perm.Rename, err = getBool(flags, flag.Name) defaults.Perm.Rename, err = flags.GetBool(flag.Name)
case "perm.modify": case "perm.modify":
defaults.Perm.Modify, err = getBool(flags, flag.Name) defaults.Perm.Modify, err = flags.GetBool(flag.Name)
case "perm.delete": case "perm.delete":
defaults.Perm.Delete, err = getBool(flags, flag.Name) defaults.Perm.Delete, err = flags.GetBool(flag.Name)
case "perm.share": case "perm.share":
defaults.Perm.Share, err = getBool(flags, flag.Name) defaults.Perm.Share, err = flags.GetBool(flag.Name)
case "perm.download": case "perm.download":
defaults.Perm.Download, err = getBool(flags, flag.Name) defaults.Perm.Download, err = flags.GetBool(flag.Name)
case "commands": case "commands":
defaults.Commands, err = flags.GetStringSlice(flag.Name) defaults.Commands, err = flags.GetStringSlice(flag.Name)
case "sorting.by": case "sorting.by":
defaults.Sorting.By, err = getString(flags, flag.Name) defaults.Sorting.By, err = flags.GetString(flag.Name)
case "sorting.asc": case "sorting.asc":
defaults.Sorting.Asc, err = getBool(flags, flag.Name) defaults.Sorting.Asc, err = flags.GetBool(flag.Name)
case "hideDotfiles":
defaults.HideDotfiles, err = flags.GetBool(flag.Name)
} }
if err != nil { if err != nil {
visitErr = err errs = append(errs, err)
} }
} }
@@ -146,5 +152,6 @@ func getUserDefaults(flags *pflag.FlagSet, defaults *settings.UserDefaults, all
} else { } else {
flags.Visit(visit) flags.Visit(visit)
} }
return visitErr
return errors.Join(errs...)
} }

View File

@@ -16,12 +16,13 @@ var usersAddCmd = &cobra.Command{
Short: "Create a new user", Short: "Create a new user",
Long: `Create a new user and add it to the database.`, Long: `Create a new user and add it to the database.`,
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
RunE: python(func(cmd *cobra.Command, args []string, d *pythonData) error { RunE: withStore(func(cmd *cobra.Command, args []string, st *store) error {
s, err := d.store.Settings.Get() flags := cmd.Flags()
s, err := st.Settings.Get()
if err != nil { if err != nil {
return err return err
} }
err = getUserDefaults(cmd.Flags(), &s.Defaults, false) err = getUserDefaults(flags, &s.Defaults, false)
if err != nil { if err != nil {
return err return err
} }
@@ -31,39 +32,36 @@ var usersAddCmd = &cobra.Command{
return err return err
} }
lockPassword, err := getBool(cmd.Flags(), "lockPassword")
if err != nil {
return err
}
dateFormat, err := getBool(cmd.Flags(), "dateFormat")
if err != nil {
return err
}
hideDotfiles, err := getBool(cmd.Flags(), "hideDotfiles")
if err != nil {
return err
}
user := &users.User{ user := &users.User{
Username: args[0], Username: args[0],
Password: password, Password: password,
LockPassword: lockPassword, }
DateFormat: dateFormat,
HideDotfiles: hideDotfiles, user.LockPassword, err = flags.GetBool("lockPassword")
if err != nil {
return err
}
user.DateFormat, err = flags.GetBool("dateFormat")
if err != nil {
return err
}
user.HideDotfiles, err = flags.GetBool("hideDotfiles")
if err != nil {
return err
} }
s.Defaults.Apply(user) s.Defaults.Apply(user)
servSettings, err := d.store.Settings.GetServer() servSettings, err := st.Settings.GetServer()
if err != nil { if err != nil {
return err return err
} }
// since getUserDefaults() polluted s.Defaults.Scope // since getUserDefaults() polluted s.Defaults.Scope
// which makes the Scope not the one saved in the db // which makes the Scope not the one saved in the db
// we need the right s.Defaults.Scope here // we need the right s.Defaults.Scope here
s2, err := d.store.Settings.Get() s2, err := st.Settings.Get()
if err != nil { if err != nil {
return err return err
} }
@@ -74,11 +72,11 @@ var usersAddCmd = &cobra.Command{
} }
user.Scope = userHome user.Scope = userHome
err = d.store.Users.Save(user) err = st.Users.Save(user)
if err != nil { if err != nil {
return err return err
} }
printUsers([]*users.User{user}) printUsers([]*users.User{user})
return nil return nil
}, pythonConfig{}), }, storeOptions{}),
} }

View File

@@ -14,8 +14,8 @@ var usersExportCmd = &cobra.Command{
Long: `Export all users to a json or yaml file. Please indicate the Long: `Export all users to a json or yaml file. Please indicate the
path to the file where you want to write the users.`, path to the file where you want to write the users.`,
Args: jsonYamlArg, Args: jsonYamlArg,
RunE: python(func(_ *cobra.Command, args []string, d *pythonData) error { RunE: withStore(func(_ *cobra.Command, args []string, st *store) error {
list, err := d.store.Users.Gets("") list, err := st.Users.Gets("")
if err != nil { if err != nil {
return err return err
} }
@@ -25,5 +25,5 @@ path to the file where you want to write the users.`,
return err return err
} }
return nil return nil
}, pythonConfig{}), }, storeOptions{}),
} }

View File

@@ -26,7 +26,7 @@ var usersLsCmd = &cobra.Command{
RunE: findUsers, RunE: findUsers,
} }
var findUsers = python(func(_ *cobra.Command, args []string, d *pythonData) error { var findUsers = withStore(func(_ *cobra.Command, args []string, st *store) error {
var ( var (
list []*users.User list []*users.User
user *users.User user *users.User
@@ -36,14 +36,14 @@ var findUsers = python(func(_ *cobra.Command, args []string, d *pythonData) erro
if len(args) == 1 { if len(args) == 1 {
username, id := parseUsernameOrID(args[0]) username, id := parseUsernameOrID(args[0])
if username != "" { if username != "" {
user, err = d.store.Users.Get("", username) user, err = st.Users.Get("", username)
} else { } else {
user, err = d.store.Users.Get("", id) user, err = st.Users.Get("", id)
} }
list = []*users.User{user} list = []*users.User{user}
} else { } else {
list, err = d.store.Users.Gets("") list, err = st.Users.Gets("")
} }
if err != nil { if err != nil {
@@ -51,4 +51,4 @@ var findUsers = python(func(_ *cobra.Command, args []string, d *pythonData) erro
} }
printUsers(list) printUsers(list)
return nil return nil
}, pythonConfig{}) }, storeOptions{})

View File

@@ -25,7 +25,8 @@ file. You can use this command to import new users to your
installation. For that, just don't place their ID on the files installation. For that, just don't place their ID on the files
list or set it to 0.`, list or set it to 0.`,
Args: jsonYamlArg, Args: jsonYamlArg,
RunE: python(func(cmd *cobra.Command, args []string, d *pythonData) error { RunE: withStore(func(cmd *cobra.Command, args []string, st *store) error {
flags := cmd.Flags()
fd, err := os.Open(args[0]) fd, err := os.Open(args[0])
if err != nil { if err != nil {
return err return err
@@ -45,13 +46,13 @@ list or set it to 0.`,
} }
} }
replace, err := getBool(cmd.Flags(), "replace") replace, err := flags.GetBool("replace")
if err != nil { if err != nil {
return err return err
} }
if replace { if replace {
oldUsers, userImportErr := d.store.Users.Gets("") oldUsers, userImportErr := st.Users.Gets("")
if userImportErr != nil { if userImportErr != nil {
return userImportErr return userImportErr
} }
@@ -62,20 +63,20 @@ list or set it to 0.`,
} }
for _, user := range oldUsers { for _, user := range oldUsers {
err = d.store.Users.Delete(user.ID) err = st.Users.Delete(user.ID)
if err != nil { if err != nil {
return err return err
} }
} }
} }
overwrite, err := getBool(cmd.Flags(), "overwrite") overwrite, err := flags.GetBool("overwrite")
if err != nil { if err != nil {
return err return err
} }
for _, user := range list { for _, user := range list {
onDB, err := d.store.Users.Get("", user.ID) onDB, err := st.Users.Get("", user.ID)
// User exists in DB. // User exists in DB.
if err == nil { if err == nil {
@@ -87,7 +88,7 @@ list or set it to 0.`,
// with the new username. If there is, print an error and cancel the // with the new username. If there is, print an error and cancel the
// operation // operation
if user.Username != onDB.Username { if user.Username != onDB.Username {
if conflictuous, err := d.store.Users.Get("", user.Username); err == nil { //nolint:govet if conflictuous, err := st.Users.Get("", user.Username); err == nil {
return usernameConflictError(user.Username, conflictuous.ID, user.ID) return usernameConflictError(user.Username, conflictuous.ID, user.ID)
} }
} }
@@ -97,13 +98,13 @@ list or set it to 0.`,
user.ID = 0 user.ID = 0
} }
err = d.store.Users.Save(user) err = st.Users.Save(user)
if err != nil { if err != nil {
return err return err
} }
} }
return nil return nil
}, pythonConfig{}), }, storeOptions{}),
} }
func usernameConflictError(username string, originalID, newID uint) error { func usernameConflictError(username string, originalID, newID uint) error {

View File

@@ -15,14 +15,14 @@ var usersRmCmd = &cobra.Command{
Short: "Delete a user by username or id", Short: "Delete a user by username or id",
Long: `Delete a user by username or id`, Long: `Delete a user by username or id`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: python(func(_ *cobra.Command, args []string, d *pythonData) error { RunE: withStore(func(_ *cobra.Command, args []string, st *store) error {
username, id := parseUsernameOrID(args[0]) username, id := parseUsernameOrID(args[0])
var err error var err error
if username != "" { if username != "" {
err = d.store.Users.Delete(username) err = st.Users.Delete(username)
} else { } else {
err = d.store.Users.Delete(id) err = st.Users.Delete(id)
} }
if err != nil { if err != nil {
@@ -30,5 +30,5 @@ var usersRmCmd = &cobra.Command{
} }
fmt.Println("user deleted successfully") fmt.Println("user deleted successfully")
return nil return nil
}, pythonConfig{}), }, storeOptions{}),
} }

View File

@@ -21,19 +21,20 @@ var usersUpdateCmd = &cobra.Command{
Long: `Updates an existing user. Set the flags for the Long: `Updates an existing user. Set the flags for the
options you want to change.`, options you want to change.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: python(func(cmd *cobra.Command, args []string, d *pythonData) error { RunE: withStore(func(cmd *cobra.Command, args []string, st *store) error {
username, id := parseUsernameOrID(args[0])
flags := cmd.Flags() flags := cmd.Flags()
password, err := getString(flags, "password") username, id := parseUsernameOrID(args[0])
if err != nil { password, err := flags.GetString("password")
return err
}
newUsername, err := getString(flags, "username")
if err != nil { if err != nil {
return err return err
} }
s, err := d.store.Settings.Get() newUsername, err := flags.GetString("username")
if err != nil {
return err
}
s, err := st.Settings.Get()
if err != nil { if err != nil {
return err return err
} }
@@ -41,46 +42,50 @@ options you want to change.`,
var ( var (
user *users.User user *users.User
) )
if id != 0 { if id != 0 {
user, err = d.store.Users.Get("", id) user, err = st.Users.Get("", id)
} else { } else {
user, err = d.store.Users.Get("", username) user, err = st.Users.Get("", username)
} }
if err != nil { if err != nil {
return err return err
} }
defaults := settings.UserDefaults{ defaults := settings.UserDefaults{
Scope: user.Scope, Scope: user.Scope,
Locale: user.Locale, Locale: user.Locale,
ViewMode: user.ViewMode, ViewMode: user.ViewMode,
SingleClick: user.SingleClick, SingleClick: user.SingleClick,
Perm: user.Perm, RedirectAfterCopyMove: user.RedirectAfterCopyMove,
Sorting: user.Sorting, Perm: user.Perm,
Commands: user.Commands, Sorting: user.Sorting,
Commands: user.Commands,
} }
err = getUserDefaults(flags, &defaults, false) err = getUserDefaults(flags, &defaults, false)
if err != nil { if err != nil {
return err return err
} }
user.Scope = defaults.Scope user.Scope = defaults.Scope
user.Locale = defaults.Locale user.Locale = defaults.Locale
user.ViewMode = defaults.ViewMode user.ViewMode = defaults.ViewMode
user.SingleClick = defaults.SingleClick user.SingleClick = defaults.SingleClick
user.RedirectAfterCopyMove = defaults.RedirectAfterCopyMove
user.Perm = defaults.Perm user.Perm = defaults.Perm
user.Commands = defaults.Commands user.Commands = defaults.Commands
user.Sorting = defaults.Sorting user.Sorting = defaults.Sorting
user.LockPassword, err = getBool(flags, "lockPassword") user.LockPassword, err = flags.GetBool("lockPassword")
if err != nil { if err != nil {
return err return err
} }
user.DateFormat, err = getBool(flags, "dateFormat")
user.DateFormat, err = flags.GetBool("dateFormat")
if err != nil { if err != nil {
return err return err
} }
user.HideDotfiles, err = getBool(flags, "hideDotfiles")
user.HideDotfiles, err = flags.GetBool("hideDotfiles")
if err != nil { if err != nil {
return err return err
} }
@@ -96,11 +101,11 @@ options you want to change.`,
} }
} }
err = d.store.Users.Update(user) err = st.Users.Update(user)
if err != nil { if err != nil {
return err return err
} }
printUsers([]*users.User{user}) printUsers([]*users.User{user})
return nil return nil
}, pythonConfig{}), }, storeOptions{}),
} }

View File

@@ -12,51 +12,34 @@ import (
"strings" "strings"
"github.com/asdine/storm/v3" "github.com/asdine/storm/v3"
homedir "github.com/mitchellh/go-homedir"
"github.com/samber/lo"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag" "github.com/spf13/pflag"
yaml "gopkg.in/yaml.v2" "github.com/spf13/viper"
yaml "gopkg.in/yaml.v3"
"github.com/filebrowser/filebrowser/v2/settings" "github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/storage" "github.com/filebrowser/filebrowser/v2/storage"
"github.com/filebrowser/filebrowser/v2/storage/bolt" "github.com/filebrowser/filebrowser/v2/storage/bolt"
) )
const dbPerms = 0640 const databasePermissions = 0640
func returnErr(err error) error { func getAndParseFileMode(flags *pflag.FlagSet, name string) (fs.FileMode, error) {
if err != nil { mode, err := flags.GetString(name)
return err
}
return nil
}
func getString(flags *pflag.FlagSet, flag string) (string, error) {
s, err := flags.GetString(flag)
return s, returnErr(err)
}
func getMode(flags *pflag.FlagSet, flag string) (fs.FileMode, error) {
s, err := getString(flags, flag)
if err != nil { if err != nil {
return 0, err return 0, err
} }
b, err := strconv.ParseUint(s, 0, 32)
b, err := strconv.ParseUint(mode, 0, 32)
if err != nil { if err != nil {
return 0, err return 0, err
} }
return fs.FileMode(b), nil return fs.FileMode(b), nil
} }
func getBool(flags *pflag.FlagSet, flag string) (bool, error) {
b, err := flags.GetBool(flag)
return b, returnErr(err)
}
func getUint(flags *pflag.FlagSet, flag string) (uint, error) {
b, err := flags.GetUint(flag)
return b, returnErr(err)
}
func generateKey() []byte { func generateKey() []byte {
k, err := settings.GenerateKey() k, err := settings.GenerateKey()
if err != nil { if err != nil {
@@ -65,20 +48,6 @@ func generateKey() []byte {
return k return k
} }
type cobraFunc func(cmd *cobra.Command, args []string) error
type pythonFunc func(cmd *cobra.Command, args []string, data *pythonData) error
type pythonConfig struct {
noDB bool
allowNoDB bool
}
type pythonData struct {
hadDB bool
store *storage.Storage
err error
}
func dbExists(path string) (bool, error) { func dbExists(path string) (bool, error) {
stat, err := os.Stat(path) stat, err := os.Stat(path)
if err == nil { if err == nil {
@@ -89,7 +58,7 @@ func dbExists(path string) (bool, error) {
d := filepath.Dir(path) d := filepath.Dir(path)
_, err = os.Stat(d) _, err = os.Stat(d)
if os.IsNotExist(err) { if os.IsNotExist(err) {
if err := os.MkdirAll(d, 0700); err != nil { //nolint:govet if err := os.MkdirAll(d, 0700); err != nil {
return false, err return false, err
} }
return false, nil return false, nil
@@ -99,42 +68,137 @@ func dbExists(path string) (bool, error) {
return false, err return false, err
} }
func python(fn pythonFunc, cfg pythonConfig) cobraFunc { // Generate the replacements for all environment variables. This allows to
// use FB_BRANDING_DISABLE_EXTERNAL environment variables, even when the
// option name is branding.disableExternal.
func generateEnvKeyReplacements(cmd *cobra.Command) []string {
replacements := []string{}
cmd.Flags().VisitAll(func(f *pflag.Flag) {
oldName := strings.ToUpper(f.Name)
newName := strings.ToUpper(lo.SnakeCase(f.Name))
replacements = append(replacements, oldName, newName)
})
return replacements
}
func initViper(cmd *cobra.Command) (*viper.Viper, error) {
v := viper.New()
// Get config file from flag
cfgFile, err := cmd.Flags().GetString("config")
if err != nil {
return nil, err
}
// Configuration file
if cfgFile == "" {
home, err := homedir.Dir()
if err != nil {
return nil, err
}
v.AddConfigPath(".")
v.AddConfigPath(home)
v.AddConfigPath("/etc/filebrowser/")
v.SetConfigName(".filebrowser")
} else {
v.SetConfigFile(cfgFile)
}
// Environment variables
v.SetEnvPrefix("FB")
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(generateEnvKeyReplacements(cmd)...))
// Bind the flags
err = v.BindPFlags(cmd.Flags())
if err != nil {
return nil, err
}
// Read in configuration
if err := v.ReadInConfig(); err != nil {
if errors.Is(err, viper.ConfigParseError{}) {
return nil, err
}
log.Println("No config file used")
} else {
log.Printf("Using config file: %s", v.ConfigFileUsed())
}
// Return Viper
return v, nil
}
type store struct {
*storage.Storage
databaseExisted bool
}
type storeOptions struct {
expectsNoDatabase bool
allowsNoDatabase bool
}
type cobraFunc func(cmd *cobra.Command, args []string) error
// withViperAndStore initializes Viper and the storage.Store and passes them to the callback function.
// This function should only be used by [withStore] and the root command. No other command should call
// this function directly.
func withViperAndStore(fn func(cmd *cobra.Command, args []string, v *viper.Viper, store *store) error, options storeOptions) cobraFunc {
return func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error {
data := &pythonData{hadDB: true} v, err := initViper(cmd)
path := getStringParam(cmd.Flags(), "database")
absPath, err := filepath.Abs(path)
if err != nil { if err != nil {
panic(err) return err
} }
path, err := filepath.Abs(v.GetString("database"))
if err != nil {
return err
}
exists, err := dbExists(path) exists, err := dbExists(path)
switch {
if err != nil { case err != nil:
panic(err) return err
} else if exists && cfg.noDB { case exists && options.expectsNoDatabase:
log.Fatal(absPath + " already exists") log.Fatal(path + " already exists")
} else if !exists && !cfg.noDB && !cfg.allowNoDB { case !exists && !options.expectsNoDatabase && !options.allowsNoDatabase:
log.Fatal(absPath + " does not exist. Please run 'filebrowser config init' first.") log.Fatal(path + " does not exist. Please run 'filebrowser config init' first.")
} else if !exists && !cfg.noDB { case !exists && !options.expectsNoDatabase:
log.Println("Warning: filebrowser.db can't be found. Initialing in " + strings.TrimSuffix(absPath, "filebrowser.db")) log.Println("WARNING: filebrowser.db can't be found. Initialing in " + strings.TrimSuffix(path, "filebrowser.db"))
} }
log.Println("Using database: " + absPath) log.Println("Using database: " + path)
data.hadDB = exists
db, err := storm.Open(path, storm.BoltOptions(dbPerms, nil)) db, err := storm.Open(path, storm.BoltOptions(databasePermissions, nil))
if err != nil { if err != nil {
return err return err
} }
defer db.Close() defer db.Close()
data.store, err = bolt.NewStorage(db)
storage, err := bolt.NewStorage(db)
if err != nil { if err != nil {
return err return err
} }
return fn(cmd, args, data)
store := &store{
Storage: storage,
databaseExisted: exists,
}
return fn(cmd, args, v, store)
} }
} }
func withStore(fn func(cmd *cobra.Command, args []string, store *store) error, options storeOptions) cobraFunc {
return withViperAndStore(func(cmd *cobra.Command, args []string, _ *viper.Viper, store *store) error {
return fn(cmd, args, store)
}, options)
}
func marshal(filename string, data interface{}) error { func marshal(filename string, data interface{}) error {
fd, err := os.Create(filename) fd, err := os.Create(filename)
if err != nil { if err != nil {

View File

@@ -1,34 +0,0 @@
module.exports = {
rules: {
'body-leading-blank': [1, 'always'],
'body-max-line-length': [2, 'always', 100],
'footer-leading-blank': [1, 'always'],
'footer-max-line-length': [2, 'always', 100],
'header-max-length': [2, 'always', 100],
'scope-case': [2, 'always', 'lower-case'],
'subject-case': [
2,
'never',
['sentence-case', 'start-case', 'pascal-case', 'upper-case'],
],
'subject-full-stop': [2, 'never', '.'],
'type-case': [2, 'always', 'lower-case'],
'type-empty': [2, 'never'],
'type-enum': [
2,
'always',
[
'feat',
'fix',
'perf',
'revert',
'refactor',
'build',
'ci',
'test',
'chore',
'docs',
],
],
},
};

View File

@@ -1,28 +0,0 @@
SHELL := /usr/bin/env bash
DATE ?= $(shell date +%FT%T%z)
BASE_PATH := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
VERSION ?= $(shell git describe --tags --always --match=v* 2> /dev/null || \
cat $(CURDIR)/.version 2> /dev/null || echo v0)
VERSION_HASH = $(shell git rev-parse HEAD)
BRANCH = $(shell git rev-parse --abbrev-ref HEAD)
go = GOGC=off go
MODULE = $(shell env GO111MODULE=on go list -m)
# printing
# $Q (quiet) is used in the targets as a replacer for @.
# This macro helps to print the command for debugging by setting V to 1. Example `make test-unit V=1`
V = 0
Q = $(if $(filter 1,$V),,@)
# $M is a macro to print a colored ▶ character. Example `$(info $(M) running coverage tests…)` will print "▶ running coverage tests…"
M = $(shell printf "\033[34;1m▶\033[0m")
GREEN := $(shell tput -Txterm setaf 2)
YELLOW := $(shell tput -Txterm setaf 3)
WHITE := $(shell tput -Txterm setaf 7)
CYAN := $(shell tput -Txterm setaf 6)
RESET := $(shell tput -Txterm sgr0)
define global_option
printf " ${YELLOW}%-20s${GREEN}%s${RESET}\n" $(1) $(2)
endef

View File

@@ -2,7 +2,7 @@ package diskcache
import ( import (
"context" "context"
"crypto/sha1" //nolint:gosec "crypto/sha1"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
@@ -103,7 +103,7 @@ func (f *FileCache) getScopedLocks(key string) (lock sync.Locker) {
} }
func (f *FileCache) getFileName(key string) string { func (f *FileCache) getFileName(key string) string {
hasher := sha1.New() //nolint:gosec hasher := sha1.New()
_, _ = hasher.Write([]byte(key)) _, _ = hasher.Write([]byte(key))
hash := hex.EncodeToString(hasher.Sum(nil)) hash := hex.EncodeToString(hasher.Sum(nil))
return fmt.Sprintf("%s/%s/%s", hash[:1], hash[1:3], hash) return fmt.Sprintf("%s/%s/%s", hash[:1], hash[1:3], hash)

View File

@@ -25,12 +25,12 @@ func TestFileCache(t *testing.T) {
// store new key // store new key
err := cache.Store(ctx, key, []byte(value)) err := cache.Store(ctx, key, []byte(value))
require.NoError(t, err) require.NoError(t, err)
checkValue(t, ctx, fs, filepath.Join(cacheRoot, cachedFilePath), cache, key, value) checkValue(ctx, t, fs, filepath.Join(cacheRoot, cachedFilePath), cache, key, value)
// update existing key // update existing key
err = cache.Store(ctx, key, []byte(newValue)) err = cache.Store(ctx, key, []byte(newValue))
require.NoError(t, err) require.NoError(t, err)
checkValue(t, ctx, fs, filepath.Join(cacheRoot, cachedFilePath), cache, key, newValue) checkValue(ctx, t, fs, filepath.Join(cacheRoot, cachedFilePath), cache, key, newValue)
// delete key // delete key
err = cache.Delete(ctx, key) err = cache.Delete(ctx, key)
@@ -40,7 +40,7 @@ func TestFileCache(t *testing.T) {
require.False(t, exists) require.False(t, exists)
} }
func checkValue(t *testing.T, ctx context.Context, fs afero.Fs, fileFullPath string, cache *FileCache, key, wantValue string) { //nolint:revive func checkValue(ctx context.Context, t *testing.T, fs afero.Fs, fileFullPath string, cache *FileCache, key, wantValue string) {
t.Helper() t.Helper()
// check actual file content // check actual file content
b, err := afero.ReadFile(fs, fileFullPath) b, err := afero.ReadFile(fs, fileFullPath)

View File

@@ -5,4 +5,4 @@
"log": "stdout", "log": "stdout",
"database": "/database/filebrowser.db", "database": "/database/filebrowser.db",
"root": "/srv" "root": "/srv"
} }

View File

@@ -1,40 +1,28 @@
package errors package fberrors
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"syscall"
)
const (
ExitCodeSigTerm = 128 + int(syscall.SIGTERM)
ExitCodeSighup = 128 + int(syscall.SIGHUP)
ExitCodeSigint = 128 + int(syscall.SIGINT)
ExitCodeSigquit = 128 + int(syscall.SIGQUIT)
) )
var ( var (
ErrEmptyKey = errors.New("empty key") ErrEmptyKey = errors.New("empty key")
ErrExist = errors.New("the resource already exists") ErrExist = errors.New("the resource already exists")
ErrNotExist = errors.New("the resource does not exist") ErrNotExist = errors.New("the resource does not exist")
ErrEmptyPassword = errors.New("password is empty") ErrEmptyPassword = errors.New("password is empty")
ErrEasyPassword = errors.New("password is too easy") ErrEasyPassword = errors.New("password is too easy")
ErrEmptyUsername = errors.New("username is empty") ErrEmptyUsername = errors.New("username is empty")
ErrEmptyRequest = errors.New("empty request") ErrEmptyRequest = errors.New("empty request")
ErrScopeIsRelative = errors.New("scope is a relative path") ErrScopeIsRelative = errors.New("scope is a relative path")
ErrInvalidDataType = errors.New("invalid data type") ErrInvalidDataType = errors.New("invalid data type")
ErrIsDirectory = errors.New("file is directory") ErrIsDirectory = errors.New("file is directory")
ErrInvalidOption = errors.New("invalid option") ErrInvalidOption = errors.New("invalid option")
ErrInvalidAuthMethod = errors.New("invalid auth method") ErrInvalidAuthMethod = errors.New("invalid auth method")
ErrPermissionDenied = errors.New("permission denied") ErrPermissionDenied = errors.New("permission denied")
ErrInvalidRequestParams = errors.New("invalid request params") ErrInvalidRequestParams = errors.New("invalid request params")
ErrSourceIsParent = errors.New("source is parent") ErrSourceIsParent = errors.New("source is parent")
ErrRootUserDeletion = errors.New("user with id 1 can't be deleted") ErrRootUserDeletion = errors.New("user with id 1 can't be deleted")
ErrSigTerm = errors.New("exit on signal: sigterm") ErrCurrentPasswordIncorrect = errors.New("the current password is incorrect")
ErrSighup = errors.New("exit on signal: sighup")
ErrSigint = errors.New("exit on signal: sigint")
ErrSigquit = errors.New("exit on signal: sigquit")
) )
type ErrShortPassword struct { type ErrShortPassword struct {
@@ -44,44 +32,3 @@ type ErrShortPassword struct {
func (e ErrShortPassword) Error() string { func (e ErrShortPassword) Error() string {
return fmt.Sprintf("password is too short, minimum length is %d", e.MinimumLength) return fmt.Sprintf("password is too short, minimum length is %d", e.MinimumLength)
} }
// GetExitCode returns the exit code for a given error.
func GetExitCode(err error) int {
if err == nil {
return 0
}
exitCodeMap := map[error]int{
ErrSigTerm: ExitCodeSigTerm,
ErrSighup: ExitCodeSighup,
ErrSigint: ExitCodeSigint,
ErrSigquit: ExitCodeSigquit,
}
for e, code := range exitCodeMap {
if errors.Is(err, e) {
return code
}
}
if exitErr, ok := err.(interface{ ExitCode() int }); ok {
return exitErr.ExitCode()
}
var pathErr *os.PathError
if errors.As(err, &pathErr) {
return 1
}
var syscallErr *os.SyscallError
if errors.As(err, &syscallErr) {
return 1
}
var errno syscall.Errno
if errors.As(err, &errno) {
return 1
}
return 1
}

View File

@@ -1,8 +1,8 @@
package files package files
import ( import (
"crypto/md5" //nolint:gosec "crypto/md5"
"crypto/sha1" //nolint:gosec "crypto/sha1"
"crypto/sha256" "crypto/sha256"
"crypto/sha512" "crypto/sha512"
"encoding/hex" "encoding/hex"
@@ -23,7 +23,7 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
fbErrors "github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/rules" "github.com/filebrowser/filebrowser/v2/rules"
) )
@@ -35,22 +35,24 @@ var (
// FileInfo describes a file. // FileInfo describes a file.
type FileInfo struct { type FileInfo struct {
*Listing *Listing
Fs afero.Fs `json:"-"` Fs afero.Fs `json:"-"`
Path string `json:"path"` Path string `json:"path"`
Name string `json:"name"` Name string `json:"name"`
Size int64 `json:"size"` Size int64 `json:"size"`
Extension string `json:"extension"` Extension string `json:"extension"`
ModTime time.Time `json:"modified"` ModTime time.Time `json:"modified"`
Mode os.FileMode `json:"mode"` Mode os.FileMode `json:"mode"`
IsDir bool `json:"isDir"` IsDir bool `json:"isDir"`
IsSymlink bool `json:"isSymlink"` IsSymlink bool `json:"isSymlink"`
Type string `json:"type"` Type string `json:"type"`
Subtitles []string `json:"subtitles,omitempty"` Subtitles []string `json:"subtitles,omitempty"`
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
Checksums map[string]string `json:"checksums,omitempty"` Checksums map[string]string `json:"checksums,omitempty"`
Token string `json:"token,omitempty"` // Tags holds audio metadata (ID3, VorbisComment, MP4 tags, ...)
currentDir []os.FileInfo `json:"-"` Tags map[string]interface{} `json:"tags,omitempty"`
Resolution *ImageResolution `json:"resolution,omitempty"` Token string `json:"token,omitempty"`
currentDir []os.FileInfo `json:"-"`
Resolution *ImageResolution `json:"resolution,omitempty"`
} }
// FileOptions are the options when getting a file info. // FileOptions are the options when getting a file info.
@@ -60,6 +62,7 @@ type FileOptions struct {
Modify bool Modify bool
Expand bool Expand bool
ReadHeader bool ReadHeader bool
CalcImgRes bool
Token string Token string
Checker rules.Checker Checker rules.Checker
Content bool Content bool
@@ -90,13 +93,13 @@ func NewFileInfo(opts *FileOptions) (*FileInfo, error) {
if opts.Expand { if opts.Expand {
if file.IsDir { if file.IsDir {
if err := file.readListing(opts.Checker, opts.ReadHeader); err != nil { //nolint:govet if err := file.readListing(opts.Checker, opts.ReadHeader, opts.CalcImgRes); err != nil {
return nil, err return nil, err
} }
return file, nil return file, nil
} }
err = file.detectType(opts.Modify, opts.Content, true) err = file.detectType(opts.Modify, opts.Content, true, opts.CalcImgRes)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -168,7 +171,7 @@ func stat(opts *FileOptions) (*FileInfo, error) {
// algorithm. The checksums data is saved on File object. // algorithm. The checksums data is saved on File object.
func (i *FileInfo) Checksum(algo string) error { func (i *FileInfo) Checksum(algo string) error {
if i.IsDir { if i.IsDir {
return fbErrors.ErrIsDirectory return fberrors.ErrIsDirectory
} }
if i.Checksums == nil { if i.Checksums == nil {
@@ -183,7 +186,6 @@ func (i *FileInfo) Checksum(algo string) error {
var h hash.Hash var h hash.Hash
//nolint:gosec
switch algo { switch algo {
case "md5": case "md5":
h = md5.New() h = md5.New()
@@ -194,7 +196,7 @@ func (i *FileInfo) Checksum(algo string) error {
case "sha512": case "sha512":
h = sha512.New() h = sha512.New()
default: default:
return fbErrors.ErrInvalidOption return fberrors.ErrInvalidOption
} }
_, err = io.Copy(h, reader) _, err = io.Copy(h, reader)
@@ -219,7 +221,7 @@ func (i *FileInfo) RealPath() string {
return i.Path return i.Path
} }
func (i *FileInfo) detectType(modify, saveContent, readHeader bool) error { func (i *FileInfo) detectType(modify, saveContent, readHeader bool, calcImgRes bool) error {
if IsNamedPipe(i.Mode) { if IsNamedPipe(i.Mode) {
i.Type = "blob" i.Type = "blob"
return nil return nil
@@ -250,11 +252,13 @@ func (i *FileInfo) detectType(modify, saveContent, readHeader bool) error {
return nil return nil
case strings.HasPrefix(mimetype, "image"): case strings.HasPrefix(mimetype, "image"):
i.Type = "image" i.Type = "image"
resolution, err := calculateImageResolution(i.Fs, i.Path) if calcImgRes {
if err != nil { resolution, err := calculateImageResolution(i.Fs, i.Path)
log.Printf("Error calculating image resolution: %v", err) if err != nil {
} else { log.Printf("Error calculating image resolution: %v", err)
i.Resolution = resolution } else {
i.Resolution = resolution
}
} }
return nil return nil
case strings.HasSuffix(mimetype, "pdf"): case strings.HasSuffix(mimetype, "pdf"):
@@ -388,7 +392,7 @@ func (i *FileInfo) addSubtitle(fPath string) {
i.Subtitles = append(i.Subtitles, fPath) i.Subtitles = append(i.Subtitles, fPath)
} }
func (i *FileInfo) readListing(checker rules.Checker, readHeader bool) error { func (i *FileInfo) readListing(checker rules.Checker, readHeader bool, calcImgRes bool) error {
afs := &afero.Afero{Fs: i.Fs} afs := &afero.Afero{Fs: i.Fs}
dir, err := afs.ReadDir(i.Path) dir, err := afs.ReadDir(i.Path)
if err != nil { if err != nil {
@@ -435,7 +439,7 @@ func (i *FileInfo) readListing(checker rules.Checker, readHeader bool) error {
currentDir: dir, currentDir: dir,
} }
if !file.IsDir && strings.HasPrefix(mime.TypeByExtension(file.Extension), "image/") { if !file.IsDir && strings.HasPrefix(mime.TypeByExtension(file.Extension), "image/") && calcImgRes {
resolution, err := calculateImageResolution(file.Fs, file.Path) resolution, err := calculateImageResolution(file.Fs, file.Path)
if err != nil { if err != nil {
log.Printf("Error calculating resolution for image %s: %v", file.Path, err) log.Printf("Error calculating resolution for image %s: %v", file.Path, err)
@@ -452,7 +456,7 @@ func (i *FileInfo) readListing(checker rules.Checker, readHeader bool) error {
if isInvalidLink { if isInvalidLink {
file.Type = "invalid_link" file.Type = "invalid_link"
} else { } else {
err := file.detectType(true, false, readHeader) err := file.detectType(true, false, readHeader, calcImgRes)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -600,7 +600,6 @@ var types = map[string]string{
".epub": "application/epub+zip", ".epub": "application/epub+zip",
} }
//nolint:gochecknoinits
func init() { func init() {
for ext, typ := range types { for ext, typ := range types {
// skip errors // skip errors

221
files/tags.go Normal file
View File

@@ -0,0 +1,221 @@
package files
import (
"bytes"
"encoding/base64"
"io"
"log"
"os/exec"
"path/filepath"
"strings"
"github.com/dhowden/tag"
)
// ReadAudioTags extracts common audio metadata from the file and stores it in
// the FileInfo.Tags map. It attempts to extract common fields and includes
// embedded picture(s) as base64 where present.
// Multi-valued tags like ARTISTS and ALBUMARTISTS are extracted as arrays.
func (i *FileInfo) ReadAudioTags() error {
if i.IsDir {
return nil
}
f, err := i.Fs.Open(i.Path)
if err != nil {
return err
}
defer f.Close()
m, err := tag.ReadFrom(f)
if err != nil {
// don't fail hard; log and return nil so callers still get file info
log.Printf("ReadAudioTags: failed to read tags for %s: %v", i.Path, err)
return nil
}
tags := map[string]interface{}{}
if v := m.Title(); v != "" {
tags["title"] = v
}
if v := m.Album(); v != "" {
tags["album"] = v
}
if v := m.Artist(); v != "" {
tags["artist"] = v
}
if v := m.AlbumArtist(); v != "" {
tags["albumartist"] = v
}
if v := m.Composer(); v != "" {
tags["composer"] = v
}
if v := m.Year(); v != 0 {
tags["year"] = v
}
if t, _ := m.Track(); t > 0 {
tags["track"] = t
}
if dnum, _ := m.Disc(); dnum > 0 {
tags["disc"] = dnum
}
if v := m.Genre(); v != "" {
tags["genre"] = v
}
// Pictures: encode as data URI (base64) so frontend can preview if needed.
if pic := m.Picture(); pic != nil {
// safe encode image bytes as base64
var buf bytes.Buffer
if _, err := io.Copy(&buf, bytes.NewReader(pic.Data)); err == nil {
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
dataURI := "data:" + pic.MIMEType + ";base64," + b64
tags["picture"] = map[string]interface{}{
"mime": pic.MIMEType,
"data": dataURI,
}
}
}
// Keep raw metadata map if available (some formats expose additional fields)
// Also extract multi-valued tags like ARTISTS and ALBUMARTISTS
if raw := m.Raw(); raw != nil {
// First pass: collect multi-valued tags (ARTISTS, ALBUMARTISTS)
artists := extractMultiValuedTag(raw, "ARTISTS", "artists")
albumArtists := extractMultiValuedTag(raw, "ALBUMARTISTS", "albumartists")
// Store multi-valued tags as arrays if present
if len(artists) > 0 {
tags["artists"] = artists
}
if len(albumArtists) > 0 {
tags["albumartists"] = albumArtists
}
for k, v := range raw {
// Skip raw APIC entries (attached picture frame) to avoid
// exposing large binary blobs. We already expose a friendly
// `picture` key above when a picture exists.
if strings.EqualFold(k, "APIC") {
continue
}
// Skip multi-valued tags we already handled
kLower := strings.ToLower(k)
if kLower == "artists" || kLower == "albumartists" {
continue
}
// avoid overwriting already set common fields
if _, ok := tags[k]; !ok {
tags[k] = v
}
}
}
// For FLAC files, use metaflac to read multi-valued tags properly
// since dhowden/tag doesn't handle them correctly
if realPath := i.RealPath(); realPath != "" && strings.EqualFold(filepath.Ext(realPath), ".flac") {
if multiTags := readMultiValuedTagsWithMetaflac(realPath); multiTags != nil {
if artists, ok := multiTags["ARTISTS"]; ok && len(artists) > 0 {
tags["artists"] = artists
}
if albumArtists, ok := multiTags["ALBUMARTISTS"]; ok && len(albumArtists) > 0 {
tags["albumartists"] = albumArtists
}
}
}
// Attach tags map
if len(tags) > 0 {
i.Tags = tags
}
return nil
}
// extractMultiValuedTag extracts values for a multi-valued tag from raw metadata.
// It handles both Vorbis-style (multiple entries with same key) and ID3-style
// (single string that may need splitting) formats.
func extractMultiValuedTag(raw map[string]interface{}, keys ...string) []string {
var result []string
seen := make(map[string]bool)
for _, key := range keys {
for rawKey, rawVal := range raw {
if !strings.EqualFold(rawKey, key) {
continue
}
// Handle different value types returned by the tag library
switch v := rawVal.(type) {
case string:
v = strings.TrimSpace(v)
if v != "" && !seen[v] {
result = append(result, v)
seen[v] = true
}
case []string:
for _, s := range v {
s = strings.TrimSpace(s)
if s != "" && !seen[s] {
result = append(result, s)
seen[s] = true
}
}
case []interface{}:
for _, item := range v {
if s, ok := item.(string); ok {
s = strings.TrimSpace(s)
if s != "" && !seen[s] {
result = append(result, s)
seen[s] = true
}
}
}
}
}
}
return result
}
// readMultiValuedTagsWithMetaflac uses metaflac to read Vorbis comments from FLAC files.
// This properly handles multi-valued tags (multiple entries with same key).
func readMultiValuedTagsWithMetaflac(filepath string) map[string][]string {
if _, err := exec.LookPath("metaflac"); err != nil {
return nil
}
// Use metaflac to export all tags
cmd := exec.Command("metaflac", "--export-tags-to=-", filepath)
out, err := cmd.Output()
if err != nil {
log.Printf("metaflac export failed for %s: %v", filepath, err)
return nil
}
// Parse output: each line is TAG=VALUE
result := make(map[string][]string)
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
idx := strings.Index(line, "=")
if idx < 0 {
continue
}
key := strings.ToUpper(strings.TrimSpace(line[:idx]))
value := strings.TrimSpace(line[idx+1:])
if value == "" {
continue
}
// Collect multi-valued artist tags (ARTISTS, ARTIST, ALBUMARTISTS)
// Normalize key variations
if key == "ARTISTS" || key == "ARTIST" {
result["ARTISTS"] = append(result["ARTISTS"], value)
} else if key == "ALBUMARTISTS" || key == "ALBUMARTIST" || key == "ALBUM ARTIST" || key == "ALBUM_ARTIST" {
result["ALBUMARTISTS"] = append(result["ALBUMARTISTS"], value)
}
}
return result
}

7
files/write_mp3.go Normal file
View File

@@ -0,0 +1,7 @@
package files
// This file is a placeholder to avoid accidental module resolution for
// non-implemented MP3 metadata writers. The actual implementation is not
// required at runtime, and we should not import external packages here.
// Intentionally left blank.

View File

@@ -1,5 +1,4 @@
//go:build !dev //go:build !dev
// +build !dev
package frontend package frontend

View File

@@ -1,15 +0,0 @@
//go:build dev
// +build dev
package frontend
import (
"io/fs"
"os"
)
var assets fs.FS = os.DirFS("frontend")
func Assets() fs.FS {
return assets
}

View File

@@ -12,7 +12,11 @@
<link rel="icon" type="image/svg+xml" href="/img/icons/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/img/icons/favicon.svg" />
<link rel="shortcut icon" href="/img/icons/favicon.ico" /> <link rel="shortcut icon" href="/img/icons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/icons/apple-touch-icon.png" /> <link
rel="apple-touch-icon"
sizes="180x180"
href="/img/icons/apple-touch-icon.png"
/>
<meta name="apple-mobile-web-app-title" content="File Browser" /> <meta name="apple-mobile-web-app-title" content="File Browser" />
<!-- Add to home screen for Android and modern mobile browsers --> <!-- Add to home screen for Android and modern mobile browsers -->
@@ -35,6 +39,7 @@
DisableUsedPercentage: false, DisableUsedPercentage: false,
EnableExec: true, EnableExec: true,
EnableThumbs: true, EnableThumbs: true,
LogoutPage: "",
LoginPage: true, LoginPage: true,
Name: "", Name: "",
NoAuth: false, NoAuth: false,

7924
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,37 +4,35 @@
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.0.0", "node": ">=24.0.0",
"pnpm": ">=9.0.0" "pnpm": ">=10.0.0"
}, },
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "pnpm run typecheck && vite build", "build": "pnpm run typecheck && vite build",
"clean": "find ./dist -maxdepth 1 -mindepth 1 ! -name '.gitkeep' -exec rm -r {} +", "clean": "find ./dist -maxdepth 1 -mindepth 1 ! -name '.gitkeep' -exec rm -r {} +",
"typecheck": "vue-tsc -p ./tsconfig.tsc.json --noEmit", "typecheck": "vue-tsc -p ./tsconfig.app.json --noEmit",
"lint": "eslint src/", "lint": "eslint src/",
"lint:fix": "eslint --fix src/", "lint:fix": "eslint --fix src/",
"format": "prettier --write .", "format": "prettier --write ."
"test": "playwright test"
}, },
"dependencies": { "dependencies": {
"@chenfengyuan/vue-number-input": "^2.0.1", "@chenfengyuan/vue-number-input": "^2.0.1",
"@vueuse/core": "^12.5.0", "@vueuse/core": "^14.0.0",
"@vueuse/integrations": "^12.5.0", "@vueuse/integrations": "^14.0.0",
"ace-builds": "^1.43.2", "ace-builds": "^1.43.2",
"core-js": "^3.44.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
"epubjs": "^0.3.93", "epubjs": "^0.3.93",
"filesize": "^10.1.1", "filesize": "^11.0.13",
"js-base64": "^3.7.7", "js-base64": "^3.7.7",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"marked": "^15.0.6", "marked": "^17.0.0",
"material-icons": "^1.13.14", "material-icons": "^1.13.14",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"pinia": "^2.3.1", "pinia": "^3.0.4",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^7.1.0",
"qrcode.vue": "^3.6.0", "qrcode.vue": "^3.6.0",
"tus-js-client": "^4.3.1", "tus-js-client": "^4.3.1",
"utif": "^3.1.0", "utif": "^3.1.0",
@@ -50,30 +48,28 @@
"vue-toastification": "^2.0.0-rc.5" "vue-toastification": "^2.0.0-rc.5"
}, },
"devDependencies": { "devDependencies": {
"@intlify/unplugin-vue-i18n": "^6.0.8", "@intlify/unplugin-vue-i18n": "^11.0.1",
"@playwright/test": "^1.54.1", "@tsconfig/node24": "^24.0.2",
"@tsconfig/node22": "^22.0.2",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^22.10.10", "@types/node": "^24.10.1",
"@typescript-eslint/eslint-plugin": "^8.37.0", "@typescript-eslint/eslint-plugin": "^8.37.0",
"@vitejs/plugin-legacy": "^6.0.0", "@vitejs/plugin-legacy": "^7.2.1",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0", "@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.7.0", "@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"concurrently": "^9.2.0",
"eslint": "^9.31.0", "eslint": "^9.31.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.5.1", "eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-vue": "^9.24.0", "eslint-plugin-vue": "^10.5.1",
"jsdom": "^26.1.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"terser": "^5.43.1", "terser": "^5.43.1",
"vite": "^6.4.1", "typescript": "^5.9.3",
"vite-plugin-compression2": "^1.0.0", "vite": "^7.2.2",
"vue-tsc": "^2.2.0" "vite-plugin-compression2": "^2.3.1",
"vue-tsc": "^3.1.3"
}, },
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
} }

View File

@@ -1,80 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1:5173",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
// {
// name: "webkit",
// use: { ...devices["Desktop Safari"] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: "npm run dev",
url: "http://127.0.0.1:5173",
reuseExistingServer: !process.env.CI,
},
});

4737
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,147 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="560" height="560" version="1.1" id="prefix__svg44" clip-rule="evenodd" fill-rule="evenodd" image-rendering="optimizeQuality" shape-rendering="geometricPrecision" text-rendering="geometricPrecision"><defs id="prefix__defs4"><style type="text/css" id="style2">.prefix__fil1{fill:#fefefe}.prefix__fil6{fill:#006498}.prefix__fil5{fill:#bdeaff}</style></defs><g id="prefix__g85" transform="translate(-70 -70)"><path class="prefix__fil1" d="M350 71c154 0 279 125 279 279S504 629 350 629 71 504 71 350 196 71 350 71z" id="prefix__path9" fill="#fefefe"/><path d="M475 236l118 151c3 116-149 252-292 198l-76-99 114-156s138-95 136-94z" id="prefix__path11" fill="#332c2b" fill-opacity=".149"/><path d="M231 211h208l38 24v246c0 5-3 8-8 8H231c-5 0-8-3-8-8V219c0-5 3-8 8-8z" id="prefix__path13" fill="#2bbcff"/><path d="M231 211h208l38 24v2l-37-23H231c-4 0-7 3-7 7v263c-1-1-1-2-1-3V219c0-5 3-8 8-8z" id="prefix__path15" fill="#53c6fc"/><path class="prefix__fil5" id="prefix__polygon17" fill="#bdeaff" d="M305 212h113v98H305z"/><path class="prefix__fil5" d="M255 363h189c3 0 5 2 5 4v116H250V367c0-2 2-4 5-4z" id="prefix__path19" fill="#bdeaff"/><path class="prefix__fil6" id="prefix__polygon21" fill="#006498" d="M250 470h199v13H250z"/><path class="prefix__fil6" d="M380 226h10c3 0 6 2 6 5v40c0 3-3 6-6 6h-10c-3 0-6-3-6-6v-40c0-3 3-5 6-5z" id="prefix__path23" fill="#006498"/><path class="prefix__fil1" d="M254 226c10 0 17 7 17 17 0 9-7 16-17 16-9 0-17-7-17-16 0-10 8-17 17-17z" id="prefix__path25" fill="#fefefe"/><path class="prefix__fil6" d="M267 448h165c2 0 3 1 3 3 0 1-1 3-3 3H267c-2 0-3-2-3-3 0-2 1-3 3-3z" id="prefix__path27" fill="#006498"/><path class="prefix__fil6" d="M267 415h165c2 0 3 1 3 3 0 1-1 2-3 2H267c-2 0-3-1-3-2 0-2 1-3 3-3z" id="prefix__path29" fill="#006498"/><path class="prefix__fil6" d="M267 381h165c2 0 3 2 3 3 0 2-1 3-3 3H267c-2 0-3-1-3-3 0-1 1-3 3-3z" id="prefix__path31" fill="#006498"/><path class="prefix__fil1" d="M236 472c3 0 5 2 5 5 0 2-2 4-5 4s-5-2-5-4c0-3 2-5 5-5z" id="prefix__path33" fill="#fefefe"/><path class="prefix__fil1" d="M463 472c3 0 5 2 5 5 0 2-2 4-5 4s-5-2-5-4c0-3 2-5 5-5z" id="prefix__path35" fill="#fefefe"/><path class="prefix__fil6" id="prefix__polygon37" fill="#006498" d="M305 212h-21v98h21z"/><path d="M477 479v2c0 5-3 8-8 8H231c-5 0-8-3-8-8v-2c0 4 3 8 8 8h238c5 0 8-4 8-8z" id="prefix__path39" fill="#0ea5eb"/><path d="M350 70c155 0 280 125 280 280S505 630 350 630 70 505 70 350 195 70 350 70zm0 46c129 0 234 105 234 234S479 584 350 584 116 479 116 350s105-234 234-234z" id="prefix__path41" fill="#2979ff"/></g></svg>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xml:space="preserve"
width="560"
height="560"
version="1.1"
style="clip-rule:evenodd;fill-rule:evenodd;image-rendering:optimizeQuality;shape-rendering:geometricPrecision;text-rendering:geometricPrecision"
viewBox="0 0 560 560"
id="svg44"
sodipodi:docname="icon_raw.svg"
inkscape:version="0.92.3 (2405546, 2018-03-11)"
inkscape:export-filename="/home/umarcor/filebrowser/logo/icon_raw.svg.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"><metadata
id="metadata48"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1366"
inkscape:window-height="711"
id="namedview46"
showgrid="false"
inkscape:zoom="0.33714286"
inkscape:cx="-172.33051"
inkscape:cy="280"
inkscape:window-x="0"
inkscape:window-y="20"
inkscape:window-maximized="1"
inkscape:current-layer="svg44" />
<defs
id="defs4">
<style
type="text/css"
id="style2">
<![CDATA[
.fil1 {fill:#FEFEFE}
.fil6 {fill:#006498}
.fil7 {fill:#0EA5EB}
.fil8 {fill:#2979FF}
.fil3 {fill:#2BBCFF}
.fil0 {fill:#455A64}
.fil4 {fill:#53C6FC}
.fil5 {fill:#BDEAFF}
.fil2 {fill:#332C2B;fill-opacity:0.149020}
]]>
</style>
</defs>
<g
id="g85"
transform="translate(-70,-70)"><path
class="fil1"
d="M 350,71 C 504,71 629,196 629,350 629,504 504,629 350,629 196,629 71,504 71,350 71,196 196,71 350,71 Z"
id="path9"
inkscape:connector-curvature="0"
style="fill:#fefefe" /><path
class="fil2"
d="M 475,236 593,387 C 596,503 444,639 301,585 L 225,486 339,330 c 0,0 138,-95 136,-94 z"
id="path11"
inkscape:connector-curvature="0"
style="fill:#332c2b;fill-opacity:0.14902003" /><path
class="fil3"
d="m 231,211 h 208 l 38,24 v 246 c 0,5 -3,8 -8,8 H 231 c -5,0 -8,-3 -8,-8 V 219 c 0,-5 3,-8 8,-8 z"
id="path13"
inkscape:connector-curvature="0"
style="fill:#2bbcff" /><path
class="fil4"
d="m 231,211 h 208 l 38,24 v 2 L 440,214 H 231 c -4,0 -7,3 -7,7 v 263 c -1,-1 -1,-2 -1,-3 V 219 c 0,-5 3,-8 8,-8 z"
id="path15"
inkscape:connector-curvature="0"
style="fill:#53c6fc" /><polygon
class="fil5"
points="305,212 418,212 418,310 305,310 "
id="polygon17"
style="fill:#bdeaff" /><path
class="fil5"
d="m 255,363 h 189 c 3,0 5,2 5,4 V 483 H 250 V 367 c 0,-2 2,-4 5,-4 z"
id="path19"
inkscape:connector-curvature="0"
style="fill:#bdeaff" /><polygon
class="fil6"
points="250,470 449,470 449,483 250,483 "
id="polygon21"
style="fill:#006498" /><path
class="fil6"
d="m 380,226 h 10 c 3,0 6,2 6,5 v 40 c 0,3 -3,6 -6,6 h -10 c -3,0 -6,-3 -6,-6 v -40 c 0,-3 3,-5 6,-5 z"
id="path23"
inkscape:connector-curvature="0"
style="fill:#006498" /><path
class="fil1"
d="m 254,226 c 10,0 17,7 17,17 0,9 -7,16 -17,16 -9,0 -17,-7 -17,-16 0,-10 8,-17 17,-17 z"
id="path25"
inkscape:connector-curvature="0"
style="fill:#fefefe" /><path
class="fil6"
d="m 267,448 h 165 c 2,0 3,1 3,3 v 0 c 0,1 -1,3 -3,3 H 267 c -2,0 -3,-2 -3,-3 v 0 c 0,-2 1,-3 3,-3 z"
id="path27"
inkscape:connector-curvature="0"
style="fill:#006498" /><path
class="fil6"
d="m 267,415 h 165 c 2,0 3,1 3,3 v 0 c 0,1 -1,2 -3,2 H 267 c -2,0 -3,-1 -3,-2 v 0 c 0,-2 1,-3 3,-3 z"
id="path29"
inkscape:connector-curvature="0"
style="fill:#006498" /><path
class="fil6"
d="m 267,381 h 165 c 2,0 3,2 3,3 v 0 c 0,2 -1,3 -3,3 H 267 c -2,0 -3,-1 -3,-3 v 0 c 0,-1 1,-3 3,-3 z"
id="path31"
inkscape:connector-curvature="0"
style="fill:#006498" /><path
class="fil1"
d="m 236,472 c 3,0 5,2 5,5 0,2 -2,4 -5,4 -3,0 -5,-2 -5,-4 0,-3 2,-5 5,-5 z"
id="path33"
inkscape:connector-curvature="0"
style="fill:#fefefe" /><path
class="fil1"
d="m 463,472 c 3,0 5,2 5,5 0,2 -2,4 -5,4 -3,0 -5,-2 -5,-4 0,-3 2,-5 5,-5 z"
id="path35"
inkscape:connector-curvature="0"
style="fill:#fefefe" /><polygon
class="fil6"
points="305,212 284,212 284,310 305,310 "
id="polygon37"
style="fill:#006498" /><path
class="fil7"
d="m 477,479 v 2 c 0,5 -3,8 -8,8 H 231 c -5,0 -8,-3 -8,-8 v -2 c 0,4 3,8 8,8 h 238 c 5,0 8,-4 8,-8 z"
id="path39"
inkscape:connector-curvature="0"
style="fill:#0ea5eb" /><path
class="fil8"
d="M 350,70 C 505,70 630,195 630,350 630,505 505,630 350,630 195,630 70,505 70,350 70,195 195,70 350,70 Z m 0,46 C 479,116 584,221 584,350 584,479 479,584 350,584 221,584 116,479 116,350 116,221 221,116 350,116 Z"
id="path41"
inkscape:connector-curvature="0"
style="fill:#2979ff" /></g>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -18,9 +18,17 @@
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
<link rel="icon" type="image/svg+xml" href="[{[ .StaticURL ]}]/img/icons/favicon.svg" /> <link
rel="icon"
type="image/svg+xml"
href="[{[ .StaticURL ]}]/img/icons/favicon.svg"
/>
<link rel="shortcut icon" href="[{[ .StaticURL ]}]/img/icons/favicon.ico" /> <link rel="shortcut icon" href="[{[ .StaticURL ]}]/img/icons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="[{[ .StaticURL ]}]/img/icons/apple-touch-icon.png" /> <link
rel="apple-touch-icon"
sizes="180x180"
href="[{[ .StaticURL ]}]/img/icons/apple-touch-icon.png"
/>
<meta name="apple-mobile-web-app-title" content="File Browser" /> <meta name="apple-mobile-web-app-title" content="File Browser" />
<!-- Add to home screen for Android and modern mobile browsers --> <!-- Add to home screen for Android and modern mobile browsers -->

View File

@@ -188,6 +188,38 @@ export async function checksum(url: string, algo: ChecksumAlg) {
return (await data.json()).checksums[algo]; return (await data.json()).checksums[algo];
} }
export async function metadata(url: string) {
// Fetch metadata for a resource. Backend support is required for this to work.
const res = await resourceAction(`${url}?metadata=1`, "GET");
try {
const data = await res.json();
// backend returns a FileInfo object; prefer returning the audio tags map when present
if (data && typeof data === "object" && data.tags) {
return data.tags;
}
// fallback: if server returned just the tags map or other structure
return data;
} catch (e) {
return null;
}
}
import { normalizeChanges, normalizeMixedChanges } from "@/utils/metadata";
export async function updateMetadata(url: string, content: any) {
// Update metadata for a resource. Backend must support PATCH with action=metadata.
// Normalize outgoing changes to canonical keys so backend gets stable names, preserving arrays for multi-valued tags.
if (content && content.__clear__) {
const clear = content.__clear__;
const { __clear__, ...rest } = content;
content = normalizeMixedChanges(rest || {});
content.__clear__ = clear;
} else {
content = normalizeMixedChanges(content || {});
}
return resourceAction(`${url}?action=metadata`, "PATCH", JSON.stringify(content));
}
export function getDownloadURL(file: ResourceItem, inline: any) { export function getDownloadURL(file: ResourceItem, inline: any) {
const params = { const params = {
...(inline && { inline: "true" }), ...(inline && { inline: "true" }),

View File

@@ -41,12 +41,12 @@ export function download(
let url = `${baseURL}/api/public/dl/${hash}`; let url = `${baseURL}/api/public/dl/${hash}`;
if (files.length === 1) { if (files.length === 1) {
url += encodeURIComponent(files[0]) + "?"; url += files[0] + "?";
} else { } else {
let arg = ""; let arg = "";
for (const file of files) { for (const file of files) {
arg += encodeURIComponent(file) + ","; arg += file + ",";
} }
arg = arg.substring(0, arg.length - 1); arg = arg.substring(0, arg.length - 1);

View File

@@ -1,7 +1,12 @@
import { fetchURL, removePrefix } from "./utils"; import { fetchURL, removePrefix, StatusError } from "./utils";
import url from "../utils/url"; import url from "../utils/url";
export default async function search(base: string, query: string) { export default async function search(
base: string,
query: string,
signal: AbortSignal,
callback: (item: ResourceItem) => void
) {
base = removePrefix(base); base = removePrefix(base);
query = encodeURIComponent(query); query = encodeURIComponent(query);
@@ -9,19 +14,60 @@ export default async function search(base: string, query: string) {
base += "/"; base += "/";
} }
const res = await fetchURL(`/api/search${base}?query=${query}`, {}); const res = await fetchURL(`/api/search${base}?query=${query}`, { signal });
if (!res.body) {
throw new StatusError("000 No connection", 0);
}
try {
// Try streaming approach first (modern browsers)
if (res.body && typeof res.body.pipeThrough === "function") {
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (value) {
buffer += value;
}
const lines = buffer.split(/\n/);
let lastLine = lines.pop();
// Save incomplete last line
if (!lastLine) {
lastLine = "";
}
buffer = lastLine;
let data = await res.json(); for (const line of lines) {
if (line) {
data = data.map((item: ResourceItem & { dir: boolean }) => { const item = JSON.parse(line) as ResourceItem;
item.url = `/files${base}` + url.encodePath(item.path); item.url = `/files${base}` + url.encodePath(item.path);
if (item.isDir) {
if (item.dir) { item.url += "/";
item.url += "/"; }
callback(item);
}
}
if (done) break;
}
} else {
// Fallback for browsers without streaming support (e.g., Safari)
const text = await res.text();
const lines = text.split(/\n/);
for (const line of lines) {
if (line) {
const item = JSON.parse(line) as ResourceItem;
item.url = `/files${base}` + url.encodePath(item.path);
if (item.isDir) {
item.url += "/";
}
callback(item);
}
}
} }
} catch (e) {
return item; // Check if the error is an intentional cancellation
}); if (e instanceof Error && e.name === "AbortError") {
throw new StatusError("000 No connection", 0, true);
return data; }
throw e;
}
} }

View File

@@ -8,12 +8,13 @@ export async function get(id: number) {
return fetchJSON<IUser>(`/api/users/${id}`, {}); return fetchJSON<IUser>(`/api/users/${id}`, {});
} }
export async function create(user: IUser) { export async function create(user: IUser, currentPassword: string) {
const res = await fetchURL(`/api/users`, { const res = await fetchURL(`/api/users`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
what: "user", what: "user",
which: [], which: [],
current_password: currentPassword,
data: user, data: user,
}), }),
}); });
@@ -25,19 +26,30 @@ export async function create(user: IUser) {
throw new StatusError(await res.text(), res.status); throw new StatusError(await res.text(), res.status);
} }
export async function update(user: Partial<IUser>, which = ["all"]) { export async function update(
user: Partial<IUser>,
which = ["all"],
currentPassword: string | null = null
) {
await fetchURL(`/api/users/${user.id}`, { await fetchURL(`/api/users/${user.id}`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ body: JSON.stringify({
what: "user", what: "user",
which: which, which: which,
...(currentPassword != null ? { current_password: currentPassword } : {}),
data: user, data: user,
}), }),
}); });
} }
export async function remove(id: number) { export async function remove(
id: number,
currentPassword: string | null = null
) {
await fetchURL(`/api/users/${id}`, { await fetchURL(`/api/users/${id}`, {
method: "DELETE", method: "DELETE",
body: JSON.stringify({
...(currentPassword != null ? { current_password: currentPassword } : {}),
}),
}); });
} }

View File

@@ -0,0 +1,47 @@
<template>
<div
class="context-menu"
ref="contextMenu"
v-show="show"
:style="{
top: `${props.pos.y}px`,
left: `${left}px`,
}"
>
<slot />
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed, onUnmounted } from "vue";
const emit = defineEmits(["hide"]);
const props = defineProps<{ show: boolean; pos: { x: number; y: number } }>();
const contextMenu = ref<HTMLElement | null>(null);
const left = computed(() => {
return Math.min(
props.pos.x,
window.innerWidth - (contextMenu.value?.clientWidth ?? 0)
);
});
const hideContextMenu = () => {
emit("hide");
};
watch(
() => props.show,
(val) => {
if (val) {
document.addEventListener("click", hideContextMenu);
} else {
document.removeEventListener("click", hideContextMenu);
}
}
);
onUnmounted(() => {
document.removeEventListener("click", hideContextMenu);
});
</script>

View File

@@ -5,10 +5,11 @@
v-if="active" v-if="active"
class="action" class="action"
@click="close" @click="close"
:aria-label="$t('buttons.close')" :aria-label="closeButtonTitle"
:title="$t('buttons.close')" :title="closeButtonTitle"
> >
<i class="material-icons">arrow_back</i> <i v-if="ongoing" class="material-icons">stop_circle</i>
<i v-else class="material-icons">arrow_back</i>
</button> </button>
<i v-else class="material-icons">search</i> <i v-else class="material-icons">search</i>
<input <input
@@ -21,6 +22,15 @@
:aria-label="$t('search.search')" :aria-label="$t('search.search')"
:placeholder="$t('search.search')" :placeholder="$t('search.search')"
/> />
<i
v-show="ongoing"
class="material-icons spin"
style="display: inline-block"
>autorenew
</i>
<span style="margin-top: 5px" v-show="results.length > 0">
{{ results.length }}
</span>
</div> </div>
<div id="result" ref="result"> <div id="result" ref="result">
@@ -57,9 +67,6 @@
</li> </li>
</ul> </ul>
</div> </div>
<p id="renew">
<i class="material-icons spin">autorenew</i>
</p>
</div> </div>
</div> </div>
</template> </template>
@@ -70,10 +77,11 @@ import { useLayoutStore } from "@/stores/layout";
import url from "@/utils/url"; import url from "@/utils/url";
import { search } from "@/api"; import { search } from "@/api";
import { computed, inject, onMounted, ref, watch } from "vue"; import { computed, inject, onMounted, ref, watch, onUnmounted } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { StatusError } from "@/api/utils";
const boxes = { const boxes = {
image: { label: "images", icon: "insert_photo" }, image: { label: "images", icon: "insert_photo" },
@@ -84,6 +92,7 @@ const boxes = {
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const fileStore = useFileStore(); const fileStore = useFileStore();
let searchAbortController = new AbortController();
const { currentPromptName } = storeToRefs(layoutStore); const { currentPromptName } = storeToRefs(layoutStore);
@@ -124,9 +133,7 @@ watch(currentPromptName, (newVal, oldVal) => {
}); });
watch(prompt, () => { watch(prompt, () => {
if (results.value.length) { reset();
reset();
}
}); });
// ...mapState(useFileStore, ["isListing"]), // ...mapState(useFileStore, ["isListing"]),
@@ -149,6 +156,10 @@ const filteredResults = computed(() => {
return results.value.slice(0, resultsCount.value); return results.value.slice(0, resultsCount.value);
}); });
const closeButtonTitle = computed(() => {
return ongoing.value ? t("buttons.stopSearch") : t("buttons.close");
});
onMounted(() => { onMounted(() => {
if (result.value === null) { if (result.value === null) {
return; return;
@@ -164,14 +175,23 @@ onMounted(() => {
}); });
}); });
onUnmounted(() => {
abortLastSearch();
});
const open = () => { const open = () => {
!active.value && layoutStore.showHover("search"); !active.value && layoutStore.showHover("search");
}; };
const close = (event: Event) => { const close = (event: Event) => {
event.stopPropagation(); if (ongoing.value) {
event.preventDefault(); abortLastSearch();
layoutStore.closeHovers(); ongoing.value = false;
} else {
event.stopPropagation();
event.preventDefault();
layoutStore.closeHovers();
}
}; };
const keyup = (event: KeyboardEvent) => { const keyup = (event: KeyboardEvent) => {
@@ -188,11 +208,16 @@ const init = (string: string) => {
}; };
const reset = () => { const reset = () => {
abortLastSearch();
ongoing.value = false; ongoing.value = false;
resultsCount.value = 50; resultsCount.value = 50;
results.value = []; results.value = [];
}; };
const abortLastSearch = () => {
searchAbortController.abort();
};
const submit = async (event: Event) => { const submit = async (event: Event) => {
event.preventDefault(); event.preventDefault();
@@ -208,8 +233,16 @@ const submit = async (event: Event) => {
ongoing.value = true; ongoing.value = true;
try { try {
results.value = await search(path, prompt.value); abortLastSearch();
searchAbortController = new AbortController();
results.value = [];
await search(path, prompt.value, searchAbortController.signal, (item) =>
results.value.push(item)
);
} catch (error: any) { } catch (error: any) {
if (error instanceof StatusError && error.is_canceled) {
return;
}
$showError(error); $showError(error);
} }

View File

@@ -63,6 +63,7 @@
</template> </template>
<template v-else> <template v-else>
<router-link <router-link
v-if="!hideLoginButton"
class="action" class="action"
to="/login" to="/login"
:aria-label="$t('sidebar.login')" :aria-label="$t('sidebar.login')"
@@ -124,9 +125,11 @@ import * as auth from "@/utils/auth";
import { import {
version, version,
signup, signup,
hideLoginButton,
disableExternal, disableExternal,
disableUsedPercentage, disableUsedPercentage,
noAuth, noAuth,
logoutPage,
loginPage, loginPage,
} from "@/utils/constants"; } from "@/utils/constants";
import { files as api } from "@/api"; import { files as api } from "@/api";
@@ -153,10 +156,11 @@ export default {
return this.currentPromptName === "sidebar"; return this.currentPromptName === "sidebar";
}, },
signup: () => signup, signup: () => signup,
hideLoginButton: () => hideLoginButton,
version: () => version, version: () => version,
disableExternal: () => disableExternal, disableExternal: () => disableExternal,
disableUsedPercentage: () => disableUsedPercentage, disableUsedPercentage: () => disableUsedPercentage,
canLogout: () => !noAuth && loginPage, canLogout: () => !noAuth && (loginPage || logoutPage !== "/login"),
}, },
methods: { methods: {
...mapActions(useLayoutStore, ["closeHovers", "showHover"]), ...mapActions(useLayoutStore, ["closeHovers", "showHover"]),

View File

@@ -0,0 +1,252 @@
<template>
<div class="csv-viewer">
<div v-if="displayError" class="csv-error">
<i class="material-icons">error</i>
<p>{{ displayError }}</p>
</div>
<div v-else-if="data.headers.length === 0" class="csv-empty">
<i class="material-icons">description</i>
<p>{{ $t("files.lonely") }}</p>
</div>
<div v-else class="csv-table-container" @wheel.stop @touchmove.stop>
<table class="csv-table">
<thead>
<tr>
<th v-for="(header, index) in data.headers" :key="index">
{{ header || `Column ${index + 1}` }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in data.rows" :key="rowIndex">
<td v-for="(cell, cellIndex) in row" :key="cellIndex">
{{ cell }}
</td>
</tr>
</tbody>
</table>
<div class="csv-footer">
<div class="csv-info" v-if="data.rows.length > 100">
<i class="material-icons">info</i>
<span>
{{ $t("files.showingRows", { count: data.rows.length }) }}</span
>
</div>
<div class="column-separator">
<label for="columnSeparator">{{ $t("files.columnSeparator") }}</label>
<select
id="columnSeparator"
class="input input--block"
v-model="columnSeparator"
>
<option :value="[',']">
{{ $t("files.csvSeparators.comma") }}
</option>
<option :value="[';']">
{{ $t("files.csvSeparators.semicolon") }}
</option>
<option :value="[',', ';']">
{{ $t("files.csvSeparators.both") }}
</option>
</select>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { parseCSV, type CsvData } from "@/utils/csv";
import { computed, ref } from "vue";
interface Props {
content: string;
error?: string;
}
const props = withDefaults(defineProps<Props>(), {
error: "",
});
const columnSeparator = ref([","]);
const data = computed<CsvData>(() => {
try {
return parseCSV(props.content, columnSeparator.value);
} catch (e) {
console.error("Failed to parse CSV:", e);
return { headers: [], rows: [] };
}
});
const displayError = computed(() => {
// External error takes priority (e.g., file too large)
if (props.error) {
return props.error;
}
// Check for parse errors
if (
props.content &&
props.content.trim().length > 0 &&
data.value.headers.length === 0
) {
return "Failed to parse CSV file";
}
return null;
});
</script>
<style scoped>
.csv-viewer {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--surfacePrimary);
color: var(--textSecondary);
padding: 1rem;
padding-top: 4em;
box-sizing: border-box;
}
.csv-error,
.csv-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 1rem;
color: var(--textPrimary);
}
.csv-error i,
.csv-empty i {
font-size: 4rem;
opacity: 0.5;
}
.csv-error p,
.csv-empty p {
font-size: 1.1rem;
margin: 0;
}
.csv-table-container {
flex: 1;
overflow: auto;
background-color: var(--surfacePrimary);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* Scrollbar styling for better visibility */
.csv-table-container::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.csv-table-container::-webkit-scrollbar-track {
background: var(--background);
border-radius: 4px;
}
.csv-table-container::-webkit-scrollbar-thumb {
background: var(--borderSecondary);
border-radius: 4px;
}
.csv-table-container::-webkit-scrollbar-thumb:hover {
background: var(--textPrimary);
}
.csv-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
background-color: var(--surfacePrimary);
}
.csv-table thead {
position: sticky;
top: 0;
z-index: 10;
background-color: var(--surfaceSecondary);
}
.csv-table th {
padding: 0.875rem 1rem;
text-align: left;
font-weight: 600;
border-bottom: 2px solid var(--borderSecondary);
background-color: var(--surfaceSecondary);
white-space: nowrap;
color: var(--textSecondary);
font-size: 0.875rem;
}
.csv-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--borderPrimary);
white-space: nowrap;
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
color: var(--textSecondary);
}
.csv-table tbody tr:nth-child(even) {
background-color: var(--background);
}
.csv-table tbody tr:hover {
background-color: var(--hover);
transition: background-color 0.15s ease;
}
.csv-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.5rem;
}
.csv-footer > :only-child {
margin-left: auto;
}
.csv-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
margin-top: 0.5rem;
background-color: var(--surfaceSecondary);
border-radius: 4px;
border-left: 3px solid var(--blue);
color: var(--textSecondary);
font-size: 0.875rem;
}
.column-separator {
display: flex;
align-items: center;
gap: 0.5rem;
}
.column-separator > label {
font-size: small;
text-align: end;
}
.column-separator > select {
margin-bottom: 0;
}
.csv-info i {
font-size: 1.2rem;
color: var(--blue);
}
</style>

View File

@@ -20,6 +20,7 @@
:aria-label="name" :aria-label="name"
:aria-selected="isSelected" :aria-selected="isSelected"
:data-ext="getExtension(name).toLowerCase()" :data-ext="getExtension(name).toLowerCase()"
@contextmenu="contextMenu"
> >
<div> <div>
<img <img
@@ -239,6 +240,17 @@ const itemClick = (event: Event | KeyboardEvent) => {
else click(event); else click(event);
}; };
const contextMenu = (event: MouseEvent) => {
event.preventDefault();
if (
fileStore.selected.length === 0 ||
event.ctrlKey ||
fileStore.selected.indexOf(props.index) === -1
) {
click(event);
}
};
const click = (event: Event | KeyboardEvent) => { const click = (event: Event | KeyboardEvent) => {
if (!singleClick.value && fileStore.selectedCount !== 0) if (!singleClick.value && fileStore.selectedCount !== 0)
event.preventDefault(); event.preventDefault();
@@ -253,7 +265,15 @@ const click = (event: Event | KeyboardEvent) => {
} }
if (fileStore.selected.indexOf(props.index) !== -1) { if (fileStore.selected.indexOf(props.index) !== -1) {
fileStore.removeSelected(props.index); if (
(event as KeyboardEvent).ctrlKey ||
(event as KeyboardEvent).metaKey ||
fileStore.multiple
) {
fileStore.removeSelected(props.index);
} else {
fileStore.selected = [props.index];
}
return; return;
} }
@@ -279,7 +299,6 @@ const click = (event: Event | KeyboardEvent) => {
} }
if ( if (
!singleClick.value &&
!(event as KeyboardEvent).ctrlKey && !(event as KeyboardEvent).ctrlKey &&
!(event as KeyboardEvent).metaKey && !(event as KeyboardEvent).metaKey &&
!fileStore.multiple !fileStore.multiple

View File

@@ -109,7 +109,8 @@ export default {
return; return;
} }
this.$router.push({ path: this.dest }); if (this.user.redirectAfterCopyMove)
this.$router.push({ path: this.dest });
}) })
.catch((e) => { .catch((e) => {
buttons.done("copy"); buttons.done("copy");

View File

@@ -18,7 +18,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick, defineProps } from "vue"; import { ref, computed, watch, nextTick } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { useFileStore } from "@/stores/file"; import { useFileStore } from "@/stores/file";
import url from "@/utils/url"; import url from "@/utils/url";

View File

@@ -0,0 +1,834 @@
<template>
<div class="card floating metadata-card">
<div class="card-title title-with-actions">
<h2>{{ trans('files.metadata','Metadata') }}</h2>
<div class="title-actions">
<button
type="button"
class="button button--flat"
@click="toggleAddField"
:aria-label="trans('buttons.addField','Add field')"
:title="trans('buttons.addField','Add field')"
>
{{ trans('buttons.addField','Add field') }}
</button>
<button
type="button"
class="button button--primary"
@click.prevent="applyAll"
:aria-label="trans('buttons.apply','Apply')"
:title="trans('buttons.apply','Apply')"
>
{{ trans('buttons.apply','Apply') }}
</button>
</div>
</div>
<div class="card-content">
<p v-if="selectedCount > 1">
{{ $t('prompts.filesSelected', { count: selectedCount }) }}
</p>
<template v-if="selectedCount === 0">
<p>{{ $t('prompts.noFileSelected') }}</p>
</template>
<template v-else>
<div v-if="addFieldVisible" class="add-field-row">
<div class="add-field-controls">
<input
v-model="addFieldKey"
type="text"
:placeholder="trans('prompts.newFieldName','Field name')"
list="tag-suggestions"
/>
<datalist id="tag-suggestions">
<option v-for="s in tagSuggestions" :key="s" :value="s">{{ s }}</option>
</datalist>
<input
v-model="addFieldValue"
type="text"
:placeholder="trans('prompts.newFieldValue','Value')"
/>
<button
type="button"
class="button button--primary"
@click="confirmAddField"
:aria-label="trans('buttons.add','Add')"
:title="trans('buttons.add','Add')"
>
{{ trans('buttons.add','Add') }}
</button>
<button
type="button"
class="button button--flat"
@click="cancelAddField"
>
{{ trans('buttons.cancel','Cancel') }}
</button>
</div>
</div>
<div class="metadata-table-wrapper">
<table class="metadata-table">
<colgroup>
<col class="col-label" />
<col class="col-current" />
<col class="col-new" />
</colgroup>
<thead>
<tr>
<th class="hdr-field">{{ trans('prompts.field','Field') }}</th>
<th class="hdr-current">{{ trans('prompts.current','Current') }}</th>
<th class="hdr-new">{{ trans('prompts.new','New') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="field in fields" :key="field" class="metadata-row">
<td class="metadata-title">
<strong>{{ fieldLabel(field) }}</strong>
<span v-if="isMultiValued(field)" class="multi-value-hint">{{ trans('prompts.multiValueHint','(multi-valued)') }}</span>
</td>
<td class="metadata-current">
<template v-if="isPicture(field)">
<img :src="pictureSrc(field)" alt="cover" class="cover-preview" v-if="pictureSrc(field)" />
<span v-else class="metadata-muted">{{ trans('prompts.noCover','No cover') }}</span>
</template>
<template v-else-if="isMultiValued(field)">
<div class="multi-value-display">
<div v-for="(val, idx) in displayCurrentArray(field)" :key="idx" class="multi-value-item">
{{ val }}
</div>
<span v-if="displayCurrentArray(field).length === 0" class="metadata-muted"></span>
</div>
</template>
<template v-else>
{{ displayCurrent(field) }}
</template>
</td>
<td class="metadata-edit-cell">
<div class="edit-with-actions">
<template v-if="isMultiValued(field)">
<div class="multi-value-editor">
<div v-for="(val, idx) in getMultiValueEntries(field)" :key="idx" class="multi-value-entry">
<input
:value="val"
@input="updateMultiValueEntry(field, idx, $event.target.value)"
:placeholder="trans('prompts.artistName','Artist name')"
type="text"
class="multi-value-input"
/>
<button
type="button"
class="button button--flat button--red icon-button mini-button"
:aria-label="trans('buttons.removeEntry','Remove')"
:title="trans('buttons.removeEntry','Remove')"
@click="removeMultiValueEntry(field, idx)"
>
<i class="material-icons">close</i>
</button>
</div>
<button
type="button"
class="button button--flat button--small add-entry-btn"
@click="addMultiValueEntry(field)"
>
<i class="material-icons">add</i>
{{ trans('buttons.addEntry','Add entry') }}
</button>
</div>
</template>
<template v-else>
<input
v-model="newValues[field]"
:placeholder="placeholders[field] || ''"
type="text"
/>
</template>
<button
type="button"
class="button button--flat button--red icon-button"
:aria-label="trans('buttons.remove','Remove field')"
:title="trans('buttons.remove','Remove field')"
@click="removeField(field)"
>
<i class="material-icons">delete</i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div class="card-action">
<button
id="focus-prompt"
type="button"
@click="closeHovers"
class="button button--flat"
:aria-label="trans('buttons.ok','OK')"
:title="trans('buttons.ok','OK')"
>
{{ trans('buttons.ok','OK') }}
</button>
<button
id="apply-metadata"
type="button"
@click.prevent="applyAll"
class="button button--primary"
:aria-label="trans('buttons.apply','Apply')"
:title="trans('buttons.apply','Apply')"
>
{{ trans('buttons.apply','Apply') }}
</button>
</div>
</div>
</template>
<script>
import { mapActions, mapState } from "pinia";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import { metadata as apiMetadata, updateMetadata } from "@/api/files";
import { fieldLabelFor, normalizeChanges, canonicalizeKeys, canonicalizeKey, navidromeTags, isMultiValuedTag, isMultiValuedKey, splitMultiValues, normalizeMixedChanges, formatMultiValueDisplay, parseMultiValueInput } from "@/utils/metadata";
export default {
name: "modifyMetadata",
inject: ["$showError"],
data() {
return {
// fields will be derived dynamically from metadata across selected files
fields: [],
fieldLabels: {},
placeholders: {},
metadataList: [],
newValues: {},
toClear: new Set(),
// add field UI state
addFieldVisible: false,
addFieldKey: "",
addFieldValue: "",
tagSuggestions: navidromeTags,
};
},
computed: {
...mapState(useFileStore, ["req", "selected", "selectedCount"]),
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
fieldLabel(key) {
return fieldLabelFor(key);
},
async mountedFetch() {
if (!this.req) return;
const files = this.selected.map((i) => this.req.items[i].url);
// Fetch metadata for each selected file if possible
try {
const promises = files.map((u) => apiMetadata(u));
this.metadataList = (await Promise.all(promises)).map((m) => m || {});
} catch (e) {
this.metadataList = [];
}
// Build union of fields
const keys = new Set();
this.metadataList.forEach((m) => {
Object.keys(m).forEach((k) => keys.add(k));
});
// Ensure stable ordering: put common fields first (including multi-valued artists/albumartists)
const preferred = ["title", "artist", "artists", "album", "track", "genre", "date", "comment", "disc", "composer", "year", "albumartist", "albumartists", "picture", "cover"];
const rest = Array.from(keys).filter((k) => !preferred.includes(k)).sort();
this.fields = preferred.filter((k) => keys.has(k)).concat(rest);
// Setup field labels (use translation when available, otherwise humanize)
// For multi-valued fields, initialize newValues appropriately
this.fields.forEach((f) => {
this.fieldLabels[f] = this.trans(`prompts.${f}`, this.humanize(f));
if (isMultiValuedKey(f)) {
// Initialize with current values or empty array
const current = this.getCurrentMultiValues(f);
this.newValues[f] = current.length > 0 ? [...current] : [];
} else {
this.newValues[f] = "";
}
});
},
// Get current multi-values directly from metadataList (for initialization)
getCurrentMultiValues(field) {
if (!this.metadataList || this.metadataList.length === 0) return [];
const allValues = [];
const seen = new Set();
for (const m of this.metadataList) {
const v = m[field];
if (Array.isArray(v)) {
for (const item of v) {
const s = String(item || '').trim();
if (s && !seen.has(s)) {
allValues.push(s);
seen.add(s);
}
}
} else if (v !== undefined && v !== null) {
const s = String(v).trim();
if (s && !seen.has(s)) {
allValues.push(s);
seen.add(s);
}
}
}
return allValues;
},
toggleAddField() {
this.addFieldVisible = !this.addFieldVisible;
if (this.addFieldVisible) {
this.addFieldKey = "";
this.addFieldValue = "";
}
},
cancelAddField() {
this.addFieldVisible = false;
this.addFieldKey = "";
this.addFieldValue = "";
},
confirmAddField() {
const key = String(this.addFieldKey || "").trim();
const val = String(this.addFieldValue || "").trim();
if (!key) return;
// If field doesn't exist yet, add it to the top for visibility
if (!this.fields.includes(key)) {
this.fields.unshift(key);
// keep labels in English technical form
// label is computed via fieldLabel()
}
// Initialize newValues entry and set initial value if provided
if (isMultiValuedTag(canonicalizeKey(key))) {
// For multi-valued tags, initialize as array
if (val) {
this.newValues[key] = splitMultiValues(val);
} else {
this.newValues[key] = [''];
}
} else {
if (!(key in this.newValues)) this.newValues[key] = "";
if (val !== "") this.newValues[key] = val;
}
this.cancelAddField();
},
// Check if a field is multi-valued (ARTISTS, ALBUMARTISTS)
isMultiValued(field) {
return isMultiValuedKey(field);
},
// Get current values as array for multi-valued display
displayCurrentArray(field) {
return this.getCurrentMultiValues(field);
},
// Get entries for editing multi-valued field
getMultiValueEntries(field) {
const val = this.newValues[field];
if (Array.isArray(val)) {
return val;
}
// Initialize from current if not yet edited
if (!val || val === '') {
const current = this.getCurrentMultiValues(field);
if (current.length > 0) {
this.newValues[field] = [...current];
return this.newValues[field];
}
// Return empty array with one entry for new field
this.newValues[field] = [''];
return this.newValues[field];
}
// Handle legacy string format
const arr = splitMultiValues(String(val));
this.newValues[field] = arr.length > 0 ? arr : [''];
return this.newValues[field];
},
// Update a single entry in a multi-valued field
updateMultiValueEntry(field, index, value) {
if (!Array.isArray(this.newValues[field])) {
this.newValues[field] = this.getMultiValueEntries(field);
}
this.newValues[field][index] = value;
},
// Remove an entry from a multi-valued field
removeMultiValueEntry(field, index) {
if (!Array.isArray(this.newValues[field])) {
this.newValues[field] = this.getMultiValueEntries(field);
}
this.newValues[field].splice(index, 1);
// Keep at least one empty entry for UX
if (this.newValues[field].length === 0) {
this.newValues[field] = [''];
}
},
// Add a new entry to a multi-valued field
addMultiValueEntry(field) {
if (!Array.isArray(this.newValues[field])) {
this.newValues[field] = this.getMultiValueEntries(field);
}
this.newValues[field].push('');
},
removeField(field) {
// Remove an added or existing field from the edit list (UI only)
this.fields = this.fields.filter((f) => f !== field);
delete this.newValues[field];
delete this.placeholders[field];
delete this.fieldLabels[field];
// Mark field for clearing on apply
const canon = canonicalizeKey(field);
this.toClear.add(canon);
},
displayCurrent(field) {
if (!this.metadataList || this.metadataList.length === 0) return "";
const vals = this.metadataList.map((m) => {
const v = m[field];
if (v === undefined || v === null) return "";
if (typeof v === "object") return JSON.stringify(v);
return String(v);
});
const nonEmpty = vals.filter((v) => v !== "");
if (nonEmpty.length === 0) return "";
const allEqual = nonEmpty.every((v) => v === nonEmpty[0]);
if (allEqual) return nonEmpty[0];
// Count how many distinct values
const distinct = new Set(nonEmpty);
// If fields are objects (e.g., picture) avoid returning huge JSON blobs.
// Prefer a short summary.
const sample = nonEmpty[0];
if (typeof sample === 'string') {
return this.trans('prompts.multipleValuesCount', `(different on ${distinct.size} files)`).replace('{count}', String(distinct.size));
}
try {
const s = JSON.stringify(sample);
return s.length > 200 ? s.slice(0, 200) + '…' : s;
} catch (e) {
return this.trans('prompts.multipleValuesCount', `(different on ${distinct.size} files)`).replace('{count}', String(distinct.size));
}
},
isPicture(field) {
if (!this.metadataList || this.metadataList.length === 0) return false;
for (const m of this.metadataList) {
const v = m[field];
if (v && typeof v === 'object') {
if (v.data && typeof v.data === 'string') return true;
if (v.picture && v.picture.data) return true;
}
}
return false;
},
pictureSrc(field) {
if (!this.metadataList) return null;
for (const m of this.metadataList) {
const v = m[field];
if (v && typeof v === 'object') {
if (v.data && typeof v.data === 'string') return v.data;
if (v.picture && v.picture.data) return v.picture.data;
// sometimes library stores a raw buffer as base64 directly in 'data'
}
}
return null;
},
trans(key, fallback) {
// Fallback to a human readable string if translation key is missing
try {
if (this.$te && this.$te(key)) return this.$t(key);
} catch (e) {
// ignore
}
return fallback;
},
humanize(str) {
if (!str) return str;
// replace camelCase / snake_case / dots with spaces and capitalize
const s = String(str)
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[_\.\-]+/g, " ");
return s.charAt(0).toUpperCase() + s.slice(1);
},
async applyAll() {
if (!this.req) return;
// collect changed fields
const changes = {};
Object.keys(this.newValues).forEach((k) => {
const v = this.newValues[k];
const canon = canonicalizeKey(k);
if (isMultiValuedTag(canon)) {
// For multi-valued tags, filter out empty entries
let arr = Array.isArray(v) ? v : splitMultiValues(String(v || ""));
arr = arr.map(s => String(s || '').trim()).filter(Boolean);
if (arr.length > 0) changes[canon] = arr;
} else if (v !== undefined && v !== null && String(v).trim() !== "") {
changes[canon] = v;
}
});
// If no value changes but some fields were removed, proceed
const hasValueChanges = Object.keys(changes).length > 0;
const hasClears = this.toClear && this.toClear.size > 0;
if (!hasValueChanges && !hasClears) {
return;
}
const files = this.selected.map((i) => this.req.items[i].url);
try {
// apply changes to each file
// Normalize keys on client before sending
const toSend = normalizeMixedChanges(changes);
// Attach clear list (canonicalized) so backend can remove tags
toSend.__clear__ = canonicalizeKeys(Array.from(this.toClear));
await Promise.all(files.map((u) => updateMetadata(u, toSend)));
// refresh listing
const fileStore = useFileStore();
fileStore.reload = true;
this.closeHovers();
this.toClear = new Set();
} catch (e) {
this.$showError(e);
}
},
// per-field apply removed in favor of global applyAll
},
mounted() {
this.mountedFetch();
},
};
</script>
<style scoped>
.metadata-card {
max-height: calc(100vh - 120px);
display: flex;
flex-direction: column;
box-sizing: border-box;
/* prefer a wider layout on large screens but stay responsive */
/* increase target width to give more horizontal space */
width: min(1400px, 98vw);
max-width: 98vw;
min-width: 680px;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
}
.title-with-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.title-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.add-field-row {
padding: 0 1rem 0.5rem 1rem;
}
.add-field-controls {
display: flex;
gap: 0.5rem;
}
.add-field-controls input {
flex: 1 1 auto;
}
/* Override global small max-width for floating cards for this metadata modal */
.card.floating.metadata-card {
/* remove the global max-width:25em constraint */
max-width: none !important;
width: min(1400px, 98vw) !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
}
.metadata-field {
margin-bottom: 1rem;
}
.metadata-title {
margin: 0.25rem 0;
}
.metadata-current {
color: var(--color-muted, #666);
margin: 0.25rem 0 0.5rem 0;
}
.metadata-edit {
display: flex;
gap: 0.5rem;
}
.metadata-edit input {
flex: 1 1 auto;
}
.edit-with-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.icon-button {
padding: 0.25em 0.4em;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-button .material-icons {
font-size: 18px;
line-height: 1;
}
.metadata-table-wrapper {
overflow-x: auto;
width: 100%;
padding: 0 1rem; /* add horizontal padding so left column isn't flush to modal edge */
}
.metadata-table {
border-collapse: separate;
width: auto; /* allow table to size to content */
/* allow a comfortable minimum width for readable columns on very large screens
but let it shrink responsively in smaller viewports */
min-width: 0;
max-width: 100%;
table-layout: auto;
border-spacing: 0;
border: 1px solid rgba(0,0,0,0.08);
border-radius: 10px;
overflow: hidden;
}
.metadata-table td, .metadata-table th { word-break: break-word; }
.metadata-table th,
.metadata-table td {
border: none;
padding: 0.75rem;
text-align: left;
vertical-align: top;
}
/* Header styling: subtle background and divider */
.metadata-table thead {
background: rgba(0,0,0,0.04);
}
.metadata-table thead th {
border-bottom: 1px solid rgba(0,0,0,0.08);
}
/* extra left padding for the first column so labels don't touch the modal edge */
.metadata-table td:first-child,
.metadata-table th:first-child,
.metadata-title {
padding-left: 1rem;
}
/* Use CSS Grid per row so columns adapt to content without one taking all space */
.metadata-table thead tr,
.metadata-table tbody tr {
display: grid;
/* Fix column widths to prevent jitter when a cell contains very long content.
First column (labels) is fixed; current/new columns are flexible with a comfortable minimum. */
grid-template-columns: 220px minmax(280px, 1fr) minmax(280px, 1fr);
gap: 1rem;
align-items: start;
}
.metadata-table thead tr {
border-bottom: none;
}
.metadata-table thead th,
.metadata-table tbody td {
display: block; /* make cells behave as grid items */
}
.metadata-table thead th { font-weight: 600; }
/* column sizing helpers */
.metadata-table .col-label { width: 1%; }
.metadata-table .col-current { width: auto; }
.metadata-table .col-new { width: auto; }
.metadata-edit-cell input {
width: 100%;
box-sizing: border-box;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.12);
padding: 0.5rem 0.6rem;
}
/* Ensure long labels wrap inside the first column without expanding it */
.metadata-title strong {
display: block;
word-break: break-word;
overflow-wrap: anywhere;
}
/* Row aesthetics: zebra striping and hover */
.metadata-table tbody tr {
border-top: 1px solid rgba(0,0,0,0.06);
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.metadata-table tbody tr:nth-child(odd) {
background: rgba(0,0,0,0.02);
}
.metadata-table tbody tr:hover {
background: rgba(0,0,0,0.04);
}
/* Subtle vertical separators between columns */
.metadata-table tbody td:not(:last-child) {
border-right: 1px solid rgba(0,0,0,0.06);
}
/* On small screens, stack rows to improve readability */
@media (max-width: 640px) {
.metadata-table {
min-width: 0;
}
.metadata-table thead {
display: none;
}
.metadata-table tr {
display: block;
margin-bottom: 0.75rem;
border: 1px solid rgba(0,0,0,0.08);
padding: 0.75rem;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.metadata-table td {
display: block;
width: 100%;
padding: 0.25rem 0;
}
.metadata-title {
font-weight: 600;
}
}
/* On medium screens allow a comfortable min width; on very large screens keep roomy layout */
@media (min-width: 1200px) {
.metadata-table {
min-width: 100ch;
}
}
/* Make sure table cells wrap content and images don't overflow */
.metadata-table td, .metadata-table th {
overflow-wrap: anywhere;
word-break: break-word;
hyphens: auto;
}
.metadata-table img {
max-width: 100%;
height: auto;
display: block;
}
.cover-preview {
max-width: 160px;
max-height: 160px;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.12);
}
.metadata-muted { color: var(--color-muted, #666); font-size: 0.9em; }
.metadata-current-row td {
color: var(--color-muted, #666);
}
.metadata-edit-row td {
background: rgba(0,0,0,0.02);
}
/* Multi-valued tag styling */
.multi-value-hint {
display: block;
font-size: 0.75em;
color: var(--color-muted, #888);
font-weight: normal;
margin-top: 0.15em;
}
.multi-value-display {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.multi-value-item {
background: rgba(0,0,0,0.04);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.9em;
}
.multi-value-editor {
display: flex;
flex-direction: column;
gap: 0.35rem;
flex: 1;
}
.multi-value-entry {
display: flex;
align-items: center;
gap: 0.25rem;
}
.multi-value-input {
flex: 1;
box-sizing: border-box;
border-radius: 6px;
border: 1px solid rgba(0,0,0,0.12);
padding: 0.35rem 0.5rem;
font-size: 0.9em;
}
.mini-button {
padding: 0.15em 0.25em !important;
min-width: auto !important;
}
.mini-button .material-icons {
font-size: 16px !important;
}
.add-entry-btn {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.85em;
padding: 0.25rem 0.5rem;
margin-top: 0.15rem;
}
.add-entry-btn .material-icons {
font-size: 16px;
}
/* Ensure multi-value editor aligns well in the edit cell */
.edit-with-actions {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.edit-with-actions > .multi-value-editor {
flex: 1;
}
.edit-with-actions > .icon-button {
flex-shrink: 0;
margin-top: 0.25rem;
}
</style>

View File

@@ -5,6 +5,7 @@
</div> </div>
<div class="card-content"> <div class="card-content">
<p>{{ $t("prompts.moveMessage") }}</p>
<file-list <file-list
ref="fileList" ref="fileList"
@update:selected="(val) => (dest = val)" @update:selected="(val) => (dest = val)"
@@ -78,7 +79,7 @@ export default {
computed: { computed: {
...mapState(useFileStore, ["req", "selected"]), ...mapState(useFileStore, ["req", "selected"]),
...mapState(useAuthStore, ["user"]), ...mapState(useAuthStore, ["user"]),
...mapWritableState(useFileStore, ["preselect"]), ...mapWritableState(useFileStore, ["reload", "preselect"]),
excludedFolders() { excludedFolders() {
return this.selected return this.selected
.filter((idx) => this.req.items[idx].isDir) .filter((idx) => this.req.items[idx].isDir)
@@ -107,7 +108,9 @@ export default {
.then(() => { .then(() => {
buttons.success("move"); buttons.success("move");
this.preselect = removePrefix(items[0].to); this.preselect = removePrefix(items[0].to);
this.$router.push({ path: this.dest }); if (this.user.redirectAfterCopyMove)
this.$router.push({ path: this.dest });
else this.reload = true;
}) })
.catch((e) => { .catch((e) => {
buttons.done("move"); buttons.done("move");

View File

@@ -25,6 +25,7 @@ import Share from "./Share.vue";
import ShareDelete from "./ShareDelete.vue"; import ShareDelete from "./ShareDelete.vue";
import Upload from "./Upload.vue"; import Upload from "./Upload.vue";
import DiscardEditorChanges from "./DiscardEditorChanges.vue"; import DiscardEditorChanges from "./DiscardEditorChanges.vue";
import ModifyMetadata from "./ModifyMetadata.vue";
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
@@ -44,6 +45,7 @@ const components = new Map<string, any>([
["replace-rename", ReplaceRename], ["replace-rename", ReplaceRename],
["share", Share], ["share", Share],
["upload", Upload], ["upload", Upload],
["modifyMetadata", ModifyMetadata],
["share-delete", ShareDelete], ["share-delete", ShareDelete],
["deleteUser", DeleteUser], ["deleteUser", DeleteUser],
["discardEditorChanges", DiscardEditorChanges], ["discardEditorChanges", DiscardEditorChanges],

View File

@@ -12,6 +12,7 @@
<th>{{ $t("settings.shareDuration") }}</th> <th>{{ $t("settings.shareDuration") }}</th>
<th></th> <th></th>
<th></th> <th></th>
<th></th>
</tr> </tr>
<tr v-for="link in links" :key="link.hash"> <tr v-for="link in links" :key="link.hash">
@@ -24,7 +25,7 @@
</td> </td>
<td class="small"> <td class="small">
<button <button
class="action copy-clipboard" class="action"
:aria-label="$t('buttons.copyToClipboard')" :aria-label="$t('buttons.copyToClipboard')"
:title="$t('buttons.copyToClipboard')" :title="$t('buttons.copyToClipboard')"
@click="copyToClipboard(buildLink(link))" @click="copyToClipboard(buildLink(link))"
@@ -32,6 +33,17 @@
<i class="material-icons">content_paste</i> <i class="material-icons">content_paste</i>
</button> </button>
</td> </td>
<td class="small">
<button
class="action"
:aria-label="$t('buttons.copyDownloadLinkToClipboard')"
:title="$t('buttons.copyDownloadLinkToClipboard')"
:disabled="!!link.password_hash"
@click="copyToClipboard(buildDownloadLink(link))"
>
<i class="material-icons">content_paste_go</i>
</button>
</td>
<td class="small"> <td class="small">
<button <button
class="action" class="action"
@@ -132,7 +144,7 @@
<script> <script>
import { mapActions, mapState } from "pinia"; import { mapActions, mapState } from "pinia";
import { useFileStore } from "@/stores/file"; import { useFileStore } from "@/stores/file";
import { share as api } from "@/api"; import * as api from "@/api/index";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useLayoutStore } from "@/stores/layout"; import { useLayoutStore } from "@/stores/layout";
import { copy } from "@/utils/clipboard"; import { copy } from "@/utils/clipboard";
@@ -172,7 +184,7 @@ export default {
}, },
async beforeMount() { async beforeMount() {
try { try {
const links = await api.get(this.url); const links = await api.share.get(this.url);
this.links = links; this.links = links;
this.sort(); this.sort();
@@ -211,9 +223,14 @@ export default {
let res = null; let res = null;
if (!this.time) { if (!this.time) {
res = await api.create(this.url, this.password); res = await api.share.create(this.url, this.password);
} else { } else {
res = await api.create(this.url, this.password, this.time, this.unit); res = await api.share.create(
this.url,
this.password,
this.time,
this.unit
);
} }
this.links.push(res); this.links.push(res);
@@ -231,7 +248,7 @@ export default {
deleteLink: async function (event, link) { deleteLink: async function (event, link) {
event.preventDefault(); event.preventDefault();
try { try {
await api.remove(link.hash); await api.share.remove(link.hash);
this.links = this.links.filter((item) => item.hash !== link.hash); this.links = this.links.filter((item) => item.hash !== link.hash);
if (this.links.length == 0) { if (this.links.length == 0) {
@@ -245,7 +262,16 @@ export default {
return dayjs(time * 1000).fromNow(); return dayjs(time * 1000).fromNow();
}, },
buildLink(share) { buildLink(share) {
return api.getShareURL(share); return api.share.getShareURL(share);
},
buildDownloadLink(share) {
return api.pub.getDownloadURL(
{
hash: share.hash,
path: "",
},
true
);
}, },
sort() { sort() {
this.links = this.links.sort((a, b) => { this.links = this.links.sort((a, b) => {

View File

@@ -1,5 +1,9 @@
<template> <template>
<select name="selectAceEditorTheme" v-on:change="change" :value="aceEditorTheme"> <select
name="selectAceEditorTheme"
v-on:change="change"
:value="aceEditorTheme"
>
<option v-for="theme in themes" :value="theme.theme" :key="theme.theme"> <option v-for="theme in themes" :value="theme.theme" :key="theme.theme">
{{ theme.name }} {{ theme.name }}
</option> </option>

View File

@@ -15,9 +15,8 @@ export default {
data() { data() {
const dataObj = {}; const dataObj = {};
const locales = { const locales = {
he: "עברית",
hu: "Magyar",
ar: "العربية", ar: "العربية",
bg: "български език",
ca: "Català", ca: "Català",
cs: "Čeština", cs: "Čeština",
de: "Deutsch", de: "Deutsch",
@@ -25,15 +24,18 @@ export default {
en: "English", en: "English",
es: "Español", es: "Español",
fr: "Français", fr: "Français",
he: "עברית",
hr: "Hrvatski",
hu: "Magyar",
is: "Icelandic", is: "Icelandic",
it: "Italiano", it: "Italiano",
ja: "日本語", ja: "日本語",
ko: "한국어", ko: "한국어",
"nl-be": "Dutch (Belgium)",
no: "Norsk", no: "Norsk",
"nl-be": "Dutch (Belgium)",
pl: "Polski", pl: "Polski",
"pt-br": "Português", "pt-br": "Português (Brasil)",
pt: "Português (Brasil)", pt: "Português (Portugal)",
ro: "Romanian", ro: "Romanian",
ru: "Русский", ru: "Русский",
sk: "Slovenčina", sk: "Slovenčina",

View File

@@ -185,3 +185,7 @@ html[dir="rtl"] .breadcrumbs a {
.vfm-modal { .vfm-modal {
z-index: 9999999 !important; z-index: 9999999 !important;
} }
body > div[style*="z-index: 9990"] {
z-index: 10000 !important;
}

View File

@@ -0,0 +1,21 @@
.context-menu {
position: absolute;
background: var(--surfacePrimary);
min-width: 180px;
max-width: 220px;
border: 1px solid var(--borderSecondary);
box-shadow: 0 2px 4px var(--borderPrimary);
z-index: 999;
}
.context-menu .action {
display: block;
width: 100%;
border-radius: 0;
display: flex;
align-items: center;
}
.context-menu .action .counter {
left: 1.75em;
}

View File

@@ -63,8 +63,8 @@
local("Roboto"), local("Roboto"),
local("Roboto-Regular"), local("Roboto-Regular"),
url(../assets/fonts/roboto/normal-latin-ext.woff2) format("woff2"); url(../assets/fonts/roboto/normal-latin-ext.woff2) format("woff2");
unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, unicode-range:
U+2C60-2C7F, U+A720-A7FF; U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
} }
@font-face { @font-face {
@@ -75,8 +75,9 @@
local("Roboto"), local("Roboto"),
local("Roboto-Regular"), local("Roboto-Regular"),
url(../assets/fonts/roboto/normal-latin.woff2) format("woff2"); url(../assets/fonts/roboto/normal-latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, unicode-range:
U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F,
U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
} }
@font-face { @font-face {
@@ -142,8 +143,8 @@
local("Roboto Medium"), local("Roboto Medium"),
local("Roboto-Medium"), local("Roboto-Medium"),
url(../assets/fonts/roboto/medium-latin-ext.woff2) format("woff2"); url(../assets/fonts/roboto/medium-latin-ext.woff2) format("woff2");
unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, unicode-range:
U+2C60-2C7F, U+A720-A7FF; U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
} }
@font-face { @font-face {
@@ -154,8 +155,9 @@
local("Roboto Medium"), local("Roboto Medium"),
local("Roboto-Medium"), local("Roboto-Medium"),
url(../assets/fonts/roboto/medium-latin.woff2) format("woff2"); url(../assets/fonts/roboto/medium-latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, unicode-range:
U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F,
U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
} }
@font-face { @font-face {
@@ -221,8 +223,8 @@
local("Roboto Bold"), local("Roboto Bold"),
local("Roboto-Bold"), local("Roboto-Bold"),
url(../assets/fonts/roboto/bold-latin-ext.woff2) format("woff2"); url(../assets/fonts/roboto/bold-latin-ext.woff2) format("woff2");
unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, unicode-range:
U+2C60-2C7F, U+A720-A7FF; U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
} }
@font-face { @font-face {
@@ -233,8 +235,9 @@
local("Roboto Bold"), local("Roboto Bold"),
local("Roboto-Bold"), local("Roboto-Bold"),
url(../assets/fonts/roboto/bold-latin.woff2) format("woff2"); url(../assets/fonts/roboto/bold-latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, unicode-range:
U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F,
U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
} }
.material-icons { .material-icons {

View File

@@ -17,6 +17,7 @@
@import "./mobile.css"; @import "./mobile.css";
@import "./epubReader.css"; @import "./epubReader.css";
@import "./mdPreview.css"; @import "./mdPreview.css";
@import "./context-menu.css";
/* For testing only /* For testing only
:focus { :focus {
@@ -97,7 +98,8 @@ main .spinner .bounce2 {
position: relative; position: relative;
} }
.action.disabled { .action.disabled,
.action[disabled] {
opacity: 0.2; opacity: 0.2;
cursor: not-allowed; cursor: not-allowed;
} }

View File

@@ -24,7 +24,7 @@
"ok": "موافق", "ok": "موافق",
"permalink": "الحصول على رابط دائم", "permalink": "الحصول على رابط دائم",
"previous": "السابق", "previous": "السابق",
"preview": "Preview", "preview": "معاينة",
"publish": "نشر", "publish": "نشر",
"rename": "إعادة تسمية", "rename": "إعادة تسمية",
"replace": "استبدال", "replace": "استبدال",
@@ -43,7 +43,11 @@
"upload": "رفع", "upload": "رفع",
"openFile": "فتح الملف", "openFile": "فتح الملف",
"discardChanges": "إلغاء التغييرات", "discardChanges": "إلغاء التغييرات",
"saveChanges": "Save changes" "stopSearch": "توقف عن البحث",
"saveChanges": "حفظ التغييرات",
"editAsText": "تعديل على شكل نص",
"increaseFontSize": "زيادة حجم الخط",
"decreaseFontSize": "تصغير حجم الخط"
}, },
"download": { "download": {
"downloadFile": "تحميل الملف", "downloadFile": "تحميل الملف",
@@ -75,7 +79,16 @@
"sortByLastModified": "الترتيب بآخر تعديل", "sortByLastModified": "الترتيب بآخر تعديل",
"sortByName": "الترتيب باﻹسم", "sortByName": "الترتيب باﻹسم",
"sortBySize": "الترتيب بالحجم", "sortBySize": "الترتيب بالحجم",
"noPreview": "لا يوجد عرض مسبق لهذا الملف." "noPreview": "لا يوجد عرض مسبق لهذا الملف.",
"csvTooLarge": "حجم الملف اكبر من (<5MB), يرجى تحميل الملف للمعاينة",
"csvLoadFailed": "Failed to load CSV file.",
"showingRows": "Showing {count} row(s)",
"columnSeparator": "Column Separator",
"csvSeparators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
}
}, },
"help": { "help": {
"click": "حدد الملف أو المجلد", "click": "حدد الملف أو المجلد",
@@ -102,6 +115,7 @@
"username": "إسم المستخدم", "username": "إسم المستخدم",
"usernameTaken": "إسم المستخدم غير متاح", "usernameTaken": "إسم المستخدم غير متاح",
"wrongCredentials": "بيانات دخول خاطئة", "wrongCredentials": "بيانات دخول خاطئة",
"passwordTooShort": "Password must be at least {min} characters",
"logout_reasons": { "logout_reasons": {
"inactivity": "You have been logged out due to inactivity." "inactivity": "You have been logged out due to inactivity."
} }
@@ -124,7 +138,7 @@
"filesSelected": "تم تحديد {count} ملفات.", "filesSelected": "تم تحديد {count} ملفات.",
"lastModified": "آخر تعديل", "lastModified": "آخر تعديل",
"move": "نقل", "move": "نقل",
"moveMessage": "إختر مكان جديد للملفات أو المجلدات المراد نقلها:", "moveMessage": "اختر منزلاً جديداً لملفك (ملفاتك)/مجلدك (مجلداتك):",
"newArchetype": "إنشاء منشور من المنشور اﻷصلي. الملف سيتم انشاءه في مجلد المحتويات.", "newArchetype": "إنشاء منشور من المنشور اﻷصلي. الملف سيتم انشاءه في مجلد المحتويات.",
"newDir": "مجلد جديد", "newDir": "مجلد جديد",
"newDirMessage": "أدخل اسم المجلد الجديد.", "newDirMessage": "أدخل اسم المجلد الجديد.",
@@ -166,6 +180,7 @@
"allowNew": "إنشاء ملفات و مجلدات جديدة", "allowNew": "إنشاء ملفات و مجلدات جديدة",
"allowPublish": "نشر مقالات و صفحات جديدة", "allowPublish": "نشر مقالات و صفحات جديدة",
"allowSignup": "اسمح للمستخدمين بالاشتراك", "allowSignup": "اسمح للمستخدمين بالاشتراك",
"hideLoginButton": "Hide the login button from public pages",
"avoidChanges": "(أتركه فارغاً إن لم ترد تغييره)", "avoidChanges": "(أتركه فارغاً إن لم ترد تغييره)",
"branding": "الشعار", "branding": "الشعار",
"brandingDirectoryPath": "مسار مجلد الشعار", "brandingDirectoryPath": "مسار مجلد الشعار",
@@ -174,15 +189,15 @@
"commandRunner": "منفذ اﻷوامر", "commandRunner": "منفذ اﻷوامر",
"commandRunnerHelp": "هنا بإمكانك تعيين اﻷوامر التي سيتم تنفيذها في اﻷحداث المسماة. يجب كتابة أمر واحد في كل سطر. ستكون المتغيرات البيئية (env) {0} و {1} متاحة، حيث {0} نسبي لـ {1}. لمزيد من المعلومات حول هذه الميزة و المتغيرات البيئية المتاحة، يرجى قراءة {2}.", "commandRunnerHelp": "هنا بإمكانك تعيين اﻷوامر التي سيتم تنفيذها في اﻷحداث المسماة. يجب كتابة أمر واحد في كل سطر. ستكون المتغيرات البيئية (env) {0} و {1} متاحة، حيث {0} نسبي لـ {1}. لمزيد من المعلومات حول هذه الميزة و المتغيرات البيئية المتاحة، يرجى قراءة {2}.",
"commandsUpdated": "تم تحديث اﻷوامر", "commandsUpdated": "تم تحديث اﻷوامر",
"createUserDir": "إنشاء مجلد المستخدم (home) تلقائياً عند إنشاء مستخدم جديد", "createUserDir": "إنشاء مجلد المستخدم الرئيسي تلقائياً عند إنشاء مستخدم جديد",
"minimumPasswordLength": "Minimum password length", "minimumPasswordLength": "Minimum password length",
"tusUploads": "التحميلات المتقطعة", "tusUploads": "التحميلات المتقطعة",
"tusUploadsHelp": "يدعم متصفح الملفات تحميل الملفات المتقطعة، مما يسمح بتحميلات الملفات بشكل فعال و موثوق و قابلة للمتابغة و متقطعة حتى على الشبكات غير الموثوقة.", "tusUploadsHelp": "يدعم متصفح الملفات تحميل الملفات المتقطعة، مما يسمح بتحميلات الملفات بشكل فعال و موثوق و قابلة للمتابغة و متقطعة حتى على الشبكات غير الموثوقة.",
"tusUploadsChunkSize": "يشير إلى الحد اﻷقصى لحجم الطلب (سيتم استخدام التحميل المباشر للتحميلات صغيرة الخحم). يمكنك إدخال عدد صحيح عادي يدل على الحجم بوحدة البايت أو نمظ مثل10MB, 1GB, إلخ.", "tusUploadsChunkSize": "يشير إلى الحد اﻷقصى لحجم الطلب (سيتم استخدام التحميل المباشر للتحميلات صغيرة الخحم). يمكنك إدخال عدد صحيح عادي يدل على الحجم بوحدة البايت أو نمظ مثل10MB, 1GB, إلخ.",
"tusUploadsRetryCount": "عدد مرات إعادة المحاولة إذا فشلت عملية تحميل القطعة.", "tusUploadsRetryCount": "عدد مرات إعادة المحاولة إذا فشلت عملية تحميل القطعة.",
"userHomeBasePath": "المسار الرئيسي لمجلد المستخدم (home)", "userHomeBasePath": "المسار الرئيسي لمجلد المستخدم الرئيسي",
"userScopeGenerationPlaceholder": "سيتم تعيين نطاق المستخدم تلقائياً", "userScopeGenerationPlaceholder": "سيتم تعيين نطاق المستخدم تلقائياً",
"createUserHomeDirectory": "إنشاء مجلد المستخدم (home)", "createUserHomeDirectory": "إنشاء مجلد المستخدم الرئيسي",
"customStylesheet": "ستايل مخصص", "customStylesheet": "ستايل مخصص",
"defaultUserDescription": "هذه اﻹعدادات اﻹفتراضية للمستخدمين الجدد.", "defaultUserDescription": "هذه اﻹعدادات اﻹفتراضية للمستخدمين الجدد.",
"disableExternalLinks": "تعطيل الروابط الخارجية (بإسثناء الوثائق)", "disableExternalLinks": "تعطيل الروابط الخارجية (بإسثناء الوثائق)",
@@ -217,6 +232,7 @@
"permissions": "الصلاحيات", "permissions": "الصلاحيات",
"permissionsHelp": "يمكنك تعيين المستخدم كـ \"مدير\" أو تحديد الصلاحيات بشكل منفرد.\n إذا قمت بتحديد المستخدم كـ \"مدير\"، باقي الخيارات سيتم تحديدها تلقائياً.\n إدارة المستخدمين تبقى صلاحية فريدة للـ \"مدير\" فقط.\n", "permissionsHelp": "يمكنك تعيين المستخدم كـ \"مدير\" أو تحديد الصلاحيات بشكل منفرد.\n إذا قمت بتحديد المستخدم كـ \"مدير\"، باقي الخيارات سيتم تحديدها تلقائياً.\n إدارة المستخدمين تبقى صلاحية فريدة للـ \"مدير\" فقط.\n",
"profileSettings": "إعدادات الحساب", "profileSettings": "إعدادات الحساب",
"redirectAfterCopyMove": "Redirect to destination after copy/move",
"ruleExample1": "منع الوصول إلى الملفات التي تبدأ بنقطة مثل (.git، و .gitignore) في كل مجلد.\n", "ruleExample1": "منع الوصول إلى الملفات التي تبدأ بنقطة مثل (.git، و .gitignore) في كل مجلد.\n",
"ruleExample2": "منع الوصول إلى الملف المسمى Caddyfile في نطاق الجذر.", "ruleExample2": "منع الوصول إلى الملف المسمى Caddyfile في نطاق الجذر.",
"rules": "المجموعات", "rules": "المجموعات",
@@ -243,7 +259,8 @@
"userManagement": "إدارة المستخدمين", "userManagement": "إدارة المستخدمين",
"userUpdated": "تم تعديل المستخدم", "userUpdated": "تم تعديل المستخدم",
"username": "إسم المستخدم", "username": "إسم المستخدم",
"users": "المستخدمين" "users": "المستخدمين",
"currentPassword": "Your Current Password"
}, },
"sidebar": { "sidebar": {
"help": "مساعدة", "help": "مساعدة",

288
frontend/src/i18n/bg.json Normal file
View File

@@ -0,0 +1,288 @@
{
"buttons": {
"cancel": "Отмени",
"clear": "Изчисти",
"close": "Затвори",
"continue": "Продължи",
"copy": "Копирай",
"copyFile": "Копирай файл",
"copyToClipboard": "Копирай в клипборда",
"copyDownloadLinkToClipboard": "Копирай линк за сваляне в клипборда",
"create": "Създай",
"delete": "Изтрий",
"download": "Свали",
"file": "Файл",
"folder": "Папка",
"fullScreen": "Превключване на цял екран",
"hideDotfiles": "Скрий файлове започващи с точка",
"info": "Информация",
"more": "Повече",
"move": "Премести",
"moveFile": "Премести файл",
"new": "Нов",
"next": "Следващ",
"ok": "Потвърди",
"permalink": "Вземи постоянен линк",
"previous": "Предишен",
"preview": "Преглед",
"publish": "Публикуване",
"rename": "Преименуване",
"replace": "Замяна",
"reportIssue": "Докладвай проблем",
"save": "Запис",
"schedule": "График",
"search": "Търсене",
"select": "Избери",
"selectMultiple": "Избери няколко",
"share": "Сподели",
"shell": "Превключване на терминала",
"submit": "Изпрати",
"switchView": "Смени изгледа",
"toggleSidebar": "Превключване на страничен панел",
"update": "Обнови",
"upload": "Качи",
"openFile": "Отвори файл",
"discardChanges": "Изчисти",
"stopSearch": "Stop searching",
"saveChanges": "Запиши промените",
"editAsText": "Edit as Text",
"increaseFontSize": "Increase font size",
"decreaseFontSize": "Decrease font size"
},
"download": {
"downloadFile": "Свали файл",
"downloadFolder": "Свали папка",
"downloadSelected": "Свали избраното"
},
"upload": {
"abortUpload": "Сигурни ли сте, че искате да прекратите?"
},
"errors": {
"forbidden": "Нямате право на достъп.",
"internal": "Взникна грешка.",
"notFound": "Локацията не може да бъде достигната.",
"connection": "Сървъра не може да бъде достигнат."
},
"files": {
"body": "Тяло",
"closePreview": "Затвори прегледа",
"files": "Файлове",
"folders": "Папки",
"home": "Начало",
"lastModified": "Последна промяна",
"loading": "Зареждане ...",
"lonely": "Тук е самотно ...",
"metadata": "Метаданни",
"multipleSelectionEnabled": "Множествения избор е разрешен",
"name": "Име",
"size": "Размер",
"sortByLastModified": "Подредба по последна промяна",
"sortByName": "Подредба по име",
"sortBySize": "Подредба по размер",
"noPreview": "За този файл не е наличен преглед.",
"csvTooLarge": "CSV file is too large for preview (>5MB). Please download to view.",
"csvLoadFailed": "Failed to load CSV file.",
"showingRows": "Showing {count} row(s)",
"columnSeparator": "Column Separator",
"csvSeparators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
}
},
"help": {
"click": "избери файл или директория",
"ctrl": {
"click": "избери файлове или директории",
"f": "отваря търсене",
"s": "запиши файл или свари директория тук"
},
"del": "изтрий избраните",
"doubleClick": "отвори файл или директория",
"esc": "изтрий избраното и/или затвори",
"f1": "тази информация",
"f2": "преименувай файл",
"help": "Помощ"
},
"login": {
"createAnAccount": "Създай акаунт",
"loginInstead": "Вече имаш акаунт",
"password": "Парола",
"passwordConfirm": "Парола Потвърждение",
"passwordsDontMatch": "Паролите не съвпадат",
"signup": "Абониране",
"submit": "Вход",
"username": "Потребителско име",
"usernameTaken": "Потребителското име вече се използва",
"wrongCredentials": "Грешни потребителско име и/или парола",
"passwordTooShort": "Password must be at least {min} characters",
"logout_reasons": {
"inactivity": "Бяхте разлогнати поради неактивност"
}
},
"permanent": "Постоянен",
"prompts": {
"copy": "Копирай",
"copyMessage": "Избери къде да копираш файловете си:",
"currentlyNavigating": "В момента навигира към:",
"deleteMessageMultiple": "Сигурни ли сте, че искате да изтриете {count} файл(а)?",
"deleteMessageSingle": "Сигурни ли сте, че искате да изтриете този файл/папка?",
"deleteMessageShare": "Сигурни ли сте, че искате на изтриете това споделяне({path})?",
"deleteUser": "Сигурни ли сте, че искате да изтриете този потребител?",
"deleteTitle": "Изтрий файлове",
"displayName": "Име за показване:",
"download": "Свали файлове",
"downloadMessage": "Изберете формата, в който искате да свалите.",
"error": "Възникна грешка",
"fileInfo": "Информация за файла",
"filesSelected": "Избрани са {count} файла.",
"lastModified": "Последна промяна",
"move": "Премести",
"moveMessage": "Избери ново място за вашите файл(ове)/папк(а/и):",
"newArchetype": "Създай нова публикация базирана на шаблон. Вашия файл ще бъде създаден в папката за съдържание.",
"newDir": "Нова директория",
"newDirMessage": "Именувайте вашата нова директория.",
"newFile": "Нов файл",
"newFileMessage": "Именувайте вашия нов файл.",
"numberDirs": "Брой на директорийте",
"numberFiles": "Брой на файловете",
"rename": "Преименувай",
"renameMessage": "Вмъкни ново име за",
"replace": "Замени",
"replaceMessage": "Файл, които се опитвате да качите има конфликтно име. Искате ли да го пропуснете и да продължите качването или да замените съществуващия файл?\n",
"schedule": "График",
"scheduleMessage": "Изберете дата и час за публикуване на тази публикация.",
"show": "Покажи",
"size": "Размер",
"upload": "Качване",
"uploadFiles": "Качване на {files} файла...",
"uploadMessage": "Изберете опция за качване.",
"optionalPassword": "Опционална парола",
"resolution": "Резолюция",
"discardEditorChanges": "Сигурни ли сте, че искате да откажете направените промени?"
},
"search": {
"images": "Изображения",
"music": "Музика",
"pdf": "PDF",
"pressToSearch": "За търсене, натиснете Enter ...",
"search": "Търсене ...",
"typeToSearch": "Пишете за търсене ...",
"types": "Типове",
"video": "Видео"
},
"settings": {
"aceEditorTheme": "Тема на \"Ace редактор\"",
"admin": "Админ",
"administrator": "Администратор",
"allowCommands": "Изпълни команди",
"allowEdit": "Редактира, преименува и изтрива файлове и директории",
"allowNew": "Създава нови файлове и директорий",
"allowPublish": "Публикува нови публикации и страници",
"allowSignup": "Разреши потребителите да се абонират",
"hideLoginButton": "Скрий логин бутона от публичните страници",
"avoidChanges": "(остави празно за да избегнеш промени)",
"branding": "Брандиране",
"brandingDirectoryPath": "Брандиране - път до директория",
"brandingHelp": "Можете да настроите как изглежда вашия File Browser, като промените името и логото му, да добавите стилове и дори да забраните външни линкове към GitHub.\nЗа повече информация за бандиране се обърнете към {0}",
"changePassword": "Промени парола",
"commandRunner": "Изпълнение на команди",
"commandRunnerHelp": "Тук можете да зададете команди, които да се изпълнят при определени събития. Пишете по една команда на ред. Системните променливите {0} и {1} ще са на разположение, като {0} е релативна на {1}. За повече информация относно тази възможност и наличните системни променливи, моля прочетете {2}.",
"commandsUpdated": "Командите са запаметени!",
"createUserDir": "Създай автоматично собствена директория на потребителя, когато го добавяш.",
"minimumPasswordLength": "Минимална дължина на паролата",
"tusUploads": "Качване на части",
"tusUploadsHelp": "File Browser поддържа качване на части, което позволява съзадавнето на ефективно, надеждно, и възобновяемо качване на части дори и при ненадеждна мрежа.",
"tusUploadsChunkSize": "Максимален размер на заявката (за малки качвания ще бъдат използвано директо качване). Можете да въведете цяло число, което означава размера на данните в байтове, или пък текст от вида на 10MB, 1GB и т.н..",
"tusUploadsRetryCount": "Брой повторения, когато част от файл не се качи успешно.",
"userHomeBasePath": "Основен път до личните директории на потребителите",
"userScopeGenerationPlaceholder": "Обхватът ще бъде автоматично генериран",
"createUserHomeDirectory": "Създай лична директория на потребителя",
"customStylesheet": "Потребителски Стилове",
"defaultUserDescription": "Настройки по подразбиране за нови потребители.",
"disableExternalLinks": "Забрани външните връзки (с изключение на документацията)",
"disableUsedDiskPercentage": "Забрани графиката за използване на диска",
"documentation": "документация",
"examples": "Примери",
"executeOnShell": "Изпълни в шела",
"executeOnShellDescription": "По подразбиране, File Browser изпълнява командите директно. Ако искате да ги изпълните в сесия (като Bash или PowerShell), можете да я дефинирате тук заедно с необходимите аргументи и флагове. Ако това е зададено, командата която изпълните ще бъде добавена като аргумент. Това се отнася както за потребителски команди, така и за обработка на събития.",
"globalRules": "Това е общия списък от правила за разрешения или забрани. Те са приложими за всеки потребител. Можете да дефинирате специфични правила в настройките на всеки потребител, които ще имат приоритет над общите.",
"globalSettings": "Глобални Настройки",
"hideDotfiles": "Скрий фаловете започващи с точка",
"insertPath": "Вмъкни пътя",
"insertRegex": "Вмъкни регулярен израз",
"instanceName": "Име на инстанцията",
"language": "Език",
"lockPassword": "Забрани на потребителя да променя паролата",
"newPassword": "Вашата нова парола",
"newPasswordConfirm": "Потвърди вашата нова парола",
"newUser": "Нов потребител",
"password": "Парола",
"passwordUpdated": "Паролата е променена!",
"path": "Път",
"perm": {
"create": "Създаване на файлове и директорий",
"delete": "Изтриване на файлове и директорий",
"download": "Сваляне",
"execute": "Изпълни команди",
"modify": "Редактирай файлове",
"rename": "Преименувай или премести файлове и директорий",
"share": "Сподели файлове"
},
"permissions": "Разрешения",
"permissionsHelp": "Можете да зададете потребител да бъде администратор или да изберете разрешения индивидуално. Ако изберете \"Администратор\" всички други опции ще бъдат автоматично отметнати. Управлението на потребителите е привилегия на администратор.\n",
"profileSettings": "Настройки на Профила",
"redirectAfterCopyMove": "Redirect to destination after copy/move",
"ruleExample1": "предотвратете достъпа до всеки файл започващ с точка (като .git, .gitignore) във всяка папка.\n",
"ruleExample2": "блокира достъпа до файл именуван Caddyfile поставен в началото за обхвата.",
"rules": "Правила",
"rulesHelp": "Тук можете да дефинирате списък от правила за разрешаване и забрана за точно този потребител. Блокираните файлове няма да се покажат в списъците и няма да са достъпни за потребителя. Поддържаме регулярни изрази и пътища релативни на обхвата.\n",
"scope": "Обхват",
"setDateFormat": "Задайте точен формат на дата",
"settingsUpdated": "Настройките са обновени!",
"shareDuration": "Продължителност на споделянето",
"shareManagement": "Управление на споделянето",
"shareDeleted": "Споделянето е премахнато!",
"singleClick": "Използвайте единичен клик за да отворите файлове или директорий",
"themes": {
"default": "Настройки по подразбиране",
"dark": "Тъмна",
"light": "Светла",
"title": "Тема"
},
"user": "Потребител",
"userCommands": "Команди",
"userCommandsHelp": "Списък разделен с интервал от наличните команди за този потребител.\n",
"userCreated": "Потребителя е създаден!",
"userDefaults": "Настройки по подразбиране на потребителя",
"userDeleted": "Потребителя е изтрит!",
"userManagement": "Управление на потребители",
"userUpdated": "Потребителя е обновен!",
"username": "Потребителско име",
"users": "Потребители",
"currentPassword": "Your Current Password"
},
"sidebar": {
"help": "Помощ",
"hugoNew": "Hugo New",
"login": "Вход",
"logout": "Изход",
"myFiles": "Моите файлове",
"newFile": "Нов файл",
"newFolder": "Нова папка",
"preview": "Преглед",
"settings": "Настройки",
"signup": "Абониране",
"siteSettings": "Настройки на сървъра"
},
"success": {
"linkCopied": "Връзката е копирана!"
},
"time": {
"days": "Дни",
"hours": "Часове",
"minutes": "Минути",
"seconds": "Секунди",
"unit": "Единица за време"
}
}

View File

@@ -43,7 +43,11 @@
"upload": "Pujar", "upload": "Pujar",
"openFile": "Obrir fitxer", "openFile": "Obrir fitxer",
"discardChanges": "Descartar", "discardChanges": "Descartar",
"saveChanges": "Save changes" "stopSearch": "Stop searching",
"saveChanges": "Save changes",
"editAsText": "Edit as Text",
"increaseFontSize": "Increase font size",
"decreaseFontSize": "Decrease font size"
}, },
"download": { "download": {
"downloadFile": "Descarregar fitxer", "downloadFile": "Descarregar fitxer",
@@ -75,7 +79,16 @@
"sortByLastModified": "Ordenar per última modificació", "sortByLastModified": "Ordenar per última modificació",
"sortByName": "Ordenar per nom", "sortByName": "Ordenar per nom",
"sortBySize": "Ordenar per mida", "sortBySize": "Ordenar per mida",
"noPreview": "La vista prèvia no està disponible per a aquest fitxer." "noPreview": "La vista prèvia no està disponible per a aquest fitxer.",
"csvTooLarge": "CSV file is too large for preview (>5MB). Please download to view.",
"csvLoadFailed": "Failed to load CSV file.",
"showingRows": "Showing {count} row(s)",
"columnSeparator": "Column Separator",
"csvSeparators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
}
}, },
"help": { "help": {
"click": "seleccionar fitxer o carpeta", "click": "seleccionar fitxer o carpeta",
@@ -102,6 +115,7 @@
"username": "Usuari", "username": "Usuari",
"usernameTaken": "Nom d'usuari no disponible", "usernameTaken": "Nom d'usuari no disponible",
"wrongCredentials": "Usuari i/o contrasenya incorrectes", "wrongCredentials": "Usuari i/o contrasenya incorrectes",
"passwordTooShort": "Password must be at least {min} characters",
"logout_reasons": { "logout_reasons": {
"inactivity": "You have been logged out due to inactivity." "inactivity": "You have been logged out due to inactivity."
} }
@@ -166,6 +180,7 @@
"allowNew": "Crear nous fitxers i carpetes", "allowNew": "Crear nous fitxers i carpetes",
"allowPublish": "Publicar nous posts i pàgines", "allowPublish": "Publicar nous posts i pàgines",
"allowSignup": "Permetre registre d'usuaris", "allowSignup": "Permetre registre d'usuaris",
"hideLoginButton": "Hide the login button from public pages",
"avoidChanges": "(deixar en blanc per evitar canvis)", "avoidChanges": "(deixar en blanc per evitar canvis)",
"branding": "Marca", "branding": "Marca",
"brandingDirectoryPath": "Ruta de la carpeta de personalització de marca", "brandingDirectoryPath": "Ruta de la carpeta de personalització de marca",
@@ -217,6 +232,7 @@
"permissions": "Permisos", "permissions": "Permisos",
"permissionsHelp": "Pots nomenar l'usuari com a administrador o triar els permisos individualment. Si selecciones \"Administrador\", totes les altres opcions s'activaran automàticament. L'administració d'usuaris és un privilegi d'administrador.\n", "permissionsHelp": "Pots nomenar l'usuari com a administrador o triar els permisos individualment. Si selecciones \"Administrador\", totes les altres opcions s'activaran automàticament. L'administració d'usuaris és un privilegi d'administrador.\n",
"profileSettings": "Ajustos del perfil", "profileSettings": "Ajustos del perfil",
"redirectAfterCopyMove": "Redirect to destination after copy/move",
"ruleExample1": "prevé l'accés a una extensió de fitxer (Com .git) en cada carpeta.\n", "ruleExample1": "prevé l'accés a una extensió de fitxer (Com .git) en cada carpeta.\n",
"ruleExample2": "bloqueja l'accés al fitxer anomenat Caddyfile a la carpeta arrel.", "ruleExample2": "bloqueja l'accés al fitxer anomenat Caddyfile a la carpeta arrel.",
"rules": "Regles", "rules": "Regles",
@@ -243,7 +259,8 @@
"userManagement": "Administració d'usuaris", "userManagement": "Administració d'usuaris",
"userUpdated": "Usuari actualitzat!", "userUpdated": "Usuari actualitzat!",
"username": "Usuari", "username": "Usuari",
"users": "Usuaris" "users": "Usuaris",
"currentPassword": "Your Current Password"
}, },
"sidebar": { "sidebar": {
"help": "Ajuda", "help": "Ajuda",

View File

@@ -43,7 +43,11 @@
"upload": "Nahrát", "upload": "Nahrát",
"openFile": "Otevřít soubor", "openFile": "Otevřít soubor",
"discardChanges": "Zrušit změny", "discardChanges": "Zrušit změny",
"saveChanges": "Save changes" "stopSearch": "Stop searching",
"saveChanges": "Save changes",
"editAsText": "Edit as Text",
"increaseFontSize": "Increase font size",
"decreaseFontSize": "Decrease font size"
}, },
"download": { "download": {
"downloadFile": "Stáhnout soubor", "downloadFile": "Stáhnout soubor",
@@ -75,7 +79,16 @@
"sortByLastModified": "Seřadit podle poslední změny", "sortByLastModified": "Seřadit podle poslední změny",
"sortByName": "Seřadit podle názvu", "sortByName": "Seřadit podle názvu",
"sortBySize": "Seřadit podle velikosti", "sortBySize": "Seřadit podle velikosti",
"noPreview": "Náhled pro tento soubor není k dispozici." "noPreview": "Náhled pro tento soubor není k dispozici.",
"csvTooLarge": "CSV file is too large for preview (>5MB). Please download to view.",
"csvLoadFailed": "Failed to load CSV file.",
"showingRows": "Showing {count} row(s)",
"columnSeparator": "Column Separator",
"csvSeparators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
}
}, },
"help": { "help": {
"click": "vyberte soubor nebo adresář", "click": "vyberte soubor nebo adresář",
@@ -102,6 +115,7 @@
"username": "Uživatelské jméno", "username": "Uživatelské jméno",
"usernameTaken": "Uživatelské jméno již existuje", "usernameTaken": "Uživatelské jméno již existuje",
"wrongCredentials": "Nesprávné přihlašovací údaje", "wrongCredentials": "Nesprávné přihlašovací údaje",
"passwordTooShort": "Password must be at least {min} characters",
"logout_reasons": { "logout_reasons": {
"inactivity": "You have been logged out due to inactivity." "inactivity": "You have been logged out due to inactivity."
} }
@@ -166,6 +180,7 @@
"allowNew": "Vytvářet nové soubory a adresáře", "allowNew": "Vytvářet nové soubory a adresáře",
"allowPublish": "Publikovat nové příspěvky a stránky", "allowPublish": "Publikovat nové příspěvky a stránky",
"allowSignup": "Povolit uživatelům registraci", "allowSignup": "Povolit uživatelům registraci",
"hideLoginButton": "Hide the login button from public pages",
"avoidChanges": "(ponechte prázdné pro zabránění změnám)", "avoidChanges": "(ponechte prázdné pro zabránění změnám)",
"branding": "Branding", "branding": "Branding",
"brandingDirectoryPath": "Cesta ke složce s brandingem", "brandingDirectoryPath": "Cesta ke složce s brandingem",
@@ -217,6 +232,7 @@
"permissions": "Oprávnění", "permissions": "Oprávnění",
"permissionsHelp": "Můžete nastavit uživatele jako administrátora nebo zvolit jednotlivá oprávnění. Pokud vyberete \"Administrátor\", všechny ostatní možnosti budou automaticky zaškrtnuty. Správa uživatelů zůstává výsadou administrátora.\n", "permissionsHelp": "Můžete nastavit uživatele jako administrátora nebo zvolit jednotlivá oprávnění. Pokud vyberete \"Administrátor\", všechny ostatní možnosti budou automaticky zaškrtnuty. Správa uživatelů zůstává výsadou administrátora.\n",
"profileSettings": "Nastavení profilu", "profileSettings": "Nastavení profilu",
"redirectAfterCopyMove": "Redirect to destination after copy/move",
"ruleExample1": "zabraňuje přístupu k jakémukoli skrytému souboru (např. .git, .gitignore) v každé složce.\n", "ruleExample1": "zabraňuje přístupu k jakémukoli skrytému souboru (např. .git, .gitignore) v každé složce.\n",
"ruleExample2": "blokuje přístup k souboru s názvem Caddyfile v kořenovém adresáři.", "ruleExample2": "blokuje přístup k souboru s názvem Caddyfile v kořenovém adresáři.",
"rules": "Pravidla", "rules": "Pravidla",
@@ -243,7 +259,8 @@
"userManagement": "Správa uživatelů", "userManagement": "Správa uživatelů",
"userUpdated": "Uživatel aktualizován!", "userUpdated": "Uživatel aktualizován!",
"username": "Uživatelské jméno", "username": "Uživatelské jméno",
"users": "Uživatelé" "users": "Uživatelé",
"currentPassword": "Your Current Password"
}, },
"sidebar": { "sidebar": {
"help": "Nápověda", "help": "Nápověda",

View File

@@ -1,14 +1,14 @@
{ {
"buttons": { "buttons": {
"cancel": "Abbrechen", "cancel": "Abbrechen",
"clear": "Schließen", "clear": "Leeren",
"close": "Schließen", "close": "Schließen",
"continue": "Fortfahren", "continue": "Fortfahren",
"copy": "Kopieren", "copy": "Kopieren",
"copyFile": "Kopiere Datei", "copyFile": "Kopiere Datei",
"copyToClipboard": "In Zwischenablage kopieren", "copyToClipboard": "In Zwischenablage kopieren",
"copyDownloadLinkToClipboard": "Download-Link in die Zwischenablage kopieren", "copyDownloadLinkToClipboard": "Download-Link in die Zwischenablage kopieren",
"create": "Neu", "create": "Erstellen",
"delete": "Löschen", "delete": "Löschen",
"download": "Herunterladen", "download": "Herunterladen",
"file": "Datei", "file": "Datei",
@@ -16,17 +16,17 @@
"fullScreen": "Vollbildmodus umschalten", "fullScreen": "Vollbildmodus umschalten",
"hideDotfiles": "Versteckte Dateien ausblenden", "hideDotfiles": "Versteckte Dateien ausblenden",
"info": "Info", "info": "Info",
"more": "mehr", "more": "Mehr",
"move": "Verschieben", "move": "Verschieben",
"moveFile": "Verschiebe Datei", "moveFile": "Verschiebe Datei",
"new": "Neu", "new": "Neu",
"next": "nächste", "next": "Nächste",
"ok": "OK", "ok": "OK",
"permalink": "permanenten Verweis anzeigen", "permalink": "permanenten Verweis anzeigen",
"previous": "vorherige", "previous": "Vorherige",
"preview": "Vorschau", "preview": "Vorschau",
"publish": "Veröffentlichen", "publish": "Veröffentlichen",
"rename": "umbenennen", "rename": "Umbenennen",
"replace": "Ersetzen", "replace": "Ersetzen",
"reportIssue": "Fehler melden", "reportIssue": "Fehler melden",
"save": "Speichern", "save": "Speichern",
@@ -39,15 +39,19 @@
"submit": "Absenden", "submit": "Absenden",
"switchView": "Ansicht wechseln", "switchView": "Ansicht wechseln",
"toggleSidebar": "Seitenleiste anzeigen", "toggleSidebar": "Seitenleiste anzeigen",
"update": "Update", "update": "Aktualisieren",
"upload": "Upload", "upload": "Hochladen",
"openFile": "Datei öffnen", "openFile": "Datei öffnen",
"discardChanges": "Verwerfen", "discardChanges": "Verwerfen",
"saveChanges": "Save changes" "stopSearch": "Suche abbrechen",
"saveChanges": "Änderungen speichern",
"editAsText": "Als Text bearbeiten",
"increaseFontSize": "Schriftgröße vergrößern",
"decreaseFontSize": "Schriftgröße verkleinern"
}, },
"download": { "download": {
"downloadFile": "Download Datei", "downloadFile": "Datei herunterladen",
"downloadFolder": "Download Ordner", "downloadFolder": "Ordner herunterladen",
"downloadSelected": "Auswahl herunterladen" "downloadSelected": "Auswahl herunterladen"
}, },
"upload": { "upload": {
@@ -60,12 +64,12 @@
"connection": "Der Server ist nicht erreichbar." "connection": "Der Server ist nicht erreichbar."
}, },
"files": { "files": {
"body": "Body", "body": "Inhalt",
"closePreview": "Vorschau schließen", "closePreview": "Vorschau schließen",
"files": "Dateien", "files": "Dateien",
"folders": "Ordner", "folders": "Ordner",
"home": "Home", "home": "Startseite",
"lastModified": "zuletzt verändert", "lastModified": "Zuletzt geändert",
"loading": "Lade...", "loading": "Lade...",
"lonely": "Hier scheint nichts zu sein...", "lonely": "Hier scheint nichts zu sein...",
"metadata": "Metadaten", "metadata": "Metadaten",
@@ -75,7 +79,16 @@
"sortByLastModified": "Nach Änderungsdatum sortieren", "sortByLastModified": "Nach Änderungsdatum sortieren",
"sortByName": "Nach Namen sortieren", "sortByName": "Nach Namen sortieren",
"sortBySize": "Nach Größe sortieren", "sortBySize": "Nach Größe sortieren",
"noPreview": "Für diese Datei ist keine Vorschau verfügbar." "noPreview": "Für diese Datei ist keine Vorschau verfügbar.",
"csvTooLarge": "Die CSV-Datei ist zu groß für die Vorschau (>5 MB). Bitte herunterladen, um sie anzuzeigen.",
"csvLoadFailed": "Fehler beim Laden der CSV-Datei.",
"showingRows": "{count} Zeile(n) werden angezeigt",
"columnSeparator": "Spaltentrennzeichen",
"csvSeparators": {
"comma": "Komma (,)",
"semicolon": "Semikolon (;)",
"both": "Sowohl (,) als auch (;)"
}
}, },
"help": { "help": {
"click": "Wähle Datei oder Ordner", "click": "Wähle Datei oder Ordner",
@@ -102,8 +115,9 @@
"username": "Benutzername", "username": "Benutzername",
"usernameTaken": "Benutzername ist bereits vergeben", "usernameTaken": "Benutzername ist bereits vergeben",
"wrongCredentials": "Falsche Zugangsdaten", "wrongCredentials": "Falsche Zugangsdaten",
"passwordTooShort": "Passwort muss mindestens {min} Zeichen lang sein",
"logout_reasons": { "logout_reasons": {
"inactivity": "You have been logged out due to inactivity." "inactivity": "Sie wurden aufgrund von Inaktivität abgemeldet."
} }
}, },
"permanent": "Permanent", "permanent": "Permanent",
@@ -117,7 +131,7 @@
"deleteUser": "Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?", "deleteUser": "Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?",
"deleteTitle": "Lösche Dateien", "deleteTitle": "Lösche Dateien",
"displayName": "Anzeigename:", "displayName": "Anzeigename:",
"download": "Lade Dateien", "download": "Dateien herunterladen",
"downloadMessage": "Wählen Sie ein Format zum Herunterladen aus.", "downloadMessage": "Wählen Sie ein Format zum Herunterladen aus.",
"error": "Etwas ist schiefgelaufen", "error": "Etwas ist schiefgelaufen",
"fileInfo": "Dateiinformation", "fileInfo": "Dateiinformation",
@@ -135,14 +149,14 @@
"rename": "Umbenennen", "rename": "Umbenennen",
"renameMessage": "Fügen Sie einen Namen ein für", "renameMessage": "Fügen Sie einen Namen ein für",
"replace": "Ersetzen", "replace": "Ersetzen",
"replaceMessage": "Eine der Datei mit dem gleichen Namen, wie die Sie hochladen wollen, existiert bereits. Soll die vorhandene Datei übersprungen oder ersetzt werden?\n", "replaceMessage": "Eine Datei mit dem gleichen Namen existiert bereits. Soll die vorhandene Datei übersprungen oder ersetzt werden?",
"schedule": "Plan", "schedule": "Plan",
"scheduleMessage": "Wählen Sie ein Datum und eine Zeit für die Veröffentlichung dieses Beitrags.", "scheduleMessage": "Wählen Sie ein Datum und eine Zeit für die Veröffentlichung dieses Beitrags.",
"show": "Anzeigen", "show": "Anzeigen",
"size": "Größe", "size": "Größe",
"upload": "Upload", "upload": "Hochladen",
"uploadFiles": "Upload von {files} Dateien...", "uploadFiles": "{files} Dateien werden hochgeladen...",
"uploadMessage": "Wählen Sie eine Upload-Methode", "uploadMessage": "Wählen Sie eine Methode zum Hochladen",
"optionalPassword": "Optionales Passwort", "optionalPassword": "Optionales Passwort",
"resolution": "Auflösung", "resolution": "Auflösung",
"discardEditorChanges": "Möchten Sie die vorgenommenen Änderungen wirklich verwerfen?" "discardEditorChanges": "Möchten Sie die vorgenommenen Änderungen wirklich verwerfen?"
@@ -158,7 +172,7 @@
"video": "Video" "video": "Video"
}, },
"settings": { "settings": {
"aceEditorTheme": "Ace editor theme", "aceEditorTheme": "Editor-Farbschema",
"admin": "Admin", "admin": "Admin",
"administrator": "Administrator", "administrator": "Administrator",
"allowCommands": "Befehle ausführen", "allowCommands": "Befehle ausführen",
@@ -166,41 +180,42 @@
"allowNew": "Erstellen neuer Dateien und Ordner", "allowNew": "Erstellen neuer Dateien und Ordner",
"allowPublish": "Veröffentlichen von neuen Beiträgen und Seiten", "allowPublish": "Veröffentlichen von neuen Beiträgen und Seiten",
"allowSignup": "Erlaube Benutzern sich zu registrieren", "allowSignup": "Erlaube Benutzern sich zu registrieren",
"hideLoginButton": "Den Login-Button auf öffentlichen Seiten ausblenden",
"avoidChanges": "(leer lassen, um Änderungen zu vermeiden)", "avoidChanges": "(leer lassen, um Änderungen zu vermeiden)",
"branding": "Design", "branding": "Design",
"brandingDirectoryPath": "Designverzeichnispfad", "brandingDirectoryPath": "Designverzeichnispfad",
"brandingHelp": "Sie können das Erscheinungsbild Ihres File Browser anpassen, in dem Sie den Namen ändern, das Logo austauschen oder eigene Stile definieren und sogar externe Links zu GitHub deaktivieren.\nUm mehr Informationen zum Anpassen des Designs zu bekommen, gehen Sie bitte zu {0}.", "brandingHelp": "Sie können das Erscheinungsbild Ihres File Browser anpassen, in dem Sie den Namen ändern, das Logo austauschen oder eigene Stile definieren und sogar externe Links zu GitHub deaktivieren.\nUm mehr Informationen zum Anpassen des Designs zu bekommen, gehen Sie bitte zu {0}.",
"changePassword": "Passwort ändern", "changePassword": "Passwort ändern",
"commandRunner": "Befehlseingabe", "commandRunner": "Befehlseingabe",
"commandRunnerHelp": "Hier könne Sie Befehle eintragen, welche bei den benannten Aktionen ausgeführt werden. Sie müssen pro Zeile jeweils einen Befehl eingeben. Die Umgebungsvariable {0} und {1} sind verfügbar, wobei {0} relative zu {1} ist. Für mehr Informationen über diese Funktion und die verfügbaren Umgebungsvariablen lesen Sie bitte die {2}.", "commandRunnerHelp": "Hier können Sie Befehle eintragen, die bei den benannten Aktionen ausgeführt werden. Geben Sie pro Zeile jeweils einen Befehl ein. Die Umgebungsvariablen {0} und {1} sind verfügbar, wobei {0} relativ zu {1} ist. Für weitere Informationen über diese Funktion und die verfügbaren Umgebungsvariablen lesen Sie bitte die {2}.",
"commandsUpdated": "Befehle aktualisiert!", "commandsUpdated": "Befehle aktualisiert!",
"createUserDir": "Automatisches Erstellen des Home-Verzeichnisses beim Anlegen neuer Benutzer", "createUserDir": "Automatisches Erstellen des Home-Verzeichnisses beim Anlegen neuer Benutzer",
"minimumPasswordLength": "Minimum password length", "minimumPasswordLength": "Mindestlänge für Passwörter",
"tusUploads": "Gestückelter Upload", "tusUploads": "Fortsetzbarer Upload",
"tusUploadsHelp": "File Browser unterstützt das Hochladen von gestückelten Dateien und ermöglicht so einen effizienten, zuverlässigen, fortsetzbaren und gestückelten Datei-Upload auch in unzuverlässigen Netzwerken.", "tusUploadsHelp": "File Browser unterstützt fortsetzbares Hochladen und ermöglicht so einen effizienten, zuverlässigen Upload auch in unzuverlässigen Netzwerken.",
"tusUploadsChunkSize": "Gibt die maximale Größe pro Anfrage an (direkte Uploads werden für kleinere Uploads verwendet). Bitte geben Sie eine Byte-Angabe oder eine Zeichenfolge wie 10 MB, 1 GB usw. an", "tusUploadsChunkSize": "Gibt die maximale Größe pro Anfrage an (direkte Uploads werden für kleinere Uploads verwendet). Bitte geben Sie eine Byte-Angabe oder eine Zeichenfolge wie 10 MB, 1 GB usw. an",
"tusUploadsRetryCount": "Anzahl der Wiederholungsversuche, wenn das Hochladen eines Stückes fehlschlägt.", "tusUploadsRetryCount": "Anzahl der Wiederholungsversuche, wenn das Hochladen eines Stückes fehlschlägt.",
"userHomeBasePath": "Basispfad für Benutzer-Home-Verzeichnisse", "userHomeBasePath": "Basispfad für Benutzer-Home-Verzeichnisse",
"userScopeGenerationPlaceholder": "Scope wird automatisch generiert", "userScopeGenerationPlaceholder": "Geltungsbereich wird automatisch generiert",
"createUserHomeDirectory": "Benutzer-Home-Verzeichnis erstellen", "createUserHomeDirectory": "Benutzer-Home-Verzeichnis erstellen",
"customStylesheet": "Individuelles Stylesheet", "customStylesheet": "Individuelles Stylesheet",
"defaultUserDescription": "Das sind die Standardeinstellung für Benutzer", "defaultUserDescription": "Dies sind die Standardeinstellungen für Benutzer",
"disableExternalLinks": "Externe Links deaktivieren (außer Dokumentation)", "disableExternalLinks": "Externe Links deaktivieren (außer Dokumentation)",
"disableUsedDiskPercentage": "Diagramm zur Festplattennutzung deaktivieren", "disableUsedDiskPercentage": "Diagramm zur Festplattennutzung deaktivieren",
"documentation": "Dokumentation", "documentation": "Dokumentation",
"examples": "Beispiele", "examples": "Beispiele",
"executeOnShell": "In Shell ausführen", "executeOnShell": "In Shell ausführen",
"executeOnShellDescription": "Es ist voreingestellt, dass der File Brower Befehle ausführt, indem er die Befehlsdateien direkt aufruft. Wenn Sie wollen, dass sie über einer Kommandozeile (wie Bash oder PowerShell) laufen, könne Sie diese hier definieren mit allen benötigten Argumenten und Optionen. Wenn gesetzt, wird das Kommando das ausgeführt werden soll als Parameter angehängt. Das gilt für Benutzerkommandos sowie auch für Ereignisse.", "executeOnShellDescription": "Standardmäßig führt der File Browser Befehle direkt aus. Wenn Sie möchten, dass Befehle über eine Kommandozeile (wie Bash oder PowerShell) ausgeführt werden, können Sie diese hier mit allen benötigten Argumenten und Optionen definieren. Der auszuführende Befehl wird als Parameter angehängt. Dies gilt für Benutzerkommandos sowie für Ereignisse.",
"globalRules": "Das ist ein globales Set von Regeln die erlauben oder nicht erlauben. Die sind für alle Benutzer zutreffend. Es können spezielle Regeln in den Einstellungen der Benutzer definiert werden, die diese überschreiben.", "globalRules": "Dies ist ein globaler Satz von Regeln, die Aktionen erlauben oder verbieten. Diese gelten für alle Benutzer. In den Benutzereinstellungen können spezielle Regeln definiert werden, die diese überschreiben.",
"globalSettings": "Globale Einstellungen", "globalSettings": "Globale Einstellungen",
"hideDotfiles": "Versteckte Dateien ausblenden", "hideDotfiles": "Versteckte Dateien ausblenden",
"insertPath": "Pfad einfügen", "insertPath": "Pfad einfügen",
"insertRegex": "Regulären Ausdruck (Regex) einfügen", "insertRegex": "Regulären Ausdruck (Regex) einfügen",
"instanceName": "Instanzname", "instanceName": "Instanzname",
"language": "Sprache", "language": "Sprache",
"lockPassword": "Verhindere, dass der Benutzer sein Passwort ändert", "lockPassword": "Benutzer darf sein Passwort nicht ändern",
"newPassword": "Ihr neues Passwort.", "newPassword": "Neues Passwort",
"newPasswordConfirm": "Bestätigen Sie Ihr neues Passwort", "newPasswordConfirm": "Neues Passwort bestätigen",
"newUser": "Neuer Benutzer", "newUser": "Neuer Benutzer",
"password": "Passwort", "password": "Passwort",
"passwordUpdated": "Passwort aktualisiert!", "passwordUpdated": "Passwort aktualisiert!",
@@ -217,11 +232,12 @@
"permissions": "Berechtigungen", "permissions": "Berechtigungen",
"permissionsHelp": "Sie können einem Benutzer Administratorrechte einräumen oder die Berechtigungen individuell festlegen. Wenn Sie \"Administrator\" auswählen, werden alle anderen Rechte automatisch vergeben. Die Nutzerverwaltung kann nur durch einen Administrator erfolgen.\n", "permissionsHelp": "Sie können einem Benutzer Administratorrechte einräumen oder die Berechtigungen individuell festlegen. Wenn Sie \"Administrator\" auswählen, werden alle anderen Rechte automatisch vergeben. Die Nutzerverwaltung kann nur durch einen Administrator erfolgen.\n",
"profileSettings": "Profileinstellungen", "profileSettings": "Profileinstellungen",
"redirectAfterCopyMove": "Nach Kopieren/Verschieben zum Zielverzeichnis wechseln",
"ruleExample1": "Verhindert den Zugang zu versteckten Dateien (dot-Files, wie .git, .gitignore) in allen Ordnern\n", "ruleExample1": "Verhindert den Zugang zu versteckten Dateien (dot-Files, wie .git, .gitignore) in allen Ordnern\n",
"ruleExample2": "blockiert den Zugang auf Dateien mit dem Namen Caddyfile in der Wurzel/Basis des Scopes.", "ruleExample2": "Blockiert den Zugang zu Dateien mit dem Namen Caddyfile im Stammverzeichnis des Geltungsbereichs.",
"rules": "Regeln", "rules": "Regeln",
"rulesHelp": "Hier können Sie erlaubte und verbotene Aktionen für einen einzelnen Benutzer festlegen. Blockierte Dateien werden nicht im Listing angezeigt und sind nicht erreichbar für den Nutzer. Wir unterstützen reguläre Ausdrücke (Regex) und Pfade die relativ zum Benutzerordner sind. \n", "rulesHelp": "Hier können Sie erlaubte und verbotene Aktionen für einen einzelnen Benutzer festlegen. Blockierte Dateien werden nicht im Listing angezeigt und sind nicht erreichbar für den Nutzer. Wir unterstützen reguläre Ausdrücke (Regex) und Pfade die relativ zum Benutzerordner sind. \n",
"scope": "Scope", "scope": "Geltungsbereich",
"setDateFormat": "Exaktes Datumsformat setzen", "setDateFormat": "Exaktes Datumsformat setzen",
"settingsUpdated": "Einstellungen aktualisiert!", "settingsUpdated": "Einstellungen aktualisiert!",
"shareDuration": "Dauer", "shareDuration": "Dauer",
@@ -238,12 +254,13 @@
"userCommands": "Befehle", "userCommands": "Befehle",
"userCommandsHelp": "Eine Liste, mit einem Leerzeichen als Trennung, mit den für diesen Nutzer verfügbaren Befehlen. Beispiel:\n", "userCommandsHelp": "Eine Liste, mit einem Leerzeichen als Trennung, mit den für diesen Nutzer verfügbaren Befehlen. Beispiel:\n",
"userCreated": "Benutzer angelegt!", "userCreated": "Benutzer angelegt!",
"userDefaults": "Benutzer Standard Einstellungen", "userDefaults": "Standard-Benutzereinstellungen",
"userDeleted": "Benutzer gelöscht!", "userDeleted": "Benutzer gelöscht!",
"userManagement": "Benutzerverwaltung", "userManagement": "Benutzerverwaltung",
"userUpdated": "Benutzer aktualisiert!", "userUpdated": "Benutzer aktualisiert!",
"username": "Nutzername", "username": "Nutzername",
"users": "Nutzer" "users": "Nutzer",
"currentPassword": "Aktuelles Passwort"
}, },
"sidebar": { "sidebar": {
"help": "Hilfe", "help": "Hilfe",

View File

@@ -43,7 +43,11 @@
"upload": "Μεταφόρτωση", "upload": "Μεταφόρτωση",
"openFile": "Άνοιγμα αρχείου", "openFile": "Άνοιγμα αρχείου",
"discardChanges": "Discard", "discardChanges": "Discard",
"saveChanges": "Save changes" "stopSearch": "Stop searching",
"saveChanges": "Save changes",
"editAsText": "Edit as Text",
"increaseFontSize": "Increase font size",
"decreaseFontSize": "Decrease font size"
}, },
"download": { "download": {
"downloadFile": "Λήψη αρχείου", "downloadFile": "Λήψη αρχείου",
@@ -75,7 +79,16 @@
"sortByLastModified": "Ταξινόμηση κατά πρόσφατη τροποποίηση", "sortByLastModified": "Ταξινόμηση κατά πρόσφατη τροποποίηση",
"sortByName": "Ταξινόμηση κατά όνομα", "sortByName": "Ταξινόμηση κατά όνομα",
"sortBySize": "Ταξινόμηση κατά μέγεθος", "sortBySize": "Ταξινόμηση κατά μέγεθος",
"noPreview": "Η προεπισκόπηση δεν είναι διαθέσιμη για αυτό το αρχείο." "noPreview": "Η προεπισκόπηση δεν είναι διαθέσιμη για αυτό το αρχείο.",
"csvTooLarge": "CSV file is too large for preview (>5MB). Please download to view.",
"csvLoadFailed": "Failed to load CSV file.",
"showingRows": "Showing {count} row(s)",
"columnSeparator": "Column Separator",
"csvSeparators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
}
}, },
"help": { "help": {
"click": "επιλέξτε αρχείο ή φάκελο", "click": "επιλέξτε αρχείο ή φάκελο",
@@ -102,6 +115,7 @@
"username": "Όνομα χρήστη", "username": "Όνομα χρήστη",
"usernameTaken": "Το όνομα χρήστη χρησιμοποιείται ήδη", "usernameTaken": "Το όνομα χρήστη χρησιμοποιείται ήδη",
"wrongCredentials": "Λάθος όνομα ή/και κωδικός πρόσβασης", "wrongCredentials": "Λάθος όνομα ή/και κωδικός πρόσβασης",
"passwordTooShort": "Password must be at least {min} characters",
"logout_reasons": { "logout_reasons": {
"inactivity": "You have been logged out due to inactivity." "inactivity": "You have been logged out due to inactivity."
} }
@@ -166,6 +180,7 @@
"allowNew": "Δημιουργία νέων αρχείων και φακέλων", "allowNew": "Δημιουργία νέων αρχείων και φακέλων",
"allowPublish": "Δημοσίευση νέων αναρτήσεων και σελίδων", "allowPublish": "Δημοσίευση νέων αναρτήσεων και σελίδων",
"allowSignup": "Να επιτρέπεται η εγγραφή νέων χρηστών", "allowSignup": "Να επιτρέπεται η εγγραφή νέων χρηστών",
"hideLoginButton": "Hide the login button from public pages",
"avoidChanges": "(αφήστε το κενό για αποφυγή αλλαγών)", "avoidChanges": "(αφήστε το κενό για αποφυγή αλλαγών)",
"branding": "Εξατομίκευση", "branding": "Εξατομίκευση",
"brandingDirectoryPath": "Διαδρομή φακέλου εξατομίκευσης", "brandingDirectoryPath": "Διαδρομή φακέλου εξατομίκευσης",
@@ -217,6 +232,7 @@
"permissions": "Δικαιώματα", "permissions": "Δικαιώματα",
"permissionsHelp": "Μπορείτε να ορίσετε τον χρήστη ως διαχειριστή ή να επιλέξετε τα δικαιώματα μεμονωμένα. Αν επιλέξετε \"Διαχειριστής\", όλες οι υπόλοιπες επιλογές θα είναι αυτόματα επιλεγμένες. Η διαχείριση χρηστών παραμένει προνόμιο ενός χρήστη με τον ρόλο του διαχειριστή.\n", "permissionsHelp": "Μπορείτε να ορίσετε τον χρήστη ως διαχειριστή ή να επιλέξετε τα δικαιώματα μεμονωμένα. Αν επιλέξετε \"Διαχειριστής\", όλες οι υπόλοιπες επιλογές θα είναι αυτόματα επιλεγμένες. Η διαχείριση χρηστών παραμένει προνόμιο ενός χρήστη με τον ρόλο του διαχειριστή.\n",
"profileSettings": "Ρυθμίσεις προφίλ", "profileSettings": "Ρυθμίσεις προφίλ",
"redirectAfterCopyMove": "Redirect to destination after copy/move",
"ruleExample1": "αποκλείει την πρόσβαση σε οποιοδήποτε κρυφό αρχείο (όπως .git, .gitignore) σε κάθε φάκελο.\n", "ruleExample1": "αποκλείει την πρόσβαση σε οποιοδήποτε κρυφό αρχείο (όπως .git, .gitignore) σε κάθε φάκελο.\n",
"ruleExample2": "αποκλείει την πρόσβαση στο αρχείο με το όνομα Caddyfile στον ριζικό φάκελο της εμβέλειας του κανόνα.", "ruleExample2": "αποκλείει την πρόσβαση στο αρχείο με το όνομα Caddyfile στον ριζικό φάκελο της εμβέλειας του κανόνα.",
"rules": "Κανόνες", "rules": "Κανόνες",
@@ -243,7 +259,8 @@
"userManagement": "Διαχείριση χρηστών", "userManagement": "Διαχείριση χρηστών",
"userUpdated": "Ο χρήστης ενημερώθηκε!", "userUpdated": "Ο χρήστης ενημερώθηκε!",
"username": "Όνομα χρήστη", "username": "Όνομα χρήστη",
"users": "Χρήστες" "users": "Χρήστες",
"currentPassword": "Your Current Password"
}, },
"sidebar": { "sidebar": {
"help": "Βοήθεια", "help": "Βοήθεια",

View File

@@ -22,6 +22,9 @@
"new": "New", "new": "New",
"next": "Next", "next": "Next",
"ok": "OK", "ok": "OK",
"apply": "Apply",
"addField": "Add field",
"add": "Add",
"permalink": "Get Permanent Link", "permalink": "Get Permanent Link",
"previous": "Previous", "previous": "Previous",
"preview": "Preview", "preview": "Preview",
@@ -43,7 +46,11 @@
"upload": "Upload", "upload": "Upload",
"openFile": "Open file", "openFile": "Open file",
"discardChanges": "Discard", "discardChanges": "Discard",
"saveChanges": "Save changes" "stopSearch": "Stop searching",
"saveChanges": "Save changes",
"editAsText": "Edit as Text",
"increaseFontSize": "Increase font size",
"decreaseFontSize": "Decrease font size"
}, },
"download": { "download": {
"downloadFile": "Download File", "downloadFile": "Download File",
@@ -75,7 +82,16 @@
"sortByLastModified": "Sort by last modified", "sortByLastModified": "Sort by last modified",
"sortByName": "Sort by name", "sortByName": "Sort by name",
"sortBySize": "Sort by size", "sortBySize": "Sort by size",
"noPreview": "Preview is not available for this file." "noPreview": "Preview is not available for this file.",
"csvTooLarge": "CSV file is too large for preview (>5MB). Please download to view.",
"csvLoadFailed": "Failed to load CSV file.",
"showingRows": "Showing {count} row(s)",
"columnSeparator": "Column Separator",
"csvSeparators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
}
}, },
"help": { "help": {
"click": "select file or directory", "click": "select file or directory",
@@ -102,6 +118,7 @@
"username": "Username", "username": "Username",
"usernameTaken": "Username already taken", "usernameTaken": "Username already taken",
"wrongCredentials": "Wrong credentials", "wrongCredentials": "Wrong credentials",
"passwordTooShort": "Password must be at least {min} characters",
"logout_reasons": { "logout_reasons": {
"inactivity": "You have been logged out due to inactivity." "inactivity": "You have been logged out due to inactivity."
} }
@@ -145,7 +162,41 @@
"uploadMessage": "Select an option to upload.", "uploadMessage": "Select an option to upload.",
"optionalPassword": "Optional password", "optionalPassword": "Optional password",
"resolution": "Resolution", "resolution": "Resolution",
"discardEditorChanges": "Are you sure you wish to discard the changes you've made?" "discardEditorChanges": "Are you sure you wish to discard the changes you've made?",
"field": "Field",
"current": "Current",
"new": "New",
"multipleValuesCount": "Different on {count} files",
"noCover": "No cover",
"newFieldName": "Field name",
"newFieldValue": "Value",
"title": "Title",
"artist": "Artist",
"album": "Album",
"track": "Track",
"genre": "Genre",
"date": "Date",
"comment": "Comment",
"disc": "Disc",
"composer": "Composer",
"year": "Year",
"albumartist": "Album artist",
"picture": "Cover",
"cover": "Cover",
"musicbrainz_releasegroupid": "MusicBrainz Release Group ID",
"musicbrainz_trackid": "MusicBrainz Track ID",
"musicbrainz_albumid": "MusicBrainz Album ID",
"musicbrainz_artistid": "MusicBrainz Artist ID",
"tsrc": "ISRC",
"encoded_by": "Encoded by",
"encoder": "Encoder",
"copyright": "Copyright"
},
"metadata": {
"field": "Field",
"current": "Current",
"new": "New",
"multipleValuesCount": "Different on {count} files"
}, },
"search": { "search": {
"images": "Images", "images": "Images",
@@ -166,6 +217,7 @@
"allowNew": "Create new files and directories", "allowNew": "Create new files and directories",
"allowPublish": "Publish new posts and pages", "allowPublish": "Publish new posts and pages",
"allowSignup": "Allow users to signup", "allowSignup": "Allow users to signup",
"hideLoginButton": "Hide the login button from public pages",
"avoidChanges": "(leave blank to avoid changes)", "avoidChanges": "(leave blank to avoid changes)",
"branding": "Branding", "branding": "Branding",
"brandingDirectoryPath": "Branding directory path", "brandingDirectoryPath": "Branding directory path",
@@ -217,6 +269,7 @@
"permissions": "Permissions", "permissions": "Permissions",
"permissionsHelp": "You can set the user to be an administrator or choose the permissions individually. If you select \"Administrator\", all of the other options will be automatically checked. The management of users remains a privilege of an administrator.\n", "permissionsHelp": "You can set the user to be an administrator or choose the permissions individually. If you select \"Administrator\", all of the other options will be automatically checked. The management of users remains a privilege of an administrator.\n",
"profileSettings": "Profile Settings", "profileSettings": "Profile Settings",
"redirectAfterCopyMove": "Redirect to destination after copy/move",
"ruleExample1": "prevents the access to any dotfile (such as .git, .gitignore) in every folder.\n", "ruleExample1": "prevents the access to any dotfile (such as .git, .gitignore) in every folder.\n",
"ruleExample2": "blocks the access to the file named Caddyfile on the root of the scope.", "ruleExample2": "blocks the access to the file named Caddyfile on the root of the scope.",
"rules": "Rules", "rules": "Rules",
@@ -243,7 +296,8 @@
"userManagement": "User Management", "userManagement": "User Management",
"userUpdated": "User updated!", "userUpdated": "User updated!",
"username": "Username", "username": "Username",
"users": "Users" "users": "Users",
"currentPassword": "Your Current Password"
}, },
"sidebar": { "sidebar": {
"help": "Help", "help": "Help",

View File

@@ -43,7 +43,11 @@
"upload": "Subir", "upload": "Subir",
"openFile": "Abrir archivo", "openFile": "Abrir archivo",
"discardChanges": "Discard", "discardChanges": "Discard",
"saveChanges": "Guardar cambios" "stopSearch": "Stop searching",
"saveChanges": "Guardar cambios",
"editAsText": "Edit as Text",
"increaseFontSize": "Increase font size",
"decreaseFontSize": "Decrease font size"
}, },
"download": { "download": {
"downloadFile": "Descargar fichero", "downloadFile": "Descargar fichero",
@@ -75,7 +79,16 @@
"sortByLastModified": "Ordenar por última modificación", "sortByLastModified": "Ordenar por última modificación",
"sortByName": "Ordenar por nombre", "sortByName": "Ordenar por nombre",
"sortBySize": "Ordenar por tamaño", "sortBySize": "Ordenar por tamaño",
"noPreview": "La vista previa no está disponible para este archivo." "noPreview": "La vista previa no está disponible para este archivo.",
"csvTooLarge": "CSV file is too large for preview (>5MB). Please download to view.",
"csvLoadFailed": "Failed to load CSV file.",
"showingRows": "Showing {count} row(s)",
"columnSeparator": "Column Separator",
"csvSeparators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
}
}, },
"help": { "help": {
"click": "seleccionar archivo o carpeta", "click": "seleccionar archivo o carpeta",
@@ -102,6 +115,7 @@
"username": "Usuario", "username": "Usuario",
"usernameTaken": "Nombre usuario no disponible", "usernameTaken": "Nombre usuario no disponible",
"wrongCredentials": "Usuario y/o contraseña incorrectos", "wrongCredentials": "Usuario y/o contraseña incorrectos",
"passwordTooShort": "Password must be at least {min} characters",
"logout_reasons": { "logout_reasons": {
"inactivity": "You have been logged out due to inactivity." "inactivity": "You have been logged out due to inactivity."
} }
@@ -166,6 +180,7 @@
"allowNew": "Crear nuevos archivos y carpetas", "allowNew": "Crear nuevos archivos y carpetas",
"allowPublish": "Publicar nuevos posts y páginas", "allowPublish": "Publicar nuevos posts y páginas",
"allowSignup": "Permitir registro de usuarios", "allowSignup": "Permitir registro de usuarios",
"hideLoginButton": "Hide the login button from public pages",
"avoidChanges": "(dejar en blanco para evitar cambios)", "avoidChanges": "(dejar en blanco para evitar cambios)",
"branding": "Marca", "branding": "Marca",
"brandingDirectoryPath": "Ruta de la carpeta de personalizacion de marca", "brandingDirectoryPath": "Ruta de la carpeta de personalizacion de marca",
@@ -217,6 +232,7 @@
"permissions": "Permisos", "permissions": "Permisos",
"permissionsHelp": "Puedes nombrar al usuario como administrador o elegir los permisos individualmente. Si seleccionas \"Administrador\", todas las otras opciones serán activadas automáticamente. La administración de usuarios es un privilegio de administrador.\n", "permissionsHelp": "Puedes nombrar al usuario como administrador o elegir los permisos individualmente. Si seleccionas \"Administrador\", todas las otras opciones serán activadas automáticamente. La administración de usuarios es un privilegio de administrador.\n",
"profileSettings": "Ajustes del perfil", "profileSettings": "Ajustes del perfil",
"redirectAfterCopyMove": "Redirect to destination after copy/move",
"ruleExample1": "previene el acceso a una extensión de archivo (Como .git) en cada carpeta.\n", "ruleExample1": "previene el acceso a una extensión de archivo (Como .git) en cada carpeta.\n",
"ruleExample2": "bloquea el acceso al archivo llamado Caddyfile en la carpeta raíz.", "ruleExample2": "bloquea el acceso al archivo llamado Caddyfile en la carpeta raíz.",
"rules": "Reglas", "rules": "Reglas",
@@ -243,7 +259,8 @@
"userManagement": "Administración de usuarios", "userManagement": "Administración de usuarios",
"userUpdated": "¡Usuario actualizado!", "userUpdated": "¡Usuario actualizado!",
"username": "Usuario", "username": "Usuario",
"users": "Usuarios" "users": "Usuarios",
"currentPassword": "Your Current Password"
}, },
"sidebar": { "sidebar": {
"help": "Ayuda", "help": "Ayuda",

Some files were not shown because too many files have changed in this diff Show More