Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba1de0599e | ||
|
|
fe7efb2e6a | ||
|
|
2651260a1c | ||
|
|
4470288ba1 | ||
|
|
58d22578e8 | ||
|
|
dfc2e887e1 | ||
|
|
aac2516637 | ||
|
|
c05ead7e8e | ||
|
|
d76b7d1610 | ||
|
|
f30fca636c | ||
|
|
ec13054671 | ||
|
|
883a36f02f | ||
|
|
8503ba61ff | ||
|
|
1fb05d65de | ||
|
|
2472fbcd30 | ||
|
|
43a404ca69 | ||
|
|
d9cf2f0100 | ||
|
|
6209f8fddd |
@@ -2,6 +2,28 @@
|
||||
|
||||
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.63.18](https://github.com/filebrowser/filebrowser/compare/v2.63.17...v2.63.18) (2026-07-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* avoid recursive conflict checks for copy and move ([#6009](https://github.com/filebrowser/filebrowser/issues/6009)) ([dfc2e88](https://github.com/filebrowser/filebrowser/commit/dfc2e887e1a19d54984a0d7e39a2a63caf73ef19))
|
||||
* deduplicate PT language ([4470288](https://github.com/filebrowser/filebrowser/commit/4470288ba1828a14453a99348fd22c62aa0b9460))
|
||||
* **preview:** keep the EPUB table-of-contents button clear of the header ([#6010](https://github.com/filebrowser/filebrowser/issues/6010)) ([aac2516](https://github.com/filebrowser/filebrowser/commit/aac25166378422135e624e305c410c54a39374fb))
|
||||
|
||||
## [2.63.17](https://github.com/filebrowser/filebrowser/compare/v2.63.16...v2.63.17) (2026-06-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** reject signup when normalized home dir collides (GHSA-7rc3-g7h6-22m7) ([883a36f](https://github.com/filebrowser/filebrowser/commit/883a36f02fcb69566a8628cb47f18fdc73348387))
|
||||
* match admin share paths by owner scope ([#5992](https://github.com/filebrowser/filebrowser/issues/5992)) ([43a404c](https://github.com/filebrowser/filebrowser/commit/43a404ca69bf25553bfbbb2b446f0f53077c6302))
|
||||
* normalize recursive listing paths to forward slashes ([#6003](https://github.com/filebrowser/filebrowser/issues/6003)) ([2472fbc](https://github.com/filebrowser/filebrowser/commit/2472fbcd30502606feb11fbc8b8dc4f3803e6641))
|
||||
* preserve SRT subtitle line breaks ([#6002](https://github.com/filebrowser/filebrowser/issues/6002)) ([d9cf2f0](https://github.com/filebrowser/filebrowser/commit/d9cf2f0100d2c4892cad8e339eacca96df1aa5b6))
|
||||
* **raw:** neutralize backslashes in archive entry names (GHSA-83xp-526h-j3ww) ([8503ba6](https://github.com/filebrowser/filebrowser/commit/8503ba61ff51d48a7313896483d130eb6a5abfe0))
|
||||
* **share:** delete exact directory share on trailing-slash delete (GHSA-pp88-jhwj-5qh5) ([f30fca6](https://github.com/filebrowser/filebrowser/commit/f30fca636c1af9ef401e9a82ff60391cb3db97e1))
|
||||
* **share:** stop exposing password hash and bypass token in share API (GHSA-833g-cqhp-h72j) ([ec13054](https://github.com/filebrowser/filebrowser/commit/ec130546713c44cd24556907552ac554c7f809c9))
|
||||
|
||||
## [2.63.16](https://github.com/filebrowser/filebrowser/compare/v2.63.15...v2.63.16) (2026-06-23)
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
## Multistage build: First stage fetches dependencies
|
||||
FROM alpine:3.23 AS fetcher
|
||||
FROM alpine:3.24 AS fetcher
|
||||
|
||||
# install and copy ca-certificates, mailcap, and tini-static; download JSON.sh
|
||||
RUN apk update && \
|
||||
@@ -7,7 +7,7 @@ RUN apk update && \
|
||||
wget -O /JSON.sh https://raw.githubusercontent.com/dominictarr/JSON.sh/0d5e5c77365f63809bf6e77ef44a1f34b0e05840/JSON.sh
|
||||
|
||||
## Second stage: Use lightweight BusyBox image for final runtime environment
|
||||
FROM busybox:1.37.0-musl
|
||||
FROM busybox:1.38.0-musl
|
||||
|
||||
# Define non-root user UID and GID
|
||||
ENV UID=1000
|
||||
|
||||
@@ -22,6 +22,7 @@ func (m *mockUserStore) Get(_ string, _ bool, id interface{}) (*users.User, erro
|
||||
return nil, fberrors.ErrNotExist
|
||||
}
|
||||
|
||||
func (m *mockUserStore) GetByScope(_ string) (*users.User, error) { return nil, fberrors.ErrNotExist }
|
||||
func (m *mockUserStore) Gets(_ string, _ bool) ([]*users.User, error) { return nil, nil }
|
||||
func (m *mockUserStore) Update(_ *users.User, _ ...string) error { return nil }
|
||||
func (m *mockUserStore) Save(user *users.User) error {
|
||||
|
||||
+13
@@ -13,6 +13,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -381,6 +382,18 @@ func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, e
|
||||
log.Println("WARNING: you fully understand and trust the contents of every user scope.")
|
||||
}
|
||||
|
||||
if set, err := st.Settings.Get(); err == nil && set.Signup {
|
||||
scope := strings.TrimSpace(set.Defaults.Scope)
|
||||
scopeIsRoot := scope == "" || scope == "." || scope == "/"
|
||||
|
||||
if !set.CreateUserDir && scopeIsRoot {
|
||||
log.Println("WARNING: Signup is enabled without createUserDir and the default scope is")
|
||||
log.Println("WARNING: the server root, so every self-registered user can read, modify and")
|
||||
log.Println("WARNING: delete all files File Browser serves, including other users' files.")
|
||||
log.Println("WARNING: Enable createUserDir, or set a default scope other than the root.")
|
||||
}
|
||||
}
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -74,5 +74,5 @@
|
||||
"vitest": "^4.1.0",
|
||||
"vue-tsc": "^3.1.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
|
||||
"packageManager": "pnpm@10.34.5+sha512.a4ee05f2f73658255bd6a89859c065a45c28a57daefae2c893a168ee2b73168c37b91e83e57ea67654ad03f03031746430e8bce38e362e042605fb8abc80192e"
|
||||
}
|
||||
|
||||
Generated
+1419
-1498
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@
|
||||
class="action"
|
||||
:aria-label="$t('buttons.copyDownloadLinkToClipboard')"
|
||||
:title="$t('buttons.copyDownloadLinkToClipboard')"
|
||||
:disabled="!!link.password_hash"
|
||||
:disabled="!!link.hasPassword"
|
||||
@click="copyToClipboard(buildDownloadLink(link))"
|
||||
>
|
||||
<i class="material-icons">content_paste_go</i>
|
||||
|
||||
@@ -71,7 +71,9 @@ const event = {
|
||||
describe("copy and move conflict prompts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkConflict).mockResolvedValue(conflict);
|
||||
vi.mocked(checkConflict).mockResolvedValue(
|
||||
conflict as ConflictingResource[]
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for copy conflict detection before calling the copy API", async () => {
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
|
||||
.epub-reader .tocButton {
|
||||
color: var(--text);
|
||||
/*
|
||||
* vue-reader positions the TOC toggle at top: 10px, which lands under the
|
||||
* fixed 4em header bar (see .header) — so clicks hit the header's Close
|
||||
* button instead of opening the table of contents. Push it just below the
|
||||
* header, tracking the header's height so it stays clear if that changes.
|
||||
*/
|
||||
top: calc(4em + 10px);
|
||||
}
|
||||
|
||||
.epub-reader .tocButton.tocButtonExpanded {
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
"rename": "이름 바꾸기",
|
||||
"replace": "대체",
|
||||
"reportIssue": "문제 보고",
|
||||
"resumeTransfer": "Resume previous transfer",
|
||||
"resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
|
||||
"resumeTransfer": "이전 전송을 재개",
|
||||
"resumeTransferTooltip": "서버에 있는 파일 중 전송이 중단되었을 가능성이 있는 작은 파일을 제외하고 충돌하는 모든 파일을 건너뜁니다.",
|
||||
"save": "저장",
|
||||
"schedule": "일정",
|
||||
"search": "검색",
|
||||
@@ -283,7 +283,7 @@
|
||||
"currentPassword": "현재 비밀번호"
|
||||
},
|
||||
"sidebar": {
|
||||
"diskUsed": "{used} of {total} used",
|
||||
"diskUsed": "{total} 중 {used} 사용",
|
||||
"help": "도움말",
|
||||
"hugoNew": "Hugo New",
|
||||
"login": "로그인",
|
||||
|
||||
@@ -238,7 +238,7 @@
|
||||
"newPasswordConfirm": "Confirmar a sua palavra-passe",
|
||||
"newUser": "Novo Utilizador",
|
||||
"password": "Palavra-passe",
|
||||
"passwordUpdated": "Palavras-passe não coincidem!",
|
||||
"passwordUpdated": "Palavra-passe atualizada!",
|
||||
"path": "Caminho",
|
||||
"perm": {
|
||||
"create": "Criar ficheiros e pastas",
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
{
|
||||
"buttons": {
|
||||
"cancel": "Cancelar",
|
||||
"clear": "Limpar",
|
||||
"close": "Fechar",
|
||||
"continue": "Continuar",
|
||||
"copy": "Copiar",
|
||||
"copyFile": "Copiar ficheiro",
|
||||
"copyToClipboard": "Copiar",
|
||||
"copyDownloadLinkToClipboard": "Copy download link to clipboard",
|
||||
"create": "Criar",
|
||||
"delete": "Eliminar",
|
||||
"download": "Descarregar",
|
||||
"file": "File",
|
||||
"folder": "Folder",
|
||||
"fullScreen": "Toggle full screen",
|
||||
"hideDotfiles": "Hide dotfiles",
|
||||
"info": "Info",
|
||||
"more": "Mais",
|
||||
"move": "Mover",
|
||||
"moveFile": "Mover ficheiro",
|
||||
"new": "Novo",
|
||||
"next": "Próximo",
|
||||
"ok": "OK",
|
||||
"permalink": "Obter link permanente",
|
||||
"previous": "Anterior",
|
||||
"preview": "Preview",
|
||||
"publish": "Publicar",
|
||||
"rename": "Alterar nome",
|
||||
"replace": "Substituir",
|
||||
"reportIssue": "Reportar erro",
|
||||
"resumeTransfer": "Resume previous transfer",
|
||||
"resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
|
||||
"save": "Guardar",
|
||||
"schedule": "Agendar",
|
||||
"search": "Pesquisar",
|
||||
"select": "Selecionar",
|
||||
"selectMultiple": "Selecionar vários",
|
||||
"share": "Partilhar",
|
||||
"shell": "Alternar shell",
|
||||
"submit": "Submit",
|
||||
"switchView": "Alterar vista",
|
||||
"toggleSidebar": "Alternar barra lateral",
|
||||
"update": "Atualizar",
|
||||
"upload": "Enviar",
|
||||
"openFile": "Open file",
|
||||
"openDirect": "View raw",
|
||||
"discardChanges": "Discard",
|
||||
"stopSearch": "Stop searching",
|
||||
"saveChanges": "Save changes",
|
||||
"editAsText": "Edit as Text",
|
||||
"increaseFontSize": "Increase font size",
|
||||
"decreaseFontSize": "Decrease font size",
|
||||
"overrideAll": "Replace all files in destination folder",
|
||||
"skipAll": "Skip all conflicting files",
|
||||
"renameAll": "Rename all files (create a copy)",
|
||||
"singleDecision": "Decide for each conflicting file"
|
||||
},
|
||||
"download": {
|
||||
"downloadFile": "Descarregar ficheiro",
|
||||
"downloadFolder": "Descarregar pasta",
|
||||
"downloadSelected": "Download Selected"
|
||||
},
|
||||
"upload": {
|
||||
"abortUpload": "Are you sure you wish to abort?"
|
||||
},
|
||||
"errors": {
|
||||
"forbidden": "Não tem permissões para aceder a isto",
|
||||
"internal": "Algo correu bastante mal.",
|
||||
"notFound": "Esta localização não é alcançável.",
|
||||
"connection": "The server can't be reached."
|
||||
},
|
||||
"files": {
|
||||
"body": "Corpo",
|
||||
"closePreview": "Fechar pré-visualização",
|
||||
"files": "Ficheiros",
|
||||
"folders": "Pastas",
|
||||
"home": "Início",
|
||||
"lastModified": "Última alteração",
|
||||
"loading": "A carregar...",
|
||||
"lonely": "Sinto-me sozinho...",
|
||||
"metadata": "Metadados",
|
||||
"multipleSelectionEnabled": "Seleção múltipla ativada",
|
||||
"name": "Nome",
|
||||
"size": "Tamanho",
|
||||
"sortByLastModified": "Ordenar pela última alteração",
|
||||
"sortByName": "Ordenar pelo nome",
|
||||
"sortBySize": "Ordenar pelo tamanho",
|
||||
"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 (;)"
|
||||
},
|
||||
"fileEncoding": "File Encoding"
|
||||
},
|
||||
"help": {
|
||||
"click": "selecionar pasta ou ficheiro",
|
||||
"ctrl": {
|
||||
"click": "selecionar várias pastas e ficheiros",
|
||||
"f": "pesquisar",
|
||||
"s": "guardar um ficheiro ou descarrega a pasta em que está a navegar"
|
||||
},
|
||||
"del": "eliminar os ficheiros selecionados",
|
||||
"doubleClick": "abrir pasta ou ficheiro",
|
||||
"esc": "limpar seleção e/ou fechar menu",
|
||||
"f1": "esta informação",
|
||||
"f2": "alterar nome do ficheiro",
|
||||
"help": "Ajuda"
|
||||
},
|
||||
"login": {
|
||||
"createAnAccount": "Criar uma conta",
|
||||
"loginInstead": "Já tenho uma conta",
|
||||
"password": "Palavra-passe",
|
||||
"passwordConfirm": "Confirmação da palavra-passe",
|
||||
"passwordsDontMatch": "As palavras-passe não coincidem",
|
||||
"signup": "Registar",
|
||||
"submit": "Entrar na conta",
|
||||
"username": "Nome de utilizador",
|
||||
"usernameTaken": "O nome de utilizador já está registado",
|
||||
"wrongCredentials": "Dados errados",
|
||||
"passwordTooShort": "Password must be at least {min} characters",
|
||||
"logout_reasons": {
|
||||
"inactivity": "You have been logged out due to inactivity."
|
||||
}
|
||||
},
|
||||
"permanent": "Permanente",
|
||||
"prompts": {
|
||||
"copy": "Copiar",
|
||||
"copyMessage": "Escolha um lugar para onde copiar os ficheiros:",
|
||||
"currentlyNavigating": "A navegar em:",
|
||||
"deleteMessageMultiple": "Quer eliminar {count} ficheiro(s)?",
|
||||
"deleteMessageSingle": "Quer eliminar esta pasta/ficheiro?",
|
||||
"deleteMessageShare": "Are you sure you wish to delete this share({path})?",
|
||||
"deleteUser": "Are you sure you want to delete this user?",
|
||||
"deleteTitle": "Eliminar ficheiros",
|
||||
"displayName": "Nome:",
|
||||
"download": "Descarregar ficheiros",
|
||||
"downloadMessage": "Escolha o formato do ficheiro que quer descarregar.",
|
||||
"error": "Algo correu mal",
|
||||
"fileInfo": "Informação do ficheiro",
|
||||
"filesSelected": "{count} ficheiros selecionados.",
|
||||
"lastModified": "Última alteração",
|
||||
"move": "Mover",
|
||||
"moveMessage": "Escolha uma nova casa para os seus ficheiros/pastas:",
|
||||
"newArchetype": "Criar um novo post baseado num \"archetype\". O seu ficheiro será criado na pasta \"content\".",
|
||||
"newDir": "Nova pasta",
|
||||
"newDirMessage": "Escreva o nome da nova pasta.",
|
||||
"newFile": "Novo ficheiro",
|
||||
"newFileMessage": "Escreva o nome do novo ficheiro.",
|
||||
"numberDirs": "Número de pastas",
|
||||
"numberFiles": "Número de ficheiros",
|
||||
"rename": "Alterar nome",
|
||||
"renameMessage": "Insira um novo nome para",
|
||||
"replace": "Substituir",
|
||||
"replaceMessage": "Já existe um ficheiro com nome igual a um dos que está a tentar enviar. Quer substituí-lo?\n",
|
||||
"schedule": "Agendar",
|
||||
"scheduleMessage": "Escolha uma data para publicar este post.",
|
||||
"show": "Mostrar",
|
||||
"size": "Tamanho",
|
||||
"upload": "Upload",
|
||||
"uploadFiles": "Uploading {files} files...",
|
||||
"uploadMessage": "Select an option to upload.",
|
||||
"optionalPassword": "Optional password",
|
||||
"resolution": "Resolution",
|
||||
"discardEditorChanges": "Are you sure you wish to discard the changes you've made?",
|
||||
"replaceOrSkip": "Replace or skip files",
|
||||
"resolveConflict": "Which files do you want to keep?",
|
||||
"singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.",
|
||||
"fastConflictResolve": "The destination folder there are {count} files with same name.",
|
||||
"uploadingFiles": "Uploading files",
|
||||
"filesInOrigin": "Files in origin",
|
||||
"filesInDest": "Files in destination",
|
||||
"override": "Overwrite",
|
||||
"skip": "Skip",
|
||||
"forbiddenError": "Forbidden Error",
|
||||
"currentPassword": "Your password",
|
||||
"currentPasswordMessage": "Enter your password to validate this action."
|
||||
},
|
||||
"search": {
|
||||
"images": "Imagens",
|
||||
"music": "Música",
|
||||
"pdf": "PDF",
|
||||
"pressToSearch": "Tecla Enter para pesquisar...",
|
||||
"search": "Pesquisar...",
|
||||
"typeToSearch": "Escrever para pesquisar...",
|
||||
"types": "Tipos",
|
||||
"video": "Vídeos"
|
||||
},
|
||||
"settings": {
|
||||
"aceEditorTheme": "Ace editor theme",
|
||||
"admin": "Admin",
|
||||
"administrator": "Administrador",
|
||||
"allowCommands": "Executar comandos",
|
||||
"allowEdit": "Editar, renomear e eliminar ficheiros ou pastas",
|
||||
"allowNew": "Criar novos ficheiros e pastas",
|
||||
"allowPublish": "Publicar novas páginas e conteúdos",
|
||||
"allowSignup": "Permitir que os utilizadores criem contas",
|
||||
"hideLoginButton": "Hide the login button from public pages",
|
||||
"avoidChanges": "(deixe em branco para manter)",
|
||||
"branding": "Marca",
|
||||
"brandingDirectoryPath": "Caminho da pasta de marca",
|
||||
"brandingHelp": "Pode personalizar a aparência do seu Navegador de Ficheiros, alterar o nome, substituindo o logótipo, adicionando estilos personalizados e mesmo desativando links externos para o GitHub.\nPara mais informações sobre marca personalizada, por favor veja {0}.",
|
||||
"changePassword": "Alterar palavra-passe",
|
||||
"commandRunner": "Execução de comandos",
|
||||
"commandRunnerHelp": "Aqui pode definir comandos que são executados nos eventos nomeados. Tem de escrever um por linha. As variáveis de ambiente {0} e {1} estarão disponíveis, sendo {0} relativo a {1}. Para mais informações sobre esta funcionalidade e as variáveis de ambiente, veja {2}.",
|
||||
"commandsUpdated": "Comandos atualizados!",
|
||||
"createUserDir": "Criar automaticamente a pasta de início ao adicionar um novo utilizador",
|
||||
"minimumPasswordLength": "Minimum password length",
|
||||
"tusUploads": "Chunked Uploads",
|
||||
"tusUploadsHelp": "File Browser supports chunked file uploads, allowing for the creation of efficient, reliable, resumable and chunked file uploads even on unreliable networks.",
|
||||
"tusUploadsChunkSize": "Indicates to maximum size of a request (direct uploads will be used for smaller uploads). You may input a plain integer denoting byte size input or a string like 10MB, 1GB etc.",
|
||||
"tusUploadsRetryCount": "Number of retries to perform if a chunk fails to upload.",
|
||||
"userHomeBasePath": "Base path for user home directories",
|
||||
"userScopeGenerationPlaceholder": "The scope will be auto generated",
|
||||
"createUserHomeDirectory": "Create user home directory",
|
||||
"customStylesheet": "Folha de estilos personalizada",
|
||||
"defaultUserDescription": "Estas são as configurações padrão para novos utilizadores.",
|
||||
"disableExternalLinks": "Desativar links externos (exceto documentação)",
|
||||
"disableUsedDiskPercentage": "Disable used disk percentage graph",
|
||||
"documentation": "documentação",
|
||||
"examples": "Exemplos",
|
||||
"executeOnShell": "Executar na shell",
|
||||
"executeOnShellDescription": "Por padrão, o Navegador de Ficheiros executa os comandos chamando os seus binários diretamente. Se em vez disso, quiser executá-los numa shell (como Bash ou PowerShell), pode definir isso aqui com os argumentos e bandeiras necessários. Se definido, o comando que executa será anexado como um argumento. Isto aplica-se tanto a comandos do utilizador como a hooks de eventos.",
|
||||
"globalRules": "Isto é um conjunto global de regras de permissão e negação. Elas aplicam-se a todos os utilizadores. Pode especificar regras específicas para cada configuração do utilizador para sobreporem-se a estas.",
|
||||
"globalSettings": "Configurações globais",
|
||||
"hideDotfiles": "Hide dotfiles",
|
||||
"insertPath": "Inserir o caminho",
|
||||
"insertRegex": "Inserir expressão regular",
|
||||
"instanceName": "Nome da instância",
|
||||
"language": "Linguagem",
|
||||
"lockPassword": "Não permitir que o utilizador altere a palavra-passe",
|
||||
"newPassword": "Nova palavra-passe",
|
||||
"newPasswordConfirm": "Confirme a nova palavra-passe",
|
||||
"newUser": "Novo utilizador",
|
||||
"password": "Palavra-passe",
|
||||
"passwordUpdated": "Palavra-passe atualizada!",
|
||||
"path": "Path",
|
||||
"perm": {
|
||||
"create": "Criar ficheiros e pastas",
|
||||
"delete": "Eliminar ficheiros e pastas",
|
||||
"download": "Descarregar",
|
||||
"execute": "Executar comandos",
|
||||
"modify": "Editar ficheiros",
|
||||
"rename": "Alterar o nome ou mover ficheiros e pastas",
|
||||
"share": "Share files (require download permission)"
|
||||
},
|
||||
"permissions": "Permissões",
|
||||
"permissionsHelp": "Pode definir o utilizador como administrador ou escolher as permissões manualmente. Se selecionar a opção \"Administrador\", todas as outras opções serão automaticamente selecionadas. A gestão dos utilizadores é um privilégio restringido aos administradores.\n",
|
||||
"profileSettings": "Configurações do utilizador",
|
||||
"redirectAfterCopyMove": "Redirect to destination after copy/move",
|
||||
"ruleExample1": "previne o acesso a qualquer \"dotfile\" (como .git, .gitignore) em qualquer pasta\n",
|
||||
"ruleExample2": "bloqueia o acesso ao ficheiro chamado Caddyfile na raiz.",
|
||||
"rules": "Regras",
|
||||
"rulesHelp": "Aqui pode definir um conjunto de regras para permitir ou bloquear o acesso do utilizador a determinados ficheiros ou pastas. Os ficheiros bloqueados não irão aparecer durante a navegação. Suportamos expressões regulares e os caminhos dos ficheiros devem ser relativos à base do utilizador.\n",
|
||||
"scope": "Base",
|
||||
"setDateFormat": "Set exact date format",
|
||||
"settingsUpdated": "Configurações atualizadas!",
|
||||
"shareDuration": "Share Duration",
|
||||
"shareManagement": "Share Management",
|
||||
"shareDeleted": "Share deleted!",
|
||||
"singleClick": "Use single clicks to open files and directories",
|
||||
"themes": {
|
||||
"default": "System default",
|
||||
"dark": "Dark",
|
||||
"light": "Light",
|
||||
"title": "Theme"
|
||||
},
|
||||
"user": "Utilizador",
|
||||
"userCommands": "Comandos",
|
||||
"userCommandsHelp": "Uma lista, separada com espaços, de comandos disponíveis para este utilizados. Exemplo:",
|
||||
"userCreated": "Utilizador criado!",
|
||||
"userDefaults": "Configurações padrão do utilizador",
|
||||
"userDeleted": "Utilizador eliminado!",
|
||||
"userManagement": "Gestão de utilizadores",
|
||||
"userUpdated": "Utilizador atualizado!",
|
||||
"username": "Nome de utilizador",
|
||||
"users": "Utilizadores",
|
||||
"currentPassword": "Your Current Password"
|
||||
},
|
||||
"sidebar": {
|
||||
"diskUsed": "{used} of {total} used",
|
||||
"help": "Ajuda",
|
||||
"hugoNew": "Hugo New",
|
||||
"login": "Entrar",
|
||||
"logout": "Sair",
|
||||
"myFiles": "Meus ficheiros",
|
||||
"newFile": "Novo ficheiro",
|
||||
"newFolder": "Nova pasta",
|
||||
"preview": "Pré-visualizar",
|
||||
"settings": "Configurações",
|
||||
"signup": "Registar",
|
||||
"siteSettings": "Configurações do site"
|
||||
},
|
||||
"success": {
|
||||
"linkCopied": "Link copiado!"
|
||||
},
|
||||
"time": {
|
||||
"days": "Dias",
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos",
|
||||
"seconds": "Segundos",
|
||||
"unit": "Unidades de tempo"
|
||||
}
|
||||
}
|
||||
@@ -28,104 +28,104 @@
|
||||
"publish": "发布",
|
||||
"rename": "重命名",
|
||||
"replace": "替换",
|
||||
"reportIssue": "报告问题",
|
||||
"resumeTransfer": "Resume previous transfer",
|
||||
"resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
|
||||
"reportIssue": "反馈问题",
|
||||
"resumeTransfer": "恢复之前的传输任务",
|
||||
"resumeTransferTooltip": "跳过所有同名冲突文件;服务器端文件更小则判定为传输中断,保留该文件并继续传输。",
|
||||
"save": "保存",
|
||||
"schedule": "计划",
|
||||
"search": "搜索",
|
||||
"select": "选择",
|
||||
"selectMultiple": "选择多个",
|
||||
"selectMultiple": "多选",
|
||||
"share": "分享",
|
||||
"shell": "激活 Shell",
|
||||
"shell": "切换终端",
|
||||
"submit": "提交",
|
||||
"switchView": "切换显示方式",
|
||||
"switchView": "切换视图",
|
||||
"toggleSidebar": "切换侧边栏",
|
||||
"update": "更新",
|
||||
"upload": "上传",
|
||||
"openFile": "打开文件",
|
||||
"openDirect": "View raw",
|
||||
"discardChanges": "放弃更改",
|
||||
"openDirect": "查看原始内容",
|
||||
"discardChanges": "放弃",
|
||||
"stopSearch": "停止搜索",
|
||||
"saveChanges": "保存更改",
|
||||
"editAsText": "以文本形式编辑",
|
||||
"increaseFontSize": "增大字体大小",
|
||||
"decreaseFontSize": "减小字体大小",
|
||||
"overrideAll": "Replace all files in destination folder",
|
||||
"skipAll": "Skip all conflicting files",
|
||||
"renameAll": "Rename all files (create a copy)",
|
||||
"singleDecision": "Decide for each conflicting file"
|
||||
"editAsText": "以文本模式编辑",
|
||||
"increaseFontSize": "放大字体",
|
||||
"decreaseFontSize": "缩小字体",
|
||||
"overrideAll": "替换目标文件夹中的所有文件",
|
||||
"skipAll": "跳过所有冲突文件",
|
||||
"renameAll": "全部重命名(创建副本)",
|
||||
"singleDecision": "逐个处理冲突文件"
|
||||
},
|
||||
"download": {
|
||||
"downloadFile": "下载文件",
|
||||
"downloadFolder": "下载文件夹",
|
||||
"downloadSelected": "下载已选"
|
||||
"downloadSelected": "下载选中项"
|
||||
},
|
||||
"upload": {
|
||||
"abortUpload": "你确定要中止吗?"
|
||||
},
|
||||
"errors": {
|
||||
"forbidden": "你无权限访问",
|
||||
"internal": "服务器出了点问题。",
|
||||
"notFound": "找不到文件。",
|
||||
"connection": "无法连接到服务器。"
|
||||
"forbidden": "权限不足,拒绝访问",
|
||||
"internal": "服务器内部错误",
|
||||
"notFound": "该位置无法访问。",
|
||||
"connection": "无法连接服务器"
|
||||
},
|
||||
"files": {
|
||||
"body": "内容",
|
||||
"closePreview": "关闭预览",
|
||||
"files": "文件",
|
||||
"folders": "文件夹",
|
||||
"home": "主页",
|
||||
"lastModified": "最后修改",
|
||||
"home": "首页",
|
||||
"lastModified": "修改时间",
|
||||
"loading": "加载中...",
|
||||
"lonely": "这里没有任何文件...",
|
||||
"lonely": "暂无任何文件...",
|
||||
"metadata": "元数据",
|
||||
"multipleSelectionEnabled": "多选模式已开启",
|
||||
"multipleSelectionEnabled": "已开启多选模式",
|
||||
"name": "名称",
|
||||
"size": "大小",
|
||||
"sortByLastModified": "按最后修改时间排序",
|
||||
"sortByLastModified": "按修改时间排序",
|
||||
"sortByName": "按名称排序",
|
||||
"sortBySize": "按大小排序",
|
||||
"noPreview": "此文件无法预览。",
|
||||
"csvTooLarge": "CSV文件大到无法预览(>5MB)。请下载查看。",
|
||||
"csvLoadFailed": "加载 CSV 文件失败。",
|
||||
"showingRows": "正在显示 {count} 行",
|
||||
"noPreview": "该文件暂不支持预览",
|
||||
"csvTooLarge": "CSV 文件过大(>5MB),无法预览,请下载后查看",
|
||||
"csvLoadFailed": "CSV 文件加载失败",
|
||||
"showingRows": "当前显示 {count} 行数据",
|
||||
"columnSeparator": "列分隔符",
|
||||
"csvSeparators": {
|
||||
"comma": "逗号 (,)",
|
||||
"semicolon": "分号 (;)",
|
||||
"both": "逗号 (,) 和分号 (;)"
|
||||
},
|
||||
"fileEncoding": "File Encoding"
|
||||
"fileEncoding": "文件编码"
|
||||
},
|
||||
"help": {
|
||||
"click": "选择文件或文件夹",
|
||||
"click": "选中文件或文件夹",
|
||||
"ctrl": {
|
||||
"click": "选择多个文件或文件夹",
|
||||
"click": "多选文件或文件夹",
|
||||
"f": "打开搜索框",
|
||||
"s": "保存文件或下载当前文件夹"
|
||||
},
|
||||
"del": "删除所选的文件/文件夹",
|
||||
"doubleClick": "打开文件/文件夹",
|
||||
"esc": "清除已选项或关闭提示信息",
|
||||
"f1": "显示该帮助信息",
|
||||
"f2": "重命名文件/文件夹",
|
||||
"esc": "取消选中或关闭提示框",
|
||||
"f1": "打开帮助",
|
||||
"f2": "重命名文件",
|
||||
"help": "帮助"
|
||||
},
|
||||
"login": {
|
||||
"createAnAccount": "创建用户",
|
||||
"loginInstead": "已有用户登录",
|
||||
"createAnAccount": "注册账号",
|
||||
"loginInstead": "已有账号",
|
||||
"password": "密码",
|
||||
"passwordConfirm": "确认密码",
|
||||
"passwordsDontMatch": "密码不一致",
|
||||
"passwordsDontMatch": "两次输入的密码不一致",
|
||||
"signup": "注册",
|
||||
"submit": "登录",
|
||||
"username": "用户名",
|
||||
"usernameTaken": "用户名已经被使用",
|
||||
"wrongCredentials": "用户名或密码错误",
|
||||
"passwordTooShort": "密码必须至少包含 {min} 个字符",
|
||||
"passwordTooShort": "密码长度至少为 {min} 位字符",
|
||||
"logout_reasons": {
|
||||
"inactivity": "由于未活动,您已登出。"
|
||||
"inactivity": "因长时间未操作,系统已自动登出."
|
||||
}
|
||||
},
|
||||
"permanent": "永久",
|
||||
@@ -136,58 +136,58 @@
|
||||
"deleteMessageMultiple": "你确定要删除这 {count} 个文件吗?",
|
||||
"deleteMessageSingle": "你确定要删除这个文件/文件夹吗?",
|
||||
"deleteMessageShare": "你确定要删除这个分享({path})吗?",
|
||||
"deleteUser": "你确定要删除这个用户吗?",
|
||||
"deleteUser": "确定要删除该用户吗?",
|
||||
"deleteTitle": "删除文件",
|
||||
"displayName": "名称:",
|
||||
"download": "下载文件",
|
||||
"downloadMessage": "请选择要下载的压缩格式。",
|
||||
"error": "出了一点问题...",
|
||||
"downloadMessage": "请选择要下载的压缩包格式。",
|
||||
"error": "出现未知错误...",
|
||||
"fileInfo": "文件信息",
|
||||
"filesSelected": "已选择 {count} 个文件。",
|
||||
"lastModified": "最后修改",
|
||||
"filesSelected": "已选中 {count} 个文件",
|
||||
"lastModified": "修改时间",
|
||||
"move": "移动",
|
||||
"moveMessage": "请选择目标目录:",
|
||||
"newArchetype": "创建一个基于原型的新帖子。你的文件将会创建在内容文件夹中。",
|
||||
"newArchetype": "基于模板新建文档,文件将创建在内容目录中。",
|
||||
"newDir": "新建文件夹",
|
||||
"newDirMessage": "请输入新文件夹的名称。",
|
||||
"newFile": "新建文件",
|
||||
"newFileMessage": "请输入新文件的名称。",
|
||||
"numberDirs": "文件夹数",
|
||||
"numberFiles": "文件数",
|
||||
"numberDirs": "文件夹数量",
|
||||
"numberFiles": "文件数量",
|
||||
"rename": "重命名",
|
||||
"renameMessage": "请输入新名称,旧名称为:",
|
||||
"renameMessage": "请输入新名称",
|
||||
"replace": "替换",
|
||||
"replaceMessage": "你尝试上传的文件中有一个与现有文件的名称存在冲突。是否替换现有的同名文件?\n",
|
||||
"replaceMessage": "你上传的文件与已有文件重名,是否跳过该文件继续上传,或是替换原有文件?\n",
|
||||
"schedule": "计划",
|
||||
"scheduleMessage": "请选择发布这篇帖子的日期与时间。",
|
||||
"show": "点击以显示",
|
||||
"scheduleMessage": "请选择文档发布的日期和时间",
|
||||
"show": "显示",
|
||||
"size": "大小",
|
||||
"upload": "上传",
|
||||
"uploadFiles": "正在上传 {files} ...",
|
||||
"uploadMessage": "选择上传选项。",
|
||||
"optionalPassword": "密码(选填,不填即无密码)",
|
||||
"uploadMessage": "请选择上传选项",
|
||||
"optionalPassword": "密码(选填,留空则无需密码)",
|
||||
"resolution": "分辨率",
|
||||
"discardEditorChanges": "你确定要放弃所做的更改吗?",
|
||||
"replaceOrSkip": "Replace or skip files",
|
||||
"resolveConflict": "Which files do you want to keep?",
|
||||
"singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.",
|
||||
"fastConflictResolve": "The destination folder there are {count} files with same name.",
|
||||
"uploadingFiles": "Uploading files",
|
||||
"filesInOrigin": "Files in origin",
|
||||
"filesInDest": "Files in destination",
|
||||
"override": "Overwrite",
|
||||
"skip": "Skip",
|
||||
"forbiddenError": "Forbidden Error",
|
||||
"currentPassword": "Your password",
|
||||
"currentPasswordMessage": "Enter your password to validate this action."
|
||||
"discardEditorChanges": "是否放弃已修改的内容?",
|
||||
"replaceOrSkip": "替换或跳过文件",
|
||||
"resolveConflict": "保留哪些文件?",
|
||||
"singleConflictResolve": "若同时保留两个版本,复制的文件会自动追加序号。",
|
||||
"fastConflictResolve": "目标目录内存在 {count} 个同名文件",
|
||||
"uploadingFiles": "正在上传文件",
|
||||
"filesInOrigin": "源文件",
|
||||
"filesInDest": "目标文件",
|
||||
"override": "覆盖",
|
||||
"skip": "跳过",
|
||||
"forbiddenError": "权限错误",
|
||||
"currentPassword": "当前密码",
|
||||
"currentPasswordMessage": "请输入当前密码以验证操作"
|
||||
},
|
||||
"search": {
|
||||
"images": "图像",
|
||||
"music": "音乐",
|
||||
"pdf": "PDF",
|
||||
"pressToSearch": "按回车以搜索...",
|
||||
"pressToSearch": "按下回车开始搜索...",
|
||||
"search": "搜索...",
|
||||
"typeToSearch": "输入以搜索...",
|
||||
"typeToSearch": "输入内容进行搜索...",
|
||||
"types": "类型",
|
||||
"video": "视频"
|
||||
},
|
||||
@@ -195,47 +195,47 @@
|
||||
"aceEditorTheme": "Ace编辑器主题",
|
||||
"admin": "管理员",
|
||||
"administrator": "管理员",
|
||||
"allowCommands": "执行命令(Shell 命令)",
|
||||
"allowCommands": "允许执行终端命令",
|
||||
"allowEdit": "编辑、重命名或删除文件/文件夹",
|
||||
"allowNew": "创建新文件和文件夹",
|
||||
"allowPublish": "发布新的帖子与页面",
|
||||
"allowSignup": "允许用户注册",
|
||||
"hideLoginButton": "从公开页面隐藏登录按钮",
|
||||
"avoidChanges": "(留空以避免更改)",
|
||||
"avoidChanges": "(留空则不修改)",
|
||||
"branding": "品牌",
|
||||
"brandingDirectoryPath": "品牌信息文件夹路径",
|
||||
"brandingHelp": "你可以通过改变实例名称,更换 Logo,加入自定义样式,甚至禁用到 Github 的外部链接来自定义 File Browser 的外观和感觉。\n想获得更多信息,请查看 {0}。",
|
||||
"brandingDirectoryPath": "品牌资源目录路径",
|
||||
"brandingHelp": "你可以修改站点名称、更换Logo、添加自定义样式,也可以禁用外部GitHub链接,来自定义本文件管理器界面。\n详情请查看 {0}。",
|
||||
"changePassword": "更改密码",
|
||||
"commandRunner": "命令执行器",
|
||||
"commandRunnerHelp": "你可以在此设置在下列事件中执行的命令。每行必须写一条命令。可以在命令中使用环境变量 {0} 和 {1},使 {0} 与 {1} 相关联。关于此功能和可用环境变量的更多信息,请阅读 {2}。",
|
||||
"commandRunnerHelp": "在此设置事件触发命令,单行单条。环境变量 {0}、{1} 可用,{0} 相对 {1} 生效。功能与变量详情请参考 {2}。",
|
||||
"commandsUpdated": "命令已更新!",
|
||||
"createUserDir": "在添加新用户的同时自动创建用户的主目录",
|
||||
"createUserDir": "新增用户时自动创建用户主目录",
|
||||
"minimumPasswordLength": "最小密码长度",
|
||||
"tusUploads": "分块上传",
|
||||
"tusUploadsHelp": "File Browser 支持分块上传,在不佳的网络下也可进行高效、可靠、可续的文件上传",
|
||||
"tusUploadsChunkSize": "分块上传大小,例如 10MB 或 1GB",
|
||||
"tusUploadsHelp": "支持文件分片上传,网络不佳时仍可稳定传输,并支持断点续传。",
|
||||
"tusUploadsChunkSize": "分块大小(例如:10MB、1GB);小于该值的文件将直接上传。",
|
||||
"tusUploadsRetryCount": "分块上传失败时的重试次数",
|
||||
"userHomeBasePath": "用户主目录的路径",
|
||||
"userScopeGenerationPlaceholder": "自动生成目录范围",
|
||||
"createUserHomeDirectory": "创建用户主目录",
|
||||
"customStylesheet": "自定义样式表(CSS)",
|
||||
"defaultUserDescription": "这些是新用户的默认设置。",
|
||||
"customStylesheet": "自定义样式表",
|
||||
"defaultUserDescription": "以下为新用户的默认配置",
|
||||
"disableExternalLinks": "禁止外部链接(帮助文档除外)",
|
||||
"disableUsedDiskPercentage": "禁用磁盘已用空间展示",
|
||||
"documentation": "帮助文档",
|
||||
"examples": "示例",
|
||||
"executeOnShell": "在 Shell 中执行",
|
||||
"executeOnShellDescription": "默认情况下,File Browser 通过直接调用命令的二进制包来执行命令,如果想在 Shell中 执行(如 Bash 或 PowerShell),你可以在这里定义所使用的 Shell 和参数。设置后,你所执行的命令会作为参数追加。本设置对用户命令和事件钩子都生效。",
|
||||
"globalRules": "这是全局允许与禁止规则。它们作用于所有用户。你可以给每个用户定义单独的特殊规则来覆盖全局规则。",
|
||||
"executeOnShell": "通过终端执行",
|
||||
"executeOnShellDescription": "默认直接调用程序执行命令。如需通过终端运行(如Bash、PowerShell 等),可在此配置解释器、参数及选项,执行命令将自动作为参数传入。本设置对用户命令和事件钩子均生效。",
|
||||
"globalRules": "全局访问规则,对所有用户生效。也可单独为用户设置规则来覆盖全局规则。",
|
||||
"globalSettings": "全局设置",
|
||||
"hideDotfiles": "不显示隐藏文件",
|
||||
"insertPath": "插入路径",
|
||||
"insertRegex": "插入正则表达式",
|
||||
"instanceName": "实例名称",
|
||||
"instanceName": "站点名称",
|
||||
"language": "语言",
|
||||
"lockPassword": "禁止用户修改密码",
|
||||
"newPassword": "你的新密码",
|
||||
"newPasswordConfirm": "再次输入以确认你的新密码",
|
||||
"newPassword": "新密码",
|
||||
"newPasswordConfirm": "再次输入新密码",
|
||||
"newUser": "新建用户",
|
||||
"password": "密码",
|
||||
"passwordUpdated": "密码已更新!",
|
||||
@@ -247,23 +247,23 @@
|
||||
"execute": "执行命令",
|
||||
"modify": "编辑",
|
||||
"rename": "重命名或移动文件和文件夹",
|
||||
"share": "Share files (require download permission)"
|
||||
"share": "分享文件(需开启下载权限)"
|
||||
},
|
||||
"permissions": "权限",
|
||||
"permissionsHelp": "你可以将该用户设置为管理员或单独选择各项权限。如果你选择了“管理员”,则其他的选项会被自动选中,同时该用户可以管理其他用户。\n",
|
||||
"profileSettings": "个人设置",
|
||||
"redirectAfterCopyMove": "复制/移动后转到目标位置",
|
||||
"ruleExample1": "阻止用户访问所有文件夹下任何以 . 开头的文件(隐藏文件, 例如: .git, .gitignore)。\n",
|
||||
"ruleExample2": "阻止用户访问其目录范围的根目录下名为 Caddyfile 的文件。",
|
||||
"ruleExample1": "禁止用户访问所有目录下以 . 开头的隐藏文件(隐藏文件, 例如: .git, .gitignore)。\n",
|
||||
"ruleExample2": "禁止用户访问个人根目录下的 Caddyfile 文件。",
|
||||
"rules": "规则",
|
||||
"rulesHelp": "你可以为该用户制定一组黑名单或白名单式的规则,被屏蔽的文件将不会显示在列表中,用户也无权限访问,支持正则表达式和相对于用户范围的路径。\n",
|
||||
"rulesHelp": "可为用户配置黑白名单规则,被限制的文件将隐藏且无法访问。支持正则表达式、相对用户目录的路径写法。\n",
|
||||
"scope": "目录范围",
|
||||
"setDateFormat": "显示精确的日期格式",
|
||||
"settingsUpdated": "设置已更新!",
|
||||
"shareDuration": "分享期限",
|
||||
"shareManagement": "分享管理",
|
||||
"shareDeleted": "分享已删除!",
|
||||
"singleClick": "使用单击来打开文件和文件夹",
|
||||
"singleClick": "单击打开文件/文件夹",
|
||||
"themes": {
|
||||
"default": "系统默认",
|
||||
"dark": "深色",
|
||||
@@ -271,30 +271,30 @@
|
||||
"title": "主题"
|
||||
},
|
||||
"user": "用户",
|
||||
"userCommands": "用户命令(Shell 命令)",
|
||||
"userCommandsHelp": "指定该用户可以执行的命令(Shell 命令),用空格分隔。例如:\n",
|
||||
"userCommands": "可用终端命令",
|
||||
"userCommandsHelp": "指定该用户可执行的终端命令,多个命令用空格分隔。示例:\n",
|
||||
"userCreated": "用户已创建!",
|
||||
"userDefaults": "用户默认设置",
|
||||
"userDeleted": "用户已删除!",
|
||||
"userManagement": "用户管理",
|
||||
"userUpdated": "用户已更新!",
|
||||
"userUpdated": "用户信息已更新!",
|
||||
"username": "用户名",
|
||||
"users": "用户",
|
||||
"currentPassword": "您当前的密码"
|
||||
"currentPassword": "当前密码"
|
||||
},
|
||||
"sidebar": {
|
||||
"diskUsed": "{used} of {total} used",
|
||||
"diskUsed": "已使用 {used} / 总容量 {total}",
|
||||
"help": "帮助",
|
||||
"hugoNew": "Hugo 新建",
|
||||
"login": "登录",
|
||||
"logout": "登出",
|
||||
"logout": "退出",
|
||||
"myFiles": "我的文件",
|
||||
"newFile": "新建文件",
|
||||
"newFolder": "新建文件夹",
|
||||
"preview": "预览",
|
||||
"settings": "设置",
|
||||
"signup": "注册",
|
||||
"siteSettings": "网站设置"
|
||||
"siteSettings": "站点设置"
|
||||
},
|
||||
"success": {
|
||||
"linkCopied": "链接已复制!"
|
||||
|
||||
Vendored
+1
-1
@@ -25,7 +25,7 @@ interface Share {
|
||||
path: string;
|
||||
expire?: any;
|
||||
userID?: number;
|
||||
token?: string;
|
||||
hasPassword?: boolean;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { files as api } from "@/api";
|
||||
|
||||
vi.mock("@/api", () => ({
|
||||
files: {
|
||||
fetch: vi.fn(),
|
||||
fetchAll: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -19,8 +20,8 @@ vi.mock("@/stores/layout", () => ({ useLayoutStore: vi.fn() }));
|
||||
vi.mock("@/stores/upload", () => ({ useUploadStore: vi.fn() }));
|
||||
vi.mock("@/utils/url", () => ({ default: {} }));
|
||||
|
||||
// A move/copy/drag item carries `name` (raw) and `to` (URL-encoded) but no
|
||||
// `fullPath` — mirroring what Move.vue / Copy.vue / ListingItem.vue build.
|
||||
// A move/copy/drag item carries name (raw) and to (URL-encoded) but no
|
||||
// fullPath - mirroring what Move.vue / Copy.vue / ListingItem.vue build.
|
||||
function moveItem(name: string, dest: string, size = 12) {
|
||||
return {
|
||||
from: `/files/source/${encodeURIComponent(name)}`,
|
||||
@@ -167,29 +168,46 @@ describe("checkConflict", () => {
|
||||
expect(conflicts[0].name).toBe("/target/folder/deep/file.txt");
|
||||
});
|
||||
|
||||
// Copy/move stats the whole destination, so a same-named directory is a
|
||||
// conflict in its own right (regression for the directory case of #5957).
|
||||
it("reports a directory conflict for copy/move (includeDirectories)", async () => {
|
||||
vi.mocked(api.fetchAll).mockResolvedValue([
|
||||
{
|
||||
path: "/target/folder",
|
||||
name: "folder",
|
||||
size: 0,
|
||||
modified: "2026-06-04T00:00:00Z",
|
||||
isDir: true,
|
||||
},
|
||||
]);
|
||||
// Copy/move only needs the target directory's direct children. A recursive
|
||||
// walk can make the UI look frozen on large destinations (regression #6005).
|
||||
it("checks only the direct destination listing for copy/move", async () => {
|
||||
vi.mocked(api.fetch).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
path: "/target/file.txt",
|
||||
name: "file.txt",
|
||||
size: 10,
|
||||
modified: "2026-06-04T00:00:00Z",
|
||||
isDir: false,
|
||||
},
|
||||
{
|
||||
path: "/target/folder",
|
||||
name: "folder",
|
||||
size: 0,
|
||||
modified: "2026-06-04T00:00:00Z",
|
||||
isDir: true,
|
||||
},
|
||||
],
|
||||
} as Resource);
|
||||
|
||||
const items = [{ ...moveItem("folder", "/files/target/", 0), isDir: true }];
|
||||
const items = [
|
||||
moveItem("file.txt", "/files/target/"),
|
||||
{ ...moveItem("folder", "/files/target/", 0), isDir: true },
|
||||
];
|
||||
|
||||
const conflicts = await checkConflict(items, "/files/target/", true);
|
||||
|
||||
expect(conflicts).toHaveLength(1);
|
||||
expect(conflicts[0].name).toBe("/target/folder");
|
||||
expect(api.fetch).toHaveBeenCalledWith("/files/target/");
|
||||
expect(api.fetchAll).not.toHaveBeenCalled();
|
||||
expect(conflicts).toHaveLength(2);
|
||||
expect(conflicts.map((conflict) => conflict.name)).toEqual([
|
||||
"/target/file.txt",
|
||||
"/target/folder",
|
||||
]);
|
||||
});
|
||||
|
||||
// Uploads merge into an existing folder, so the directory itself must not be
|
||||
// reported — only the files inside it can conflict.
|
||||
// reported - only the files inside it can conflict.
|
||||
it("ignores a directory conflict for uploads (default)", async () => {
|
||||
vi.mocked(api.fetchAll).mockResolvedValue([
|
||||
{
|
||||
@@ -220,4 +238,52 @@ describe("checkConflict", () => {
|
||||
|
||||
expect(conflicts).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Regression for #5980: a FileBrowser server running on Windows returns
|
||||
// backslash-separated paths from the recursive listing. Without normalizing
|
||||
// them, the prefix strip and key lookup never match, so the conflict modal is
|
||||
// skipped and the backend returns a bare 409.
|
||||
it("detects a conflict for backslash-separated server paths (Windows)", async () => {
|
||||
vi.mocked(api.fetchAll).mockResolvedValue([
|
||||
{
|
||||
path: "\\target\\file.txt",
|
||||
name: "file.txt",
|
||||
size: 10,
|
||||
modified: "2026-06-04T00:00:00Z",
|
||||
isDir: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const conflicts = await checkConflict(
|
||||
[moveItem("file.txt", "/files/target/")],
|
||||
"/files/target/"
|
||||
);
|
||||
|
||||
expect(conflicts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("detects nested conflicts for backslash-separated server paths (Windows)", async () => {
|
||||
vi.mocked(api.fetchAll).mockResolvedValue([
|
||||
{
|
||||
path: "\\target\\folder\\nested file.txt",
|
||||
name: "nested file.txt",
|
||||
size: 10,
|
||||
modified: "2026-06-04T00:00:00Z",
|
||||
isDir: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const files = [
|
||||
{
|
||||
name: "nested file.txt",
|
||||
size: 12,
|
||||
isDir: false,
|
||||
fullPath: "folder/nested file.txt",
|
||||
},
|
||||
];
|
||||
|
||||
const conflicts = await checkConflict(files, "/files/target/");
|
||||
|
||||
expect(conflicts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,20 +21,61 @@ function conflictKey(item: UploadEntry): string {
|
||||
return (item.fullPath || item.name).replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
type ServerConflictEntry = {
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
modified: string;
|
||||
};
|
||||
|
||||
async function fetchConflictEntries(
|
||||
basePath: string,
|
||||
includeDirectories: boolean
|
||||
): Promise<ServerConflictEntry[]> {
|
||||
if (!includeDirectories) {
|
||||
return await api.fetchAll(basePath);
|
||||
}
|
||||
|
||||
const destination = await api.fetch(basePath);
|
||||
return destination.items ?? [];
|
||||
}
|
||||
|
||||
function conflictPath(entry: ServerConflictEntry): string {
|
||||
return entry.path.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function buildConflictMap(
|
||||
serverEntries: ServerConflictEntry[],
|
||||
basePath: string,
|
||||
includeDirectories: boolean
|
||||
): Map<string, ServerConflictEntry> {
|
||||
const serverMap = new Map<string, ServerConflictEntry>();
|
||||
const normBase = removePrefix(basePath).replace(/\/+$/, "");
|
||||
for (const entry of serverEntries) {
|
||||
// A Windows server may return OS-native backslash separators; normalize to
|
||||
// forward slashes so the prefix strip and key lookup line up.
|
||||
const path = conflictPath(entry);
|
||||
const key = includeDirectories
|
||||
? entry.name
|
||||
: path.startsWith(normBase)
|
||||
? path.slice(normBase.length)
|
||||
: path;
|
||||
serverMap.set(key.replace(/^\/+/, ""), entry);
|
||||
}
|
||||
|
||||
return serverMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the entries from `files` that already exist under `basePath` on the
|
||||
* server, so the caller can prompt the user to overwrite/rename/skip.
|
||||
*
|
||||
* The whole destination tree is fetched once and indexed by path relative to
|
||||
* the destination, then every entry is looked up directly — no need to mirror
|
||||
* the upload's folder structure.
|
||||
*
|
||||
* Directory handling differs by action, hence `includeDirectories`:
|
||||
* - Upload (false): an existing folder is silently merged, so only the
|
||||
* individual files inside it can conflict.
|
||||
* - Copy/move (true): the server stats the destination and rejects it whole if
|
||||
* a same-named entry exists, so the directory itself is a conflict. The list
|
||||
* only holds the top-level items being moved, so each is reported once.
|
||||
* - Upload (false): the destination tree is fetched recursively so nested
|
||||
* file uploads can be checked; existing folders are silently merged.
|
||||
* - Copy/move (true): only the destination directory itself is fetched. These
|
||||
* operations move flat top-level selections, so a same-named direct child is
|
||||
* the only preflight conflict the backend will reject.
|
||||
*
|
||||
* @param files - flat upload list to check
|
||||
* @param basePath - server destination path (e.g. "/files/uploads/")
|
||||
@@ -47,26 +88,19 @@ export async function checkConflict(
|
||||
): Promise<ConflictingResource[]> {
|
||||
if (files.length === 0) return [];
|
||||
|
||||
let serverEntries: RecursiveEntry[];
|
||||
let serverEntries: ServerConflictEntry[];
|
||||
try {
|
||||
// Single API call: fetch the entire server tree under basePath.
|
||||
serverEntries = await api.fetchAll(basePath);
|
||||
serverEntries = await fetchConflictEntries(basePath, includeDirectories);
|
||||
} catch {
|
||||
// The destination doesn't exist yet, so nothing can conflict.
|
||||
return [];
|
||||
}
|
||||
|
||||
// The server returns paths absolute within the user's scope
|
||||
// (e.g. "/uploads/sub/file.txt"). Strip the basePath prefix so the keys line
|
||||
// up with each entry's conflictKey, which is relative to the destination.
|
||||
const normBase = removePrefix(basePath).replace(/\/+$/, "");
|
||||
const serverMap = new Map<string, RecursiveEntry>();
|
||||
for (const entry of serverEntries) {
|
||||
const rel = entry.path.startsWith(normBase)
|
||||
? entry.path.slice(normBase.length)
|
||||
: entry.path;
|
||||
serverMap.set(rel.replace(/^\/+/, ""), entry);
|
||||
}
|
||||
const serverMap = buildConflictMap(
|
||||
serverEntries,
|
||||
basePath,
|
||||
includeDirectories
|
||||
);
|
||||
|
||||
const conflicts: ConflictingResource[] = [];
|
||||
files.forEach((file, index) => {
|
||||
@@ -77,7 +111,7 @@ export async function checkConflict(
|
||||
|
||||
conflicts.push({
|
||||
index,
|
||||
name: server.path,
|
||||
name: conflictPath(server),
|
||||
origin: { lastModified: file.file?.lastModified, size: file.size },
|
||||
dest: { lastModified: server.modified, size: server.size },
|
||||
checked: ["origin"],
|
||||
|
||||
@@ -4,38 +4,38 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/asticode/go-astisub v0.40.0
|
||||
github.com/asticode/go-astisub v0.42.0
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/dsoprea/go-exif/v3 v3.0.1
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0
|
||||
github.com/jellydator/ttlcache/v3 v3.4.1
|
||||
github.com/maruel/natural v1.3.0
|
||||
github.com/marusama/semaphore/v2 v2.5.0
|
||||
github.com/mholt/archives v0.1.5
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
github.com/redis/go-redis/v9 v9.21.0
|
||||
github.com/samber/lo v1.53.0
|
||||
github.com/shirou/gopsutil/v4 v4.26.5
|
||||
github.com/shirou/gopsutil/v4 v4.26.6
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.42.0
|
||||
golang.org/x/text v0.38.0
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/text v0.40.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/asticode/go-astikit v0.59.0 // indirect
|
||||
github.com/asticode/go-astits v1.15.0 // indirect
|
||||
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||
@@ -52,16 +52,16 @@ require (
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/golang/geo v0.0.0-20260612074446-f1a45663b0f3 // indirect
|
||||
github.com/golang/geo v0.0.0-20260625163123-7c0e84413537 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/compress v1.19.0 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.1.1 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.3 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.2 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.27 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
@@ -77,8 +77,8 @@ require (
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
@@ -4,16 +4,16 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
|
||||
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
|
||||
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
|
||||
github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
|
||||
github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
|
||||
github.com/asticode/go-astikit v0.59.0 h1:tjbwDym+MTSxqkAhJoHRZmHMXK6Jv4vGx+97FptKH6k=
|
||||
github.com/asticode/go-astikit v0.59.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE=
|
||||
github.com/asticode/go-astisub v0.40.0 h1:Z8tAXHngjkh/4L9zqt49ru9UdpDTqBIe+80xexKM3/I=
|
||||
github.com/asticode/go-astisub v0.40.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
|
||||
github.com/asticode/go-astisub v0.42.0 h1:IJuJJTQwCSFeY9bx1kTzdCwkIeG27wj4CAh6nHc+pvY=
|
||||
github.com/asticode/go-astisub v0.42.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
|
||||
github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
|
||||
github.com/asticode/go-astits v1.15.0 h1:yRyCiUc8Jj4F7clt2GDxHghMpWuFL5rkaLuGUd2/0J4=
|
||||
github.com/asticode/go-astits v1.15.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
|
||||
@@ -82,8 +82,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
|
||||
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20260612074446-f1a45663b0f3 h1:UlucSQUu9SZdDRlMWv5N/T3Rig9gv615vWvHFKrXHWA=
|
||||
github.com/golang/geo v0.0.0-20260612074446-f1a45663b0f3/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/geo v0.0.0-20260625163123-7c0e84413537 h1:KeIaDS/+VEy/bhDYjG3Z78dOyLAU4HXcVxmd0WYHJTE=
|
||||
github.com/golang/geo v0.0.0-20260625163123-7c0e84413537/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@@ -101,13 +101,13 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
|
||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid v1.2.0 h1:NMpwD2G9JSFOE1/TJjGSo5zG7Yb2bTe7eq1jH+irmeE=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
@@ -134,10 +134,10 @@ github.com/minio/minlz v1.1.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM=
|
||||
github.com/minio/minlz v1.1.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/nwaples/rardecode/v2 v2.2.3 h1:qaVuy3ChZDbAQZshPLjHeNJKF3Cru8uo9jmgveKIy2A=
|
||||
github.com/nwaples/rardecode/v2 v2.2.3/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/nwaples/rardecode/v2 v2.2.5 h1:L5doqgGfQwI7qADJMqnkrSB86rpPsqQDrHeO0HWa5JY=
|
||||
github.com/nwaples/rardecode/v2 v2.2.5/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
|
||||
github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
|
||||
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
|
||||
@@ -146,8 +146,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
|
||||
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
|
||||
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
@@ -156,8 +156,8 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
||||
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
@@ -201,8 +201,8 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -213,11 +213,11 @@ go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
|
||||
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -229,8 +229,8 @@ golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81R
|
||||
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -242,14 +242,14 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
|
||||
|
||||
@@ -201,6 +201,22 @@ var signupHandler = func(w http.ResponseWriter, r *http.Request, d *data) (int,
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
user.Scope = userHome
|
||||
|
||||
// When home directories are created from the username, distinct usernames
|
||||
// can normalize to the same scope (cleanUsername is many-to-one), which would
|
||||
// silently hand the new user another user's home directory. Reject the signup
|
||||
// if the derived scope is already taken. When CreateUserDir is off, all
|
||||
// signups intentionally share the configured default scope, so this check
|
||||
// does not apply.
|
||||
if d.settings.CreateUserDir {
|
||||
switch _, err := d.store.Users.GetByScope(user.Scope); {
|
||||
case err == nil:
|
||||
return http.StatusConflict, fberrors.ErrExist
|
||||
case !errors.Is(err, fberrors.ErrNotExist):
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("new user: %s, home dir: [%s].", user.Username, userHome)
|
||||
|
||||
err = d.store.Users.Save(user)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package fbhttp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/settings"
|
||||
"github.com/filebrowser/filebrowser/v2/storage/bolt"
|
||||
)
|
||||
|
||||
// Regression for the username-normalization home-directory collision
|
||||
// (GHSA-7rc3-g7h6-22m7): with Signup and CreateUserDir enabled, two distinct
|
||||
// usernames that cleanUsername() normalizes to the same directory must not be
|
||||
// handed the same home directory. The second registration is rejected.
|
||||
func TestSignupRejectsCollidingNormalizedScope(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
db, err := storm.Open(filepath.Join(t.TempDir(), "db"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
st, err := bolt.NewStorage(db)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get storage: %v", err)
|
||||
}
|
||||
if err := st.Settings.Save(&settings.Settings{
|
||||
Key: []byte("test-signing-key"),
|
||||
Signup: true,
|
||||
CreateUserDir: true,
|
||||
UserHomeBasePath: "/users",
|
||||
MinimumPasswordLength: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
server := &settings.Server{Root: root}
|
||||
|
||||
signup := func(username string) *httptest.ResponseRecorder {
|
||||
body := `{"username":"` + username + `","password":"CollidePw12345!"}`
|
||||
req, _ := http.NewRequest(http.MethodPost, "/signup", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handle(signupHandler, "", st, server).ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// Victim registers first and gets /users/teamone-x.
|
||||
if rec := signup("teamone-x"); rec.Code != http.StatusOK {
|
||||
t.Fatalf("first signup: expected 200, got %d body=%q", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Attacker picks a distinct username that normalizes to the same scope.
|
||||
if rec := signup("teamone/x"); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("VULNERABLE: colliding signup expected 409, got %d body=%q", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// The shared scope must still be owned solely by the first user.
|
||||
owner, err := st.Users.GetByScope("/users/teamone-x")
|
||||
if err != nil {
|
||||
t.Fatalf("expected first user to own the scope: %v", err)
|
||||
}
|
||||
if owner.Username != "teamone-x" {
|
||||
t.Fatalf("scope owner = %q, want teamone-x", owner.Username)
|
||||
}
|
||||
}
|
||||
+14
-6
@@ -2,6 +2,7 @@ package fbhttp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -125,12 +126,19 @@ func getFiles(d *data, path, commonPath string) ([]archives.FileInfo, error) {
|
||||
nameInArchive := strings.TrimPrefix(path, commonPath)
|
||||
nameInArchive = strings.TrimPrefix(nameInArchive, string(filepath.Separator))
|
||||
nameInArchive = filepath.ToSlash(nameInArchive)
|
||||
// filepath.ToSlash only rewrites the host separator, so on a Linux
|
||||
// host a stored backslash survives and is emitted verbatim into the
|
||||
// archive. Windows extractors then treat "\" as a path separator,
|
||||
// allowing the entry to escape the extraction directory (zip-slip).
|
||||
// Strip Windows separators regardless of host OS.
|
||||
nameInArchive = strings.ReplaceAll(nameInArchive, "\\", "/")
|
||||
// A backslash is a legal filename character on POSIX hosts, so it can
|
||||
// reach here verbatim. Rewriting it to the path separator "/" would
|
||||
// manufacture a traversal sequence (e.g. "..\..\x" -> "../../x") that
|
||||
// escapes the extraction directory on the victim's machine, while
|
||||
// leaving it as "\" lets Windows extractors treat it as a separator.
|
||||
// Neutralize it to an inert character instead of turning it into one.
|
||||
nameInArchive = strings.ReplaceAll(nameInArchive, "\\", "_")
|
||||
|
||||
// Defense in depth: never emit an archive entry whose path escapes the
|
||||
// archive root, regardless of how the name was produced.
|
||||
if cleaned := gopath.Clean("/" + nameInArchive); cleaned != "/"+nameInArchive {
|
||||
return nil, fmt.Errorf("refusing unsafe archive entry name: %q", nameInArchive)
|
||||
}
|
||||
|
||||
archiveFiles = append(archiveFiles, archives.FileInfo{
|
||||
FileInfo: info,
|
||||
|
||||
@@ -1,14 +1,75 @@
|
||||
package fbhttp
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/files"
|
||||
"github.com/filebrowser/filebrowser/v2/settings"
|
||||
"github.com/filebrowser/filebrowser/v2/users"
|
||||
)
|
||||
|
||||
// Regression for the archive backslash-to-slash zip-slip (GHSA-83xp-526h-j3ww):
|
||||
// a single in-scope file whose name contains backslashes is a legal POSIX
|
||||
// filename, not a traversal. The archive builder must never rewrite "\" into the
|
||||
// path separator "/", which would manufacture an entry like "../../evil.sh" that
|
||||
// escapes the extraction directory on the downloader's machine.
|
||||
func TestRawArchiveDoesNotManufactureTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
userScope := filepath.Join(root, "user")
|
||||
if err := os.MkdirAll(filepath.Join(userScope, "ziptest"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// One legal Linux/macOS filename whose bytes include backslashes. It does not
|
||||
// traverse on the server; it only becomes "../../evil.sh" if the builder
|
||||
// turns "\" into "/".
|
||||
planted := filepath.Join(userScope, "ziptest", "..\\..\\evil.sh")
|
||||
if err := os.WriteFile(planted, []byte("#!/bin/sh\necho PWNED"), 0o644); err != nil {
|
||||
t.Skipf("cannot create backslash-named file: %v", err)
|
||||
}
|
||||
|
||||
key := []byte("test-signing-key")
|
||||
perm := users.Permissions{Download: true}
|
||||
st := scopedUserStorage(t, userScope, perm, key)
|
||||
signed := signToken(t, perm, key)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "/ziptest?algo=zip", http.NoBody)
|
||||
req.Header.Set("X-Auth", signed)
|
||||
rec := httptest.NewRecorder()
|
||||
handle(rawHandler, "", st, &settings.Server{}).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%q", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len()))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read zip: %v", err)
|
||||
}
|
||||
if len(zr.File) == 0 {
|
||||
t.Fatal("archive has no entries")
|
||||
}
|
||||
|
||||
for _, f := range zr.File {
|
||||
// The entry must be a normalized, root-relative path: no ".." segments
|
||||
// and no leading "/". Note a name may legitimately contain ".." as part of
|
||||
// a single filename (e.g. ".._.._evil.sh"), which Clean leaves untouched —
|
||||
// so compare against the normalized form rather than searching for "..".
|
||||
if strings.HasPrefix(f.Name, "/") || path.Clean("/"+f.Name) != "/"+f.Name {
|
||||
t.Errorf("VULNERABLE: archive entry escapes root: %q", f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetContentDisposition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+4
-1
@@ -424,7 +424,10 @@ var resourceGetRecursiveHandler = withUser(func(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
|
||||
entries = append(entries, RecursiveEntry{
|
||||
Path: fPath,
|
||||
// afero.Walk joins paths with the OS separator, so on Windows fPath
|
||||
// uses backslashes. The web API contract is forward slashes, so
|
||||
// normalize it here (mirrors search/search.go).
|
||||
Path: filepath.ToSlash(fPath),
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
|
||||
+64
-6
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -14,9 +15,40 @@ import (
|
||||
|
||||
fberrors "github.com/filebrowser/filebrowser/v2/errors"
|
||||
"github.com/filebrowser/filebrowser/v2/share"
|
||||
"github.com/filebrowser/filebrowser/v2/users"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// shareResponse is the client-facing representation of a share. It deliberately
|
||||
// omits the server-side secrets of share.Link — the bcrypt PasswordHash (which
|
||||
// would be crackable offline) and the bypass Token — exposing only whether the
|
||||
// share is password-protected via HasPassword.
|
||||
type shareResponse struct {
|
||||
Hash string `json:"hash"`
|
||||
Path string `json:"path"`
|
||||
UserID uint `json:"userID"`
|
||||
Expire int64 `json:"expire"`
|
||||
HasPassword bool `json:"hasPassword"`
|
||||
}
|
||||
|
||||
func toShareResponse(l *share.Link) *shareResponse {
|
||||
return &shareResponse{
|
||||
Hash: l.Hash,
|
||||
Path: l.Path,
|
||||
UserID: l.UserID,
|
||||
Expire: l.Expire,
|
||||
HasPassword: l.PasswordHash != "",
|
||||
}
|
||||
}
|
||||
|
||||
func toShareResponses(links []*share.Link) []*shareResponse {
|
||||
res := make([]*shareResponse, 0, len(links))
|
||||
for _, l := range links {
|
||||
res = append(res, toShareResponse(l))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func withPermShare(fn handleFunc) handleFunc {
|
||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if !d.user.Perm.Share || !d.user.Perm.Download {
|
||||
@@ -38,7 +70,7 @@ var shareListHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
|
||||
s, err = d.store.Share.FindByUserID(d.user.ID)
|
||||
}
|
||||
if errors.Is(err, fberrors.ErrNotExist) {
|
||||
return renderJSON(w, r, []*share.Link{})
|
||||
return renderJSON(w, r, []*shareResponse{})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -52,7 +84,7 @@ var shareListHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
|
||||
return s[i].Expire < s[j].Expire
|
||||
})
|
||||
|
||||
return renderJSON(w, r, s)
|
||||
return renderJSON(w, r, toShareResponses(s))
|
||||
})
|
||||
|
||||
var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
@@ -61,21 +93,47 @@ var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
|
||||
err error
|
||||
)
|
||||
if d.user.Perm.Admin {
|
||||
s, err = d.store.Share.GetsByPath(r.URL.Path)
|
||||
s, err = getSharesForAdminPath(d, r.URL.Path)
|
||||
} else {
|
||||
s, err = d.store.Share.Gets(r.URL.Path, d.user.ID)
|
||||
}
|
||||
if errors.Is(err, fberrors.ErrNotExist) {
|
||||
return renderJSON(w, r, []*share.Link{})
|
||||
return renderJSON(w, r, []*shareResponse{})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return renderJSON(w, r, s)
|
||||
return renderJSON(w, r, toShareResponses(s))
|
||||
})
|
||||
|
||||
func getSharesForAdminPath(d *data, path string) ([]*share.Link, error) {
|
||||
links, err := d.store.Share.All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
adminPath := filepath.Clean(d.user.FullPath(path))
|
||||
owners := make(map[uint]*users.User)
|
||||
filtered := make([]*share.Link, 0, len(links))
|
||||
for _, link := range links {
|
||||
owner, ok := owners[link.UserID]
|
||||
if !ok {
|
||||
owner, err = d.store.Users.Get(d.server.Root, d.server.FollowExternalSymlinks, link.UserID)
|
||||
if err != nil && !errors.Is(err, fberrors.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
owners[link.UserID] = owner // owner is nil on ErrNotExist
|
||||
}
|
||||
if owner != nil && filepath.Clean(owner.FullPath(link.Path)) == adminPath {
|
||||
filtered = append(filtered, link)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
var shareDeleteHandler = withPermShare(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
hash := strings.TrimSuffix(r.URL.Path, "/")
|
||||
hash = strings.TrimPrefix(hash, "/")
|
||||
@@ -176,7 +234,7 @@ var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return renderJSON(w, r, s)
|
||||
return renderJSON(w, r, toShareResponse(s))
|
||||
})
|
||||
|
||||
func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, err error) {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package fbhttp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/settings"
|
||||
"github.com/filebrowser/filebrowser/v2/share"
|
||||
"github.com/filebrowser/filebrowser/v2/storage/bolt"
|
||||
"github.com/filebrowser/filebrowser/v2/users"
|
||||
)
|
||||
|
||||
func TestAdminShareGetsHandlerMatchesOwnerScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
ownerScope := filepath.Join(root, "owner")
|
||||
if err := os.MkdirAll(ownerScope, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(ownerScope, "file.txt"), []byte("shared"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := storm.Open(filepath.Join(t.TempDir(), "db"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
st, err := bolt.NewStorage(db)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get storage: %v", err)
|
||||
}
|
||||
|
||||
owner := &users.User{
|
||||
Username: "owner",
|
||||
Password: "pw",
|
||||
Scope: "/owner",
|
||||
Perm: users.Permissions{Share: true, Download: true},
|
||||
}
|
||||
if err := st.Users.Save(owner); err != nil {
|
||||
t.Fatalf("failed to save owner: %v", err)
|
||||
}
|
||||
|
||||
adminPerm := users.Permissions{Admin: true, Share: true, Download: true}
|
||||
admin := &users.User{
|
||||
Username: "admin",
|
||||
Password: "pw",
|
||||
Scope: "/",
|
||||
Perm: adminPerm,
|
||||
}
|
||||
if err := st.Users.Save(admin); err != nil {
|
||||
t.Fatalf("failed to save admin: %v", err)
|
||||
}
|
||||
|
||||
if err := st.Share.Save(&share.Link{Hash: "h", UserID: owner.ID, Path: "/file.txt"}); err != nil {
|
||||
t.Fatalf("failed to save share: %v", err)
|
||||
}
|
||||
key := []byte("test-signing-key")
|
||||
if err := st.Settings.Save(&settings.Settings{Key: key}); err != nil {
|
||||
t.Fatalf("failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/owner/file.txt", http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to construct request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Auth", signShareTestToken(t, admin.ID, admin.Username, adminPerm, key))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handle(shareGetsHandler, "", st, &settings.Server{Root: root}).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var links []*share.Link
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &links); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if len(links) != 1 || links[0].Hash != "h" {
|
||||
t.Fatalf("expected admin to see owner share h, got %#v", links)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for the share secret exposure (GHSA-833g-cqhp-h72j): the share API
|
||||
// must not serialize the bcrypt password hash or the bypass token, while still
|
||||
// persisting them server-side so password-protected shares keep working.
|
||||
func TestSharePostHandlerDoesNotLeakSecrets(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
userScope := filepath.Join(root, "user")
|
||||
if err := os.MkdirAll(userScope, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(userScope, "file.txt"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
key := []byte("test-signing-key")
|
||||
perm := users.Permissions{Share: true, Download: true}
|
||||
st := scopedUserStorage(t, userScope, perm, key)
|
||||
signed := signToken(t, perm, key)
|
||||
|
||||
body := `{"password":"ShareSecret123!","expires":"24","unit":"hours"}`
|
||||
req, _ := http.NewRequest(http.MethodPost, "/file.txt", strings.NewReader(body))
|
||||
req.Header.Set("X-Auth", signed)
|
||||
rec := httptest.NewRecorder()
|
||||
handle(sharePostHandler, "", st, &settings.Server{Root: root}).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%q", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if _, ok := resp["password_hash"]; ok {
|
||||
t.Errorf("VULNERABLE: response leaks password_hash: %s", rec.Body.String())
|
||||
}
|
||||
if _, ok := resp["token"]; ok {
|
||||
t.Errorf("VULNERABLE: response leaks token: %s", rec.Body.String())
|
||||
}
|
||||
if resp["hasPassword"] != true {
|
||||
t.Errorf("expected hasPassword=true, got %v", resp["hasPassword"])
|
||||
}
|
||||
|
||||
// The secrets must still be persisted server-side (storm uses the JSON codec,
|
||||
// so the storage struct's tags must keep emitting them).
|
||||
stored, err := st.Share.GetByHash(resp["hash"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("share not stored: %v", err)
|
||||
}
|
||||
if stored.PasswordHash == "" || stored.Token == "" {
|
||||
t.Fatalf("server-side secrets not persisted: hash=%q token=%q", stored.PasswordHash, stored.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func signShareTestToken(t *testing.T, id uint, username string, perm users.Permissions, key []byte) string {
|
||||
t.Helper()
|
||||
|
||||
claims := &authToken{
|
||||
User: userInfo{ID: id, Username: username, Perm: perm},
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
},
|
||||
}
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(key)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign token: %v", err)
|
||||
}
|
||||
return signed
|
||||
}
|
||||
+13
-1
@@ -2,7 +2,9 @@ package fbhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/asticode/go-astisub"
|
||||
@@ -10,6 +12,8 @@ import (
|
||||
"github.com/filebrowser/filebrowser/v2/files"
|
||||
)
|
||||
|
||||
var srtLineBreakTag = regexp.MustCompile(`(?i)<br(?:\s+[^>]*)?\s*/?>`)
|
||||
|
||||
var subtitleHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
if !d.user.Perm.Download {
|
||||
return http.StatusAccepted, nil
|
||||
@@ -49,7 +53,11 @@ func subtitleFileHandler(w http.ResponseWriter, r *http.Request, file *files.Fil
|
||||
// load subtitle for conversion to vtt
|
||||
var sub *astisub.Subtitles
|
||||
if strings.HasSuffix(file.Name, ".srt") {
|
||||
sub, err = astisub.ReadFromSRT(fd)
|
||||
content, readErr := io.ReadAll(fd)
|
||||
if readErr != nil {
|
||||
return http.StatusInternalServerError, readErr
|
||||
}
|
||||
sub, err = astisub.ReadFromSRT(bytes.NewReader(normalizeSRTLineBreaks(content)))
|
||||
} else if strings.HasSuffix(file.Name, ".ass") || strings.HasSuffix(file.Name, ".ssa") {
|
||||
sub, err = astisub.ReadFromSSA(fd)
|
||||
}
|
||||
@@ -78,3 +86,7 @@ func subtitleFileHandler(w http.ResponseWriter, r *http.Request, file *files.Fil
|
||||
http.ServeContent(w, r, file.Name, file.ModTime, bytes.NewReader(buf.Bytes()))
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func normalizeSRTLineBreaks(content []byte) []byte {
|
||||
return srtLineBreakTag.ReplaceAll(content, []byte("\n"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package fbhttp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
|
||||
"github.com/filebrowser/filebrowser/v2/files"
|
||||
)
|
||||
|
||||
func TestNormalizeSRTLineBreaks(t *testing.T) {
|
||||
input := []byte("first<br>second<BR/>third<br />fourth<br class=\"x\">fifth")
|
||||
got := string(normalizeSRTLineBreaks(input))
|
||||
want := "first\nsecond\nthird\nfourth\nfifth"
|
||||
if got != want {
|
||||
t.Fatalf("normalizeSRTLineBreaks() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleFileHandlerConvertsSRTBreakTags(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
const path = "/sample.srt"
|
||||
const content = "1\n" +
|
||||
"00:00:01,000 --> 00:00:02,000\n" +
|
||||
"First<br>Second<BR/>Third<br />Fourth\n\n"
|
||||
|
||||
if err := afero.WriteFile(fs, path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("failed to write subtitle: %v", err)
|
||||
}
|
||||
info, err := fs.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat subtitle: %v", err)
|
||||
}
|
||||
|
||||
file := &files.FileInfo{
|
||||
Fs: fs,
|
||||
Path: path,
|
||||
Name: "sample.srt",
|
||||
ModTime: info.ModTime(),
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/subtitle/sample.srt?inline=true", http.NoBody)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
status, err := subtitleFileHandler(rec, req, file)
|
||||
if err != nil {
|
||||
t.Fatalf("subtitleFileHandler returned error: %v", err)
|
||||
}
|
||||
if status != 0 {
|
||||
t.Fatalf("subtitleFileHandler status = %d, want 0", status)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, "FirstSecond") {
|
||||
t.Fatalf("WebVTT output collapsed SRT <br> tags: %q", body)
|
||||
}
|
||||
if !strings.Contains(body, "First\nSecond\nThird\nFourth") {
|
||||
t.Fatalf("WebVTT output = %q, want converted SRT <br> tags as line breaks", body)
|
||||
}
|
||||
}
|
||||
@@ -110,23 +110,6 @@ func (s *Storage) Gets(path string, id uint) ([]*Link, error) {
|
||||
return links, nil
|
||||
}
|
||||
|
||||
// GetsByPath returns all non-expired links for a path.
|
||||
func (s *Storage) GetsByPath(path string) ([]*Link, error) {
|
||||
links, err := s.All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filtered := make([]*Link, 0, len(links))
|
||||
for _, link := range links {
|
||||
if link.Path == path {
|
||||
filtered = append(filtered, link)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// Save wraps a StorageBackend.Save
|
||||
func (s *Storage) Save(l *Link) error {
|
||||
return s.back.Save(l)
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeBackend struct {
|
||||
links []*Link
|
||||
}
|
||||
|
||||
func (f fakeBackend) All() ([]*Link, error) {
|
||||
return f.links, nil
|
||||
}
|
||||
|
||||
func (f fakeBackend) FindByUserID(id uint) ([]*Link, error) {
|
||||
var links []*Link
|
||||
for _, link := range f.links {
|
||||
if link.UserID == id {
|
||||
links = append(links, link)
|
||||
}
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
||||
func (f fakeBackend) GetByHash(hash string) (*Link, error) {
|
||||
for _, link := range f.links {
|
||||
if link.Hash == hash {
|
||||
return link, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f fakeBackend) GetPermanent(path string, id uint) (*Link, error) {
|
||||
for _, link := range f.links {
|
||||
if link.Path == path && link.UserID == id && link.Expire == 0 {
|
||||
return link, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f fakeBackend) Gets(path string, id uint) ([]*Link, error) {
|
||||
var links []*Link
|
||||
for _, link := range f.links {
|
||||
if link.Path == path && link.UserID == id {
|
||||
links = append(links, link)
|
||||
}
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
||||
func (f fakeBackend) Save(_ *Link) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f fakeBackend) Delete(_ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f fakeBackend) DeleteWithPathPrefix(_ string, _ uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetsByPathReturnsLinksFromAllUsers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := []*Link{
|
||||
{Hash: "a", Path: "/file.txt", UserID: 1},
|
||||
{Hash: "b", Path: "/file.txt", UserID: 2},
|
||||
}
|
||||
store := NewStorage(fakeBackend{
|
||||
links: append(expected, &Link{Hash: "c", Path: "/other.txt", UserID: 3}),
|
||||
})
|
||||
|
||||
links, err := store.GetsByPath("/file.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("GetsByPath returned error: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(links, expected) {
|
||||
t.Fatalf("GetsByPath returned %#v, want %#v", links, expected)
|
||||
}
|
||||
}
|
||||
@@ -78,16 +78,17 @@ func (s shareBackend) Delete(hash string) error {
|
||||
}
|
||||
|
||||
func (s shareBackend) DeleteWithPathPrefix(pathPrefix string, userID uint) error {
|
||||
// Share paths are stored without a trailing slash
|
||||
prefix := strings.TrimRight(pathPrefix, "/")
|
||||
|
||||
var links []share.Link
|
||||
if err := s.db.Prefix("Path", pathPrefix, &links); err != nil {
|
||||
if err := s.db.Prefix("Path", prefix, &links); err != nil {
|
||||
if errors.Is(err, storm.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
prefix := strings.TrimRight(pathPrefix, "/")
|
||||
|
||||
var err error
|
||||
for _, link := range links {
|
||||
if link.UserID != userID {
|
||||
|
||||
@@ -84,6 +84,44 @@ func TestDeleteWithPathPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for the trailing-slash delete leaving a stale share
|
||||
// (GHSA-pp88-jhwj-5qh5): deleting "/a/" must remove the exact "/a" share and its
|
||||
// descendants, not just the descendants. Siblings and other users are untouched.
|
||||
func TestDeleteWithPathPrefixTrailingSlash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestShareBackend(t)
|
||||
|
||||
links := []*share.Link{
|
||||
{Hash: "u1-a", Path: "/a", UserID: 1},
|
||||
{Hash: "u1-a-child", Path: "/a/child.txt", UserID: 1},
|
||||
{Hash: "u1-abc", Path: "/abc", UserID: 1}, // sibling sharing a byte prefix
|
||||
{Hash: "u2-a", Path: "/a", UserID: 2}, // other user, must remain
|
||||
}
|
||||
for _, l := range links {
|
||||
if err := s.Save(l); err != nil {
|
||||
t.Fatalf("failed to save link %s: %v", l.Hash, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete with a trailing slash, as the resource delete handler does for a
|
||||
// directory request like DELETE /api/resources/a/.
|
||||
if err := s.DeleteWithPathPrefix("/a/", 1); err != nil {
|
||||
t.Fatalf("DeleteWithPathPrefix returned error: %v", err)
|
||||
}
|
||||
|
||||
got := remainingHashes(t, s)
|
||||
want := []string{"u1-abc", "u2-a"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("remaining hashes = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("remaining hashes = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWithPathPrefixNoMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"reflect"
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/asdine/storm/v3/q"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
|
||||
fberrors "github.com/filebrowser/filebrowser/v2/errors"
|
||||
@@ -41,6 +42,19 @@ func (st usersBackend) GetBy(i interface{}) (user *users.User, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func (st usersBackend) GetByScope(scope string) (*users.User, error) {
|
||||
user := &users.User{}
|
||||
err := st.db.Select(q.Eq("Scope", scope)).First(user)
|
||||
if err != nil {
|
||||
if errors.Is(err, storm.ErrNotFound) {
|
||||
return nil, fberrors.ErrNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (st usersBackend) Gets() ([]*users.User, error) {
|
||||
var allUsers []*users.User
|
||||
err := st.db.All(&allUsers)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
// StorageBackend is the interface to implement for a users storage.
|
||||
type StorageBackend interface {
|
||||
GetBy(interface{}) (*User, error)
|
||||
GetByScope(scope string) (*User, error)
|
||||
Gets() ([]*User, error)
|
||||
Save(u *User) error
|
||||
Update(u *User, fields ...string) error
|
||||
@@ -20,6 +21,7 @@ type StorageBackend interface {
|
||||
|
||||
type Store interface {
|
||||
Get(baseScope string, followExternalSymlinks bool, id interface{}) (user *User, err error)
|
||||
GetByScope(scope string) (*User, error)
|
||||
Gets(baseScope string, followExternalSymlinks bool) ([]*User, error)
|
||||
Update(user *User, fields ...string) error
|
||||
Save(user *User) error
|
||||
@@ -56,6 +58,13 @@ func (s *Storage) Get(baseScope string, followExternalSymlinks bool, id interfac
|
||||
return
|
||||
}
|
||||
|
||||
// GetByScope returns the first user whose scope matches the given one, or
|
||||
// ErrNotExist if none does. The user is returned as stored, without setting up
|
||||
// its filesystem, as it is meant for existence checks rather than serving.
|
||||
func (s *Storage) GetByScope(scope string) (*User, error) {
|
||||
return s.back.GetByScope(scope)
|
||||
}
|
||||
|
||||
// Gets gets a list of all users.
|
||||
func (s *Storage) Gets(baseScope string, followExternalSymlinks bool) ([]*User, error) {
|
||||
users, err := s.back.Gets()
|
||||
|
||||
@@ -46,6 +46,10 @@ The Hook Authentication method in FileBrowser allows developers to delegate user
|
||||
|
||||
The hook’s output controls user permissions, scope, locale, and other attributes, making it a powerful and extensible authentication mechanism.
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> The submitted username and password are attacker-controlled and are handed to your hook command as the `USERNAME` and `PASSWORD` environment variables. File Browser runs the command directly, without a shell, so the values themselves are inert. However, your script must treat them as untrusted: always quote them (`"$USERNAME"`, `"$PASSWORD"`) and never pass them unquoted to a shell, `eval`, `bash -c`, command substitution, or backticks. A hook script that shell-evaluates these values turns any login request into remote code execution.
|
||||
|
||||
For example, the following code delegates filebrowser authentication to a PowerShell script on Windows. You can configure any command (for example, a script in Python, Node.js, etc.).
|
||||
|
||||
```sh
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
## Self-Registration (Signup)
|
||||
|
||||
File Browser allows you to enable user self-registration (signup). This can be enabled via **Settings → Global Settings**, or with `filebrowser config set --signup`. Self-registered users inherit the configured **user defaults**, including the scope.
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> By default, the user scope is the server's root, so a self-registered user could read,
|
||||
> modify, and delete every file File Browser serves. To prevent this, either:
|
||||
>
|
||||
> a. Enable `createUserDir` so each user gets their own directory; or
|
||||
> b. If users are meant to share files, set the default scope to something other than the root.
|
||||
|
||||
## Fail2ban
|
||||
|
||||
File Browser does not natively support protection against brute force attacks. Therefore, we suggest using something like [fail2ban](https://github.com/fail2ban/fail2ban), which takes care of that by tracking the logs of your File Browser instance. For more information on how fail2ban works, please refer to their [wiki](https://github.com/fail2ban/fail2ban/wiki).
|
||||
|
||||
Reference in New Issue
Block a user