diff --git a/.cirrus.yml b/.cirrus.yml
index d26a8bd9..568c34d7 100644
--- a/.cirrus.yml
+++ b/.cirrus.yml
@@ -2,17 +2,17 @@ freebsd_task:
name: FreeBSD
matrix:
- - name: FreeBSD 14.0
+ - name: FreeBSD 14.3
freebsd_instance:
- image_family: freebsd-14-0
+ image_family: freebsd-14-3
pkginstall_script:
- pkg update -f
- - pkg install -y go122
+ - pkg install -y go125
- pkg install -y git
setup_script:
- - ln -s /usr/local/bin/go122 /usr/local/bin/go
+ - ln -s /usr/local/bin/go125 /usr/local/bin/go
- pw groupadd sftpgo
- pw useradd sftpgo -g sftpgo -w none -m
- mkdir /home/sftpgo/sftpgo
@@ -20,7 +20,7 @@ freebsd_task:
- chown -R sftpgo:sftpgo /home/sftpgo/sftpgo
compile_script:
- - su sftpgo -c 'cd ~/sftpgo && go build -trimpath -tags nopgxregisterdefaulttypes -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo'
+ - su sftpgo -c 'cd ~/sftpgo && go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo'
- su sftpgo -c 'cd ~/sftpgo/tests/eventsearcher && go build -trimpath -ldflags "-s -w" -o eventsearcher'
- su sftpgo -c 'cd ~/sftpgo/tests/ipfilter && go build -trimpath -ldflags "-s -w" -o ipfilter'
@@ -28,4 +28,4 @@ freebsd_task:
- su sftpgo -c 'cd ~/sftpgo && ./sftpgo initprovider && ./sftpgo resetprovider --force'
test_script:
- - su sftpgo -c 'cd ~/sftpgo && go test -v -tags nopgxregisterdefaulttypes -p 1 -timeout 20m ./... -coverprofile=coverage.txt -covermode=atomic'
+ - su sftpgo -c 'cd ~/sftpgo && go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 20m ./... -coverprofile=coverage.txt -covermode=atomic'
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 74b99f7a..934cc584 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -7,8 +7,10 @@ body:
attributes:
value: |
### 👍 Thank you for contributing to our project!
- Before asking for help please check the [support policy](https://github.com/drakkan/sftpgo#support-policy).
- If you are a commercial user or a project sponsor please contact us using the dedicated [email address](mailto:support@sftpgo.com).
+ Before asking for help please check our [support policy](https://github.com/drakkan/sftpgo?tab=readme-ov-file#support).
+ If you are a [commercial user](https://sftpgo.com/) please contact us using the dedicated [email address](mailto:support@sftpgo.com).
+ If you'd like to contribute code, please make sure to read and understand our [Contributor License Agreement (CLA)](https://sftpgo.com/cla.html).
+ You’ll be asked to accept it when submitting a pull request.
- type: checkboxes
id: before-posting
attributes:
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index f0cf871b..5fd83037 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -2,6 +2,14 @@ name: 🚀 Feature request
description: Suggest an idea for SFTPGo
labels: ["suggestion"]
body:
+ - type: markdown
+ attributes:
+ value: |
+ ### 👍 Thank you for contributing to our project!
+ Before asking for help please check our [support policy](https://github.com/drakkan/sftpgo?tab=readme-ov-file#support).
+ If you are a [commercial user](https://sftpgo.com/) please contact us using the dedicated [email address](mailto:support@sftpgo.com).
+ If you'd like to contribute code, please make sure to read and understand our [Contributor License Agreement (CLA)](https://sftpgo.com/cla.html).
+ You’ll be asked to accept it when submitting a pull request.
- type: textarea
attributes:
label: Is your feature request related to a problem? Please describe.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 8cd157ff..0c90b458 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,11 +1,11 @@
version: 2
updates:
- - package-ecosystem: "gomod"
- directory: "/"
- schedule:
- interval: "weekly"
- open-pull-requests-limit: 2
+ #- package-ecosystem: "gomod"
+ # directory: "/"
+ # schedule:
+ # interval: "weekly"
+ # open-pull-requests-limit: 2
- package-ecosystem: "docker"
directory: "/"
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 08edea2d..db26ed74 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -15,22 +15,22 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
- go-version: '1.22'
+ go-version: '1.25'
- name: Initialize CodeQL
- uses: github/codeql-action/init@v3
+ uses: github/codeql-action/init@v4
with:
languages: go
- name: Autobuild
- uses: github/codeql-action/autobuild@v3
+ uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v3
\ No newline at end of file
+ uses: github/codeql-action/analyze@v4
\ No newline at end of file
diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml
index 1349f87e..45769de3 100644
--- a/.github/workflows/development.yml
+++ b/.github/workflows/development.yml
@@ -5,34 +5,32 @@ on:
branches: [main]
pull_request:
+permissions:
+ id-token: write
+ contents: read
+
jobs:
test-deploy:
name: Test and deploy
runs-on: ${{ matrix.os }}
strategy:
matrix:
- go: ['1.22']
+ go: ['1.26']
os: [ubuntu-latest, macos-latest]
- upload-coverage: [true]
- include:
- - go: '1.22'
- os: windows-latest
- upload-coverage: false
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go }}
- name: Build for Linux/macOS x86_64
- if: startsWith(matrix.os, 'windows-') != true
run: |
- go build -trimpath -tags nopgxregisterdefaulttypes -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
+ go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
cd tests/eventsearcher
go build -trimpath -ldflags "-s -w" -o eventsearcher
cd -
@@ -44,68 +42,35 @@ jobs:
- name: Build for macOS arm64
if: startsWith(matrix.os, 'macos-') == true
- run: CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -trimpath -tags nopgxregisterdefaulttypes -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo_arm64
-
- - name: Build for Windows
- if: startsWith(matrix.os, 'windows-')
- run: |
- $GIT_COMMIT = (git describe --always --abbrev=8 --dirty) | Out-String
- $DATE_TIME = ([datetime]::Now.ToUniversalTime().toString("yyyy-MM-ddTHH:mm:ssZ")) | Out-String
- $LATEST_TAG = ((git describe --tags $(git rev-list --tags --max-count=1)) | Out-String).Trim()
- $REV_LIST=$LATEST_TAG+"..HEAD"
- $COMMITS_FROM_TAG= ((git rev-list $REV_LIST --count) | Out-String).Trim()
- $FILE_VERSION = $LATEST_TAG.substring(1) + "." + $COMMITS_FROM_TAG
- go install github.com/tc-hib/go-winres@latest
- go-winres simply --arch amd64 --product-version $LATEST_TAG-dev-$GIT_COMMIT --file-version $FILE_VERSION --file-description "SFTPGo server" --product-name SFTPGo --copyright "AGPL-3.0" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
- go build -trimpath -tags nopgxregisterdefaulttypes -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o sftpgo.exe
- cd tests/eventsearcher
- go build -trimpath -ldflags "-s -w" -o eventsearcher.exe
- cd ../..
- cd tests/ipfilter
- go build -trimpath -ldflags "-s -w" -o ipfilter.exe
- cd ../..
- mkdir arm64
- $Env:CGO_ENABLED='0'
- $Env:GOOS='windows'
- $Env:GOARCH='arm64'
- go-winres simply --arch arm64 --product-version $LATEST_TAG-dev-$GIT_COMMIT --file-version $FILE_VERSION --file-description "SFTPGo server" --product-name SFTPGo --copyright "AGPL-3.0" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
- go build -trimpath -tags nopgxregisterdefaulttypes,nosqlite -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o .\arm64\sftpgo.exe
- mkdir x86
- $Env:GOARCH='386'
- go-winres simply --arch 386 --product-version $LATEST_TAG-dev-$GIT_COMMIT --file-version $FILE_VERSION --file-description "SFTPGo server" --product-name SFTPGo --copyright "AGPL-3.0" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
- go build -trimpath -tags nopgxregisterdefaulttypes,nosqlite -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o .\x86\sftpgo.exe
- Remove-Item Env:\CGO_ENABLED
- Remove-Item Env:\GOOS
- Remove-Item Env:\GOARCH
+ run: CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo_arm64
- name: Run test cases using SQLite provider
- run: go test -v -tags nopgxregisterdefaulttypes -p 1 -timeout 15m ./... -coverprofile=coverage.txt -covermode=atomic
+ run: go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 15m ./... -coverprofile=coverage.txt -covermode=atomic
- name: Upload coverage to Codecov
- if: ${{ matrix.upload-coverage }}
- uses: codecov/codecov-action@v4
+ uses: codecov/codecov-action@v5
with:
- file: ./coverage.txt
+ files: ./coverage.txt
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
- name: Run test cases using bolt provider
run: |
- go test -v -tags nopgxregisterdefaulttypes -p 1 -timeout 2m ./internal/config -covermode=atomic
- go test -v -tags nopgxregisterdefaulttypes -p 1 -timeout 5m ./internal/common -covermode=atomic
- go test -v -tags nopgxregisterdefaulttypes -p 1 -timeout 5m ./internal/httpd -covermode=atomic
- go test -v -tags nopgxregisterdefaulttypes -p 1 -timeout 8m ./internal/sftpd -covermode=atomic
- go test -v -tags nopgxregisterdefaulttypes -p 1 -timeout 5m ./internal/ftpd -covermode=atomic
- go test -v -tags nopgxregisterdefaulttypes -p 1 -timeout 5m ./internal/webdavd -covermode=atomic
- go test -v -tags nopgxregisterdefaulttypes -p 1 -timeout 2m ./internal/telemetry -covermode=atomic
- go test -v -tags nopgxregisterdefaulttypes -p 1 -timeout 2m ./internal/mfa -covermode=atomic
- go test -v -tags nopgxregisterdefaulttypes -p 1 -timeout 2m ./internal/command -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 2m ./internal/config -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 5m ./internal/common -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 5m ./internal/httpd -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 8m ./internal/sftpd -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 5m ./internal/ftpd -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 5m ./internal/webdavd -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 2m ./internal/telemetry -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 2m ./internal/mfa -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 2m ./internal/command -covermode=atomic
env:
SFTPGO_DATA_PROVIDER__DRIVER: bolt
SFTPGO_DATA_PROVIDER__NAME: 'sftpgo_bolt.db'
- name: Run test cases using memory provider
- run: go test -v -tags nopgxregisterdefaulttypes -p 1 -timeout 15m ./... -covermode=atomic
+ run: go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 15m ./... -covermode=atomic
env:
SFTPGO_DATA_PROVIDER__DRIVER: memory
SFTPGO_DATA_PROVIDER__NAME: ''
@@ -126,15 +91,100 @@ jobs:
./sftpgo gen man -d output/man/man1
gzip output/man/man1/*
- - name: Prepare Windows installer
- if: ${{ startsWith(matrix.os, 'windows-') && github.event_name != 'pull_request' }}
+ - name: Upload build artifact
+ if: startsWith(matrix.os, 'ubuntu-') != true
+ uses: actions/upload-artifact@v7
+ with:
+ name: sftpgo-${{ matrix.os }}-go-${{ matrix.go }}
+ path: output
+
+ test-deploy-windows:
+ name: Test and deploy Windows
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: '1.26'
+
+ - name: Run test cases using SQLite provider
run: |
+ cd tests/eventsearcher
+ go build -trimpath -ldflags "-s -w" -o eventsearcher.exe
+ cd ../..
+ cd tests/ipfilter
+ go build -trimpath -ldflags "-s -w" -o ipfilter.exe
+ cd ../..
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 15m ./... -coverprofile=coverage.txt -covermode=atomic
+
+ - name: Run test cases using bolt provider
+ run: |
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 2m ./internal/config -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 5m ./internal/common -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 5m ./internal/httpd -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 8m ./internal/sftpd -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 5m ./internal/ftpd -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 5m ./internal/webdavd -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 2m ./internal/telemetry -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 2m ./internal/mfa -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 2m ./internal/command -covermode=atomic
+ env:
+ SFTPGO_DATA_PROVIDER__DRIVER: bolt
+ SFTPGO_DATA_PROVIDER__NAME: 'sftpgo_bolt.db'
+
+ - name: Run test cases using memory provider
+ run: go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 15m ./... -covermode=atomic
+ env:
+ SFTPGO_DATA_PROVIDER__DRIVER: memory
+ SFTPGO_DATA_PROVIDER__NAME: ''
+
+ - name: Build
+ run: |
+ $GIT_COMMIT = (git describe --always --abbrev=8 --dirty) | Out-String
+ $DATE_TIME = ([datetime]::Now.ToUniversalTime().toString("yyyy-MM-ddTHH:mm:ssZ")) | Out-String
+ $LATEST_TAG = ((git describe --tags $(git rev-list --tags --max-count=1)) | Out-String).Trim()
+ $REV_LIST=$LATEST_TAG+"..HEAD"
+ $COMMITS_FROM_TAG= ((git rev-list $REV_LIST --count) | Out-String).Trim()
+ $FILE_VERSION = $LATEST_TAG.substring(1) + "." + $COMMITS_FROM_TAG
+ go install github.com/tc-hib/go-winres@latest
+ go-winres simply --arch amd64 --product-version $LATEST_TAG-dev-$GIT_COMMIT --file-version "$FILE_VERSION" --file-description "SFTPGo server" --product-name SFTPGo --copyright "2019-2025 Nicola Murino" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
+ go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o sftpgo.exe
+ mkdir arm64
+ $Env:CGO_ENABLED='0'
+ $Env:GOOS='windows'
+ $Env:GOARCH='arm64'
+ go-winres simply --arch arm64 --product-version $LATEST_TAG-dev-$GIT_COMMIT --file-version "$FILE_VERSION" --file-description "SFTPGo server" --product-name SFTPGo --copyright "2019-2025 Nicola Murino" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
+ go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules,nosqlite -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o .\arm64\sftpgo.exe
+ mkdir x86
+ $Env:GOARCH='386'
+ go-winres simply --arch 386 --product-version $LATEST_TAG-dev-$GIT_COMMIT --file-version "$FILE_VERSION" --file-description "SFTPGo server" --product-name SFTPGo --copyright "2019-2025 Nicola Murino" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
+ go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules,nosqlite -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o .\x86\sftpgo.exe
+ Remove-Item Env:\CGO_ENABLED
+ Remove-Item Env:\GOOS
+ Remove-Item Env:\GOARCH
+
+ - name: Initialize data provider
+ run: |
+ rm sftpgo.db
+ ./sftpgo initprovider
+ shell: bash
+
+ - name: Prepare Windows installers
+ if: ${{ github.event_name != 'pull_request' }}
+ run: |
+ choco install innosetup
Remove-Item -LiteralPath "output" -Force -Recurse -ErrorAction Ignore
mkdir output
copy .\sftpgo.exe .\output
copy .\sftpgo.json .\output
copy .\sftpgo.db .\output
copy .\LICENSE .\output\LICENSE.txt
+ copy .\NOTICE .\output\NOTICE.txt
mkdir output\templates
xcopy .\templates .\output\templates\ /E
mkdir output\static
@@ -145,15 +195,7 @@ jobs:
$REV_LIST=$LATEST_TAG+"..HEAD"
$COMMITS_FROM_TAG= ((git rev-list $REV_LIST --count) | Out-String).Trim()
$Env:SFTPGO_ISS_DEV_VERSION = $LATEST_TAG + "." + $COMMITS_FROM_TAG
- $CERT_PATH=(Get-Location -PSProvider FileSystem).ProviderPath + "\cert.pfx"
- [IO.File]::WriteAllBytes($CERT_PATH,[System.Convert]::FromBase64String($Env:CERT_DATA))
- certutil -f -p "$Env:CERT_PASS" -importpfx MY "$CERT_PATH"
- rm "$CERT_PATH"
- & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.20348.0/x86/signtool.exe' sign /sm /tr http://timestamp.sectigo.com /td sha256 /fd sha256 /n "Nicola Murino" /d "SFTPGo" .\sftpgo.exe
- & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.20348.0/x86/signtool.exe' sign /sm /tr http://timestamp.sectigo.com /td sha256 /fd sha256 /n "Nicola Murino" /d "SFTPGo" .\arm64\sftpgo.exe
- & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.20348.0/x86/signtool.exe' sign /sm /tr http://timestamp.sectigo.com /td sha256 /fd sha256 /n "Nicola Murino" /d "SFTPGo" .\x86\sftpgo.exe
- $INNO_S='/Ssigntool=$qC:/Program Files (x86)/Windows Kits/10/bin/10.0.20348.0/x86/signtool.exe$q sign /sm /tr http://timestamp.sectigo.com /td sha256 /fd sha256 /n $qNicola Murino$q /d $qSFTPGo$q $f'
- iscc "$INNO_S" .\windows-installer\sftpgo.iss
+ iscc .\windows-installer\sftpgo.iss
rm .\output\sftpgo.exe
rm .\output\sftpgo.db
@@ -165,40 +207,35 @@ jobs:
Remove-Item Env:\SFTPGO_DATA_PROVIDER__DRIVER
Remove-Item Env:\SFTPGO_DATA_PROVIDER__NAME
$Env:SFTPGO_ISS_ARCH='arm64'
- iscc "$INNO_S" .\windows-installer\sftpgo.iss
+ iscc .\windows-installer\sftpgo.iss
rm .\output\sftpgo.exe
copy .\x86\sftpgo.exe .\output
$Env:SFTPGO_ISS_ARCH='x86'
- iscc "$INNO_S" .\windows-installer\sftpgo.iss
- certutil -delstore MY "Nicola Murino"
- env:
- CERT_DATA: ${{ secrets.CERT_DATA }}
- CERT_PASS: ${{ secrets.CERT_PASS }}
+ iscc .\windows-installer\sftpgo.iss
- name: Upload Windows installer x86_64 artifact
- if: ${{ startsWith(matrix.os, 'windows-') && github.event_name != 'pull_request' }}
- uses: actions/upload-artifact@v4
+ if: ${{ github.event_name != 'pull_request' }}
+ uses: actions/upload-artifact@v7
with:
name: sftpgo_windows_installer_x86_64
path: ./sftpgo_windows_x86_64.exe
- name: Upload Windows installer arm64 artifact
- if: ${{ startsWith(matrix.os, 'windows-') && github.event_name != 'pull_request' }}
- uses: actions/upload-artifact@v4
+ if: ${{ github.event_name != 'pull_request' }}
+ uses: actions/upload-artifact@v7
with:
name: sftpgo_windows_installer_arm64
path: ./sftpgo_windows_arm64.exe
- name: Upload Windows installer x86 artifact
- if: ${{ startsWith(matrix.os, 'windows-') && github.event_name != 'pull_request' }}
- uses: actions/upload-artifact@v4
+ if: ${{ github.event_name != 'pull_request' }}
+ uses: actions/upload-artifact@v7
with:
name: sftpgo_windows_installer_x86
path: ./sftpgo_windows_x86.exe
- name: Prepare build artifact for Windows
- if: startsWith(matrix.os, 'windows-')
run: |
Remove-Item -LiteralPath "output" -Force -Recurse -ErrorAction Ignore
mkdir output
@@ -217,10 +254,9 @@ jobs:
xcopy .\openapi .\output\openapi\ /E
- name: Upload build artifact
- if: startsWith(matrix.os, 'ubuntu-') != true
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
- name: sftpgo-${{ matrix.os }}-go-${{ matrix.go }}
+ name: sftpgo-windows-portable
path: output
test-build-flags:
@@ -228,19 +264,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
- go-version: '1.22'
+ go-version: '1.26'
- name: Build
run: |
- go build -trimpath -tags nopgxregisterdefaulttypes,nogcs,nos3,noportable,nobolt,nomysql,nopgsql,nosqlite,nometrics,noazblob,unixcrypt -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
+ go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules,nogcs,nos3,noportable,nobolt,nomysql,nopgsql,nosqlite,nometrics,noazblob,unixcrypt -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
./sftpgo -v
cp -r openapi static templates internal/bundle/
- go build -trimpath -tags nopgxregisterdefaulttypes,bundle -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
+ go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules,bundle -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
./sftpgo -v
test-postgresql-mysql-crdb:
@@ -292,16 +328,16 @@ jobs:
- 3308:3306
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
- go-version: '1.22'
+ go-version: '1.26'
- name: Build
run: |
- go build -trimpath -tags nopgxregisterdefaulttypes -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
+ go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
cd tests/eventsearcher
go build -trimpath -ldflags "-s -w" -o eventsearcher
cd -
@@ -313,7 +349,7 @@ jobs:
run: |
./sftpgo initprovider
./sftpgo resetprovider --force
- go test -v -tags nopgxregisterdefaulttypes -p 1 -timeout 15m ./... -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 15m ./... -covermode=atomic
env:
SFTPGO_DATA_PROVIDER__DRIVER: mysql
SFTPGO_DATA_PROVIDER__NAME: sftpgo
@@ -326,7 +362,7 @@ jobs:
run: |
./sftpgo initprovider
./sftpgo resetprovider --force
- go test -v -tags nopgxregisterdefaulttypes -p 1 -timeout 15m ./... -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 15m ./... -covermode=atomic
env:
SFTPGO_DATA_PROVIDER__DRIVER: postgresql
SFTPGO_DATA_PROVIDER__NAME: sftpgo
@@ -339,7 +375,7 @@ jobs:
run: |
./sftpgo initprovider
./sftpgo resetprovider --force
- go test -v -tags nopgxregisterdefaulttypes -p 1 -timeout 15m ./... -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 15m ./... -covermode=atomic
env:
SFTPGO_DATA_PROVIDER__DRIVER: mysql
SFTPGO_DATA_PROVIDER__NAME: sftpgo
@@ -356,7 +392,7 @@ jobs:
docker exec crdb cockroach sql --insecure -e 'create database "sftpgo"'
./sftpgo initprovider
./sftpgo resetprovider --force
- go test -v -tags nopgxregisterdefaulttypes -p 1 -timeout 15m ./... -covermode=atomic
+ go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 15m ./... -covermode=atomic
docker stop crdb
env:
SFTPGO_DATA_PROVIDER__DRIVER: cockroachdb
@@ -391,7 +427,7 @@ jobs:
go: latest
go-arch: arm7
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -420,7 +456,7 @@ jobs:
echo 'export PATH=$PATH:/usr/local/go/bin' >> build.sh
echo 'go version' >> build.sh
echo 'cd /usr/local/src' >> build.sh
- echo 'go build -buildvcs=false -trimpath -tags nopgxregisterdefaulttypes -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${{ steps.get_commit.outputs.COMMIT }} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo' >> build.sh
+ echo 'go build -buildvcs=false -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${{ steps.get_commit.outputs.COMMIT }} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo' >> build.sh
chmod 755 build.sh
docker run --rm --name ubuntu-build --mount type=bind,source=`pwd`,target=/usr/local/src ${{ matrix.distro }} /usr/local/src/build.sh
@@ -436,7 +472,7 @@ jobs:
gzip output/man/man1/*
cp sftpgo output/
- - uses: uraimo/run-on-arch-action@v2
+ - uses: uraimo/run-on-arch-action@v3
if: ${{ matrix.arch != 'amd64' }}
name: Build for ${{ matrix.arch }}
id: build
@@ -471,7 +507,7 @@ jobs:
then
export GOARM=7
fi
- go build -buildvcs=false -trimpath -tags nopgxregisterdefaulttypes -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${{ steps.get_commit.outputs.COMMIT }} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
+ go build -buildvcs=false -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${{ steps.get_commit.outputs.COMMIT }} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
mkdir -p output/{init,bash_completion,zsh_completion}
cp sftpgo.json output/
cp -r templates output/
@@ -485,7 +521,7 @@ jobs:
cp sftpgo output/
- name: Upload build artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: sftpgo-linux-${{ matrix.arch }}-go-${{ matrix.go }}
path: output
@@ -500,13 +536,13 @@ jobs:
echo "pkg-version=${PKG_VERSION}" >> $GITHUB_OUTPUT
- name: Upload Debian Package
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: sftpgo-${{ steps.build_linux_pkgs.outputs.pkg-version }}-${{ matrix.go-arch }}-deb
path: pkgs/dist/deb/*
- name: Upload RPM Package
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: sftpgo-${{ steps.build_linux_pkgs.outputs.pkg-version }}-${{ matrix.go-arch }}-rpm
path: pkgs/dist/rpm/*
@@ -516,11 +552,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
- go-version: '1.22'
- - uses: actions/checkout@v4
+ go-version: '1.26'
+ - uses: actions/checkout@v6
- name: Run golangci-lint
- uses: golangci/golangci-lint-action@v6
+ uses: golangci/golangci-lint-action@v9
with:
version: latest
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 98617201..822c916e 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -33,7 +33,7 @@ jobs:
optional_deps: true
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Gather image information
id: info
@@ -42,7 +42,7 @@ jobs:
DOCKERFILE=Dockerfile
MINOR=""
MAJOR=""
- FEATURES="nopgxregisterdefaulttypes"
+ FEATURES="nopgxregisterdefaulttypes,disable_grpc_modules"
if [ "${{ github.event_name }}" = "schedule" ]; then
VERSION=nightly
elif [[ $GITHUB_REF == refs/tags/* ]]; then
@@ -141,21 +141,21 @@ jobs:
OPTIONAL_DEPS: ${{ matrix.optional_deps }}
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@v4
- name: Set up builder
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
id: builder
- name: Login to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
if: ${{ github.event_name != 'pull_request' }}
- name: Login to GitHub Container Registry
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -163,7 +163,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' }}
- name: Build and push
- uses: docker/build-push-action@v5
+ uses: docker/build-push-action@v7
with:
context: .
builder: ${{ steps.builder.outputs.name }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a752efed..9ac10ee5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,17 +4,21 @@ on:
push:
tags: 'v*'
+permissions:
+ id-token: write
+ contents: write
+
env:
- GO_VERSION: 1.22.3
+ GO_VERSION: 1.25.8
jobs:
prepare-sources-with-deps:
name: Prepare sources with deps
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
@@ -32,23 +36,20 @@ jobs:
SFTPGO_VERSION: ${{ steps.get_version.outputs.VERSION }}
- name: Upload build artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: sftpgo_${{ steps.get_version.outputs.VERSION }}_src_with_deps.tar.xz
path: ./sftpgo_${{ steps.get_version.outputs.VERSION }}_src_with_deps.tar.xz
retention-days: 1
- prepare-window-mac:
- name: Prepare binaries
- runs-on: ${{ matrix.os }}
- strategy:
- matrix:
- os: [macos-12, windows-2022]
+ prepare-windows:
+ name: Prepare Windows binaries
+ runs-on: windows-2022
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
@@ -57,46 +58,24 @@ jobs:
run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
shell: bash
- - name: Get OS name
- id: get_os_name
- run: |
- if [[ $MATRIX_OS =~ ^macos.* ]]
- then
- echo "OS=macOS" >> $GITHUB_OUTPUT
- else
- echo "OS=windows" >> $GITHUB_OUTPUT
- fi
- shell: bash
- env:
- MATRIX_OS: ${{ matrix.os }}
-
- - name: Build for macOS x86_64
- if: startsWith(matrix.os, 'windows-') != true
- run: go build -trimpath -tags nopgxregisterdefaulttypes -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
-
- - name: Build for macOS arm64
- if: startsWith(matrix.os, 'macos-') == true
- run: CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -trimpath -tags nopgxregisterdefaulttypes -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo_arm64
-
- - name: Build for Windows
- if: startsWith(matrix.os, 'windows-')
+ - name: Build
run: |
$GIT_COMMIT = (git describe --always --abbrev=8 --dirty) | Out-String
$DATE_TIME = ([datetime]::Now.ToUniversalTime().toString("yyyy-MM-ddTHH:mm:ssZ")) | Out-String
$FILE_VERSION = $Env:SFTPGO_VERSION.substring(1) + ".0"
go install github.com/tc-hib/go-winres@latest
- go-winres simply --arch amd64 --product-version $Env:SFTPGO_VERSION-$GIT_COMMIT --file-version $FILE_VERSION --file-description "SFTPGo server" --product-name SFTPGo --copyright "AGPL-3.0" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
- go build -trimpath -tags nopgxregisterdefaulttypes -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o sftpgo.exe
+ go-winres simply --arch amd64 --product-version $Env:SFTPGO_VERSION-$GIT_COMMIT --file-version "$FILE_VERSION" --file-description "SFTPGo server" --product-name SFTPGo --copyright "2019-2025 Nicola Murino" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
+ go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o sftpgo.exe
mkdir arm64
$Env:CGO_ENABLED='0'
$Env:GOOS='windows'
$Env:GOARCH='arm64'
- go-winres simply --arch arm64 --product-version $Env:SFTPGO_VERSION-$GIT_COMMIT --file-version $FILE_VERSION --file-description "SFTPGo server" --product-name SFTPGo --copyright "AGPL-3.0" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
- go build -trimpath -tags nopgxregisterdefaulttypes,nosqlite -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o .\arm64\sftpgo.exe
+ go-winres simply --arch arm64 --product-version $Env:SFTPGO_VERSION-$GIT_COMMIT --file-version "$FILE_VERSION" --file-description "SFTPGo server" --product-name SFTPGo --copyright "2019-2025 Nicola Murino" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
+ go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules,nosqlite -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o .\arm64\sftpgo.exe
mkdir x86
$Env:GOARCH='386'
- go-winres simply --arch 386 --product-version $Env:SFTPGO_VERSION-$GIT_COMMIT --file-version $FILE_VERSION --file-description "SFTPGo server" --product-name SFTPGo --copyright "AGPL-3.0" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
- go build -trimpath -tags nopgxregisterdefaulttypes,nosqlite -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o .\x86\sftpgo.exe
+ go-winres simply --arch 386 --product-version $Env:SFTPGO_VERSION-$GIT_COMMIT --file-version "$FILE_VERSION" --file-description "SFTPGo server" --product-name SFTPGo --copyright "2019-2025 Nicola Murino" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
+ go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules,nosqlite -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o .\x86\sftpgo.exe
Remove-Item Env:\CGO_ENABLED
Remove-Item Env:\GOOS
Remove-Item Env:\GOARCH
@@ -107,14 +86,123 @@ jobs:
run: ./sftpgo initprovider
shell: bash
- - name: Prepare Release for macOS
- if: startsWith(matrix.os, 'macos-')
+ - name: Prepare Release
+ run: |
+ mkdir output
+ copy .\sftpgo.exe .\output
+ copy .\sftpgo.json .\output
+ copy .\sftpgo.db .\output
+ copy .\LICENSE .\output\LICENSE.txt
+ copy .\NOTICE .\output\NOTICE.txt
+ mkdir output\templates
+ xcopy .\templates .\output\templates\ /E
+ mkdir output\static
+ xcopy .\static .\output\static\ /E
+ mkdir output\openapi
+ xcopy .\openapi .\output\openapi\ /E
+ iscc .\windows-installer\sftpgo.iss
+ rm .\output\sftpgo.exe
+ rm .\output\sftpgo.db
+ copy .\arm64\sftpgo.exe .\output
+ (Get-Content .\output\sftpgo.json).replace('"sqlite"', '"bolt"') | Set-Content .\output\sftpgo.json
+ $Env:SFTPGO_DATA_PROVIDER__DRIVER='bolt'
+ $Env:SFTPGO_DATA_PROVIDER__NAME='.\output\sftpgo.db'
+ .\sftpgo.exe initprovider
+ Remove-Item Env:\SFTPGO_DATA_PROVIDER__DRIVER
+ Remove-Item Env:\SFTPGO_DATA_PROVIDER__NAME
+ $Env:SFTPGO_ISS_ARCH='arm64'
+ iscc .\windows-installer\sftpgo.iss
+
+ rm .\output\sftpgo.exe
+ copy .\x86\sftpgo.exe .\output
+ $Env:SFTPGO_ISS_ARCH='x86'
+ iscc .\windows-installer\sftpgo.iss
+ env:
+ SFTPGO_ISS_VERSION: ${{ steps.get_version.outputs.VERSION }}
+
+ - name: Prepare Portable Release
+ run: |
+ mkdir win-portable
+ copy .\sftpgo.exe .\win-portable
+ mkdir win-portable\arm64
+ copy .\arm64\sftpgo.exe .\win-portable\arm64
+ mkdir win-portable\x86
+ copy .\x86\sftpgo.exe .\win-portable\x86
+ copy .\sftpgo.json .\win-portable
+ (Get-Content .\win-portable\sftpgo.json).replace('"sqlite"', '"bolt"') | Set-Content .\win-portable\sftpgo.json
+ copy .\output\sftpgo.db .\win-portable
+ copy .\LICENSE .\win-portable\LICENSE.txt
+ copy .\NOTICE .\win-portable\NOTICE.txt
+ mkdir win-portable\templates
+ xcopy .\templates .\win-portable\templates\ /E
+ mkdir win-portable\static
+ xcopy .\static .\win-portable\static\ /E
+ mkdir win-portable\openapi
+ xcopy .\openapi .\win-portable\openapi\ /E
+ Compress-Archive .\win-portable\* sftpgo_portable.zip
+
+ - name: Upload Windows installer x86_64 artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: sftpgo_${{ steps.get_version.outputs.VERSION }}_windows_x86_64.exe
+ path: ./sftpgo_windows_x86_64.exe
+ retention-days: 1
+
+ - name: Upload Windows installer arm64 artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: sftpgo_${{ steps.get_version.outputs.VERSION }}_windows_arm64.exe
+ path: ./sftpgo_windows_arm64.exe
+ retention-days: 1
+
+ - name: Upload Windows installer x86 artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: sftpgo_${{ steps.get_version.outputs.VERSION }}_windows_x86.exe
+ path: ./sftpgo_windows_x86.exe
+ retention-days: 1
+
+ - name: Upload Windows portable artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: sftpgo_${{ steps.get_version.outputs.VERSION }}_windows_portable.zip
+ path: ./sftpgo_portable.zip
+ retention-days: 1
+
+ prepare-mac:
+ name: Prepare macOS binaries
+ runs-on: macos-14
+
+ steps:
+ - uses: actions/checkout@v6
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: ${{ env.GO_VERSION }}
+
+ - name: Get SFTPGo version
+ id: get_version
+ run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
+ shell: bash
+
+ - name: Build for macOS x86_64
+ run: go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
+
+ - name: Build for macOS arm64
+ run: CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo_arm64
+
+ - name: Initialize data provider
+ run: ./sftpgo initprovider
+ shell: bash
+
+ - name: Prepare Release
run: |
mkdir -p output/{init,sqlite,bash_completion,zsh_completion}
echo "For documentation please take a look here:" > output/README.txt
echo "" >> output/README.txt
- echo "https://github.com/drakkan/sftpgo/blob/${SFTPGO_VERSION}/README.md" >> output/README.txt
+ echo "https://docs.sftpgo.com" >> output/README.txt
cp LICENSE output/
+ cp NOTICE output/
cp sftpgo output/
cp sftpgo.json output/
cp sftpgo.db output/sqlite/
@@ -127,130 +215,27 @@ jobs:
./sftpgo gen man -d output/man/man1
gzip output/man/man1/*
cd output
- tar cJvf ../sftpgo_${SFTPGO_VERSION}_${OS}_x86_64.tar.xz *
+ tar cJvf ../sftpgo_${SFTPGO_VERSION}_macOS_x86_64.tar.xz *
cd ..
cp sftpgo_arm64 output/sftpgo
cd output
- tar cJvf ../sftpgo_${SFTPGO_VERSION}_${OS}_arm64.tar.xz *
+ tar cJvf ../sftpgo_${SFTPGO_VERSION}_macOS_arm64.tar.xz *
cd ..
env:
SFTPGO_VERSION: ${{ steps.get_version.outputs.VERSION }}
- OS: ${{ steps.get_os_name.outputs.OS }}
-
- - name: Prepare Release for Windows
- if: startsWith(matrix.os, 'windows-')
- run: |
- mkdir output
- copy .\sftpgo.exe .\output
- copy .\sftpgo.json .\output
- copy .\sftpgo.db .\output
- copy .\LICENSE .\output\LICENSE.txt
- mkdir output\templates
- xcopy .\templates .\output\templates\ /E
- mkdir output\static
- xcopy .\static .\output\static\ /E
- mkdir output\openapi
- xcopy .\openapi .\output\openapi\ /E
- $CERT_PATH=(Get-Location -PSProvider FileSystem).ProviderPath + "\cert.pfx"
- [IO.File]::WriteAllBytes($CERT_PATH,[System.Convert]::FromBase64String($Env:CERT_DATA))
- certutil -f -p "$Env:CERT_PASS" -importpfx MY "$CERT_PATH"
- rm "$CERT_PATH"
- & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.20348.0/x86/signtool.exe' sign /sm /tr http://timestamp.sectigo.com /td sha256 /fd sha256 /n "Nicola Murino" /d "SFTPGo" .\sftpgo.exe
- & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.20348.0/x86/signtool.exe' sign /sm /tr http://timestamp.sectigo.com /td sha256 /fd sha256 /n "Nicola Murino" /d "SFTPGo" .\arm64\sftpgo.exe
- & 'C:/Program Files (x86)/Windows Kits/10/bin/10.0.20348.0/x86/signtool.exe' sign /sm /tr http://timestamp.sectigo.com /td sha256 /fd sha256 /n "Nicola Murino" /d "SFTPGo" .\x86\sftpgo.exe
- $INNO_S='/Ssigntool=$qC:/Program Files (x86)/Windows Kits/10/bin/10.0.20348.0/x86/signtool.exe$q sign /sm /tr http://timestamp.sectigo.com /td sha256 /fd sha256 /n $qNicola Murino$q /d $qSFTPGo$q $f'
- iscc "$INNO_S" .\windows-installer\sftpgo.iss
-
- rm .\output\sftpgo.exe
- rm .\output\sftpgo.db
- copy .\arm64\sftpgo.exe .\output
- (Get-Content .\output\sftpgo.json).replace('"sqlite"', '"bolt"') | Set-Content .\output\sftpgo.json
- $Env:SFTPGO_DATA_PROVIDER__DRIVER='bolt'
- $Env:SFTPGO_DATA_PROVIDER__NAME='.\output\sftpgo.db'
- .\sftpgo.exe initprovider
- Remove-Item Env:\SFTPGO_DATA_PROVIDER__DRIVER
- Remove-Item Env:\SFTPGO_DATA_PROVIDER__NAME
- $Env:SFTPGO_ISS_ARCH='arm64'
- iscc "$INNO_S" .\windows-installer\sftpgo.iss
-
- rm .\output\sftpgo.exe
- copy .\x86\sftpgo.exe .\output
- $Env:SFTPGO_ISS_ARCH='x86'
- iscc "$INNO_S" .\windows-installer\sftpgo.iss
- certutil -delstore MY "Nicola Murino"
- env:
- SFTPGO_ISS_VERSION: ${{ steps.get_version.outputs.VERSION }}
- SFTPGO_ISS_DOC_URL: https://github.com/drakkan/sftpgo/blob/${{ steps.get_version.outputs.VERSION }}/README.md
- CERT_DATA: ${{ secrets.CERT_DATA }}
- CERT_PASS: ${{ secrets.CERT_PASS }}
-
- - name: Prepare Portable Release for Windows
- if: startsWith(matrix.os, 'windows-')
- run: |
- mkdir win-portable
- copy .\sftpgo.exe .\win-portable
- mkdir win-portable\arm64
- copy .\arm64\sftpgo.exe .\win-portable\arm64
- mkdir win-portable\x86
- copy .\x86\sftpgo.exe .\win-portable\x86
- copy .\sftpgo.json .\win-portable
- (Get-Content .\win-portable\sftpgo.json).replace('"sqlite"', '"bolt"') | Set-Content .\win-portable\sftpgo.json
- copy .\output\sftpgo.db .\win-portable
- copy .\LICENSE .\win-portable\LICENSE.txt
- mkdir win-portable\templates
- xcopy .\templates .\win-portable\templates\ /E
- mkdir win-portable\static
- xcopy .\static .\win-portable\static\ /E
- mkdir win-portable\openapi
- xcopy .\openapi .\win-portable\openapi\ /E
- Compress-Archive .\win-portable\* sftpgo_portable.zip
- name: Upload macOS x86_64 artifact
- if: startsWith(matrix.os, 'macos-')
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
- name: sftpgo_${{ steps.get_version.outputs.VERSION }}_${{ steps.get_os_name.outputs.OS }}_x86_64.tar.xz
- path: ./sftpgo_${{ steps.get_version.outputs.VERSION }}_${{ steps.get_os_name.outputs.OS }}_x86_64.tar.xz
+ name: sftpgo_${{ steps.get_version.outputs.VERSION }}_macOS_x86_64.tar.xz
+ path: ./sftpgo_${{ steps.get_version.outputs.VERSION }}_macOS_x86_64.tar.xz
retention-days: 1
- name: Upload macOS arm64 artifact
- if: startsWith(matrix.os, 'macos-')
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
- name: sftpgo_${{ steps.get_version.outputs.VERSION }}_${{ steps.get_os_name.outputs.OS }}_arm64.tar.xz
- path: ./sftpgo_${{ steps.get_version.outputs.VERSION }}_${{ steps.get_os_name.outputs.OS }}_arm64.tar.xz
- retention-days: 1
-
- - name: Upload Windows installer x86_64 artifact
- if: startsWith(matrix.os, 'windows-')
- uses: actions/upload-artifact@v4
- with:
- name: sftpgo_${{ steps.get_version.outputs.VERSION }}_${{ steps.get_os_name.outputs.OS }}_x86_64.exe
- path: ./sftpgo_windows_x86_64.exe
- retention-days: 1
-
- - name: Upload Windows installer arm64 artifact
- if: startsWith(matrix.os, 'windows-')
- uses: actions/upload-artifact@v4
- with:
- name: sftpgo_${{ steps.get_version.outputs.VERSION }}_${{ steps.get_os_name.outputs.OS }}_arm64.exe
- path: ./sftpgo_windows_arm64.exe
- retention-days: 1
-
- - name: Upload Windows installer x86 artifact
- if: startsWith(matrix.os, 'windows-')
- uses: actions/upload-artifact@v4
- with:
- name: sftpgo_${{ steps.get_version.outputs.VERSION }}_${{ steps.get_os_name.outputs.OS }}_x86.exe
- path: ./sftpgo_windows_x86.exe
- retention-days: 1
-
- - name: Upload Windows portable artifact
- if: startsWith(matrix.os, 'windows-')
- uses: actions/upload-artifact@v4
- with:
- name: sftpgo_${{ steps.get_version.outputs.VERSION }}_${{ steps.get_os_name.outputs.OS }}_portable.zip
- path: ./sftpgo_portable.zip
+ name: sftpgo_${{ steps.get_version.outputs.VERSION }}_macOS_arm64.tar.xz
+ path: ./sftpgo_${{ steps.get_version.outputs.VERSION }}_macOS_arm64.tar.xz
retention-days: 1
prepare-linux:
@@ -285,7 +270,7 @@ jobs:
tar-arch: armv7
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Get versions
id: get_version
@@ -310,7 +295,7 @@ jobs:
echo 'export PATH=$PATH:/usr/local/go/bin' >> build.sh
echo 'go version' >> build.sh
echo 'cd /usr/local/src' >> build.sh
- echo 'go build -buildvcs=false -trimpath -tags nopgxregisterdefaulttypes -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${{ steps.get_version.outputs.COMMIT }} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo' >> build.sh
+ echo 'go build -buildvcs=false -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${{ steps.get_version.outputs.COMMIT }} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo' >> build.sh
chmod 755 build.sh
docker run --rm --name ubuntu-build --mount type=bind,source=`pwd`,target=/usr/local/src ${{ matrix.distro }} /usr/local/src/build.sh
@@ -319,6 +304,7 @@ jobs:
echo "" >> output/README.txt
echo "https://github.com/drakkan/sftpgo/blob/${SFTPGO_VERSION}/README.md" >> output/README.txt
cp LICENSE output/
+ cp NOTICE output/
cp sftpgo.json output/
cp -r templates output/
cp -r static output/
@@ -337,7 +323,7 @@ jobs:
env:
SFTPGO_VERSION: ${{ steps.get_version.outputs.SFTPGO_VERSION }}
- - uses: uraimo/run-on-arch-action@v2
+ - uses: uraimo/run-on-arch-action@v3
if: ${{ matrix.arch != 'amd64' }}
name: Build for ${{ matrix.arch }}
id: build
@@ -362,12 +348,13 @@ jobs:
run: |
export PATH=$PATH:/usr/local/go/bin
go version
- go build -buildvcs=false -trimpath -tags nopgxregisterdefaulttypes -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${{ steps.get_version.outputs.COMMIT }} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
+ go build -buildvcs=false -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${{ steps.get_version.outputs.COMMIT }} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
mkdir -p output/{init,sqlite,bash_completion,zsh_completion}
echo "For documentation please take a look here:" > output/README.txt
echo "" >> output/README.txt
echo "https://github.com/drakkan/sftpgo/blob/${{ steps.get_version.outputs.SFTPGO_VERSION }}/README.md" >> output/README.txt
cp LICENSE output/
+ cp NOTICE output/
cp sftpgo.json output/
cp -r templates output/
cp -r static output/
@@ -385,7 +372,7 @@ jobs:
cd ..
- name: Upload build artifact for ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_${{ matrix.tar-arch }}.tar.xz
path: ./output/sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_${{ matrix.tar-arch }}.tar.xz
@@ -403,14 +390,14 @@ jobs:
SFTPGO_VERSION: ${{ steps.get_version.outputs.SFTPGO_VERSION }}
- name: Upload Deb Package
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: sftpgo_${{ steps.build_linux_pkgs.outputs.pkg-version }}-1_${{ matrix.deb-arch}}.deb
path: ./pkgs/dist/deb/sftpgo_${{ steps.build_linux_pkgs.outputs.pkg-version }}-1_${{ matrix.deb-arch}}.deb
retention-days: 1
- name: Upload RPM Package
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: sftpgo-${{ steps.build_linux_pkgs.outputs.pkg-version }}-1.${{ matrix.rpm-arch}}.rpm
path: ./pkgs/dist/rpm/sftpgo-${{ steps.build_linux_pkgs.outputs.pkg-version }}-1.${{ matrix.rpm-arch}}.rpm
@@ -429,22 +416,22 @@ jobs:
shell: bash
- name: Download amd64 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_x86_64.tar.xz
- name: Download arm64 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_arm64.tar.xz
- name: Download ppc64le artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_ppc64le.tar.xz
- name: Download armv7 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_armv7.tar.xz
@@ -467,7 +454,7 @@ jobs:
SFTPGO_VERSION: ${{ steps.get_version.outputs.SFTPGO_VERSION }}
- name: Upload Linux bundle
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_bundle.tar.xz
path: ./bundle/sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_bundle.tar.xz
@@ -475,11 +462,11 @@ jobs:
create-release:
name: Release
- needs: [prepare-linux-bundle, prepare-sources-with-deps, prepare-window-mac]
+ needs: [prepare-linux-bundle, prepare-sources-with-deps, prepare-mac, prepare-windows]
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Get versions
id: get_version
run: |
@@ -490,102 +477,102 @@ jobs:
shell: bash
- name: Download amd64 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_x86_64.tar.xz
- name: Download arm64 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_arm64.tar.xz
- name: Download ppc64le artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_ppc64le.tar.xz
- name: Download armv7 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_armv7.tar.xz
- name: Download Linux bundle artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_bundle.tar.xz
- name: Download Deb amd64 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.PKG_VERSION }}-1_amd64.deb
- name: Download Deb arm64 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.PKG_VERSION }}-1_arm64.deb
- name: Download Deb ppc64le artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.PKG_VERSION }}-1_ppc64el.deb
- name: Download Deb armv7 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.PKG_VERSION }}-1_armhf.deb
- name: Download RPM x86_64 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo-${{ steps.get_version.outputs.PKG_VERSION }}-1.x86_64.rpm
- name: Download RPM aarch64 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo-${{ steps.get_version.outputs.PKG_VERSION }}-1.aarch64.rpm
- name: Download RPM ppc64le artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo-${{ steps.get_version.outputs.PKG_VERSION }}-1.ppc64le.rpm
- name: Download RPM armv7 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo-${{ steps.get_version.outputs.PKG_VERSION }}-1.armv7hl.rpm
- name: Download macOS x86_64 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_macOS_x86_64.tar.xz
- name: Download macOS arm64 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_macOS_arm64.tar.xz
- name: Download Windows installer x86_64 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_windows_x86_64.exe
- name: Download Windows installer arm64 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_windows_arm64.exe
- name: Download Windows installer x86 artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_windows_x86.exe
- name: Download Windows portable artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_windows_portable.zip
- name: Download source with deps artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_src_with_deps.tar.xz
diff --git a/.golangci.yml b/.golangci.yml
index ac545673..14f9c1f9 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,52 +1,66 @@
+version: "2"
run:
- timeout: 10m
issues-exit-code: 1
tests: true
-
-
-linters-settings:
- dupl:
- threshold: 150
- errcheck:
- check-type-assertions: false
- check-blank: false
- goconst:
- min-len: 3
- min-occurrences: 3
- gocyclo:
- min-complexity: 15
- gofmt:
- simplify: true
- goimports:
- local-prefixes: github.com/drakkan/sftpgo
- #govet:
- # report about shadowed variables
- #check-shadowing: true
- #enable:
- # - fieldalignment
-
-issues:
- include:
- - EXC0002
- - EXC0012
- - EXC0013
- - EXC0014
- - EXC0015
-
linters:
enable:
- - goconst
- - errcheck
- - gofmt
- - goimports
- - revive
- - unconvert
- - unparam
- bodyclose
+ - dogsled
+ - dupl
+ - goconst
- gocyclo
- misspell
- - whitespace
- - dupl
+ - revive
- rowserrcheck
- - dogsled
- - govet
+ - unconvert
+ - unparam
+ - whitespace
+ settings:
+ dupl:
+ threshold: 150
+ errcheck:
+ check-type-assertions: false
+ check-blank: false
+ goconst:
+ min-len: 3
+ min-occurrences: 3
+ gocyclo:
+ min-complexity: 15
+ # https://golangci-lint.run/usage/linters/#revive
+ revive:
+ rules:
+ - name: var-naming
+ severity: warning
+ disabled: true
+ exclude: [""]
+ arguments:
+ - ["ID"] # AllowList
+ - ["VM"] # DenyList
+ - - upper-case-const: true
+ - - skip-package-name-checks: true
+ exclusions:
+ generated: lax
+ presets:
+ - common-false-positives
+ - legacy
+ - std-error-handling
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
+formatters:
+ enable:
+ - gofmt
+ - goimports
+ settings:
+ gofmt:
+ simplify: true
+ goimports:
+ local-prefixes:
+ - github.com/drakkan/sftpgo
+ exclusions:
+ generated: lax
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
diff --git a/Dockerfile b/Dockerfile
index bd26b684..65371870 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.22-bookworm as builder
+FROM golang:1.26-trixie AS builder
ENV GOFLAGS="-mod=readonly"
@@ -10,7 +10,7 @@ WORKDIR /workspace
ARG GOPROXY
COPY go.mod go.sum ./
-RUN go mod download
+RUN go mod download && go mod verify
ARG COMMIT_SHA
@@ -30,14 +30,14 @@ ARG DOWNLOAD_PLUGINS=false
RUN if [ "${DOWNLOAD_PLUGINS}" = "true" ]; then apt-get update && apt-get install --no-install-recommends -y curl && ./docker/scripts/download-plugins.sh; fi
-FROM debian:bookworm-slim
+FROM debian:trixie-slim
-# Set to "true" to install jq and the optional git and rsync dependencies
+# Set to "true" to install jq
ARG INSTALL_OPTIONAL_PACKAGES=false
RUN apt-get update && apt-get -y upgrade && apt-get install --no-install-recommends -y ca-certificates media-types && rm -rf /var/lib/apt/lists/*
-RUN if [ "${INSTALL_OPTIONAL_PACKAGES}" = "true" ]; then apt-get update && apt-get install --no-install-recommends -y jq git rsync && rm -rf /var/lib/apt/lists/*; fi
+RUN if [ "${INSTALL_OPTIONAL_PACKAGES}" = "true" ]; then apt-get update && apt-get install --no-install-recommends -y jq && rm -rf /var/lib/apt/lists/*; fi
RUN mkdir -p /etc/sftpgo /var/lib/sftpgo /usr/share/sftpgo /srv/sftpgo/data /srv/sftpgo/backups
diff --git a/Dockerfile.alpine b/Dockerfile.alpine
index 3b9f82f5..494442dc 100644
--- a/Dockerfile.alpine
+++ b/Dockerfile.alpine
@@ -1,4 +1,4 @@
-FROM golang:1.22-alpine3.19 AS builder
+FROM golang:1.26-alpine3.23 AS builder
ENV GOFLAGS="-mod=readonly"
@@ -10,7 +10,7 @@ WORKDIR /workspace
ARG GOPROXY
COPY go.mod go.sum ./
-RUN go mod download
+RUN go mod download && go mod verify
ARG COMMIT_SHA
@@ -25,14 +25,14 @@ RUN set -xe && \
export COMMIT_SHA=${COMMIT_SHA:-$(git describe --always --abbrev=8 --dirty)} && \
go build $(if [ -n "${FEATURES}" ]; then echo "-tags ${FEATURES}"; fi) -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${COMMIT_SHA} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -v -o sftpgo
-FROM alpine:3.19
+FROM alpine:3.23
-# Set to "true" to install jq and the optional git and rsync dependencies
+# Set to "true" to install jq
ARG INSTALL_OPTIONAL_PACKAGES=false
RUN apk -U upgrade --no-cache && apk add --update --no-cache ca-certificates tzdata mailcap
-RUN if [ "${INSTALL_OPTIONAL_PACKAGES}" = "true" ]; then apk add --update --no-cache jq git rsync; fi
+RUN if [ "${INSTALL_OPTIONAL_PACKAGES}" = "true" ]; then apk add --update --no-cache jq; fi
RUN mkdir -p /etc/sftpgo /var/lib/sftpgo /usr/share/sftpgo /srv/sftpgo/data /srv/sftpgo/backups
diff --git a/Dockerfile.distroless b/Dockerfile.distroless
index a88e2fc4..36bc1406 100644
--- a/Dockerfile.distroless
+++ b/Dockerfile.distroless
@@ -1,4 +1,4 @@
-FROM golang:1.22-bookworm as builder
+FROM golang:1.26-trixie AS builder
ENV CGO_ENABLED=0 GOFLAGS="-mod=readonly"
@@ -10,7 +10,7 @@ WORKDIR /workspace
ARG GOPROXY
COPY go.mod go.sum ./
-RUN go mod download
+RUN go mod download && go mod verify
ARG COMMIT_SHA
@@ -32,7 +32,7 @@ RUN sed -i 's|"users_base_dir": "",|"users_base_dir": "/srv/sftpgo/data",|' sftp
RUN mkdir /etc/sftpgo /var/lib/sftpgo /srv/sftpgo
-FROM gcr.io/distroless/static-debian12
+FROM gcr.io/distroless/static-debian13
COPY --from=builder --chown=1000:1000 /etc/sftpgo /etc/sftpgo
COPY --from=builder --chown=1000:1000 /srv/sftpgo /srv/sftpgo
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 00000000..f683d86f
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,12 @@
+Additional terms under GNU AGPL version 3 section 7.3(b) and 13.1:
+
+If you have included SFTPGo so that it is offered through any network
+interactions, including by means of an external user interface, or
+any other integration, even without modifying its source code and then
+SFTPGo is partially, fully or optionally configured via your frontend,
+you must provide reasonable but clear attribution to the SFTPGo project
+and its author(s), not imply any endorsement by or affiliation with the
+SFTPGo project, and you must prominently offer all users interacting
+with it remotely through a computer network an opportunity to receive
+the Corresponding Source of the SFTPGo version you include by providing
+a link to the Corresponding Source in the SFTPGo source code repository.
diff --git a/README.md b/README.md
index ef80d315..bee0bc4f 100644
--- a/README.md
+++ b/README.md
@@ -1,31 +1,51 @@
# SFTPGo
-[](https://github.com/drakkan/sftpgo/workflows/CI/badge.svg?branch=main&event=push)
-[](https://codecov.io/gh/drakkan/sftpgo/branch/main)
+[](https://github.com/drakkan/sftpgo/workflows/CI/badge.svg)
[](https://www.gnu.org/licenses/agpl-3.0)
[](https://github.com/avelino/awesome-go)
-Full-featured and highly configurable event-driven file transfer solution.
-Server protocols: SFTP, HTTP/S, FTP/S, WebDAV.
-Storage backends: local filesystem, encrypted local filesystem, S3 (compatible) Object Storage, Google Cloud Storage, Azure Blob Storage, other SFTP servers.
+Full-featured and highly configurable event-driven file transfer solution. Server protocols: SFTP, HTTP/S, FTP/S, WebDAV. Storage backends: local filesystem, encrypted local filesystem, S3 (compatible) Object Storage, Google Cloud Storage, Azure Blob Storage, other SFTP servers.
With SFTPGo you can leverage local and cloud storage backends for exchanging and storing files internally or with business partners using the same tools and processes you are already familiar with.
-The WebAdmin UI allows to easily create and manage your users, folders, groups and other resources.
+## Project Status & Editions
-The WebClient UI allows end users to change their credentials, browse and manage their files in the browser and setup two-factor authentication which works with Microsoft Authenticator, Google Authenticator, Authy and other compatible apps.
+SFTPGo is an open-source project with a sustainable business model. We offer two editions to suit different requirements, ensuring the project remains healthy and maintained for everyone.
+
+### Open Source (Community)
+
+Free, Copyleft (AGPLv3), Community Supported. The Community edition is a fully functional, production-ready solution widely adopted worldwide. It includes all the core protocols, storage backends, and the WebAdmin/WebClient UIs. It is ideal for:
+
+- Standard file transfer needs.
+- Integrating storage backends (S3, GCS, Azure Blob) with legacy protocols.
+- Projects that are comfortable with AGPLv3 licensing.
+
+### SFTPGo Enterprise
+
+Commercial License, Professional Support, ISO 27001 Vendor. The Enterprise edition is built on the same core but extends it for mission-critical environments, compliance-heavy industries, and advanced workflows. It is a drop-in replacement (seamless upgrade).
+
+| Feature | Open Source (Community) | Enterprise Edition |
+| :--- | :--- | :--- |
+| **License Type** | AGPLv3 (Copyleft) | **Commercial License**
Proprietary/No Copyleft |
+| **Vendor Compliance** | Not Applicable
Community Project | **Certified Vendor**
ISO 27001 & Supply Chain Validation |
+| **Support** | Community (GitHub) | **Direct from Authors** |
+| **Cloud Storage Engine** | Standard | **High Performance & Scalable**
In-memory streaming (no local temp files) and up to 70% faster |
+| **High Availability (HA)** | Standard
Shared DB & Storage | **Advanced**
Enhanced event handling and optimized instance coordination |
+| **Automation Logic** | Simple Placeholders | **Dynamic Logic & Virtual Folders**
Conditions, loops, route data across storage backends |
+| **Data Lifecycle** | Delete / Retain | **Smart Archiving**
Move data to external Cloud/SFTP storage via Virtual Folders |
+| **Email Data Ingestion** | - | **Native IMAP Integration**
Auto-extract attachments from email to storage |
+| **Public Sharing** | Standard Links | **Advanced & Collaborative**
Email Authentication & Group Delegation |
+| **Data Protection** | - | **Encryption & Scanning**
Automated PGP, Antivirus & DLP via ICAP |
+| **Advanced Identity (SSO)** | Standard | **Extended Controls**
Advanced Single Sign-On parameters |
+| **Document Editing** | - | **Included**
View, edit, and co-author in browser |
+
+**Note**: We are committed to keeping the Open Source edition powerful and maintained. The Enterprise edition helps fund the development of the entire SFTPGo ecosystem.
## Sponsors
-We strongly believe in Open Source software model, so we decided to make SFTPGo available to everyone, but maintaining and evolving SFTPGo takes a lot of time and work. To make development and maintenance sustainable you should consider to support the project with a [sponsorship](https://github.com/sponsors/drakkan).
+If you rely on SFTPGo in your projects, consider becoming a [sponsor](https://github.com/sponsors/drakkan).
-We also provide [professional services](https://sftpgo.com/#pricing) to support you in using SFTPGo to the fullest.
-
-The open source license grant you freedom but not assurance of help. So why would you rely on free software without support or any guarantee it will stay healthy and maintained for the upcoming years?
-
-Supporting the project benefit businesses and the community because if the project is financially sustainable, using this business model, we don't have to restrict features and/or switch to an [Open-core](https://en.wikipedia.org/wiki/Open-core_model) model. The technology stays truly open source. Everyone wins.
-
-You should support the project for its ongoing maintenance, even if you don't have any questions or need new features. If SFTPGo is no longer maintained you will have troubles and your company will lose money: bugs and security vulnerabilities will no longer be fixed, new algorithms will not be added to support newer clients, and so on. You will be forced to switch to a similar proprietary product and pay for its license and the migration cost.
+Your sponsorship helps cover maintenance, security updates and ongoing development of the open-source edition.
### Thank you to our sponsors
@@ -45,23 +65,39 @@ You should support the project for its ongoing maintenance, even if you don't ha
[
](https://www.7digital.com/)
-[
](https://www.vps2day.com/)
-
-## Support policy
-
-You can use SFTPGo for free, respecting the obligations of the Open Source license, but please do not ask or expect free support as well.
-
-Use [discussions](https://github.com/drakkan/sftpgo/discussions) to ask questions and get support from the community.
-
-If you report an invalid issue and/or ask for step-by-step support, your issue will be closed as invalid without further explanation. Invalid bug reports left open may confuse other users. Thanks for understanding.
+[
](https://servinga.com/)
+
+[
](https://www.reui.io/)
## Documentation
-You can read more about supported features and documentation at [sftpgo.github.io](https://sftpgo.github.io/).
+You can explore all supported features and configuration options at [docs.sftpgo.com](https://docs.sftpgo.com/latest/).
+
+**Note:** The link above refers to the **Community Edition**.
+For details on **Enterprise Edition**, please refer to the [Enterprise Documentation](https://docs.sftpgo.com/enterprise/).
+
+## Support
+
+- **Community Support**: use [GitHub Discussions](https://github.com/drakkan/sftpgo/discussions) to ask questions, share feedback, and engage with other users.
+- **Commercial Support**: If you require guaranteed SLAs, expert guidance, or the advanced features listed above, check out [SFTPGo Enterprise](https://sftpgo.com).
+
+SFTPGo Enterprise is available as:
+
+- On-premises: Full control on your infrastructure. More details: [sftpgo.com/on-premises](https://sftpgo.com/on-premises)
+- Fully managed SaaS: We handle the infrastructure. More details: [sftpgo.com/saas](https://sftpgo.com/saas)
+
+## Internationalization
+
+The translations are available via [Crowdin](https://crowdin.com/project/sftpgo), who have granted us an open source license.
+
+Before translating please take a look at our contribution [guidelines](https://docs.sftpgo.com/latest/web-interfaces/#internationalization).
## Release Cadence
-SFTPGo releases are feature-driven, we don't have a fixed time based schedule. As a rough estimate, you can expect 1 or 2 new releases per year.
+SFTPGo follows a feature-driven release cycle.
+
+- Enterprise Edition: Receives major new features first and follows a faster [release cadence](https://docs.sftpgo.com/enterprise/changelog/).
+- Community Edition: Remains maintained, receiving bug fixes, security updates, and updates to core features.
## Acknowledgements
@@ -71,7 +107,7 @@ We are very grateful to all the people who contributed with ideas and/or pull re
Thank you to [ysura](https://www.ysura.com/) for granting us stable access to a test AWS S3 account.
-Thank you to [KeenThemes](https://keenthemes.com/) for granting us a custom license to use their amazing [Mega Bundle](https://keenthemes.com/products/templates-mega-bundle) for SFTPGo UI.
+Thank you to [KeenThemes](https://keenthemes.com/) for granting us a custom license to use their amazing [themes](https://keenthemes.com/bootstrap-templates) for the SFTPGo WebAdmin and WebClient user interfaces, across both the Open Source and Open Core versions.
Thank you to [Crowdin](https://crowdin.com/) for granting us an Open Source License.
@@ -79,15 +115,17 @@ Thank you to [Incode](https://www.incode.it/) for helping us to improve the UI/U
## License
-SFTPGo source code is licensed under the GNU AGPL-3.0-only.
+SFTPGo source code is licensed under the GNU AGPL-3.0-only with [additional terms](./NOTICE).
-The [theme](https://keenthemes.com/products/templates-mega-bundle) used in WebAdmin and WebClient user interfaces is proprietary, this means:
+The [theme](https://keenthemes.com/bootstrap-templates) used in WebAdmin and WebClient user interfaces is proprietary, this means:
- KeenThemes HTML/CSS/JS components are allowed for use only within the SFTPGo product and restricted to be used in a resealable HTML template that can compete with KeenThemes products anyhow.
- The SFTPGo WebAdmin and WebClient user interfaces (HTML, CSS and JS components) based on this theme are allowed for use only within the SFTPGo product and therefore cannot be used in derivative works/products without an explicit grant from the [SFTPGo Team](mailto:support@sftpgo.com).
More information about [compliance](https://sftpgo.com/compliance.html).
+**Note:** We do not provide legal advice. If you have questions about license compliance or whether your use case is permitted under the license terms, please consult your legal team.
+
## Copyright
-Copyright (C) 2019 Nicola Murino
+Copyright (C) 2019 - 2026 Nicola Murino
diff --git a/SECURITY.md b/SECURITY.md
index 05e5daf7..01650ca6 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,8 +2,18 @@
## Supported Versions
-Only the current release of the software is actively supported.
-[Contact us](mailto:support@sftpgo.com) if you need early security patches and enterprise-grade security.
+We actively maintain the latest stable release of SFTPGo. While we strive to keep the Open Source version secure and up-to-date, maintenance is performed on a best-effort basis by the community and contributors.
+
+## Scope and Dependency Policy
+
+Our security advisories focus on vulnerabilities found within the **SFTPGo codebase itself**.
+
+To ensure the long-term sustainability of the project, we handle upstream dependencies (like the Go standard library, external packages, or Docker base images) as follows:
+
+- Community Updates: For the Open Source version, vulnerabilities in upstream components (such as the Go standard library or third-party packages) are addressed during our **regular release cycles**. We generally do not provide immediate, out-of-band or ad-hoc releases to address dependency-only CVEs.
+- Empowering Users: One of the strengths of SFTPGo being open-source is that you have full control. If your security scanners require an immediate fix, you can always rebuild the project using the latest patched Go toolchain or updated dependencies.
+- Compatibility: We are committed to keeping SFTPGo compatible with the latest stable Go compiler. If an upstream fix breaks SFTPGo, fixing that becomes a priority for us.
+- Professional Needs: We understand that some organizations have strict compliance requirements or internal SLAs that require guaranteed, immediate response times and out-of-band patches. For these cases, we offer [SFTPGo Enterprise](https://sftpgo.com/on-premises) to cover the additional maintenance and support overhead.
## Reporting a Vulnerability
diff --git a/docker/scripts/download-plugins.sh b/docker/scripts/download-plugins.sh
index 6d9cbdd0..7638cd56 100755
--- a/docker/scripts/download-plugins.sh
+++ b/docker/scripts/download-plugins.sh
@@ -1,13 +1,13 @@
#!/usr/bin/env bash
-set -e
+set -euo pipefail
-ARCH=`uname -m`
+ARCH=$(uname -m)
case ${ARCH} in
- "x86_64")
+ x86_64)
SUFFIX=amd64
;;
- "aarch64")
+ aarch64)
SUFFIX=arm64
;;
*)
@@ -15,11 +15,21 @@ case ${ARCH} in
;;
esac
-echo "download plugins for arch ${SUFFIX}"
+echo "Downloading plugins for arch ${SUFFIX}"
-for PLUGIN in geoipfilter kms pubsub eventstore eventsearch auth
-do
- echo "download plugin from https://github.com/sftpgo/sftpgo-plugin-${PLUGIN}/releases/latest/download/sftpgo-plugin-${PLUGIN}-linux-${SUFFIX}"
- curl -L "https://github.com/sftpgo/sftpgo-plugin-${PLUGIN}/releases/latest/download/sftpgo-plugin-${PLUGIN}-linux-${SUFFIX}" --output "/usr/local/bin/sftpgo-plugin-${PLUGIN}"
- chmod 755 "/usr/local/bin/sftpgo-plugin-${PLUGIN}"
+PLUGINS=(geoipfilter kms pubsub eventstore eventsearch auth)
+
+for PLUGIN in "${PLUGINS[@]}"; do
+ URL="https://github.com/sftpgo/sftpgo-plugin-${PLUGIN}/releases/latest/download/sftpgo-plugin-${PLUGIN}-linux-${SUFFIX}"
+ DEST="/usr/local/bin/sftpgo-plugin-${PLUGIN}"
+
+ echo "Downloading ${PLUGIN}..."
+ if curl --fail --silent --show-error -L "${URL}" --output "${DEST}"; then
+ chmod 755 "${DEST}"
+ else
+ echo "Error: Failed to download ${PLUGIN}" >&2
+ exit 1
+ fi
done
+
+echo "All plugins downloaded successfully"
diff --git a/examples/backup/README.md b/examples/backup/README.md
index d00ce1d8..3190390c 100644
--- a/examples/backup/README.md
+++ b/examples/backup/README.md
@@ -1,6 +1,6 @@
# Data Backup
-:warning: Since v2.4.0 you can use the [EventManager](https://sftpgo.github.io/latest/eventmanager/) to schedule backups.
+:warning: Since v2.4.0 you can use the [EventManager](https://docs.sftpgo.com/latest/eventmanager/) to schedule backups.
The `backup` example script shows how to use the SFTPGo REST API to backup your data.
diff --git a/examples/data-retention/README.md b/examples/data-retention/README.md
deleted file mode 100644
index 6508c295..00000000
--- a/examples/data-retention/README.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# File retention policies
-
-:warning: Since v2.4.0 you can use the [EventManager](https://sftpgo.github.io/latest/eventmanager/) to schedule data retention checks.
-
-The `checkretention` example script shows how to use the SFTPGo REST API to manage data retention.
-
-:warning: Deleting files is an irreversible action, please make sure you fully understand what you are doing before using this feature, you may have users with overlapping home directories or virtual folders shared between multiple users, it is relatively easy to inadvertently delete files you need.
-
-The example shows how to setup a really simple retention policy, for each user it sends this request:
-
-```json
-[
- {
- "path": "/",
- "retention": 168,
- "delete_empty_dirs": true,
- "ignore_user_permissions": false
- }
-]
-```
-
-so alls files with modification time older than 168 hours (7 days) will be deleted. Empty directories will be removed and the check will respect user's permissions, so if the user cannot delete a file/folder it will be skipped.
-
-You can define different retention policies per-user and per-folder and you can exclude a folder setting the retention to `0`.
-
-You can use this script as a starting point, please edit it according to your needs.
-
-The script is written in Python and has the following requirements:
-
-- python3 or python2
-- python [Requests](https://requests.readthedocs.io/en/master/) module
-
-The provided example tries to connect to an SFTPGo instance running on `127.0.0.1:8080` using the following credentials:
-
-- username: `admin`
-- password: `password`
diff --git a/examples/data-retention/checkretention b/examples/data-retention/checkretention
deleted file mode 100755
index 1474b073..00000000
--- a/examples/data-retention/checkretention
+++ /dev/null
@@ -1,115 +0,0 @@
-#!/usr/bin/env python
-
-from datetime import datetime
-import sys
-import time
-
-import pytz
-import requests
-
-try:
- import urllib.parse as urlparse
-except ImportError:
- import urlparse
-
-# change base_url to point to your SFTPGo installation
-base_url = "http://127.0.0.1:8080"
-# set to False if you want to skip TLS certificate validation
-verify_tls_cert = True
-# set the credentials for a valid admin here
-admin_user = "admin"
-admin_password = "password"
-
-
-class CheckRetention:
-
- def __init__(self):
- self.limit = 100
- self.offset = 0
- self.access_token = ""
- self.access_token_expiration = None
-
- def printLog(self, message):
- print("{} - {}".format(datetime.now(), message))
-
- def checkAccessToken(self):
- if self.access_token != "" and self.access_token_expiration:
- expire_diff = self.access_token_expiration - datetime.now(tz=pytz.UTC)
- # we don't use total_seconds to be python 2 compatible
- seconds_to_expire = expire_diff.days * 86400 + expire_diff.seconds
- if seconds_to_expire > 180:
- return
-
- auth = requests.auth.HTTPBasicAuth(admin_user, admin_password)
- r = requests.get(urlparse.urljoin(base_url, "api/v2/token"), auth=auth, verify=verify_tls_cert, timeout=10)
- if r.status_code != 200:
- self.printLog("error getting access token: {}".format(r.text))
- sys.exit(1)
- self.access_token = r.json()["access_token"]
- self.access_token_expiration = pytz.timezone("UTC").localize(datetime.strptime(r.json()["expires_at"],
- "%Y-%m-%dT%H:%M:%SZ"))
-
- def getAuthHeader(self):
- self.checkAccessToken()
- return {"Authorization": "Bearer " + self.access_token}
-
- def waitForRentionCheck(self, username):
- while True:
- auth_header = self.getAuthHeader()
- r = requests.get(urlparse.urljoin(base_url, "api/v2/retention/users/checks"), headers=auth_header, verify=verify_tls_cert,
- timeout=10)
- if r.status_code != 200:
- self.printLog("error getting retention checks while waiting for {}: {}".format(username, r.text))
- sys.exit(1)
-
- checking = False
- for check in r.json():
- if check["username"] == username:
- checking = True
- if not checking:
- break
- self.printLog("waiting for the retention check to complete for user {}".format(username))
- time.sleep(2)
-
- self.printLog("retention check for user {} finished".format(username))
-
- def checkUserRetention(self, username):
- self.printLog("starting retention check for user {}".format(username))
- auth_header = self.getAuthHeader()
- retention = [
- {
- "path": "/",
- "retention": 168,
- "delete_empty_dirs": True,
- "ignore_user_permissions": False
- }
- ]
- r = requests.post(urlparse.urljoin(base_url, "api/v2/retention/users/" + username + "/check"), headers=auth_header,
- json=retention, verify=verify_tls_cert, timeout=10)
- if r.status_code != 202:
- self.printLog("error starting retention check for user {}: {}".format(username, r.text))
- sys.exit(1)
- self.waitForRentionCheck(username)
-
- def checkUsersRetention(self):
- while True:
- self.printLog("get users, limit {} offset {}".format(self.limit, self.offset))
- auth_header = self.getAuthHeader()
- payload = {"limit":self.limit, "offset":self.offset}
- r = requests.get(urlparse.urljoin(base_url, "api/v2/users"), headers=auth_header, params=payload,
- verify=verify_tls_cert, timeout=10)
- if r.status_code != 200:
- self.printLog("error getting users: {}".format(r.text))
- sys.exit(1)
- users = r.json()
- for user in users:
- self.checkUserRetention(user["username"])
-
- self.offset += len(users)
- if len(users) < self.limit:
- break
-
-
-if __name__ == '__main__':
- c = CheckRetention()
- c.checkUsersRetention()
diff --git a/examples/ldapauth/go.mod b/examples/ldapauth/go.mod
index 4b04e152..81df3ad5 100644
--- a/examples/ldapauth/go.mod
+++ b/examples/ldapauth/go.mod
@@ -1,15 +1,15 @@
module github.com/drakkan/ldapauth
-go 1.22.2
+go 1.25.0
require (
- github.com/go-ldap/ldap/v3 v3.4.8
- golang.org/x/crypto v0.23.0
+ github.com/go-ldap/ldap/v3 v3.4.12
+ golang.org/x/crypto v0.45.0
)
require (
- github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
- github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
+ github.com/Azure/go-ntlmssp v0.1.0 // indirect
+ github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/google/uuid v1.6.0 // indirect
- golang.org/x/sys v0.20.0 // indirect
+ golang.org/x/sys v0.38.0 // indirect
)
diff --git a/examples/ldapauth/go.sum b/examples/ldapauth/go.sum
index 6c72d1cd..67fe852a 100644
--- a/examples/ldapauth/go.sum
+++ b/examples/ldapauth/go.sum
@@ -1,20 +1,15 @@
-github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
-github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
-github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
-github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
+github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
+github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
+github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
-github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
-github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
-github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ=
-github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk=
+github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
+github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
+github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
+github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
-github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
-github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -31,69 +26,15 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
-golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
-golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
-golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
-golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
-golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
-golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
-golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
-golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
-golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
-golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
-golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
+golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/examples/ldapauthserver/go.mod b/examples/ldapauthserver/go.mod
index 3b5fcf73..f65828cc 100644
--- a/examples/ldapauthserver/go.mod
+++ b/examples/ldapauthserver/go.mod
@@ -1,43 +1,37 @@
module github.com/drakkan/sftpgo/ldapauthserver
-go 1.22.2
+go 1.25.0
require (
- github.com/go-chi/chi/v5 v5.0.12
+ github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/render v1.0.3
- github.com/go-ldap/ldap/v3 v3.4.8
+ github.com/go-ldap/ldap/v3 v3.4.12
github.com/nathanaelle/password/v2 v2.0.1
- github.com/rs/zerolog v1.32.0
- github.com/spf13/cobra v1.8.0
- github.com/spf13/viper v1.18.2
- golang.org/x/crypto v0.23.0
+ github.com/rs/zerolog v1.34.0
+ github.com/spf13/cobra v1.10.1
+ github.com/spf13/viper v1.21.0
+ golang.org/x/crypto v0.45.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
- github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
+ github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/ajg/form v1.5.1 // indirect
- github.com/fsnotify/fsnotify v1.7.0 // indirect
- github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
+ github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/magiconair/properties v1.8.7 // indirect
- github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/mitchellh/mapstructure v1.5.0 // indirect
- github.com/pelletier/go-toml/v2 v2.2.2 // indirect
- github.com/sagikazarmark/locafero v0.4.0 // indirect
- github.com/sagikazarmark/slog-shim v0.1.0 // indirect
- github.com/sourcegraph/conc v0.3.0 // indirect
- github.com/spf13/afero v1.11.0 // indirect
- github.com/spf13/cast v1.6.0 // indirect
- github.com/spf13/pflag v1.0.5 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/sagikazarmark/locafero v0.12.0 // indirect
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
- go.uber.org/multierr v1.11.0 // indirect
- golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
- golang.org/x/sys v0.20.0 // indirect
- golang.org/x/text v0.15.0 // indirect
- gopkg.in/ini.v1 v1.67.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/sys v0.38.0 // indirect
+ golang.org/x/text v0.31.0 // indirect
+ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
)
diff --git a/examples/ldapauthserver/go.sum b/examples/ldapauthserver/go.sum
index a16640f3..99ea3a37 100644
--- a/examples/ldapauthserver/go.sum
+++ b/examples/ldapauthserver/go.sum
@@ -1,40 +1,34 @@
-github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
-github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
+github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
+github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
-github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
-github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
+github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
+github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
-github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
-github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
-github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
-github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
-github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
-github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
-github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
-github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
+github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
+github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
+github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
-github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ=
-github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk=
+github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
+github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
+github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
+github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
-github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
-github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -53,132 +47,68 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
-github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
-github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
-github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/nathanaelle/password/v2 v2.0.1 h1:ItoCTdsuIWzilYmllQPa3DR3YoCXcpfxScWLqr8Ii2s=
github.com/nathanaelle/password/v2 v2.0.1/go.mod h1:eaoT+ICQEPNtikBRIAatN8ThWwMhVG+r1jTw60BvPJk=
-github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
-github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
-github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
-github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
+github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
+github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
-github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
-github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
-github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
-github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
-github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
-github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
-github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
-github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
-github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
-github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
-github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
-github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
-github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
+github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
+github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
-go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
-golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
-golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
-golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
-golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
-golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
-golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
-golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
-golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
-golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
-golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
-golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
-golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
-golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
+golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
-golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
-gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/examples/quotascan/README.md b/examples/quotascan/README.md
index 0e7c0a04..0830a308 100644
--- a/examples/quotascan/README.md
+++ b/examples/quotascan/README.md
@@ -1,6 +1,6 @@
# Update user quota
-:warning: Since v2.4.0 you can use the [EventManager](https://sftpgo.github.io/latest/eventmanager/) to schedule quota scans.
+:warning: Since v2.4.0 you can use the [EventManager](https://docs.sftpgo.com/latest/eventmanager/) to schedule quota scans.
The `scanuserquota` example script shows how to use the SFTPGo REST API to update the users' quota.
diff --git a/go.mod b/go.mod
index 667e32ed..d2fafb83 100644
--- a/go.mod
+++ b/go.mod
@@ -1,190 +1,185 @@
module github.com/drakkan/sftpgo/v2
-go 1.22.2
+go 1.25.0
require (
- cloud.google.com/go/storage v1.41.0
- github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1
- github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.2
+ cloud.google.com/go/storage v1.60.0
+ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0
+ github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
+ github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5
github.com/alexedwards/argon2id v1.0.0
github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964
- github.com/aws/aws-sdk-go-v2 v1.26.1
- github.com/aws/aws-sdk-go-v2/config v1.27.13
- github.com/aws/aws-sdk-go-v2/credentials v1.17.13
- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1
- github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.18
- github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.21.5
- github.com/aws/aws-sdk-go-v2/service/s3 v1.54.0
- github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.28.7
- github.com/aws/aws-sdk-go-v2/service/sts v1.28.7
- github.com/bmatcuk/doublestar/v4 v4.6.1
- github.com/cockroachdb/cockroach-go/v2 v2.3.8
- github.com/coreos/go-oidc/v3 v3.10.0
- github.com/drakkan/webdav v0.0.0-20240503091431-218ec83910bb
- github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
- github.com/fclairamb/ftpserverlib v0.24.0
- github.com/fclairamb/go-log v0.5.0
- github.com/go-acme/lego/v4 v4.16.1
- github.com/go-chi/chi/v5 v5.0.12
- github.com/go-chi/jwtauth/v5 v5.3.1
+ github.com/aws/aws-sdk-go-v2 v1.41.3
+ github.com/aws/aws-sdk-go-v2/config v1.32.11
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.11
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4
+ github.com/aws/aws-sdk-go-v2/service/sts v1.41.8
+ github.com/bmatcuk/doublestar/v4 v4.10.0
+ github.com/cockroachdb/cockroach-go/v2 v2.4.3
+ github.com/coreos/go-oidc/v3 v3.17.0
+ github.com/drakkan/webdav v0.0.0-20241026165615-b8b8f74ae71b
+ github.com/eikenb/pipeat v0.0.0-20251030185646-385cd3c3e07b
+ github.com/fclairamb/ftpserverlib v0.30.0
+ github.com/go-acme/lego/v4 v4.32.0
+ github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/render v1.0.3
- github.com/go-sql-driver/mysql v1.8.1
+ github.com/go-jose/go-jose/v4 v4.1.3
+ github.com/go-sql-driver/mysql v1.9.3
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/google/uuid v1.6.0
github.com/hashicorp/go-hclog v1.6.3
- github.com/hashicorp/go-plugin v1.6.1
- github.com/hashicorp/go-retryablehttp v0.7.6
- github.com/jackc/pgx/v5 v5.5.5
+ github.com/hashicorp/go-plugin v1.7.0
+ github.com/hashicorp/go-retryablehttp v0.7.8
+ github.com/jackc/pgx/v5 v5.8.0
github.com/jlaffaye/ftp v0.2.0
- github.com/klauspost/compress v1.17.8
- github.com/lestrrat-go/jwx/v2 v2.0.21
- github.com/lithammer/shortuuid/v3 v3.0.7
- github.com/mattn/go-sqlite3 v1.14.22
+ github.com/klauspost/compress v1.18.4
+ github.com/lithammer/shortuuid/v4 v4.2.0
+ github.com/mattn/go-sqlite3 v1.14.34
github.com/mhale/smtpd v0.8.3
- github.com/minio/sio v0.3.1
- github.com/otiai10/copy v1.14.0
- github.com/pires/go-proxyproto v0.7.0
- github.com/pkg/sftp v1.13.7-0.20240410063531-637088883317
- github.com/pquerna/otp v1.4.0
- github.com/prometheus/client_golang v1.19.1
+ github.com/minio/sio v0.4.3
+ github.com/otiai10/copy v1.14.1
+ github.com/pires/go-proxyproto v0.11.0
+ github.com/pkg/sftp v1.13.10
+ github.com/pquerna/otp v1.5.0
+ github.com/prometheus/client_golang v1.23.2
github.com/robfig/cron/v3 v3.0.1
- github.com/rs/cors v1.11.0
- github.com/rs/xid v1.5.0
- github.com/rs/zerolog v1.32.0
- github.com/sftpgo/sdk v0.1.7
- github.com/shirou/gopsutil/v3 v3.24.4
- github.com/spf13/afero v1.11.0
- github.com/spf13/cobra v1.8.0
- github.com/spf13/viper v1.18.2
- github.com/stretchr/testify v1.9.0
- github.com/studio-b12/gowebdav v0.9.0
+ github.com/rs/cors v1.11.1
+ github.com/rs/xid v1.6.0
+ github.com/rs/zerolog v1.34.0
+ github.com/sftpgo/sdk v0.1.9
+ github.com/shirou/gopsutil/v3 v3.24.5
+ github.com/spf13/afero v1.15.0
+ github.com/spf13/cobra v1.10.2
+ github.com/spf13/viper v1.21.0
+ github.com/stretchr/testify v1.11.1
+ github.com/studio-b12/gowebdav v0.12.0
github.com/subosito/gotenv v1.6.0
- github.com/unrolled/secure v1.14.0
+ github.com/unrolled/secure v1.17.0
github.com/wagslane/go-password-validator v0.3.0
- github.com/wneessen/go-mail v0.4.1
+ github.com/wneessen/go-mail v0.7.2
github.com/yl2chen/cidranger v1.0.3-0.20210928021809-d1cb2c52f37a
- go.etcd.io/bbolt v1.3.10
- go.uber.org/automaxprocs v1.5.3
- gocloud.dev v0.37.0
- golang.org/x/crypto v0.23.0
- golang.org/x/net v0.25.0
- golang.org/x/oauth2 v0.20.0
- golang.org/x/sys v0.20.0
- golang.org/x/term v0.20.0
- golang.org/x/time v0.5.0
- google.golang.org/api v0.180.0
+ go.etcd.io/bbolt v1.4.3
+ gocloud.dev v0.45.0
+ golang.org/x/crypto v0.49.0
+ golang.org/x/net v0.52.0
+ golang.org/x/oauth2 v0.36.0
+ golang.org/x/sys v0.42.0
+ golang.org/x/term v0.41.0
+ golang.org/x/time v0.15.0
+ google.golang.org/api v0.271.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
- cloud.google.com/go v0.113.0 // indirect
- cloud.google.com/go/auth v0.4.1 // indirect
- cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
- cloud.google.com/go/compute/metadata v0.3.0 // indirect
- cloud.google.com/go/iam v1.1.8 // indirect
- filippo.io/edwards25519 v1.1.0 // indirect
- github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 // indirect
- github.com/ajg/form v1.5.1 // indirect
- github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect
- github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
- github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.5 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.7 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.5 // indirect
- github.com/aws/aws-sdk-go-v2/service/sso v1.20.6 // indirect
- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.0 // indirect
- github.com/aws/smithy-go v1.20.2 // indirect
+ cel.dev/expr v0.25.1 // indirect
+ cloud.google.com/go v0.123.0 // indirect
+ cloud.google.com/go/auth v0.18.2 // indirect
+ cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
+ cloud.google.com/go/compute/metadata v0.9.0 // indirect
+ cloud.google.com/go/iam v1.5.3 // indirect
+ cloud.google.com/go/monitoring v1.24.3 // indirect
+ filippo.io/edwards25519 v1.2.0 // indirect
+ github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
+ github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 // indirect
+ github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
+ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
+ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
+ github.com/ajg/form v1.7.1 // indirect
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 // indirect
+ github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
+ github.com/aws/smithy-go v1.24.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
- github.com/boombuler/barcode v1.0.1 // indirect
- github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+ github.com/boombuler/barcode v1.1.0 // indirect
+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/coreos/go-systemd/v22 v22.5.0 // indirect
- github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
+ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
+ github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
- github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
- github.com/fatih/color v1.17.0 // indirect
+ github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
+ github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
+ github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
- github.com/fsnotify/fsnotify v1.7.0 // indirect
- github.com/go-jose/go-jose/v4 v4.0.2 // indirect
- github.com/go-logr/logr v1.4.1 // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
- github.com/goccy/go-json v0.10.2 // indirect
- github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+ github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
+ github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
- github.com/google/s2a-go v0.1.7 // indirect
- github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
- github.com/googleapis/gax-go/v2 v2.12.4 // indirect
+ github.com/google/s2a-go v0.1.9 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
+ github.com/googleapis/gax-go/v2 v2.18.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
- github.com/hashicorp/hcl v1.0.0 // indirect
- github.com/hashicorp/yamux v0.1.1 // indirect
+ github.com/hashicorp/yamux v0.1.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
- github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
- github.com/jackc/puddle/v2 v2.2.1 // indirect
- github.com/jmespath/go-jmespath v0.4.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/fs v0.1.0 // indirect
- github.com/lestrrat-go/blackmagic v1.0.2 // indirect
- github.com/lestrrat-go/httpcc v1.0.1 // indirect
- github.com/lestrrat-go/httprc v1.0.5 // indirect
- github.com/lestrrat-go/iter v1.0.2 // indirect
- github.com/lestrrat-go/option v1.0.1 // indirect
- github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect
- github.com/magiconair/properties v1.8.7 // indirect
- github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/kylelemons/godebug v1.1.0 // indirect
+ github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/miekg/dns v1.1.59 // indirect
- github.com/mitchellh/go-testing-interface v1.14.1 // indirect
- github.com/mitchellh/mapstructure v1.5.0 // indirect
- github.com/oklog/run v1.1.0 // indirect
- github.com/pelletier/go-toml/v2 v2.2.2 // indirect
+ github.com/miekg/dns v1.1.72 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/oklog/run v1.2.0 // indirect
+ github.com/otiai10/mint v1.6.3 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
- github.com/prometheus/client_model v0.6.1 // indirect
- github.com/prometheus/common v0.53.0 // indirect
- github.com/prometheus/procfs v0.15.0 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.67.5 // indirect
+ github.com/prometheus/procfs v0.20.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
- github.com/sagikazarmark/locafero v0.4.0 // indirect
- github.com/sagikazarmark/slog-shim v0.1.0 // indirect
- github.com/segmentio/asm v1.2.0 // indirect
- github.com/shoenig/go-m1cpu v0.1.6 // indirect
- github.com/sourcegraph/conc v0.3.0 // indirect
- github.com/spf13/cast v1.6.0 // indirect
- github.com/spf13/pflag v1.0.5 // indirect
- github.com/tklauser/go-sysconf v0.3.14 // indirect
- github.com/tklauser/numcpus v0.8.0 // indirect
+ github.com/sagikazarmark/locafero v0.12.0 // indirect
+ github.com/shoenig/go-m1cpu v0.2.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
+ github.com/tklauser/go-sysconf v0.3.16 // indirect
+ github.com/tklauser/numcpus v0.11.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
- go.opencensus.io v0.24.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect
- go.opentelemetry.io/otel v1.26.0 // indirect
- go.opentelemetry.io/otel/metric v1.26.0 // indirect
- go.opentelemetry.io/otel/trace v1.26.0 // indirect
- go.uber.org/multierr v1.11.0 // indirect
- golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
- golang.org/x/mod v0.17.0 // indirect
- golang.org/x/sync v0.7.0 // indirect
- golang.org/x/text v0.15.0 // indirect
- golang.org/x/tools v0.21.0 // indirect
- golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
- google.golang.org/genproto v0.0.0-20240513163218-0867130af1f8 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20240513163218-0867130af1f8 // indirect
- google.golang.org/grpc v1.64.0 // indirect
- google.golang.org/protobuf v1.34.1 // indirect
- gopkg.in/ini.v1 v1.67.0 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
+ go.opentelemetry.io/otel v1.42.0 // indirect
+ go.opentelemetry.io/otel/metric v1.42.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.42.0 // indirect
+ go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect
+ go.opentelemetry.io/otel/trace v1.42.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.4 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/mod v0.34.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
+ golang.org/x/tools v0.43.0 // indirect
+ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
+ google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
+ google.golang.org/grpc v1.79.2 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace (
- github.com/fclairamb/ftpserverlib => github.com/drakkan/ftpserverlib v0.0.0-20240510125431-4617586dfa1c
github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20240430173938-7ba8270c8e7f
github.com/robfig/cron/v3 => github.com/drakkan/cron/v3 v3.0.0-20230222140221-217a1e4d96c0
- golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20240509175024-33071fb6437f
)
diff --git a/go.sum b/go.sum
index 7e7a0ad5..d81c977d 100644
--- a/go.sum
+++ b/go.sum
@@ -1,222 +1,198 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.113.0 h1:g3C70mn3lWfckKBiCVsAshabrDg01pQ0pnX1MNtnMkA=
-cloud.google.com/go v0.113.0/go.mod h1:glEqlogERKYeePz6ZdkcLJ28Q2I6aERgDDErBg9GzO8=
-cloud.google.com/go/auth v0.4.1 h1:Z7YNIhlWRtrnKlZke7z3GMqzvuYzdc2z98F9D1NV5Hg=
-cloud.google.com/go/auth v0.4.1/go.mod h1:QVBuVEKpCn4Zp58hzRGvL0tjRGU0YqdRTdCHM1IHnro=
-cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
-cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
-cloud.google.com/go/compute v1.26.0 h1:uHf0NN2nvxl1Gh4QO83yRCOdMK4zivtMS5gv0dEX0hg=
-cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
-cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
-cloud.google.com/go/iam v1.1.8 h1:r7umDwhj+BQyz0ScZMp4QrGXjSTI3ZINnpgU2nlB/K0=
-cloud.google.com/go/iam v1.1.8/go.mod h1:GvE6lyMmfxXauzNq8NbgJbeVQNspG+tcdL/W8QO1+zE=
-cloud.google.com/go/kms v1.16.0 h1:1yZsRPhmargZOmY+fVAh8IKiR9HzCb0U1zsxb5g2nRY=
-cloud.google.com/go/kms v1.16.0/go.mod h1:olQUXy2Xud+1GzYfiBO9N0RhjsJk5IJLU6n/ethLXVc=
-cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU=
-cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
-cloud.google.com/go/storage v1.41.0 h1:RusiwatSu6lHeEXe3kglxakAmAbfV+rhtPqA6i8RBx0=
-cloud.google.com/go/storage v1.41.0/go.mod h1:J1WCa/Z2FcgdEDuPUY8DxT5I+d9mFKsCepp5vR6Sq80=
-filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
-filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
+cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
+cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
+cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
+cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
+cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
+cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
+cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
+cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
+cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
+cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
+cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
+cloud.google.com/go/kms v1.26.0 h1:cK9mN2cf+9V63D3H1f6koxTatWy39aTI/hCjz1I+adU=
+cloud.google.com/go/kms v1.26.0/go.mod h1:pHKOdFJm63hxBsiPkYtowZPltu9dW0MWvBa6IA4HM58=
+cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA=
+cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak=
+cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
+cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
+cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
+cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
+cloud.google.com/go/storage v1.60.0 h1:oBfZrSOCimggVNz9Y/bXY35uUcts7OViubeddTTVzQ8=
+cloud.google.com/go/storage v1.60.0/go.mod h1:q+5196hXfejkctrnx+VYU8RKQr/L3c0cBIlrjmiAKE0=
+cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
+cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
+filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
+filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1 h1:sO0/P7g68FrryJzljemN+6GTssUXdANk6aJ7T1ZxnsQ=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo=
-github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo=
-github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
-github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.5.0 h1:AifHbc4mg0x9zW52WOpKbsHaDKuRhlI7TVl47thgQ70=
-github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.5.0/go.mod h1:T5RfihdXtBDxt1Ch2wobif3TvzTdumDy29kahv6AV9A=
-github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.2 h1:YUUxeiOWgdAQE3pXt2H7QXzZs0q8UBjgRbl56qo8GYM=
-github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.2/go.mod h1:dmXQgZuiSubAecswZE+Sm8jkvEa7kQgTPVRvwL/nd0E=
-github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
-github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew=
+github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
+github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
-github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
+github.com/ajg/form v1.7.1 h1:OsnBDzTkrWdrxvEnO68I72ZVGJGNaMwPhoAm0V+llgc=
+github.com/ajg/form v1.7.1/go.mod h1:HL757PzLyNkj5AIfptT6L+iGNeXTlnrr/oDePGc/y7Q=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964 h1:I9YN9WMo3SUh7p/4wKeNvD/IQla3U3SUa61U7ul+xM4=
github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964/go.mod h1:eFiR01PwTcpbzXtdMces7zxg6utvFM5puiWHpWB8D/k=
-github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA=
-github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 h1:x6xsQXGSmW6frevwDA+vi/wqhp1ct18mVXYN08/93to=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2/go.mod h1:lPprDr1e6cJdyYeGXnRaJoP4Md+cDBvi2eOj00BlGmg=
-github.com/aws/aws-sdk-go-v2/config v1.27.13 h1:WbKW8hOzrWoOA/+35S5okqO/2Ap8hkkFUzoW8Hzq24A=
-github.com/aws/aws-sdk-go-v2/config v1.27.13/go.mod h1:XLiyiTMnguytjRER7u5RIkhIqS8Nyz41SwAWb4xEjxs=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.13 h1:XDCJDzk/u5cN7Aple7D/MiAhx1Rjo/0nueJ0La8mRuE=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.13/go.mod h1:FMNcjQrmuBYvOTZDtOLCIu0esmxjF7RuA/89iSXWzQI=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYhEpvlHpiSd38RQWhut5J4=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.17 h1:9b1Os1s11mF5qTIKLgSsyPG810di2+ySSLIIt9bwe9I=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.17/go.mod h1:9Wp7tDOMhv0+sb/FTRAkbHNQ7abYDnoJRzm5AAtCnTc=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.18 h1:fUHit8Pe+2dWEHtxpOVDTOSQR257iH24HjT17DAz6qs=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.18/go.mod h1:IX1n1o870YYxzqN56w26s7FrO5Zaw/hdatxhJDiEf2U=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.5 h1:81KE7vaZzrl7yHBYHVEzYB8sypz11NMOZ40YlWvPxsU=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.5/go.mod h1:LIt2rg7Mcgn09Ygbdh/RdIm0rQ+3BNkbP1gyVMFtRK0=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.7 h1:ZMeFZ5yk+Ek+jNr1+uwCd2tG89t6oTS5yVWpa6yy2es=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.7/go.mod h1:mxV05U+4JiHqIpGqqYXOHLPKUC6bDXC44bsUhNjOEwY=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 h1:ogRAwT1/gxJBcSWDMZlgyFUM962F51A5CRhDLbxLdmo=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7/go.mod h1:YCsIZhXfRPLFFCl5xxY+1T9RKzOKjCut+28JSX2DnAk=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.5 h1:f9RyWNtS8oH7cZlbn+/JNPpjUk5+5fLd5lM9M0i49Ys=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.5/go.mod h1:h5CoMZV2VF297/VLhRhO1WF+XYWOzXo+4HsObA4HjBQ=
-github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.21.5 h1:p2PxN+OO28p2bCCXE79sJfFBaSohwxa24bQdjuyPZCs=
-github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.21.5/go.mod h1:Q01yJLephuOzv6IYzcknrpVAriOqB66+qtGnpqgw9UE=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.53.2 h1:rq2hglTQM3yHZvOPVMtNvLS5x6hijx7JvRDgKiTNDGQ=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.53.2/go.mod h1:qmdkIIAC+GCLASF7R2whgNrJADz0QZPX+Seiw/i4S3o=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.54.0 h1:Ls94RY3P6HtB88JkzXo1lHrXzonHPpNR//OSAV63mSE=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.54.0/go.mod h1:qmdkIIAC+GCLASF7R2whgNrJADz0QZPX+Seiw/i4S3o=
-github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.28.7 h1:4cziOtpDwtgcb+wTYRzz8C+GoH1XySy0p7j4oBbqPQE=
-github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.28.7/go.mod h1:3Ba++UwWd154xtP4FRX5pUK3Gt4up5sDHCve6kVfE+g=
-github.com/aws/aws-sdk-go-v2/service/sso v1.20.6 h1:o5cTaeunSpfXiLTIBx5xo2enQmiChtu1IBbzXnfU9Hs=
-github.com/aws/aws-sdk-go-v2/service/sso v1.20.6/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.0 h1:Qe0r0lVURDDeBQJ4yP+BOrJkvkiCo/3FH/t+wY11dmw=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.0/go.mod h1:mUYPBhaF2lGiukDEjJX2BLRRKTmoUSitGDUgM4tRxak=
-github.com/aws/aws-sdk-go-v2/service/sts v1.28.7 h1:et3Ta53gotFR4ERLXXHIHl/Uuk1qYpP5uU7cvNql8ns=
-github.com/aws/aws-sdk-go-v2/service/sts v1.28.7/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw=
-github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
-github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
+github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
+github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
+github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
+github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 h1:qi3e/dmpdONhj1RyIZdi6DKKpDXS5Lb8ftr3p7cyHJc=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11/go.mod h1:aEUS4WrNk/+FxkBZZa7tVgp4pGH+kFGW40Y8rCPqt5g=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 h1:4ExZyubQ6LQQVuF2Qp9OsfEvsTdAWh5Gfwf6PgIdLdk=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4/go.mod h1:NF3JcMGOiARAss1ld3WGORCw71+4ExDD2cbbdKS5PpA=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
+github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
+github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
-github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
+github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
-github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
-github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
-github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
-github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
-github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
-github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
-github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
+github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
+github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
+github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
-github.com/cockroachdb/cockroach-go/v2 v2.3.8 h1:53yoUo4+EtrC1NrAEgnnad4AS3ntNvGup1PAXZ7UmpE=
-github.com/cockroachdb/cockroach-go/v2 v2.3.8/go.mod h1:9uH5jK4yQ3ZQUT9IXe4I2fHzMIF5+JC/oOdzTRgJYJk=
-github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU=
-github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac=
-github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
+github.com/cockroachdb/cockroach-go/v2 v2.4.3 h1:LJO3K3jC5WXvMePRQSJE1NsIGoFGcEx1LW83W6RAlhw=
+github.com/cockroachdb/cockroach-go/v2 v2.4.3/go.mod h1:9U179XbCx4qFWtNhc7BiWLPfuyMVQ7qdAhfrwLz1vH0=
+github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
+github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
-github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
-github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
+github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
-github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/drakkan/cron/v3 v3.0.0-20230222140221-217a1e4d96c0 h1:EW9gIJRmt9lzk66Fhh4S8VEtURA6QHZqGeSRE9Nb2/U=
github.com/drakkan/cron/v3 v3.0.0-20230222140221-217a1e4d96c0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
-github.com/drakkan/crypto v0.0.0-20240509175024-33071fb6437f h1:4+0I7deWH0/8dTS1xVgFrNSq7aaNvKrfaqLlfFBNV64=
-github.com/drakkan/crypto v0.0.0-20240509175024-33071fb6437f/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
github.com/drakkan/ftp v0.0.0-20240430173938-7ba8270c8e7f h1:S9JUlrOzjK58UKoLqqb40YLyVlt0bcIFtYrvnanV3zc=
github.com/drakkan/ftp v0.0.0-20240430173938-7ba8270c8e7f/go.mod h1:4p8lUl4vQ80L598CygL+3IFtm+3nggvvW/palOlViwE=
-github.com/drakkan/ftpserverlib v0.0.0-20240510125431-4617586dfa1c h1:cO3eqB2Bjv8WM8HUDfajAt3bFFGj6FUQ2eIxsxVvyC8=
-github.com/drakkan/ftpserverlib v0.0.0-20240510125431-4617586dfa1c/go.mod h1:+9afJRWESpCq4/O8Vr00Q2jfinRxP6PiCpXph6CgGuc=
-github.com/drakkan/webdav v0.0.0-20240503091431-218ec83910bb h1:067/Uo8cfeY7QC0yzWCr/RImuNcM0rLWAsBUyMks59o=
-github.com/drakkan/webdav v0.0.0-20240503091431-218ec83910bb/go.mod h1:zOVb1QDhwwqWn2L2qZ0U3swMSO4GTSNyIwXCGO/UGWE=
-github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001 h1:/ZshrfQzayqRSBDodmp3rhNCHJCff+utvgBuWRbiqu4=
-github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001/go.mod h1:kltMsfRMTHSFdMbK66XdS8mfMW77+FZA1fGY1xYMF84=
-github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
-github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/drakkan/webdav v0.0.0-20241026165615-b8b8f74ae71b h1:Y1tLiQ8fnxM5f3wiBjAXsHzHNwiY9BR+mXZA75nZwrs=
+github.com/drakkan/webdav v0.0.0-20241026165615-b8b8f74ae71b/go.mod h1:zOVb1QDhwwqWn2L2qZ0U3swMSO4GTSNyIwXCGO/UGWE=
+github.com/eikenb/pipeat v0.0.0-20251030185646-385cd3c3e07b h1:G2Mm3YhlyjkFrNnvu5E6LtNcPJtggXL1i5ekDV4hDD4=
+github.com/eikenb/pipeat v0.0.0-20251030185646-385cd3c3e07b/go.mod h1:XccPiThW83W5pzeOCsJAylEUtWeH+3zQVwiO402FXXc=
+github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
+github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
+github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
+github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
+github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
+github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
-github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
-github.com/fclairamb/go-log v0.5.0 h1:Gz9wSamEaA6lta4IU2cjJc2xSq5sV5VYSB5w/SUHhVc=
-github.com/fclairamb/go-log v0.5.0/go.mod h1:XoRO1dYezpsGmLLkZE9I+sHqpqY65p8JA+Vqblb7k40=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/fclairamb/ftpserverlib v0.30.0 h1:caB9sDn1Au//q0j2ev/icPn388qPuk4k1ajSvglDcMQ=
+github.com/fclairamb/ftpserverlib v0.30.0/go.mod h1:QmogtltTOgkihyKza0GNo37Mu4AEzbJ+sH6W9Y0MBIQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
-github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
-github.com/go-acme/lego/v4 v4.16.1 h1:JxZ93s4KG0jL27rZ30UsIgxap6VGzKuREsSkkyzeoCQ=
-github.com/go-acme/lego/v4 v4.16.1/go.mod h1:AVvwdPned/IWpD/ihHhMsKnveF7HHYAz/CmtXi7OZoE=
-github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
-github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
-github.com/go-chi/jwtauth/v5 v5.3.1 h1:1ePWrjVctvp1tyBq5b/2ER8Th/+RbYc7x4qNsc5rh5A=
-github.com/go-chi/jwtauth/v5 v5.3.1/go.mod h1:6Fl2RRmWXs3tJYE1IQGX81FsPoGqDwq9c15j52R5q80=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-acme/lego/v4 v4.32.0 h1:z7Ss7aa1noabhKj+DBzhNCO2SM96xhE3b0ucVW3x8Tc=
+github.com/go-acme/lego/v4 v4.32.0/go.mod h1:lI2fZNdgeM/ymf9xQ9YKbgZm6MeDuf91UrohMQE4DhI=
+github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
+github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
-github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
-github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
-github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=
-github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
-github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA=
-github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
+github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
+github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
-github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
-github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
-github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
-github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
-github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
+github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
-github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
-github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
-github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
-github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
-github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
-github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
-github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
-github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
+github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
-github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
-github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
+github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
+github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
-github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
-github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
-github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
-github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
-github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg=
-github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI=
+github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
+github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
+github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
+github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
+github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI=
+github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -226,33 +202,28 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
-github.com/hashicorp/go-plugin v1.6.1 h1:P7MR2UP6gNKGPp+y7EZw2kOiq4IR9WiqLvp0XOsVdwI=
-github.com/hashicorp/go-plugin v1.6.1/go.mod h1:XPHFku2tFo3o3QKFgSYo+cghcUhw1NA1hZyMK0PWAw0=
-github.com/hashicorp/go-retryablehttp v0.7.6 h1:TwRYfx2z2C4cLbXmT8I5PgP/xmuqASDyiVuGYfs9GZM=
-github.com/hashicorp/go-retryablehttp v0.7.6/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
-github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
-github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
+github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA=
+github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
+github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
+github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
+github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
+github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
-github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
-github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
-github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
-github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0=
-github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
-github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
-github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
-github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
-github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
-github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
-github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
-github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
-github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
-github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
+github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
+github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
+github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
+github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
+github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
+github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -261,217 +232,185 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
-github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
-github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
-github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
-github.com/lestrrat-go/httprc v1.0.5 h1:bsTfiH8xaKOJPrg1R+E3iE/AWZr/x0Phj9PBTG/OLUk=
-github.com/lestrrat-go/httprc v1.0.5/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
-github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
-github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
-github.com/lestrrat-go/jwx/v2 v2.0.21 h1:jAPKupy4uHgrHFEdjVjNkUgoBKtVDgrQPB/h55FHrR0=
-github.com/lestrrat-go/jwx/v2 v2.0.21/go.mod h1:09mLW8zto6bWL9GbwnqAli+ArLf+5M33QLQPDggkUWM=
-github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
-github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
-github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
-github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
-github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI=
-github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
-github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
-github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
+github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
+github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
+github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
-github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
-github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
+github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mhale/smtpd v0.8.3 h1:8j8YNXajksoSLZja3HdwvYVZPuJSqAxFsib3adzRRt8=
github.com/mhale/smtpd v0.8.3/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
-github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs=
-github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk=
-github.com/minio/sio v0.3.1 h1:d59r5RTHb1OsQaSl1EaTWurzMMDRLA5fgNmjzD4eVu4=
-github.com/minio/sio v0.3.1/go.mod h1:S0ovgVgc+sTlQyhiXA1ppBLv7REM7TYi5yyq2qL/Y6o=
-github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
-github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
-github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
-github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
-github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
-github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
-github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
-github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks=
-github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
-github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
-github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
-github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
-github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
+github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
+github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
+github.com/minio/sio v0.4.3 h1:JqyID1XM86KwBZox5RAdLD4MLPIDoCY2cke2CXCJCkg=
+github.com/minio/sio v0.4.3/go.mod h1:4ANoe4CCXqnt1FCiLM0+vlBUhhWZzVOhYCz0069KtFc=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E=
+github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk=
+github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8=
+github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I=
+github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs=
+github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
+github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/sftp v1.13.7-0.20240410063531-637088883317 h1:kupFhKi4R3XqKmUmqGSHWn/WZbC9CnwSoW421tL1gGw=
-github.com/pkg/sftp v1.13.7-0.20240410063531-637088883317/go.mod h1:KMKI0t3T6hfA+lTR/ssZdunHo+uwq7ghoN09/FSu3DY=
+github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
+github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
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/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
-github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
-github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
-github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
-github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
-github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
-github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
-github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
-github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE=
-github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U=
-github.com/prometheus/procfs v0.15.0 h1:A82kmvXJq2jTu5YUhSGNlYoxh85zLnKgPz4bMZgI5Ek=
-github.com/prometheus/procfs v0.15.0/go.mod h1:Y0RJ/Y5g5wJpkTisOtqwDSo4HwhGmLB4VQSw2sQJLHk=
-github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
-github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
-github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
-github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
-github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
-github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
-github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
-github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
+github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
+github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
+github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
+github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
+github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
+github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
+github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
+github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
+github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
-github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
-github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
-github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
+github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
+github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
-github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
-github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
-github.com/sftpgo/sdk v0.1.7 h1:lzOKBDnKb1PpSMlskqCPxBYKxVWz34uMBhT78r/13iA=
-github.com/sftpgo/sdk v0.1.7/go.mod h1:ler/KG6kMLlsOs/8s6dVN3oom+z+NkbXBVWO//Cv/WA=
-github.com/shirou/gopsutil/v3 v3.24.4 h1:dEHgzZXt4LMNm+oYELpzl9YCqV65Yr/6SfrvgRBtXeU=
-github.com/shirou/gopsutil/v3 v3.24.4/go.mod h1:lTd2mdiOspcqLgAnr9/nGi71NkeMpWKdmhuxm9GusH8=
-github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
-github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
-github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
-github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
-github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
-github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
-github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
-github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
-github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
-github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
-github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
-github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
-github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
+github.com/sftpgo/sdk v0.1.9 h1:onBWfibCt34xHeKC2KFYPZ1DBqXGl9um/cAw+AVdgzY=
+github.com/sftpgo/sdk v0.1.9/go.mod h1:ehimvlTP+XTEiE3t1CPwWx9n7+6A6OGvMGlZ7ouvKFk=
+github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
+github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
+github.com/shoenig/go-m1cpu v0.2.0 h1:t4GNqvPZ84Vjtpboo/kT3pIkbaK3vc+JIlD/Wz1zSFY=
+github.com/shoenig/go-m1cpu v0.2.0/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w=
+github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
+github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
+github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
+github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/studio-b12/gowebdav v0.9.0 h1:1j1sc9gQnNxbXXM4M/CebPOX4aXYtr7MojAVcN4dHjU=
-github.com/studio-b12/gowebdav v0.9.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOVUs5kM=
+github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
-github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
-github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
-github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
-github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
-github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY=
-github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE=
-github.com/unrolled/secure v1.14.0 h1:u9vJTU/pR4Bny0ntLUMxdfLtmIRGvQf2sEFuA0TG9AE=
-github.com/unrolled/secure v1.14.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
+github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
+github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
+github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
+github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
+github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
+github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I=
github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ=
-github.com/wneessen/go-mail v0.4.1 h1:m2rSg/sc8FZQCdtrV5M8ymHYOFrC6KJAQAIcgrXvqoo=
-github.com/wneessen/go-mail v0.4.1/go.mod h1:zxOlafWCP/r6FEhAaRgH4IC1vg2YXxO0Nar9u0IScZ8=
+github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
+github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
github.com/yl2chen/cidranger v1.0.3-0.20210928021809-d1cb2c52f37a h1:XfF01GyP+0eWCaVp0y6rNN+kFp7pt9Da4UUYrJ5XPWA=
github.com/yl2chen/cidranger v1.0.3-0.20210928021809-d1cb2c52f37a/go.mod h1:aXb8yZQEWo1XHGMf1qQfnb83GR/EJ2EBlwtUgAaNBoE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
-go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0=
-go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ=
-go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
-go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 h1:A3SayB3rNyt+1S6qpI9mHPkeHTZbD7XILEqWnYZb2l0=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0/go.mod h1:27iA5uvhuRNmalO+iEUdVn5ZMj2qy10Mm+XRIpRmyuU=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc=
-go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs=
-go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4=
-go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30=
-go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4=
-go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
-go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
-go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA=
-go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0=
-go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
-go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
-go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
-go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
-gocloud.dev v0.37.0 h1:XF1rN6R0qZI/9DYjN16Uy0durAmSlf58DHOcb28GPro=
-gocloud.dev v0.37.0/go.mod h1:7/O4kqdInCNsc6LqgmuFnS0GRew4XNNYWpA44yQnwco=
-golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
-golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
+go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
+go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
+go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
+go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI=
+go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
+go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
+go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
+go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
+go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
+go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
+go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
+go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+gocloud.dev v0.45.0 h1:WknIK8IbRdmynDvara3Q7G6wQhmEiOGwpgJufbM39sY=
+gocloud.dev v0.45.0/go.mod h1:0kXKmkCLG6d31N7NyLZWzt7jDSQura9zD/mWgiB6THI=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
-golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
+golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
-golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
-golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
-golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo=
-golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
-golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -483,86 +422,56 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
-golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
-golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
-golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
+golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
+golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
-golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
-golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw=
-golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
+golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
-golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
-google.golang.org/api v0.180.0 h1:M2D87Yo0rGBPWpo1orwfCLehUUL6E7/TYe5gvMQWDh4=
-google.golang.org/api v0.180.0/go.mod h1:51AiyoEg1MJPSZ9zvklA8VnRILPXxn1iVen9v25XHAE=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20240513163218-0867130af1f8 h1:XpH03M6PDRKTo1oGfZBXu2SzwcbfxUokgobVinuUZoU=
-google.golang.org/genproto v0.0.0-20240513163218-0867130af1f8/go.mod h1:OLh2Ylz+WlYAJaSBRpJIJLP8iQP+8da+fpxbwNEAV/o=
-google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8 h1:W5Xj/70xIA4x60O/IFyXivR5MGqblAb8R3w26pnD6No=
-google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8/go.mod h1:vPrPUTsDCYxXWjP7clS81mZ6/803D8K4iM9Ma27VKas=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240513163218-0867130af1f8 h1:mxSlqyb8ZAHsYDCfiXN1EDdNTdvjUJSLY+OnAUtYNYA=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240513163218-0867130af1f8/go.mod h1:I7Y+G38R2bu5j1aLzfFmQfTcU/WnFuqDwLZAbvKTKpM=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
-google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
-google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
-google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
-google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
-google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
-google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
-google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
-google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
-google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
-google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
-google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
-google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
-google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
+golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
+google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY=
+google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q=
+google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c h1:ZhFDeBMmFc/4g8/GwxnJ4rzB3O4GwQVNr+8Mh7Y5z4g=
+google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c/go.mod h1:hf4r/rBuzaTkLUWRO03771Xvcs6P5hwdQK3UUEJjqo0=
+google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c h1:OyQPd6I3pN/9gDxz6L13kYGJgqkpdrAohJRBeXyxlgI=
+google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c/go.mod h1:X2gu9Qwng7Nn009s/r3RUxqkzQNqOrAy79bluY7ojIg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
+google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
-gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/img/logo.png b/img/logo.png
index 0c320ff7..81b1f94b 100644
Binary files a/img/logo.png and b/img/logo.png differ
diff --git a/img/reui.png b/img/reui.png
new file mode 100644
index 00000000..20973803
Binary files /dev/null and b/img/reui.png differ
diff --git a/img/servinga.png b/img/servinga.png
new file mode 100644
index 00000000..c5ccb638
Binary files /dev/null and b/img/servinga.png differ
diff --git a/img/vps2day.png b/img/vps2day.png
deleted file mode 100644
index faddcf15..00000000
Binary files a/img/vps2day.png and /dev/null differ
diff --git a/init/sftpgo b/init/sftpgo
new file mode 100755
index 00000000..6da97d15
--- /dev/null
+++ b/init/sftpgo
@@ -0,0 +1,102 @@
+#! /bin/sh
+
+### BEGIN INIT INFO
+# Provides: SFTPGo
+# Required-Start: $remote_fs $syslog
+# Required-Stop: $remote_fs $syslog
+# Default-Start: 2 3 4 5
+# Default-Stop:
+# Short-Description: SFTPGo server
+### END INIT INFO
+
+set -e
+
+# /etc/init.d/sftpgo: start and stop the SFTPGo "server" daemon
+
+SFTPGO_USER="sftpgo"
+SFTPGO_GROUP="sftpgo"
+SFTPGO_BIN_NAME="sftpgo"
+SFTPGO_BIN="/usr/bin/sftpgo"
+SFTPGO_PID="/run/sftpgo.pid"
+SFTPGO_CONF_DIR="/etc/sftpgo"
+SFTPGO_CONF_FILE="sftpgo.json"
+SFTPGO_OPTS="serve -c $SFTPGO_CONF_DIR --config-file $SFTPGO_CONF_FILE"
+
+umask 022
+
+test -x $SFTPGO_BIN || exit 0
+
+
+if test -f /etc/default/$SFTPGO_BIN_NAME; then
+ . /etc/default/$SFTPGO_BIN_NAME
+fi
+
+. /lib/lsb/init-functions
+
+if [ -n "$2" ]; then
+ SFTPGO_OPTS="$SFTPGO_OPTS $2"
+fi
+
+# Are we running from init?
+run_by_init() {
+ ([ "$previous" ] && [ "$runlevel" ]) || [ "$runlevel" = S ]
+}
+
+check_dev_null() {
+ if [ ! -c /dev/null ]; then
+ if [ "$1" = log_end_msg ]; then
+ log_end_msg 1 || true
+ fi
+ if ! run_by_init; then
+ log_action_msg "/dev/null is not a character device!" || true
+ fi
+ exit 1
+ fi
+}
+
+write_pid() {
+ sleep 0.25
+ echo $(/bin/pidof $SFTPGO_BIN_NAME) > $SFTPGO_PID
+}
+
+export PATH="${PATH:+$PATH:}/usr/sbin:/sbin"
+
+case "$1" in
+ start)
+ check_dev_null
+ log_daemon_msg "Starting SFTPGo server" "$SFTPGO_BIN_NAME" || true
+ if start-stop-daemon --start --background --quiet --oknodo --chuid $SFTPGO_USER:$SFTPGO_GROUP --pidfile $SFTPGO_PID --exec $SFTPGO_BIN -- $SFTPGO_OPTS; then
+ log_end_msg 0 || true
+ write_pid
+ else
+ log_end_msg 1 || true
+ fi
+ ;;
+ stop)
+ log_daemon_msg "Stopping SFTPGo server" "$SFTPGO_BIN_NAME" || true
+ if start-stop-daemon --stop --quiet --oknodo --pidfile $SFTPGO_PID --exec $SFTPGO_BIN; then
+ log_end_msg 0 || true
+ else
+ log_end_msg 1 || true
+ fi
+ ;;
+
+ reload)
+ log_daemon_msg "Reloading SFTPGo server" "$SFTPGO_BIN_NAME" || true
+ if start-stop-daemon --stop --signal 1 --quiet --oknodo --pidfile $SFTPGO_PID --exec $SFTPGO_BIN; then
+ log_end_msg 0 || true
+ else
+ log_end_msg 1 || true
+ fi
+ ;;
+
+ status)
+ status_of_proc -p $SFTPGO_PID $SFTPGO_BIN $SFTPGO_BIN_NAME && exit 0 || exit $?
+ ;;
+
+ *)
+ log_action_msg "Usage: /etc/init.d/$SFTPGO_BIN_NAME {start|stop|reload|status}" || true
+ exit 1
+esac
+
+exit 0
diff --git a/internal/acme/acme.go b/internal/acme/acme.go
index 409ef260..6c4612e5 100644
--- a/internal/acme/acme.go
+++ b/internal/acme/acme.go
@@ -30,6 +30,7 @@ import (
"net/url"
"os"
"path/filepath"
+ "slices"
"strconv"
"strings"
"time"
@@ -43,6 +44,7 @@ import (
"github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/providers/http/webroot"
"github.com/go-acme/lego/v4/registration"
+ "github.com/hashicorp/go-retryablehttp"
"github.com/robfig/cron/v3"
"github.com/drakkan/sftpgo/v2/internal/common"
@@ -249,7 +251,7 @@ func (c *Configuration) Initialize(configDir string) error {
if c.RenewDays < 1 {
return fmt.Errorf("invalid number of days remaining before renewal: %d", c.RenewDays)
}
- if !util.Contains(supportedKeyTypes, c.KeyType) {
+ if !slices.Contains(supportedKeyTypes, c.KeyType) {
return fmt.Errorf("invalid key type %q", c.KeyType)
}
caURL, err := url.Parse(c.CAEndpoint)
@@ -489,7 +491,16 @@ func (c *Configuration) setup() (*account, *lego.Client, error) {
config := lego.NewConfig(&account)
config.CADirURL = c.CAEndpoint
config.Certificate.KeyType = certcrypto.KeyType(c.KeyType)
+ config.Certificate.OverallRequestLimit = 6
config.UserAgent = version.GetServerVersion("/", false)
+
+ retryClient := retryablehttp.NewClient()
+ retryClient.Logger = &logger.LeveledLogger{Sender: "RetryableHTTPClient"}
+ retryClient.RetryMax = 5
+ retryClient.HTTPClient = config.HTTPClient
+
+ config.HTTPClient = retryClient.StandardClient()
+
client, err := lego.NewClient(config)
if err != nil {
acmeLog(logger.LevelError, "unable to get ACME client: %v", err)
@@ -557,6 +568,13 @@ func (c *Configuration) tryRecoverRegistration(privateKey crypto.PrivateKey) (*r
config.CADirURL = c.CAEndpoint
config.UserAgent = version.GetServerVersion("/", false)
+ retryClient := retryablehttp.NewClient()
+ retryClient.Logger = &logger.LeveledLogger{Sender: "RetryableHTTPClient"}
+ retryClient.RetryMax = 5
+ retryClient.HTTPClient = config.HTTPClient
+
+ config.HTTPClient = retryClient.StandardClient()
+
client, err := lego.NewClient(config)
if err != nil {
acmeLog(logger.LevelError, "unable to get the ACME client: %v", err)
@@ -671,7 +689,7 @@ func (c *Configuration) notifyCertificateRenewal(domain string, err error) {
params := common.EventParams{
Name: domain,
Event: "Certificate renewal",
- Timestamp: time.Now().UnixNano(),
+ Timestamp: time.Now(),
}
if err != nil {
params.Status = 2
@@ -749,7 +767,7 @@ func (c *Configuration) renewCertificates() error {
func isDomainValid(domain string) (string, bool) {
isValid := false
- for _, d := range strings.Split(domain, ",") {
+ for d := range strings.SplitSeq(domain, ",") {
d = strings.TrimSpace(d)
if d != "" {
isValid = true
@@ -767,7 +785,7 @@ func getDomains(domain string) []string {
delimiter = " "
}
- for _, d := range strings.Split(domain, delimiter) {
+ for d := range strings.SplitSeq(domain, delimiter) {
d = strings.TrimSpace(d)
if d != "" {
domains = append(domains, d)
diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go
index 70934ace..077fb360 100644
--- a/internal/bundle/bundle.go
+++ b/internal/bundle/bundle.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build bundle
-// +build bundle
package bundle
diff --git a/internal/cmd/acme.go b/internal/cmd/acme.go
index bce51598..e98cf515 100644
--- a/internal/cmd/acme.go
+++ b/internal/cmd/acme.go
@@ -24,6 +24,7 @@ import (
"github.com/drakkan/sftpgo/v2/internal/config"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
"github.com/drakkan/sftpgo/v2/internal/logger"
+ "github.com/drakkan/sftpgo/v2/internal/plugin"
"github.com/drakkan/sftpgo/v2/internal/util"
)
@@ -56,6 +57,15 @@ renewed by the SFTPGo service
logger.ErrorToConsole("unable to initialize KMS: %v", err)
os.Exit(1)
}
+ if config.HasKMSPlugin() {
+ if err := plugin.Initialize(config.GetPluginsConfig(), "debug"); err != nil {
+ logger.ErrorToConsole("unable to initialize plugin system: %v", err)
+ os.Exit(1)
+ }
+ registerSignals()
+ defer plugin.Handler.Cleanup()
+ }
+
mfaConfig := config.GetMFAConfig()
err = mfaConfig.Initialize()
if err != nil {
diff --git a/internal/cmd/awscontainer.go b/internal/cmd/awscontainer.go
deleted file mode 100644
index 72deee90..00000000
--- a/internal/cmd/awscontainer.go
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2019 Nicola Murino
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published
-// by the Free Software Foundation, version 3.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-//go:build awscontainer
-// +build awscontainer
-
-package cmd
-
-import (
- "github.com/spf13/cobra"
- "github.com/spf13/viper"
-)
-
-func addAWSContainerFlags(cmd *cobra.Command) {
- viper.SetDefault("disable_aws_installation_code", false)
- viper.BindEnv("disable_aws_installation_code", "SFTPGO_DISABLE_AWS_INSTALLATION_CODE") //nolint:errcheck
- cmd.Flags().BoolVar(&disableAWSInstallationCode, "disable-aws-installation-code", viper.GetBool("disable_aws_installation_code"),
- `Disable installation code for the AWS container.
-This flag can be set using
-SFTPGO_DISABLE_AWS_INSTALLATION_CODE env var too.
-`)
- viper.BindPFlag("disable_aws_installation_code", cmd.Flags().Lookup("disable-aws-installation-code")) //nolint:errcheck
-}
diff --git a/internal/cmd/initprovider.go b/internal/cmd/initprovider.go
index c61ad228..8862d261 100644
--- a/internal/cmd/initprovider.go
+++ b/internal/cmd/initprovider.go
@@ -21,9 +21,11 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
+ "github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/config"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
"github.com/drakkan/sftpgo/v2/internal/logger"
+ "github.com/drakkan/sftpgo/v2/internal/plugin"
"github.com/drakkan/sftpgo/v2/internal/service"
"github.com/drakkan/sftpgo/v2/internal/util"
)
@@ -65,6 +67,15 @@ Please take a look at the usage below to customize the options.`,
logger.ErrorToConsole("Unable to initialize KMS: %v", err)
os.Exit(1)
}
+ if config.HasKMSPlugin() {
+ if err := plugin.Initialize(config.GetPluginsConfig(), "debug"); err != nil {
+ logger.ErrorToConsole("unable to initialize plugin system: %v", err)
+ os.Exit(1)
+ }
+ registerSignals()
+ defer plugin.Handler.Cleanup()
+ }
+
mfaConfig := config.GetMFAConfig()
err = mfaConfig.Initialize()
if err != nil {
@@ -78,15 +89,20 @@ Please take a look at the usage below to customize the options.`,
providerConf.Actions.ExecuteOn = nil
logger.InfoToConsole("Initializing provider: %q config file: %q", providerConf.Driver, viper.ConfigFileUsed())
err = dataprovider.InitializeDatabase(providerConf, configDir)
- if err == nil {
+ switch err {
+ case nil:
logger.InfoToConsole("Data provider successfully initialized/updated")
- } else if err == dataprovider.ErrNoInitRequired {
+ case dataprovider.ErrNoInitRequired:
logger.InfoToConsole("%v", err.Error())
- } else {
+ default:
logger.ErrorToConsole("Unable to initialize/update the data provider: %v", err)
os.Exit(1)
}
if providerConf.Driver != dataprovider.MemoryDataProviderName && loadDataFrom != "" {
+ if err := common.Initialize(config.GetCommonConfig(), providerConf.GetShared()); err != nil {
+ logger.ErrorToConsole("%v", err)
+ os.Exit(1)
+ }
service := service.Service{
LoadDataFrom: loadDataFrom,
LoadDataMode: loadDataMode,
diff --git a/internal/cmd/portable.go b/internal/cmd/portable.go
index 0a39fb20..356371a9 100644
--- a/internal/cmd/portable.go
+++ b/internal/cmd/portable.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build !noportable
-// +build !noportable
package cmd
@@ -497,7 +496,7 @@ func getPatternsFilterValues(value string) (string, []string) {
if len(dirExts) > 1 {
dir := strings.TrimSpace(dirExts[0])
exts := []string{}
- for _, e := range strings.Split(dirExts[1], ",") {
+ for e := range strings.SplitSeq(dirExts[1], ",") {
cleanedExt := strings.TrimSpace(e)
if cleanedExt != "" {
exts = append(exts, cleanedExt)
diff --git a/internal/cmd/portable_disabled.go b/internal/cmd/portable_disabled.go
index 5e2b7974..f043ee7e 100644
--- a/internal/cmd/portable_disabled.go
+++ b/internal/cmd/portable_disabled.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build noportable
-// +build noportable
package cmd
diff --git a/internal/cmd/resetpwd.go b/internal/cmd/resetpwd.go
index b46740af..eb009e31 100644
--- a/internal/cmd/resetpwd.go
+++ b/internal/cmd/resetpwd.go
@@ -27,6 +27,7 @@ import (
"github.com/drakkan/sftpgo/v2/internal/config"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
"github.com/drakkan/sftpgo/v2/internal/logger"
+ "github.com/drakkan/sftpgo/v2/internal/plugin"
"github.com/drakkan/sftpgo/v2/internal/util"
)
@@ -37,6 +38,7 @@ var (
Short: "Reset the password for the specified administrator",
Long: `This command reads the data provider connection details from the specified
configuration file and resets the password for the specified administrator.
+Two-factor authentication is also disabled.
This command is not supported for the memory provider.
For embedded providers like bolt and SQLite you should stop the running SFTPGo
instance to avoid database corruption.
@@ -57,6 +59,15 @@ Please take a look at the usage below to customize the options.`,
logger.ErrorToConsole("unable to initialize KMS: %v", err)
os.Exit(1)
}
+ if config.HasKMSPlugin() {
+ if err := plugin.Initialize(config.GetPluginsConfig(), "debug"); err != nil {
+ logger.ErrorToConsole("unable to initialize plugin system: %v", err)
+ os.Exit(1)
+ }
+ registerSignals()
+ defer plugin.Handler.Cleanup()
+ }
+
mfaConfig := config.GetMFAConfig()
err = mfaConfig.Initialize()
if err != nil {
@@ -98,6 +109,7 @@ Please take a look at the usage below to customize the options.`,
os.Exit(1)
}
admin.Password = string(pwd)
+ admin.Filters.TOTPConfig.Enabled = false
if err := dataprovider.UpdateAdmin(&admin, dataprovider.ActionExecutorSystem, "", ""); err != nil {
logger.ErrorToConsole("Unable to update password: %v", err)
os.Exit(1)
diff --git a/internal/cmd/revertprovider.go b/internal/cmd/revertprovider.go
index 64de3529..09b8d1a8 100644
--- a/internal/cmd/revertprovider.go
+++ b/internal/cmd/revertprovider.go
@@ -24,6 +24,7 @@ import (
"github.com/drakkan/sftpgo/v2/internal/config"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
"github.com/drakkan/sftpgo/v2/internal/logger"
+ "github.com/drakkan/sftpgo/v2/internal/plugin"
"github.com/drakkan/sftpgo/v2/internal/util"
)
@@ -40,8 +41,8 @@ Please take a look at the usage below to customize the options.`,
Run: func(_ *cobra.Command, _ []string) {
logger.DisableLogger()
logger.EnableConsoleLogger(zerolog.DebugLevel)
- if revertProviderTargetVersion != 28 {
- logger.WarnToConsole("Unsupported target version, 28 is the only supported one")
+ if revertProviderTargetVersion != 33 {
+ logger.WarnToConsole("Unsupported target version, 33 is the only supported one")
os.Exit(1)
}
configDir = util.CleanDirInput(configDir)
@@ -56,6 +57,21 @@ Please take a look at the usage below to customize the options.`,
logger.ErrorToConsole("unable to initialize KMS: %v", err)
os.Exit(1)
}
+ if config.HasKMSPlugin() {
+ if err := plugin.Initialize(config.GetPluginsConfig(), "debug"); err != nil {
+ logger.ErrorToConsole("unable to initialize plugin system: %v", err)
+ os.Exit(1)
+ }
+ registerSignals()
+ defer plugin.Handler.Cleanup()
+ }
+
+ mfaConfig := config.GetMFAConfig()
+ err = mfaConfig.Initialize()
+ if err != nil {
+ logger.ErrorToConsole("Unable to initialize MFA: %v", err)
+ os.Exit(1)
+ }
providerConf := config.GetProviderConf()
logger.InfoToConsole("Reverting provider: %q config file: %q target version %d", providerConf.Driver,
viper.ConfigFileUsed(), revertProviderTargetVersion)
@@ -71,7 +87,7 @@ Please take a look at the usage below to customize the options.`,
func init() {
addConfigFlags(revertProviderCmd)
- revertProviderCmd.Flags().IntVar(&revertProviderTargetVersion, "to-version", 28, `28 means the version supported in v2.5.x`)
+ revertProviderCmd.Flags().IntVar(&revertProviderTargetVersion, "to-version", 33, `33 means the version supported in v2.7.x`)
rootCmd.AddCommand(revertProviderCmd)
}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index ba96be58..00759236 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -85,8 +85,6 @@ var (
loadDataQuotaScan int
loadDataClean bool
graceTime int
- // used if awscontainer build tag is enabled
- disableAWSInstallationCode bool
rootCmd = &cobra.Command{
Use: "sftpgo",
diff --git a/internal/cmd/serve.go b/internal/cmd/serve.go
index 2bc9e004..348fb834 100644
--- a/internal/cmd/serve.go
+++ b/internal/cmd/serve.go
@@ -16,13 +16,21 @@ package cmd
import (
"os"
+ "path/filepath"
+ "strconv"
+ "strings"
"github.com/spf13/cobra"
+ "github.com/subosito/gotenv"
"github.com/drakkan/sftpgo/v2/internal/service"
"github.com/drakkan/sftpgo/v2/internal/util"
)
+const (
+ envFileMaxSize = 1048576
+)
+
var (
serveCmd = &cobra.Command{
Use: "serve",
@@ -34,9 +42,11 @@ $ sftpgo serve
Please take a look at the usage below to customize the startup options`,
Run: func(_ *cobra.Command, _ []string) {
+ configDir := util.CleanDirInput(configDir)
+ checkServeParamsFromEnvFiles(configDir)
service.SetGraceTime(graceTime)
service := service.Service{
- ConfigDir: util.CleanDirInput(configDir),
+ ConfigDir: configDir,
ConfigFile: configFile,
LogFilePath: logFilePath,
LogMaxSize: logMaxSize,
@@ -51,7 +61,7 @@ Please take a look at the usage below to customize the startup options`,
LoadDataClean: loadDataClean,
Shutdown: make(chan bool),
}
- if err := service.Start(disableAWSInstallationCode); err == nil {
+ if err := service.Start(); err == nil {
service.Wait()
if service.Error == nil {
os.Exit(0)
@@ -62,8 +72,76 @@ Please take a look at the usage below to customize the startup options`,
}
)
+func setIntFromEnv(receiver *int, val string) {
+ converted, err := strconv.Atoi(val)
+ if err == nil {
+ *receiver = converted
+ }
+}
+
+func setBoolFromEnv(receiver *bool, val string) {
+ converted, err := strconv.ParseBool(strings.TrimSpace(val))
+ if err == nil {
+ *receiver = converted
+ }
+}
+
+func checkServeParamsFromEnvFiles(configDir string) { //nolint:gocyclo
+ // The logger is not yet initialized here, we have no way to report errors.
+ envd := filepath.Join(configDir, "env.d")
+ entries, err := os.ReadDir(envd)
+ if err != nil {
+ return
+ }
+ for _, entry := range entries {
+ info, err := entry.Info()
+ if err == nil && info.Mode().IsRegular() {
+ envFile := filepath.Join(envd, entry.Name())
+ if info.Size() > envFileMaxSize {
+ continue
+ }
+ envVars, err := gotenv.Read(envFile)
+ if err != nil {
+ return
+ }
+ for k, v := range envVars {
+ if _, isSet := os.LookupEnv(k); isSet {
+ continue
+ }
+ switch k {
+ case "SFTPGO_LOG_FILE_PATH":
+ logFilePath = v
+ case "SFTPGO_LOG_MAX_SIZE":
+ setIntFromEnv(&logMaxSize, v)
+ case "SFTPGO_LOG_MAX_BACKUPS":
+ setIntFromEnv(&logMaxBackups, v)
+ case "SFTPGO_LOG_MAX_AGE":
+ setIntFromEnv(&logMaxAge, v)
+ case "SFTPGO_LOG_COMPRESS":
+ setBoolFromEnv(&logCompress, v)
+ case "SFTPGO_LOG_LEVEL":
+ logLevel = v
+ case "SFTPGO_LOG_UTC_TIME":
+ setBoolFromEnv(&logUTCTime, v)
+ case "SFTPGO_CONFIG_FILE":
+ configFile = v
+ case "SFTPGO_LOADDATA_FROM":
+ loadDataFrom = v
+ case "SFTPGO_LOADDATA_MODE":
+ setIntFromEnv(&loadDataMode, v)
+ case "SFTPGO_LOADDATA_CLEAN":
+ setBoolFromEnv(&loadDataClean, v)
+ case "SFTPGO_LOADDATA_QUOTA_SCAN":
+ setIntFromEnv(&loadDataQuotaScan, v)
+ case "SFTPGO_GRACE_TIME":
+ setIntFromEnv(&graceTime, v)
+ }
+ }
+ }
+ }
+}
+
func init() {
rootCmd.AddCommand(serveCmd)
addServeFlags(serveCmd)
- addAWSContainerFlags(serveCmd)
}
diff --git a/internal/sftpd/cmd_unix.go b/internal/cmd/signals_unix.go
similarity index 58%
rename from internal/sftpd/cmd_unix.go
rename to internal/cmd/signals_unix.go
index 1d0eaca4..91849b9a 100644
--- a/internal/sftpd/cmd_unix.go
+++ b/internal/cmd/signals_unix.go
@@ -1,4 +1,4 @@
-// Copyright (C) 2019 Nicola Murino
+// Copyright (C) 2025 Nicola Murino
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
@@ -13,26 +13,29 @@
// along with this program. If not, see .
//go:build !windows
-// +build !windows
-package sftpd
+package cmd
import (
"os"
- "os/exec"
+ "os/signal"
"syscall"
+
+ "github.com/drakkan/sftpgo/v2/internal/logger"
+ "github.com/drakkan/sftpgo/v2/internal/plugin"
)
-var (
- processUID = os.Geteuid()
- processGID = os.Getegid()
-)
-
-func wrapCmd(cmd *exec.Cmd, uid, gid int) *exec.Cmd {
- isCurrentUser := processUID == uid && processGID == gid
- if (uid > 0 || gid > 0) && !isCurrentUser {
- cmd.SysProcAttr = &syscall.SysProcAttr{}
- cmd.SysProcAttr.Credential = &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}
- }
- return cmd
+func registerSignals() {
+ c := make(chan os.Signal, 1)
+ signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
+ go func() {
+ for sig := range c {
+ switch sig {
+ case syscall.SIGINT, syscall.SIGTERM:
+ logger.DebugToConsole("Received interrupt request")
+ plugin.Handler.Cleanup()
+ os.Exit(0)
+ }
+ }
+ }()
}
diff --git a/internal/cmd/awscontainer_disabled.go b/internal/cmd/signals_windows.go
similarity index 62%
rename from internal/cmd/awscontainer_disabled.go
rename to internal/cmd/signals_windows.go
index 3dbd8599..3ea1e6ff 100644
--- a/internal/cmd/awscontainer_disabled.go
+++ b/internal/cmd/signals_windows.go
@@ -1,4 +1,4 @@
-// Copyright (C) 2019 Nicola Murino
+// Copyright (C) 2025 Nicola Murino
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
@@ -12,13 +12,25 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-//go:build !awscontainer
-// +build !awscontainer
-
package cmd
import (
- "github.com/spf13/cobra"
+ "os"
+ "os/signal"
+
+ "github.com/drakkan/sftpgo/v2/internal/logger"
+ "github.com/drakkan/sftpgo/v2/internal/plugin"
)
-func addAWSContainerFlags(_ *cobra.Command) {}
+func registerSignals() {
+ c := make(chan os.Signal, 1)
+ signal.Notify(c, os.Interrupt)
+
+ go func() {
+ for range c {
+ logger.DebugToConsole("Received interrupt request")
+ plugin.Handler.Cleanup()
+ os.Exit(0)
+ }
+ }()
+}
diff --git a/internal/cmd/start_windows.go b/internal/cmd/start_windows.go
index be34dec2..5e4dde64 100644
--- a/internal/cmd/start_windows.go
+++ b/internal/cmd/start_windows.go
@@ -17,7 +17,6 @@ package cmd
import (
"fmt"
"os"
- "path/filepath"
"github.com/spf13/cobra"
@@ -31,21 +30,23 @@ var (
Short: "Start the SFTPGo Windows Service",
Run: func(_ *cobra.Command, _ []string) {
configDir = util.CleanDirInput(configDir)
- if !filepath.IsAbs(logFilePath) && util.IsFileInputValid(logFilePath) {
- logFilePath = filepath.Join(configDir, logFilePath)
- }
+ checkServeParamsFromEnvFiles(configDir)
service.SetGraceTime(graceTime)
s := service.Service{
- ConfigDir: configDir,
- ConfigFile: configFile,
- LogFilePath: logFilePath,
- LogMaxSize: logMaxSize,
- LogMaxBackups: logMaxBackups,
- LogMaxAge: logMaxAge,
- LogCompress: logCompress,
- LogLevel: logLevel,
- LogUTCTime: logUTCTime,
- Shutdown: make(chan bool),
+ ConfigDir: configDir,
+ ConfigFile: configFile,
+ LogFilePath: logFilePath,
+ LogMaxSize: logMaxSize,
+ LogMaxBackups: logMaxBackups,
+ LogMaxAge: logMaxAge,
+ LogCompress: logCompress,
+ LogLevel: logLevel,
+ LogUTCTime: logUTCTime,
+ LoadDataFrom: loadDataFrom,
+ LoadDataMode: loadDataMode,
+ LoadDataQuotaScan: loadDataQuotaScan,
+ LoadDataClean: loadDataClean,
+ Shutdown: make(chan bool),
}
winService := service.WindowsService{
Service: s,
diff --git a/internal/cmd/startsubsys.go b/internal/cmd/startsubsys.go
deleted file mode 100644
index 322a62e2..00000000
--- a/internal/cmd/startsubsys.go
+++ /dev/null
@@ -1,227 +0,0 @@
-// Copyright (C) 2019 Nicola Murino
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published
-// by the Free Software Foundation, version 3.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package cmd
-
-import (
- "io"
- "os"
- "os/user"
- "path/filepath"
-
- "github.com/rs/xid"
- "github.com/rs/zerolog"
- "github.com/spf13/cobra"
- "github.com/spf13/viper"
-
- "github.com/drakkan/sftpgo/v2/internal/common"
- "github.com/drakkan/sftpgo/v2/internal/config"
- "github.com/drakkan/sftpgo/v2/internal/dataprovider"
- "github.com/drakkan/sftpgo/v2/internal/logger"
- "github.com/drakkan/sftpgo/v2/internal/plugin"
- "github.com/drakkan/sftpgo/v2/internal/sftpd"
- "github.com/drakkan/sftpgo/v2/internal/version"
-)
-
-var (
- logJournalD = false
- preserveHomeDir = false
- baseHomeDir = ""
- subsystemCmd = &cobra.Command{
- Use: "startsubsys",
- Short: "Use sftpgo as SFTP file transfer subsystem",
- Long: `In this mode SFTPGo speaks the server side of SFTP protocol to stdout and
-expects client requests from stdin.
-This mode is not intended to be called directly, but from sshd using the
-Subsystem option.
-For example adding a line like this one in "/etc/ssh/sshd_config":
-
-Subsystem sftp sftpgo startsubsys
-
-Command-line flags should be specified in the Subsystem declaration.
-`,
- Run: func(_ *cobra.Command, _ []string) {
- logSender := "startsubsys"
- connectionID := xid.New().String()
- var zeroLogLevel zerolog.Level
- switch logLevel {
- case "info":
- zeroLogLevel = zerolog.InfoLevel
- case "warn":
- zeroLogLevel = zerolog.WarnLevel
- case "error":
- zeroLogLevel = zerolog.ErrorLevel
- default:
- zeroLogLevel = zerolog.DebugLevel
- }
- logger.SetLogTime(logUTCTime)
- if logJournalD {
- logger.InitJournalDLogger(zeroLogLevel)
- } else {
- logger.InitStdErrLogger(zeroLogLevel)
- }
- osUser, err := user.Current()
- if err != nil {
- logger.Error(logSender, connectionID, "unable to get the current user: %v", err)
- os.Exit(1)
- }
- username := osUser.Username
- homedir := osUser.HomeDir
- logger.Info(logSender, connectionID, "starting SFTPGo %v as subsystem, user %q home dir %q config dir %q base home dir %q",
- version.Get(), username, homedir, configDir, baseHomeDir)
- err = config.LoadConfig(configDir, configFile)
- if err != nil {
- logger.Error(logSender, connectionID, "unable to load configuration: %v", err)
- os.Exit(1)
- }
- kmsConfig := config.GetKMSConfig()
- if err := kmsConfig.Initialize(); err != nil {
- logger.Error(logSender, connectionID, "unable to initialize KMS: %v", err)
- os.Exit(1)
- }
- mfaConfig := config.GetMFAConfig()
- err = mfaConfig.Initialize()
- if err != nil {
- logger.Error(logSender, "", "unable to initialize MFA: %v", err)
- os.Exit(1)
- }
- dataProviderConf := config.GetProviderConf()
- if dataProviderConf.Driver == dataprovider.SQLiteDataProviderName || dataProviderConf.Driver == dataprovider.BoltDataProviderName {
- logger.Debug(logSender, connectionID, "data provider %q not supported in subsystem mode, using %q provider",
- dataProviderConf.Driver, dataprovider.MemoryDataProviderName)
- dataProviderConf.Driver = dataprovider.MemoryDataProviderName
- dataProviderConf.Name = ""
- }
- config.SetProviderConf(dataProviderConf)
- err = dataprovider.Initialize(dataProviderConf, configDir, false)
- if err != nil {
- logger.Error(logSender, connectionID, "unable to initialize the data provider: %v", err)
- os.Exit(1)
- }
- if err := plugin.Initialize(config.GetPluginsConfig(), logLevel); err != nil {
- logger.Error(logSender, connectionID, "unable to initialize plugin system: %v", err)
- os.Exit(1)
- }
- smtpConfig := config.GetSMTPConfig()
- err = smtpConfig.Initialize(configDir, false)
- if err != nil {
- logger.Error(logSender, connectionID, "unable to initialize SMTP configuration: %v", err)
- os.Exit(1)
- }
- commonConfig := config.GetCommonConfig()
- // idle connection are managed externally
- commonConfig.IdleTimeout = 0
- config.SetCommonConfig(commonConfig)
- if err := common.Initialize(config.GetCommonConfig(), dataProviderConf.GetShared()); err != nil {
- logger.Error(logSender, connectionID, "%v", err)
- os.Exit(1)
- }
- httpConfig := config.GetHTTPConfig()
- if err := httpConfig.Initialize(configDir); err != nil {
- logger.Error(logSender, connectionID, "unable to initialize http client: %v", err)
- os.Exit(1)
- }
- commandConfig := config.GetCommandConfig()
- if err := commandConfig.Initialize(); err != nil {
- logger.Error(logSender, connectionID, "unable to initialize commands configuration: %v", err)
- os.Exit(1)
- }
- user, err := dataprovider.UserExists(username, "")
- if err == nil {
- if user.HomeDir != filepath.Clean(homedir) && !preserveHomeDir {
- // update the user
- user.HomeDir = filepath.Clean(homedir)
- err = dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSystem, "", "")
- if err != nil {
- logger.Error(logSender, connectionID, "unable to update user %q: %v", username, err)
- os.Exit(1)
- }
- }
- } else {
- user.Username = username
- if baseHomeDir != "" && filepath.IsAbs(baseHomeDir) {
- user.HomeDir = filepath.Join(baseHomeDir, username)
- } else {
- user.HomeDir = filepath.Clean(homedir)
- }
- logger.Debug(logSender, connectionID, "home dir for new user %q", user.HomeDir)
- user.Password = connectionID
- user.Permissions = make(map[string][]string)
- user.Permissions["/"] = []string{dataprovider.PermAny}
- err = dataprovider.AddUser(&user, dataprovider.ActionExecutorSystem, "", "")
- if err != nil {
- logger.Error(logSender, connectionID, "unable to add user %q: %v", username, err)
- os.Exit(1)
- }
- }
- err = user.LoadAndApplyGroupSettings()
- if err != nil {
- logger.Error(logSender, connectionID, "unable to apply group settings for user %q: %v", username, err)
- os.Exit(1)
- }
- err = sftpd.ServeSubSystemConnection(&user, connectionID, os.Stdin, os.Stdout)
- if err != nil && err != io.EOF {
- logger.Warn(logSender, connectionID, "serving subsystem finished with error: %v", err)
- os.Exit(1)
- }
- logger.Info(logSender, connectionID, "serving subsystem finished")
- plugin.Handler.Cleanup()
- os.Exit(0)
- },
- }
-)
-
-func init() {
- subsystemCmd.Flags().BoolVarP(&preserveHomeDir, "preserve-home", "p", false, `If the user already exists, the existing home
-directory will not be changed`)
- subsystemCmd.Flags().StringVarP(&baseHomeDir, "base-home-dir", "d", "", `If the user does not exist specify an alternate
-starting directory. The home directory for a new
-user will be:
-
-[base-home-dir]/[username]
-
-base-home-dir must be an absolute path.`)
- subsystemCmd.Flags().BoolVarP(&logJournalD, "log-to-journald", "j", false, `Send logs to journald. Only available on Linux.
-Use:
-
-$ journalctl -o verbose -f
-
-To see full logs.
-If not set, the logs will be sent to the standard
-error`)
-
- addConfigFlags(subsystemCmd)
-
- viper.SetDefault(logLevelKey, defaultLogLevel)
- viper.BindEnv(logLevelKey, "SFTPGO_LOG_LEVEL") //nolint:errcheck
- subsystemCmd.Flags().StringVar(&logLevel, logLevelFlag, viper.GetString(logLevelKey),
- `Set the log level. Supported values:
-
-debug, info, warn, error.
-
-This flag can be set
-using SFTPGO_LOG_LEVEL env var too.
-`)
- viper.BindPFlag(logLevelKey, subsystemCmd.Flags().Lookup(logLevelFlag)) //nolint:errcheck
-
- viper.SetDefault(logUTCTimeKey, defaultLogUTCTime)
- viper.BindEnv(logUTCTimeKey, "SFTPGO_LOG_UTC_TIME") //nolint:errcheck
- subsystemCmd.Flags().BoolVar(&logUTCTime, logUTCTimeFlag, viper.GetBool(logUTCTimeKey),
- `Use UTC time for logging. This flag can be set
-using SFTPGO_LOG_UTC_TIME env var too.
-`)
- viper.BindPFlag(logUTCTimeKey, subsystemCmd.Flags().Lookup(logUTCTimeFlag)) //nolint:errcheck
-
- rootCmd.AddCommand(subsystemCmd)
-}
diff --git a/internal/command/command.go b/internal/command/command.go
index b3d56aa3..512c2d4e 100644
--- a/internal/command/command.go
+++ b/internal/command/command.go
@@ -17,10 +17,9 @@ package command
import (
"fmt"
+ "slices"
"strings"
"time"
-
- "github.com/drakkan/sftpgo/v2/internal/util"
)
const (
@@ -36,7 +35,6 @@ const (
HookStartup = "startup"
HookPostConnect = "post_connect"
HookPostDisconnect = "post_disconnect"
- HookDataRetention = "data_retention"
HookCheckPassword = "check_password"
HookPreLogin = "pre_login"
HookPostLogin = "post_login"
@@ -47,7 +45,7 @@ const (
var (
config Config
supportedHooks = []string{HookFsActions, HookProviderActions, HookStartup, HookPostConnect, HookPostDisconnect,
- HookDataRetention, HookCheckPassword, HookPreLogin, HookPostLogin, HookExternalAuth, HookKeyboardInteractive}
+ HookCheckPassword, HookPreLogin, HookPostLogin, HookExternalAuth, HookKeyboardInteractive}
)
// Command define the configuration for a specific commands
@@ -117,7 +115,7 @@ func (c Config) Initialize() error {
}
// don't validate args, we allow to pass empty arguments
if cmd.Hook != "" {
- if !util.Contains(supportedHooks, cmd.Hook) {
+ if !slices.Contains(supportedHooks, cmd.Hook) {
return fmt.Errorf("invalid hook name %q, supported values: %+v", cmd.Hook, supportedHooks)
}
}
diff --git a/internal/common/actions.go b/internal/common/actions.go
index 446068c1..d6fd04b0 100644
--- a/internal/common/actions.go
+++ b/internal/common/actions.go
@@ -25,6 +25,7 @@ import (
"os/exec"
"path"
"path/filepath"
+ "slices"
"strings"
"sync/atomic"
"time"
@@ -37,7 +38,6 @@ import (
"github.com/drakkan/sftpgo/v2/internal/httpclient"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/plugin"
- "github.com/drakkan/sftpgo/v2/internal/util"
)
var (
@@ -86,13 +86,14 @@ func InitializeActionHandler(handler ActionHandler) {
func ExecutePreAction(conn *BaseConnection, operation, filePath, virtualPath string, fileSize int64, openFlags int) (int, error) {
var event *notifier.FsEvent
hasNotifiersPlugin := plugin.Handler.HasNotifiers()
- hasHook := util.Contains(Config.Actions.ExecuteOn, operation)
+ hasHook := slices.Contains(Config.Actions.ExecuteOn, operation)
hasRules := eventManager.hasFsRules()
if !hasHook && !hasNotifiersPlugin && !hasRules {
return 0, nil
}
+ dateTime := time.Now()
event = newActionNotification(&conn.User, operation, filePath, virtualPath, "", "", "",
- conn.protocol, conn.GetRemoteIP(), conn.ID, fileSize, openFlags, conn.getNotificationStatus(nil), 0, nil)
+ conn.protocol, conn.GetRemoteIP(), conn.ID, fileSize, openFlags, conn.getNotificationStatus(nil), 0, dateTime, nil)
if hasNotifiersPlugin {
plugin.Handler.NotifyFsEvent(event)
}
@@ -112,7 +113,7 @@ func ExecutePreAction(conn *BaseConnection, operation, filePath, virtualPath str
Protocol: event.Protocol,
IP: event.IP,
Role: event.Role,
- Timestamp: event.Timestamp,
+ Timestamp: dateTime,
Email: conn.User.Email,
Object: nil,
}
@@ -132,13 +133,14 @@ func ExecuteActionNotification(conn *BaseConnection, operation, filePath, virtua
fileSize int64, err error, elapsed int64, metadata map[string]string,
) error {
hasNotifiersPlugin := plugin.Handler.HasNotifiers()
- hasHook := util.Contains(Config.Actions.ExecuteOn, operation)
+ hasHook := slices.Contains(Config.Actions.ExecuteOn, operation)
hasRules := eventManager.hasFsRules()
if !hasHook && !hasNotifiersPlugin && !hasRules {
return nil
}
+ dateTime := time.Now()
notification := newActionNotification(&conn.User, operation, filePath, virtualPath, target, virtualTarget, sshCmd,
- conn.protocol, conn.GetRemoteIP(), conn.ID, fileSize, 0, conn.getNotificationStatus(err), elapsed, metadata)
+ conn.protocol, conn.GetRemoteIP(), conn.ID, fileSize, 0, conn.getNotificationStatus(err), elapsed, dateTime, metadata)
if hasNotifiersPlugin {
plugin.Handler.NotifyFsEvent(notification)
}
@@ -159,7 +161,7 @@ func ExecuteActionNotification(conn *BaseConnection, operation, filePath, virtua
Protocol: notification.Protocol,
IP: notification.IP,
Role: notification.Role,
- Timestamp: notification.Timestamp,
+ Timestamp: dateTime,
Email: conn.User.Email,
Object: nil,
Metadata: metadata,
@@ -173,7 +175,7 @@ func ExecuteActionNotification(conn *BaseConnection, operation, filePath, virtua
}
}
if hasHook {
- if util.Contains(Config.Actions.ExecuteSync, operation) {
+ if slices.Contains(Config.Actions.ExecuteSync, operation) {
_, err := actionHandler.Handle(notification)
return err
}
@@ -197,6 +199,7 @@ func newActionNotification(
operation, filePath, virtualPath, target, virtualTarget, sshCmd, protocol, ip, sessionID string,
fileSize int64,
openFlags, status int, elapsed int64,
+ datetime time.Time,
metadata map[string]string,
) *notifier.FsEvent {
var bucket, endpoint string
@@ -238,7 +241,7 @@ func newActionNotification(
SessionID: sessionID,
OpenFlags: openFlags,
Role: user.Role,
- Timestamp: time.Now().UnixNano(),
+ Timestamp: datetime.UnixNano(),
Elapsed: elapsed,
Metadata: metadata,
}
@@ -247,7 +250,7 @@ func newActionNotification(
type defaultActionHandler struct{}
func (h *defaultActionHandler) Handle(event *notifier.FsEvent) (int, error) {
- if !util.Contains(Config.Actions.ExecuteOn, event.Action) {
+ if !slices.Contains(Config.Actions.ExecuteOn, event.Action) {
return 0, nil
}
@@ -345,7 +348,7 @@ func notificationAsEnvVars(event *notifier.FsEvent) []string {
if len(event.Metadata) > 0 {
data, err := json.Marshal(event.Metadata)
if err == nil {
- result = append(result, fmt.Sprintf("SFTPGO_ACTION_METADATA=%s", util.BytesToString(data)))
+ result = append(result, fmt.Sprintf("SFTPGO_ACTION_METADATA=%s", data))
}
}
return result
diff --git a/internal/common/actions_test.go b/internal/common/actions_test.go
index 28eea408..6ac0463c 100644
--- a/internal/common/actions_test.go
+++ b/internal/common/actions_test.go
@@ -22,8 +22,9 @@ import (
"path/filepath"
"runtime"
"testing"
+ "time"
- "github.com/lithammer/shortuuid/v3"
+ "github.com/lithammer/shortuuid/v4"
"github.com/rs/xid"
"github.com/sftpgo/sdk"
"github.com/sftpgo/sdk/plugin/notifier"
@@ -71,7 +72,7 @@ func TestNewActionNotification(t *testing.T) {
c := NewBaseConnection("id", ProtocolSSH, "", "", user)
sessionID := xid.New().String()
a := newActionNotification(&user, operationDownload, "path", "vpath", "target", "", "", ProtocolSFTP, "", sessionID,
- 123, 0, c.getNotificationStatus(errors.New("fake error")), 0, nil)
+ 123, 0, c.getNotificationStatus(errors.New("fake error")), 0, time.Now(), nil)
assert.Equal(t, user.Username, a.Username)
assert.Equal(t, 0, len(a.Bucket))
assert.Equal(t, 0, len(a.Endpoint))
@@ -79,38 +80,38 @@ func TestNewActionNotification(t *testing.T) {
user.FsConfig.Provider = sdk.S3FilesystemProvider
a = newActionNotification(&user, operationDownload, "path", "vpath", "target", "", "", ProtocolSSH, "", sessionID,
- 123, 0, c.getNotificationStatus(nil), 0, nil)
+ 123, 0, c.getNotificationStatus(nil), 0, time.Now(), nil)
assert.Equal(t, "s3bucket", a.Bucket)
assert.Equal(t, "endpoint", a.Endpoint)
assert.Equal(t, 1, a.Status)
user.FsConfig.Provider = sdk.GCSFilesystemProvider
a = newActionNotification(&user, operationDownload, "path", "vpath", "target", "", "", ProtocolSCP, "", sessionID,
- 123, 0, c.getNotificationStatus(ErrQuotaExceeded), 0, nil)
+ 123, 0, c.getNotificationStatus(ErrQuotaExceeded), 0, time.Now(), nil)
assert.Equal(t, "gcsbucket", a.Bucket)
assert.Equal(t, 0, len(a.Endpoint))
assert.Equal(t, 3, a.Status)
a = newActionNotification(&user, operationDownload, "path", "vpath", "target", "", "", ProtocolSCP, "", sessionID,
- 123, 0, c.getNotificationStatus(fmt.Errorf("wrapper quota error: %w", ErrQuotaExceeded)), 0, nil)
+ 123, 0, c.getNotificationStatus(fmt.Errorf("wrapper quota error: %w", ErrQuotaExceeded)), 0, time.Now(), nil)
assert.Equal(t, "gcsbucket", a.Bucket)
assert.Equal(t, 0, len(a.Endpoint))
assert.Equal(t, 3, a.Status)
user.FsConfig.Provider = sdk.HTTPFilesystemProvider
a = newActionNotification(&user, operationDownload, "path", "vpath", "target", "", "", ProtocolSSH, "", sessionID,
- 123, 0, c.getNotificationStatus(nil), 0, nil)
+ 123, 0, c.getNotificationStatus(nil), 0, time.Now(), nil)
assert.Equal(t, "httpendpoint", a.Endpoint)
assert.Equal(t, 1, a.Status)
user.FsConfig.Provider = sdk.AzureBlobFilesystemProvider
a = newActionNotification(&user, operationDownload, "path", "vpath", "target", "", "", ProtocolSCP, "", sessionID,
- 123, 0, c.getNotificationStatus(nil), 0, nil)
+ 123, 0, c.getNotificationStatus(nil), 0, time.Now(), nil)
assert.Equal(t, "azcontainer", a.Bucket)
assert.Equal(t, "azendpoint", a.Endpoint)
assert.Equal(t, 1, a.Status)
a = newActionNotification(&user, operationDownload, "path", "vpath", "target", "", "", ProtocolSCP, "", sessionID,
- 123, os.O_APPEND, c.getNotificationStatus(nil), 0, nil)
+ 123, os.O_APPEND, c.getNotificationStatus(nil), 0, time.Now(), nil)
assert.Equal(t, "azcontainer", a.Bucket)
assert.Equal(t, "azendpoint", a.Endpoint)
assert.Equal(t, 1, a.Status)
@@ -118,7 +119,7 @@ func TestNewActionNotification(t *testing.T) {
user.FsConfig.Provider = sdk.SFTPFilesystemProvider
a = newActionNotification(&user, operationDownload, "path", "vpath", "target", "", "", ProtocolSFTP, "", sessionID,
- 123, 0, c.getNotificationStatus(nil), 0, nil)
+ 123, 0, c.getNotificationStatus(nil), 0, time.Now(), nil)
assert.Equal(t, "sftpendpoint", a.Endpoint)
}
@@ -135,7 +136,7 @@ func TestActionHTTP(t *testing.T) {
},
}
a := newActionNotification(user, operationDownload, "path", "vpath", "target", "", "", ProtocolSFTP, "",
- xid.New().String(), 123, 0, 1, 0, nil)
+ xid.New().String(), 123, 0, 1, 0, time.Now(), nil)
status, err := actionHandler.Handle(a)
assert.NoError(t, err)
assert.Equal(t, 1, status)
@@ -175,7 +176,7 @@ func TestActionCMD(t *testing.T) {
}
sessionID := shortuuid.New()
a := newActionNotification(user, operationDownload, "path", "vpath", "target", "", "", ProtocolSFTP, "", sessionID,
- 123, 0, 1, 0, map[string]string{"key": "value"})
+ 123, 0, 1, 0, time.Now(), map[string]string{"key": "value"})
status, err := actionHandler.Handle(a)
assert.NoError(t, err)
assert.Equal(t, 1, status)
@@ -208,7 +209,7 @@ func TestWrongActions(t *testing.T) {
}
a := newActionNotification(user, operationUpload, "", "", "", "", "", ProtocolSFTP, "", xid.New().String(),
- 123, 0, 1, 0, nil)
+ 123, 0, 1, 0, time.Now(), nil)
status, err := actionHandler.Handle(a)
assert.Error(t, err, "action with bad command must fail")
assert.Equal(t, 1, status)
diff --git a/internal/common/common.go b/internal/common/common.go
index e35316bc..99b5e8d1 100644
--- a/internal/common/common.go
+++ b/internal/common/common.go
@@ -19,12 +19,14 @@ import (
"context"
"errors"
"fmt"
+ "io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
+ "slices"
"strconv"
"strings"
"sync"
@@ -124,6 +126,9 @@ func init() {
Connections.clients = clientsMap{
clients: make(map[string]int),
}
+ Connections.transfers = clientsMap{
+ clients: make(map[string]int),
+ }
Connections.perUserConns = make(map[string]int)
Connections.mapping = make(map[string]int)
Connections.sshMapping = make(map[string]int)
@@ -163,13 +168,20 @@ var (
rateLimiters map[string][]*rateLimiter
isShuttingDown atomic.Bool
ftpLoginCommands = []string{"PASS", "USER"}
+ fnUpdateBranding func(*dataprovider.BrandingConfigs)
)
+// SetUpdateBrandingFn sets the function to call to update branding configs.
+func SetUpdateBrandingFn(fn func(*dataprovider.BrandingConfigs)) {
+ fnUpdateBranding = fn
+}
+
// Initialize sets the common configuration
func Initialize(c Configuration, isShared int) error {
isShuttingDown.Store(false)
util.SetUmask(c.Umask)
version.SetConfig(c.ServerVersion)
+ dataprovider.SetTZ(c.TZ)
Config = c
Config.Actions.ExecuteOn = util.RemoveDuplicates(Config.Actions.ExecuteOn, true)
Config.Actions.ExecuteSync = util.RemoveDuplicates(Config.Actions.ExecuteSync, true)
@@ -200,7 +212,7 @@ func Initialize(c Configuration, isShared int) error {
Config.rateLimitersList = rateLimitersList
}
if c.DefenderConfig.Enabled {
- if !util.Contains(supportedDefenderDrivers, c.DefenderConfig.Driver) {
+ if !slices.Contains(supportedDefenderDrivers, c.DefenderConfig.Driver) {
return fmt.Errorf("unsupported defender driver %q", c.DefenderConfig.Driver)
}
var defender Defender
@@ -228,6 +240,9 @@ func Initialize(c Configuration, isShared int) error {
if err := c.initializeProxyProtocol(); err != nil {
return err
}
+ if err := c.EventManager.validate(); err != nil {
+ return err
+ }
vfs.SetTempPath(c.TempPath)
dataprovider.SetTempPath(c.TempPath)
vfs.SetAllowSelfConnections(c.AllowSelfConnections)
@@ -236,6 +251,7 @@ func Initialize(c Configuration, isShared int) error {
vfs.SetResumeMaxSize(c.ResumeMaxSize)
vfs.SetUploadMode(c.UploadMode)
dataprovider.SetAllowSelfConnections(c.AllowSelfConnections)
+ dataprovider.EnabledActionCommands = c.EventManager.EnabledCommands
transfersChecker = getTransfersChecker(isShared)
return nil
}
@@ -327,6 +343,13 @@ func Reload() error {
return nil
}
+// DelayLogin applies the configured login delay
+func DelayLogin(err error) {
+ if Config.defender != nil {
+ Config.defender.DelayLogin(err)
+ }
+}
+
// IsBanned returns true if the specified IP address is banned
func IsBanned(ip, protocol string) bool {
if plugin.Handler.IsIPBanned(ip, protocol) {
@@ -395,6 +418,23 @@ func AddDefenderEvent(ip, protocol string, event HostEvent) bool {
return Config.defender.AddEvent(ip, protocol, event)
}
+func reloadProviderConfigs() {
+ configs, err := dataprovider.GetConfigs()
+ if err != nil {
+ logger.Error(logSender, "", "unable to load config from provider: %v", err)
+ return
+ }
+ configs.SetNilsToEmpty()
+ if fnUpdateBranding != nil {
+ fnUpdateBranding(configs.Branding)
+ }
+ if err := configs.SMTP.TryDecrypt(); err != nil {
+ logger.Error(logSender, "", "unable to decrypt smtp config: %v", err)
+ return
+ }
+ smtp.Activate(configs.SMTP)
+}
+
func startPeriodicChecks(duration time.Duration, isShared int) {
startEventScheduler()
spec := fmt.Sprintf("@every %s", duration)
@@ -403,7 +443,7 @@ func startPeriodicChecks(duration time.Duration, isShared int) {
logger.Info(logSender, "", "scheduled overquota transfers check, schedule %q", spec)
if isShared == 1 {
logger.Info(logSender, "", "add reload configs task")
- _, err := eventScheduler.AddFunc("@every 10m", smtp.ReloadProviderConf)
+ _, err := eventScheduler.AddFunc("@every 10m", reloadProviderConfigs)
util.PanicOnError(err)
}
if Config.IdleTimeout > 0 {
@@ -423,6 +463,7 @@ type ActiveTransfer interface {
GetDownloadedSize() int64
GetUploadedSize() int64
GetVirtualPath() string
+ GetFsPath() string
GetStartTime() time.Time
SignalClose(err error)
Truncate(fsPath string, size int64) (int64, error)
@@ -477,6 +518,23 @@ type ConnectionTransfer struct {
DLSize int64 `json:"-"`
}
+// EventManagerConfig defines the configuration for the EventManager
+type EventManagerConfig struct {
+ // EnabledCommands defines the system commands that can be executed via EventManager,
+ // an empty list means that any command is allowed to be executed.
+ // Commands must be set as an absolute path
+ EnabledCommands []string `json:"enabled_commands" mapstructure:"enabled_commands"`
+}
+
+func (c *EventManagerConfig) validate() error {
+ for _, c := range c.EnabledCommands {
+ if !filepath.IsAbs(c) {
+ return fmt.Errorf("invalid command %q: it must be an absolute path", c)
+ }
+ }
+ return nil
+}
+
// MetadataConfig defines how to handle metadata for cloud storage backends
type MetadataConfig struct {
// If not zero the metadata will be read before downloads and will be
@@ -558,9 +616,6 @@ type Configuration struct {
// Absolute path to an external program or an HTTP URL to invoke after an SSH/FTP connection ends.
// Leave empty do disable.
PostDisconnectHook string `json:"post_disconnect_hook" mapstructure:"post_disconnect_hook"`
- // Absolute path to an external program or an HTTP URL to invoke after a data retention check completes.
- // Leave empty do disable.
- DataRetentionHook string `json:"data_retention_hook" mapstructure:"data_retention_hook"`
// Maximum number of concurrent client connections. 0 means unlimited
MaxTotalConnections int `json:"max_total_connections" mapstructure:"max_total_connections"`
// Maximum number of concurrent client connections from the same host (IP). 0 means unlimited
@@ -581,8 +636,14 @@ type Configuration struct {
Umask string `json:"umask" mapstructure:"umask"`
// Defines the server version
ServerVersion string `json:"server_version" mapstructure:"server_version"`
+ // TZ defines the time zone to use for the EventManager scheduler and to
+ // control time-based access restrictions. Set to "local" to use the
+ // server's local time, otherwise UTC will be used.
+ TZ string `json:"tz" mapstructure:"tz"`
// Metadata configuration
- Metadata MetadataConfig `json:"metadata" mapstructure:"metadata"`
+ Metadata MetadataConfig `json:"metadata" mapstructure:"metadata"`
+ // EventManager configuration
+ EventManager EventManagerConfig `json:"event_manager" mapstructure:"event_manager"`
idleTimeoutAsDuration time.Duration
idleLoginTimeout time.Duration
defender Defender
@@ -615,7 +676,7 @@ func (c *Configuration) initializeProxyProtocol() error {
// GetProxyListener returns a wrapper for the given listener that supports the
// HAProxy Proxy Protocol
-func (c *Configuration) GetProxyListener(listener net.Listener) (*proxyproto.Listener, error) {
+func (c *Configuration) GetProxyListener(listener net.Listener) (net.Listener, error) {
if c.ProxyProtocol > 0 {
defaultPolicy := proxyproto.REQUIRE
if c.ProxyProtocol == 1 {
@@ -624,7 +685,7 @@ func (c *Configuration) GetProxyListener(listener net.Listener) (*proxyproto.Lis
return &proxyproto.Listener{
Listener: listener,
- Policy: getProxyPolicy(c.proxyAllowed, c.proxySkipped, defaultPolicy),
+ ConnPolicy: getProxyPolicy(c.proxyAllowed, c.proxySkipped, defaultPolicy),
ReadHeaderTimeout: 10 * time.Second,
}, nil
}
@@ -742,7 +803,7 @@ func (c *Configuration) checkPostDisconnectHook(remoteAddr, protocol, username,
if c.PostDisconnectHook == "" {
return
}
- if !util.Contains(disconnHookProtocols, protocol) {
+ if !slices.Contains(disconnHookProtocols, protocol) {
return
}
go c.executePostDisconnectHook(remoteAddr, protocol, username, connID, connectionTime)
@@ -799,12 +860,14 @@ func (c *Configuration) ExecutePostConnectHook(ipAddr, protocol string) error {
return nil
}
-func getProxyPolicy(allowed, skipped []func(net.IP) bool, def proxyproto.Policy) proxyproto.PolicyFunc {
- return func(upstream net.Addr) (proxyproto.Policy, error) {
- upstreamIP, err := util.GetIPFromNetAddr(upstream)
+func getProxyPolicy(allowed, skipped []func(net.IP) bool, def proxyproto.Policy) proxyproto.ConnPolicyFunc {
+ return func(connPolicyOptions proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) {
+ upstreamIP, err := util.GetIPFromNetAddr(connPolicyOptions.Upstream)
if err != nil {
- // something is wrong with the source IP, better reject the connection
- return proxyproto.REJECT, err
+ // Something is wrong with the source IP, better reject the
+ // connection.
+ logger.Error(logSender, "", "reject connection from ip %q, err: %v", connPolicyOptions.Upstream, err)
+ return proxyproto.REJECT, proxyproto.ErrInvalidUpstream
}
for _, skippedFrom := range skipped {
@@ -822,6 +885,11 @@ func getProxyPolicy(allowed, skipped []func(net.IP) bool, def proxyproto.Policy)
}
}
+ if def == proxyproto.REQUIRE {
+ logger.Debug(logSender, "", "reject connection from ip %q: proxy protocol signature required and not set",
+ upstreamIP)
+ return proxyproto.REJECT, proxyproto.ErrInvalidUpstream
+ }
return def, nil
}
}
@@ -830,12 +898,12 @@ func getProxyPolicy(allowed, skipped []func(net.IP) bool, def proxyproto.Policy)
// Each SSH connection can open several channels for SFTP or SSH commands
type SSHConnection struct {
id string
- conn net.Conn
+ conn io.Closer
lastActivity atomic.Int64
}
// NewSSHConnection returns a new SSHConnection
-func NewSSHConnection(id string, conn net.Conn) *SSHConnection {
+func NewSSHConnection(id string, conn io.Closer) *SSHConnection {
c := &SSHConnection{
id: id,
conn: conn,
@@ -868,7 +936,9 @@ func (c *SSHConnection) Close() error {
type ActiveConnections struct {
// clients contains both authenticated and estabilished connections and the ones waiting
// for authentication
- clients clientsMap
+ clients clientsMap
+ // transfers contains active transfers, total and per-user
+ transfers clientsMap
transfersCheckStatus atomic.Bool
sync.RWMutex
connections []ActiveConnection
@@ -919,6 +989,9 @@ func (conns *ActiveConnections) Add(c ActiveConnection) error {
if val := conns.perUserConns[username]; val >= maxSessions {
return fmt.Errorf("too many open sessions: %d/%d", val, maxSessions)
}
+ if val := conns.transfers.getTotalFrom(username); val >= maxSessions {
+ return fmt.Errorf("too many open transfers: %d/%d", val, maxSessions)
+ }
}
conns.addUserConnection(username)
}
@@ -980,7 +1053,7 @@ func (conns *ActiveConnections) Remove(connectionID string) {
metric.UpdateActiveConnectionsSize(lastIdx)
logger.Debug(conn.GetProtocol(), conn.GetID(), "connection removed, local address %q, remote address %q close fs error: %v, num open connections: %d",
conn.GetLocalAddress(), conn.GetRemoteAddress(), err, lastIdx)
- if conn.GetProtocol() == ProtocolFTP && conn.GetUsername() == "" && !util.Contains(ftpLoginCommands, conn.GetCommand()) {
+ if conn.GetProtocol() == ProtocolFTP && conn.GetUsername() == "" && !slices.Contains(ftpLoginCommands, conn.GetCommand()) {
ip := util.GetIPFromRemoteAddress(conn.GetRemoteAddress())
logger.ConnectionFailedLog("", ip, dataprovider.LoginMethodNoAuthTried, ProtocolFTP,
dataprovider.ErrNoAuthTried.Error())
@@ -1089,7 +1162,7 @@ func (conns *ActiveConnections) checkIdles() {
logger.Debug(conn.GetProtocol(), conn.GetID(), "close idle connection, idle time: %s, username: %q close err: %v",
time.Since(conn.GetLastActivity()), conn.GetUsername(), err)
}(c)
- } else if !c.isAccessAllowed() {
+ } else if !isUnauthenticatedFTPUser && !c.isAccessAllowed() {
defer func(conn ActiveConnection) {
err := conn.Disconnect()
logger.Info(conn.GetProtocol(), conn.GetID(), "access conditions not met for user: %q close connection err: %v",
@@ -1179,6 +1252,35 @@ func (conns *ActiveConnections) GetClientConnections() int32 {
return conns.clients.getTotal()
}
+// GetTotalTransfers returns the total number of active transfers
+func (conns *ActiveConnections) GetTotalTransfers() int32 {
+ return conns.transfers.getTotal()
+}
+
+// IsNewTransferAllowed returns an error if the maximum number of concurrent allowed
+// transfers is exceeded
+func (conns *ActiveConnections) IsNewTransferAllowed(username string) error {
+ if isShuttingDown.Load() {
+ return ErrShuttingDown
+ }
+ if Config.MaxTotalConnections == 0 && Config.MaxPerHostConnections == 0 {
+ return nil
+ }
+ if Config.MaxPerHostConnections > 0 {
+ if transfers := conns.transfers.getTotalFrom(username); transfers >= Config.MaxPerHostConnections {
+ logger.Info(logSender, "", "active transfers from user %q: %d/%d", username, transfers, Config.MaxPerHostConnections)
+ return ErrConnectionDenied
+ }
+ }
+ if Config.MaxTotalConnections > 0 {
+ if transfers := conns.transfers.getTotal(); transfers >= int32(Config.MaxTotalConnections) {
+ logger.Info(logSender, "", "active transfers %d/%d", transfers, Config.MaxTotalConnections)
+ return ErrConnectionDenied
+ }
+ }
+ return nil
+}
+
// IsNewConnectionAllowed returns an error if the maximum number of concurrent allowed
// connections is exceeded or a whitelist is defined and the specified ipAddr is not listed
// or the service is shutting down
@@ -1219,7 +1321,11 @@ func (conns *ActiveConnections) IsNewConnectionAllowed(ipAddr, protocol string)
}
// on a single SFTP connection we could have multiple SFTP channels or commands
- // so we check the estabilished connections too
+ // so we check the estabilished connections and active uploads too
+ if transfers := conns.transfers.getTotal(); transfers >= int32(Config.MaxTotalConnections) {
+ logger.Info(logSender, "", "active transfers %d/%d", transfers, Config.MaxTotalConnections)
+ return ErrConnectionDenied
+ }
conns.RLock()
defer conns.RUnlock()
diff --git a/internal/common/common_test.go b/internal/common/common_test.go
index c7d4ba51..8566d773 100644
--- a/internal/common/common_test.go
+++ b/internal/common/common_test.go
@@ -23,6 +23,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
+ "slices"
"sync"
"testing"
"time"
@@ -63,7 +64,7 @@ func (c *fakeConnection) AddUser(user dataprovider.User) error {
if err != nil {
return err
}
- c.BaseConnection.User = user
+ c.User = user
return nil
}
@@ -216,6 +217,33 @@ func TestConnections(t *testing.T) {
Connections.RUnlock()
}
+func TestEventManagerCommandsInitialization(t *testing.T) {
+ configCopy := Config
+
+ c := Configuration{
+ EventManager: EventManagerConfig{
+ EnabledCommands: []string{"ls"}, // not an absolute path
+ },
+ }
+ err := Initialize(c, 0)
+ assert.ErrorContains(t, err, "invalid command")
+
+ var commands []string
+ if runtime.GOOS == osWindows {
+ commands = []string{"C:\\command"}
+ } else {
+ commands = []string{"/bin/ls"}
+ }
+
+ c.EventManager.EnabledCommands = commands
+ err = Initialize(c, 0)
+ assert.NoError(t, err)
+ assert.Equal(t, commands, dataprovider.EnabledActionCommands)
+
+ dataprovider.EnabledActionCommands = configCopy.EventManager.EnabledCommands
+ Config = configCopy
+}
+
func TestInitializationProxyErrors(t *testing.T) {
configCopy := Config
@@ -419,6 +447,9 @@ func TestDefenderIntegration(t *testing.T) {
ObservationTime: 15,
EntriesSoftLimit: 100,
EntriesHardLimit: 150,
+ LoginDelay: LoginDelay{
+ PasswordFailed: 200,
+ },
}
err = Initialize(Config, 0)
// ScoreInvalid cannot be greater than threshold
@@ -477,6 +508,16 @@ func TestDefenderIntegration(t *testing.T) {
assert.Nil(t, banTime)
assert.False(t, DeleteDefenderHost(ip))
+ startTime := time.Now()
+ DelayLogin(nil)
+ elapsed := time.Since(startTime)
+ assert.Less(t, elapsed, time.Millisecond*50)
+
+ startTime = time.Now()
+ DelayLogin(ErrInternalFailure)
+ elapsed = time.Since(startTime)
+ assert.Greater(t, elapsed, time.Millisecond*150)
+
Config = configCopy
}
@@ -612,11 +653,17 @@ func TestMaxConnections(t *testing.T) {
ipAddr := "192.168.7.8"
assert.NoError(t, Connections.IsNewConnectionAllowed(ipAddr, ProtocolFTP))
+ assert.NoError(t, Connections.IsNewTransferAllowed(userTestUsername))
Config.MaxTotalConnections = 1
Config.MaxPerHostConnections = perHost
assert.NoError(t, Connections.IsNewConnectionAllowed(ipAddr, ProtocolHTTP))
+ assert.NoError(t, Connections.IsNewTransferAllowed(userTestUsername))
+ isShuttingDown.Store(true)
+ assert.ErrorIs(t, Connections.IsNewTransferAllowed(userTestUsername), ErrShuttingDown)
+ isShuttingDown.Store(false)
+
c := NewBaseConnection("id", ProtocolSFTP, "", "", dataprovider.User{})
fakeConn := &fakeConnection{
BaseConnection: c,
@@ -625,6 +672,10 @@ func TestMaxConnections(t *testing.T) {
assert.NoError(t, err)
assert.Len(t, Connections.GetStats(""), 1)
assert.Error(t, Connections.IsNewConnectionAllowed(ipAddr, ProtocolSSH))
+ Connections.transfers.add(userTestUsername)
+ assert.Error(t, Connections.IsNewTransferAllowed(userTestUsername))
+ Connections.transfers.remove(userTestUsername)
+ assert.Equal(t, int32(0), Connections.GetTotalTransfers())
res := Connections.Close(fakeConn.GetID(), "")
assert.True(t, res)
@@ -636,6 +687,9 @@ func TestMaxConnections(t *testing.T) {
assert.Error(t, Connections.IsNewConnectionAllowed(ipAddr, ProtocolSSH))
Connections.RemoveClientConnection(ipAddr)
assert.NoError(t, Connections.IsNewConnectionAllowed(ipAddr, ProtocolWebDAV))
+ Connections.transfers.add(userTestUsername)
+ assert.Error(t, Connections.IsNewConnectionAllowed(ipAddr, ProtocolSSH))
+ Connections.transfers.remove(userTestUsername)
Connections.RemoveClientConnection(ipAddr)
Config.MaxTotalConnections = oldValue
@@ -774,11 +828,7 @@ func TestIdleConnections(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, Connections.GetActiveSessions(username), 2)
- cFTP := NewBaseConnection("id2", ProtocolFTP, "", "", dataprovider.User{
- BaseUser: sdk.BaseUser{
- Status: 1,
- },
- })
+ cFTP := NewBaseConnection("id2", ProtocolFTP, "", "", dataprovider.User{})
cFTP.lastActivity.Store(time.Now().UnixNano())
fakeConn = &fakeConnection{
BaseConnection: cFTP,
@@ -945,9 +995,10 @@ func TestConnectionStatus(t *testing.T) {
assert.Len(t, stats, 3)
for _, stat := range stats {
assert.Equal(t, stat.Username, username)
- if stat.ConnectionID == "SFTP_id1" {
+ switch stat.ConnectionID {
+ case "SFTP_id1":
assert.Len(t, stat.Transfers, 2)
- } else if stat.ConnectionID == "DAV_id3" {
+ case "DAV_id3":
assert.Len(t, stat.Transfers, 1)
}
}
@@ -1028,9 +1079,13 @@ func TestQuotaScansRole(t *testing.T) {
func TestProxyPolicy(t *testing.T) {
addr := net.TCPAddr{}
+ downstream := net.TCPAddr{IP: net.ParseIP("1.1.1.1")}
p := getProxyPolicy(nil, nil, proxyproto.IGNORE)
- policy, err := p(&addr)
- assert.Error(t, err)
+ policy, err := p(proxyproto.ConnPolicyOptions{
+ Upstream: &addr,
+ Downstream: &downstream,
+ })
+ assert.ErrorIs(t, err, proxyproto.ErrInvalidUpstream)
assert.Equal(t, proxyproto.REJECT, policy)
ip1 := net.ParseIP("10.8.1.1")
ip2 := net.ParseIP("10.8.1.2")
@@ -1040,31 +1095,55 @@ func TestProxyPolicy(t *testing.T) {
skipped, err := util.ParseAllowedIPAndRanges([]string{ip2.String(), ip3.String()})
assert.NoError(t, err)
p = getProxyPolicy(allowed, skipped, proxyproto.IGNORE)
- policy, err = p(&net.TCPAddr{IP: ip1})
+ policy, err = p(proxyproto.ConnPolicyOptions{
+ Upstream: &net.TCPAddr{IP: ip1},
+ Downstream: &downstream,
+ })
assert.NoError(t, err)
assert.Equal(t, proxyproto.USE, policy)
- policy, err = p(&net.TCPAddr{IP: ip2})
+ policy, err = p(proxyproto.ConnPolicyOptions{
+ Upstream: &net.TCPAddr{IP: ip2},
+ Downstream: &downstream,
+ })
assert.NoError(t, err)
assert.Equal(t, proxyproto.SKIP, policy)
- policy, err = p(&net.TCPAddr{IP: ip3})
+ policy, err = p(proxyproto.ConnPolicyOptions{
+ Upstream: &net.TCPAddr{IP: ip3},
+ Downstream: &downstream,
+ })
assert.NoError(t, err)
assert.Equal(t, proxyproto.SKIP, policy)
- policy, err = p(&net.TCPAddr{IP: net.ParseIP("10.8.1.4")})
+ policy, err = p(proxyproto.ConnPolicyOptions{
+ Upstream: &net.TCPAddr{IP: net.ParseIP("10.8.1.4")},
+ Downstream: &downstream,
+ })
assert.NoError(t, err)
assert.Equal(t, proxyproto.IGNORE, policy)
p = getProxyPolicy(allowed, skipped, proxyproto.REQUIRE)
- policy, err = p(&net.TCPAddr{IP: ip1})
+ policy, err = p(proxyproto.ConnPolicyOptions{
+ Upstream: &net.TCPAddr{IP: ip1},
+ Downstream: &downstream,
+ })
assert.NoError(t, err)
assert.Equal(t, proxyproto.REQUIRE, policy)
- policy, err = p(&net.TCPAddr{IP: ip2})
+ policy, err = p(proxyproto.ConnPolicyOptions{
+ Upstream: &net.TCPAddr{IP: ip2},
+ Downstream: &downstream,
+ })
assert.NoError(t, err)
assert.Equal(t, proxyproto.SKIP, policy)
- policy, err = p(&net.TCPAddr{IP: ip3})
+ policy, err = p(proxyproto.ConnPolicyOptions{
+ Upstream: &net.TCPAddr{IP: ip3},
+ Downstream: &downstream,
+ })
assert.NoError(t, err)
assert.Equal(t, proxyproto.SKIP, policy)
- policy, err = p(&net.TCPAddr{IP: net.ParseIP("10.8.1.5")})
- assert.NoError(t, err)
- assert.Equal(t, proxyproto.REQUIRE, policy)
+ policy, err = p(proxyproto.ConnPolicyOptions{
+ Upstream: &net.TCPAddr{IP: net.ParseIP("10.8.1.5")},
+ Downstream: &downstream,
+ })
+ assert.ErrorIs(t, err, proxyproto.ErrInvalidUpstream)
+ assert.Equal(t, proxyproto.REJECT, policy)
}
func TestProxyProtocolVersion(t *testing.T) {
@@ -1076,14 +1155,18 @@ func TestProxyProtocolVersion(t *testing.T) {
assert.Contains(t, err.Error(), "proxy protocol not configured")
}
c.ProxyProtocol = 1
- proxyListener, err := c.GetProxyListener(nil)
+ listener, err := c.GetProxyListener(nil)
assert.NoError(t, err)
- assert.NotNil(t, proxyListener.Policy)
+ proxyListener, ok := listener.(*proxyproto.Listener)
+ require.True(t, ok)
+ assert.NotNil(t, proxyListener.ConnPolicy)
c.ProxyProtocol = 2
- proxyListener, err = c.GetProxyListener(nil)
+ listener, err = c.GetProxyListener(nil)
assert.NoError(t, err)
- assert.NotNil(t, proxyListener.Policy)
+ proxyListener, ok = listener.(*proxyproto.Listener)
+ require.True(t, ok)
+ assert.NotNil(t, proxyListener.ConnPolicy)
}
func TestStartupHook(t *testing.T) {
@@ -1213,8 +1296,8 @@ func TestFolderCopy(t *testing.T) {
folder.ID = 2
folder.Users = []string{"user3"}
require.Len(t, folderCopy.Users, 2)
- require.True(t, util.Contains(folderCopy.Users, "user1"))
- require.True(t, util.Contains(folderCopy.Users, "user2"))
+ require.True(t, slices.Contains(folderCopy.Users, "user1"))
+ require.True(t, slices.Contains(folderCopy.Users, "user2"))
require.Equal(t, int64(1), folderCopy.ID)
require.Equal(t, folder.Name, folderCopy.Name)
require.Equal(t, folder.MappedPath, folderCopy.MappedPath)
@@ -1230,7 +1313,7 @@ func TestFolderCopy(t *testing.T) {
folderCopy = folder.GetACopy()
folder.FsConfig.CryptConfig.Passphrase = kms.NewEmptySecret()
require.Len(t, folderCopy.Users, 1)
- require.True(t, util.Contains(folderCopy.Users, "user3"))
+ require.True(t, slices.Contains(folderCopy.Users, "user3"))
require.Equal(t, int64(2), folderCopy.ID)
require.Equal(t, folder.Name, folderCopy.Name)
require.Equal(t, folder.MappedPath, folderCopy.MappedPath)
diff --git a/internal/common/connection.go b/internal/common/connection.go
index e7f9c9a8..4bbc89f0 100644
--- a/internal/common/connection.go
+++ b/internal/common/connection.go
@@ -21,6 +21,7 @@ import (
"io/fs"
"os"
"path"
+ "slices"
"strings"
"sync"
"sync/atomic"
@@ -62,7 +63,7 @@ type BaseConnection struct {
// NewBaseConnection returns a new BaseConnection
func NewBaseConnection(id, protocol, localAddr, remoteAddr string, user dataprovider.User) *BaseConnection {
connID := id
- if util.Contains(supportedProtocols, protocol) {
+ if slices.Contains(supportedProtocols, protocol) {
connID = fmt.Sprintf("%s_%s", protocol, id)
}
user.UploadBandwidth, user.DownloadBandwidth = user.GetBandwidthForIP(util.GetIPFromRemoteAddress(remoteAddr), connID)
@@ -131,7 +132,7 @@ func (c *BaseConnection) GetRemoteIP() string {
// SetProtocol sets the protocol for this connection
func (c *BaseConnection) SetProtocol(protocol string) {
c.protocol = protocol
- if util.Contains(supportedProtocols, c.protocol) {
+ if slices.Contains(supportedProtocols, c.protocol) {
c.ID = fmt.Sprintf("%v_%v", c.protocol, c.ID)
}
}
@@ -158,6 +159,8 @@ func (c *BaseConnection) CloseFS() error {
// AddTransfer associates a new transfer to this connection
func (c *BaseConnection) AddTransfer(t ActiveTransfer) {
+ Connections.transfers.add(c.User.Username)
+
c.Lock()
defer c.Unlock()
@@ -189,6 +192,8 @@ func (c *BaseConnection) AddTransfer(t ActiveTransfer) {
// RemoveTransfer removes the specified transfer from the active ones
func (c *BaseConnection) RemoveTransfer(t ActiveTransfer) {
+ Connections.transfers.remove(c.User.Username)
+
c.Lock()
defer c.Unlock()
@@ -291,6 +296,20 @@ func (c *BaseConnection) setTimes(fsPath string, atime time.Time, mtime time.Tim
return false
}
+// getInfoForOngoingUpload returns upload statistics for an upload currently in
+// progress on this connection.
+func (c *BaseConnection) getInfoForOngoingUpload(fsPath string) (os.FileInfo, error) {
+ c.RLock()
+ defer c.RUnlock()
+
+ for _, t := range c.activeTransfers {
+ if t.GetType() == TransferUpload && t.GetFsPath() == fsPath {
+ return vfs.NewFileInfo(t.GetVirtualPath(), false, t.GetSize(), t.GetStartTime(), false), nil
+ }
+ }
+ return nil, os.ErrNotExist
+}
+
func (c *BaseConnection) truncateOpenHandle(fsPath string, size int64) (int64, error) {
c.RLock()
defer c.RUnlock()
@@ -321,10 +340,9 @@ func (c *BaseConnection) ListDir(virtualPath string) (*DirListerAt, error) {
}
return &DirListerAt{
virtualPath: virtualPath,
- user: &c.User,
+ conn: c,
+ fs: fs,
info: c.User.GetVirtualFoldersInfo(virtualPath),
- id: c.ID,
- protocol: c.protocol,
lister: lister,
}, nil
}
@@ -449,10 +467,7 @@ func (c *BaseConnection) RemoveFile(fs vfs.Fs, fsPath, virtualPath string, info
if updateQuota && info.Mode()&os.ModeSymlink == 0 {
vfolder, err := c.User.GetVirtualFolderForPath(path.Dir(virtualPath))
if err == nil {
- dataprovider.UpdateVirtualFolderQuota(&vfolder.BaseVirtualFolder, -1, -size, false) //nolint:errcheck
- if vfolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&c.User, -1, -size, false) //nolint:errcheck
- }
+ dataprovider.UpdateUserFolderQuota(&vfolder, &c.User, -1, -size, false)
} else {
dataprovider.UpdateUserQuota(&c.User, -1, -size, false) //nolint:errcheck
}
@@ -616,13 +631,15 @@ func (c *BaseConnection) checkCopy(srcInfo, dstInfo os.FileInfo, virtualSource,
if dstInfo != nil && dstInfo.IsDir() {
return fmt.Errorf("cannot overwrite file %q with dir %q: %w", virtualSource, virtualTarget, c.GetOpUnsupportedError())
}
- if fsSourcePath == fsTargetPath {
- return fmt.Errorf("the copy source and target cannot be the same: %w", c.GetOpUnsupportedError())
+ if c.IsSameResource(virtualSource, virtualTarget) {
+ if fsSourcePath == fsTargetPath {
+ return fmt.Errorf("the copy source and target cannot be the same: %w", c.GetOpUnsupportedError())
+ }
}
return nil
}
-func (c *BaseConnection) copyFile(virtualSourcePath, virtualTargetPath string, srcSize int64) error {
+func (c *BaseConnection) copyFile(virtualSourcePath, virtualTargetPath string, srcInfo os.FileInfo) error {
if !c.User.HasPerm(dataprovider.PermCopy, virtualSourcePath) || !c.User.HasPerm(dataprovider.PermCopy, virtualTargetPath) {
return c.GetPermissionDeniedError()
}
@@ -640,12 +657,12 @@ func (c *BaseConnection) copyFile(virtualSourcePath, virtualTargetPath string, s
return err
}
startTime := time.Now()
- numFiles, sizeDiff, err := copier.CopyFile(fsSourcePath, fsTargetPath, srcSize)
+ numFiles, sizeDiff, err := copier.CopyFile(fsSourcePath, fsTargetPath, srcInfo)
elapsed := time.Since(startTime).Nanoseconds() / 1000000
updateUserQuotaAfterFileWrite(c, virtualTargetPath, numFiles, sizeDiff)
logger.CommandLog(copyLogSender, fsSourcePath, fsTargetPath, c.User.Username, "", c.ID, c.protocol, -1, -1,
- "", "", "", srcSize, c.localAddr, c.remoteAddr, elapsed)
- ExecuteActionNotification(c, operationCopy, fsSourcePath, virtualSourcePath, fsTargetPath, virtualTargetPath, "", srcSize, err, elapsed, nil) //nolint:errcheck
+ "", "", "", srcInfo.Size(), c.localAddr, c.remoteAddr, elapsed)
+ ExecuteActionNotification(c, operationCopy, fsSourcePath, virtualSourcePath, fsTargetPath, virtualTargetPath, "", srcInfo.Size(), err, elapsed, nil) //nolint:errcheck
return err
}
}
@@ -657,7 +674,7 @@ func (c *BaseConnection) copyFile(virtualSourcePath, virtualTargetPath string, s
defer rCancelFn()
defer reader.Close()
- writer, numFiles, truncatedSize, wCancelFn, err := getFileWriter(c, virtualTargetPath, srcSize)
+ writer, numFiles, truncatedSize, wCancelFn, err := getFileWriter(c, virtualTargetPath, srcInfo.Size())
if err != nil {
return fmt.Errorf("unable to get writer for path %q: %w", virtualTargetPath, err)
}
@@ -708,7 +725,7 @@ func (c *BaseConnection) doRecursiveCopy(virtualSourcePath, virtualTargetPath st
return nil
}
- return c.copyFile(virtualSourcePath, virtualTargetPath, srcInfo.Size())
+ return c.copyFile(virtualSourcePath, virtualTargetPath, srcInfo)
}
func (c *BaseConnection) recursiveCopyEntries(virtualSourcePath, virtualTargetPath string, entries []os.FileInfo, recursion int) error {
@@ -770,29 +787,27 @@ func (c *BaseConnection) Copy(virtualSourcePath, virtualTargetPath string) error
return err
}
}
- createTargetDir := true
- if dstInfo != nil && dstInfo.IsDir() {
- createTargetDir = false
- }
+ createTargetDir := dstInfo == nil || !dstInfo.IsDir()
if err := c.checkCopy(srcInfo, dstInfo, virtualSourcePath, destPath); err != nil {
return err
}
if err := c.CheckParentDirs(path.Dir(destPath)); err != nil {
return err
}
- done := make(chan bool)
- defer close(done)
- go keepConnectionAlive(c, done, 2*time.Minute)
+ stopKeepAlive := keepConnectionAlive(c, 2*time.Minute)
+ defer stopKeepAlive()
return c.doRecursiveCopy(virtualSourcePath, destPath, srcInfo, createTargetDir, 0)
}
// Rename renames (moves) virtualSourcePath to virtualTargetPath
func (c *BaseConnection) Rename(virtualSourcePath, virtualTargetPath string) error {
- return c.renameInternal(virtualSourcePath, virtualTargetPath, false)
+ return c.renameInternal(virtualSourcePath, virtualTargetPath, false, vfs.CheckParentDir)
}
-func (c *BaseConnection) renameInternal(virtualSourcePath, virtualTargetPath string, checkParentDestination bool) error {
+func (c *BaseConnection) renameInternal(virtualSourcePath, virtualTargetPath string, //nolint:gocyclo
+ checkParentDestination bool, checks int,
+) error {
if virtualSourcePath == virtualTargetPath {
return fmt.Errorf("the rename source and target cannot be the same: %w", c.GetOpUnsupportedError())
}
@@ -813,7 +828,11 @@ func (c *BaseConnection) renameInternal(virtualSourcePath, virtualTargetPath str
return c.GetPermissionDeniedError()
}
initialSize := int64(-1)
- if dstInfo, err := fsDst.Lstat(fsTargetPath); err == nil {
+ dstInfo, err := fsDst.Lstat(fsTargetPath)
+ if err != nil && !fsDst.IsNotExist(err) {
+ return err
+ }
+ if err == nil {
checkParentDestination = false
if dstInfo.IsDir() {
c.Log(logger.LevelWarn, "attempted to rename %q overwriting an existing directory %q",
@@ -835,18 +854,17 @@ func (c *BaseConnection) renameInternal(virtualSourcePath, virtualTargetPath str
return err
}
}
- if !c.hasSpaceForRename(fsSrc, virtualSourcePath, virtualTargetPath, initialSize, fsSourcePath) {
+ if !c.hasSpaceForRename(fsSrc, virtualSourcePath, virtualTargetPath, initialSize, fsSourcePath, srcInfo) {
c.Log(logger.LevelInfo, "denying cross rename due to space limit")
return c.GetGenericError(ErrQuotaExceeded)
}
if checkParentDestination {
c.CheckParentDirs(path.Dir(virtualTargetPath)) //nolint:errcheck
}
- done := make(chan bool)
- defer close(done)
- go keepConnectionAlive(c, done, 2*time.Minute)
+ stopKeepAlive := keepConnectionAlive(c, 2*time.Minute)
+ defer stopKeepAlive()
- files, size, err := fsDst.Rename(fsSourcePath, fsTargetPath)
+ files, size, err := fsDst.Rename(fsSourcePath, fsTargetPath, checks)
if err != nil {
c.Log(logger.LevelError, "failed to rename %q -> %q: %+v", fsSourcePath, fsTargetPath, err)
return c.GetFsError(fsSrc, err)
@@ -918,16 +936,6 @@ func (c *BaseConnection) CreateSymlink(virtualSourcePath, virtualTargetPath stri
return nil
}
-func (c *BaseConnection) getPathForSetStatPerms(fs vfs.Fs, fsPath, virtualPath string) string {
- pathForPerms := virtualPath
- if fi, err := fs.Lstat(fsPath); err == nil {
- if fi.IsDir() {
- pathForPerms = path.Dir(virtualPath)
- }
- }
- return pathForPerms
-}
-
func (c *BaseConnection) doStatInternal(virtualPath string, mode int, checkFilePatterns,
convertResult bool,
) (os.FileInfo, error) {
@@ -958,7 +966,19 @@ func (c *BaseConnection) doStatInternal(virtualPath string, mode int, checkFileP
info, err = fs.Stat(c.getRealFsPath(fsPath))
}
if err != nil {
- if !fs.IsNotExist(err) {
+ isNotExist := fs.IsNotExist(err)
+ if isNotExist {
+ // This is primarily useful for atomic storage backends, where files
+ // become visible only after they are closed. However, since we may
+ // be proxying (for example) an SFTP server backed by atomic
+ // storage, and this search only inspects transfers active on the
+ // current connection (typically just one), the check is inexpensive
+ // and safe to perform unconditionally.
+ if info, err := c.getInfoForOngoingUpload(fsPath); err == nil {
+ return info, nil
+ }
+ }
+ if !isNotExist {
c.Log(logger.LevelWarn, "stat error for path %q: %+v", virtualPath, err)
}
return nil, c.GetFsError(fs, err)
@@ -1064,7 +1084,7 @@ func (c *BaseConnection) SetStat(virtualPath string, attributes *StatAttributes)
if err != nil {
return err
}
- pathForPerms := c.getPathForSetStatPerms(fs, fsPath, virtualPath)
+ pathForPerms := path.Dir(virtualPath)
if attributes.Flags&StatAttrTimes != 0 {
if err = c.handleChtimes(fs, fsPath, pathForPerms, attributes); err != nil {
@@ -1121,10 +1141,7 @@ func (c *BaseConnection) truncateFile(fs vfs.Fs, fsPath, virtualPath string, siz
sizeDiff := initialSize - size
vfolder, err := c.User.GetVirtualFolderForPath(path.Dir(virtualPath))
if err == nil {
- dataprovider.UpdateVirtualFolderQuota(&vfolder.BaseVirtualFolder, 0, -sizeDiff, false) //nolint:errcheck
- if vfolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&c.User, 0, -sizeDiff, false) //nolint:errcheck
- }
+ dataprovider.UpdateUserFolderQuota(&vfolder, &c.User, 0, -sizeDiff, false)
} else {
dataprovider.UpdateUserQuota(&c.User, 0, -sizeDiff, false) //nolint:errcheck
}
@@ -1133,11 +1150,11 @@ func (c *BaseConnection) truncateFile(fs vfs.Fs, fsPath, virtualPath string, siz
}
func (c *BaseConnection) checkRecursiveRenameDirPermissions(fsSrc, fsDst vfs.Fs, sourcePath, targetPath,
- virtualSourcePath, virtualTargetPath string, fi os.FileInfo,
+ virtualSourcePath, virtualTargetPath string, srcInfo os.FileInfo,
) error {
if !c.User.HasPermissionsInside(virtualSourcePath) &&
!c.User.HasPermissionsInside(virtualTargetPath) {
- if !c.isRenamePermitted(fsSrc, fsDst, sourcePath, targetPath, virtualSourcePath, virtualTargetPath, fi) {
+ if !c.isRenamePermitted(fsSrc, fsDst, sourcePath, targetPath, virtualSourcePath, virtualTargetPath, srcInfo) {
c.Log(logger.LevelInfo, "rename %q -> %q is not allowed, virtual destination path: %q",
sourcePath, targetPath, virtualTargetPath)
return c.GetPermissionDeniedError()
@@ -1197,7 +1214,7 @@ func (c *BaseConnection) hasRenamePerms(virtualSourcePath, virtualTargetPath str
}
func (c *BaseConnection) checkFolderRename(fsSrc, fsDst vfs.Fs, fsSourcePath, fsTargetPath, virtualSourcePath,
- virtualTargetPath string, fi os.FileInfo) error {
+ virtualTargetPath string, srcInfo os.FileInfo) error {
if util.IsDirOverlapped(virtualSourcePath, virtualTargetPath, true, "/") {
c.Log(logger.LevelDebug, "renaming the folder %q->%q is not supported: nested folders",
virtualSourcePath, virtualTargetPath)
@@ -1221,7 +1238,7 @@ func (c *BaseConnection) checkFolderRename(fsSrc, fsDst vfs.Fs, fsSourcePath, fs
return fmt.Errorf("folder %q has virtual folders inside it: %w", virtualTargetPath, c.GetOpUnsupportedError())
}
if err := c.checkRecursiveRenameDirPermissions(fsSrc, fsDst, fsSourcePath, fsTargetPath,
- virtualSourcePath, virtualTargetPath, fi); err != nil {
+ virtualSourcePath, virtualTargetPath, srcInfo); err != nil {
c.Log(logger.LevelDebug, "error checking recursive permissions before renaming %q: %+v", fsSourcePath, err)
return err
}
@@ -1229,7 +1246,7 @@ func (c *BaseConnection) checkFolderRename(fsSrc, fsDst vfs.Fs, fsSourcePath, fs
}
func (c *BaseConnection) isRenamePermitted(fsSrc, fsDst vfs.Fs, fsSourcePath, fsTargetPath, virtualSourcePath,
- virtualTargetPath string, fi os.FileInfo,
+ virtualTargetPath string, srcInfo os.FileInfo,
) bool {
if !c.IsSameResource(virtualSourcePath, virtualTargetPath) {
c.Log(logger.LevelInfo, "rename %q->%q is not allowed: the paths must be on the same resource",
@@ -1259,11 +1276,11 @@ func (c *BaseConnection) isRenamePermitted(fsSrc, fsDst vfs.Fs, fsSourcePath, fs
virtualTargetPath)
return false
}
- return c.hasRenamePerms(virtualSourcePath, virtualTargetPath, fi)
+ return c.hasRenamePerms(virtualSourcePath, virtualTargetPath, srcInfo)
}
func (c *BaseConnection) hasSpaceForRename(fs vfs.Fs, virtualSourcePath, virtualTargetPath string, initialSize int64,
- fsSourcePath string) bool {
+ sourcePath string, srcInfo os.FileInfo) bool {
if dataprovider.GetQuotaTracking() == 0 {
return true
}
@@ -1293,30 +1310,28 @@ func (c *BaseConnection) hasSpaceForRename(fs vfs.Fs, virtualSourcePath, virtual
// no quota restrictions
return true
}
- return c.hasSpaceForCrossRename(fs, quotaResult, initialSize, fsSourcePath)
+ return c.hasSpaceForCrossRename(fs, quotaResult, initialSize, sourcePath, srcInfo)
}
// hasSpaceForCrossRename checks the quota after a rename between different folders
-func (c *BaseConnection) hasSpaceForCrossRename(fs vfs.Fs, quotaResult vfs.QuotaCheckResult, initialSize int64, sourcePath string) bool {
+func (c *BaseConnection) hasSpaceForCrossRename(fs vfs.Fs, quotaResult vfs.QuotaCheckResult, initialSize int64,
+ sourcePath string, srcInfo os.FileInfo,
+) bool {
if !quotaResult.HasSpace && initialSize == -1 {
// we are over quota and this is not a file replace
return false
}
- fi, err := fs.Lstat(sourcePath)
- if err != nil {
- c.Log(logger.LevelError, "cross rename denied, stat error for path %q: %v", sourcePath, err)
- return false
- }
var sizeDiff int64
var filesDiff int
- if fi.Mode().IsRegular() {
- sizeDiff = fi.Size()
+ var err error
+ if srcInfo.Mode().IsRegular() {
+ sizeDiff = srcInfo.Size()
filesDiff = 1
if initialSize != -1 {
sizeDiff -= initialSize
filesDiff = 0
}
- } else if fi.IsDir() {
+ } else if srcInfo.IsDir() {
filesDiff, sizeDiff, err = fs.GetDirSize(sourcePath)
if err != nil {
c.Log(logger.LevelError, "cross rename denied, error getting size for directory %q: %v", sourcePath, err)
@@ -1343,7 +1358,7 @@ func (c *BaseConnection) hasSpaceForCrossRename(fs vfs.Fs, quotaResult vfs.Quota
}
if quotaResult.QuotaSize > 0 {
remainingSize := quotaResult.GetRemainingSize()
- c.Log(logger.LevelDebug, "cross rename, source %q remaining size %d to add %d", sourcePath,
+ c.Log(logger.LevelDebug, "cross rename, source %q remaining size %d to add %d", srcInfo.Name(),
remainingSize, sizeDiff)
if remainingSize < sizeDiff {
return false
@@ -1518,61 +1533,40 @@ func (c *BaseConnection) updateQuotaMoveBetweenVFolders(sourceFolder, dstFolder
if sourceFolder.Name == dstFolder.Name {
// both files are inside the same virtual folder
if initialSize != -1 {
- dataprovider.UpdateVirtualFolderQuota(&dstFolder.BaseVirtualFolder, -numFiles, -initialSize, false) //nolint:errcheck
- if dstFolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&c.User, -numFiles, -initialSize, false) //nolint:errcheck
- }
+ dataprovider.UpdateUserFolderQuota(dstFolder, &c.User, -numFiles, -initialSize, false)
}
return
}
// files are inside different virtual folders
- dataprovider.UpdateVirtualFolderQuota(&sourceFolder.BaseVirtualFolder, -numFiles, -filesSize, false) //nolint:errcheck
- if sourceFolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&c.User, -numFiles, -filesSize, false) //nolint:errcheck
- }
+ dataprovider.UpdateUserFolderQuota(sourceFolder, &c.User, -numFiles, -filesSize, false)
if initialSize == -1 {
- dataprovider.UpdateVirtualFolderQuota(&dstFolder.BaseVirtualFolder, numFiles, filesSize, false) //nolint:errcheck
- if dstFolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&c.User, numFiles, filesSize, false) //nolint:errcheck
- }
- } else {
- // we cannot have a directory here, initialSize != -1 only for files
- dataprovider.UpdateVirtualFolderQuota(&dstFolder.BaseVirtualFolder, 0, filesSize-initialSize, false) //nolint:errcheck
- if dstFolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&c.User, 0, filesSize-initialSize, false) //nolint:errcheck
- }
+ dataprovider.UpdateUserFolderQuota(dstFolder, &c.User, numFiles, filesSize, false)
+ return
}
+ // we cannot have a directory here, initialSize != -1 only for files
+ dataprovider.UpdateUserFolderQuota(dstFolder, &c.User, 0, filesSize-initialSize, false)
}
func (c *BaseConnection) updateQuotaMoveFromVFolder(sourceFolder *vfs.VirtualFolder, initialSize, filesSize int64, numFiles int) {
// move between a virtual folder and the user home dir
- dataprovider.UpdateVirtualFolderQuota(&sourceFolder.BaseVirtualFolder, -numFiles, -filesSize, false) //nolint:errcheck
- if sourceFolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&c.User, -numFiles, -filesSize, false) //nolint:errcheck
- }
+ dataprovider.UpdateUserFolderQuota(sourceFolder, &c.User, -numFiles, -filesSize, false)
if initialSize == -1 {
dataprovider.UpdateUserQuota(&c.User, numFiles, filesSize, false) //nolint:errcheck
- } else {
- // we cannot have a directory here, initialSize != -1 only for files
- dataprovider.UpdateUserQuota(&c.User, 0, filesSize-initialSize, false) //nolint:errcheck
+ return
}
+ // we cannot have a directory here, initialSize != -1 only for files
+ dataprovider.UpdateUserQuota(&c.User, 0, filesSize-initialSize, false) //nolint:errcheck
}
func (c *BaseConnection) updateQuotaMoveToVFolder(dstFolder *vfs.VirtualFolder, initialSize, filesSize int64, numFiles int) {
// move between the user home dir and a virtual folder
dataprovider.UpdateUserQuota(&c.User, -numFiles, -filesSize, false) //nolint:errcheck
if initialSize == -1 {
- dataprovider.UpdateVirtualFolderQuota(&dstFolder.BaseVirtualFolder, numFiles, filesSize, false) //nolint:errcheck
- if dstFolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&c.User, numFiles, filesSize, false) //nolint:errcheck
- }
- } else {
- // we cannot have a directory here, initialSize != -1 only for files
- dataprovider.UpdateVirtualFolderQuota(&dstFolder.BaseVirtualFolder, 0, filesSize-initialSize, false) //nolint:errcheck
- if dstFolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&c.User, 0, filesSize-initialSize, false) //nolint:errcheck
- }
+ dataprovider.UpdateUserFolderQuota(dstFolder, &c.User, numFiles, filesSize, false)
+ return
}
+ // we cannot have a directory here, initialSize != -1 only for files
+ dataprovider.UpdateUserFolderQuota(dstFolder, &c.User, 0, filesSize-initialSize, false)
}
func (c *BaseConnection) updateQuotaAfterRename(fs vfs.Fs, virtualSourcePath, virtualTargetPath, targetPath string,
@@ -1814,20 +1808,19 @@ func (c *BaseConnection) GetFsAndResolvedPath(virtualPath string) (vfs.Fs, strin
// DirListerAt defines a directory lister implementing the ListAt method.
type DirListerAt struct {
virtualPath string
- user *dataprovider.User
+ conn *BaseConnection
+ fs vfs.Fs
info []os.FileInfo
- id string
- protocol string
mu sync.Mutex
lister vfs.DirLister
}
-// Add adds the given os.FileInfo to the internal cache
-func (l *DirListerAt) Add(fi os.FileInfo) {
+// Prepend adds the given os.FileInfo as first element of the internal cache
+func (l *DirListerAt) Prepend(fi os.FileInfo) {
l.mu.Lock()
defer l.mu.Unlock()
- l.info = append(l.info, fi)
+ l.info = slices.Insert(l.info, 0, fi)
}
// ListAt implements sftp.ListerAt
@@ -1840,10 +1833,10 @@ func (l *DirListerAt) ListAt(f []os.FileInfo, _ int64) (int, error) {
}
if len(f) <= len(l.info) {
files := make([]os.FileInfo, 0, len(f))
- for idx := len(l.info) - 1; idx >= 0; idx-- {
+ for idx := range l.info {
files = append(files, l.info[idx])
if len(files) == len(f) {
- l.info = l.info[:idx]
+ l.info = l.info[idx+1:]
n := copy(f, files)
return n, nil
}
@@ -1860,14 +1853,12 @@ func (l *DirListerAt) Next(limit int) ([]os.FileInfo, error) {
for {
files, err := l.lister.Next(limit)
if err != nil && !errors.Is(err, io.EOF) {
- logger.Debug(l.protocol, l.id, "error retrieving directory entries: %+v", err)
- return files, err
+ l.conn.Log(logger.LevelDebug, "error retrieving directory entries: %+v", err)
+ return files, l.conn.GetFsError(l.fs, err)
}
- files = l.user.FilterListDir(files, l.virtualPath)
+ files = l.conn.User.FilterListDir(files, l.virtualPath)
if len(l.info) > 0 {
- for _, fi := range l.info {
- files = util.PrependFileInfo(files, fi)
- }
+ files = slices.Concat(l.info, files)
l.info = nil
}
if err != nil || len(files) > 0 {
@@ -1902,18 +1893,22 @@ func getPermissionDeniedError(protocol string) error {
}
}
-func keepConnectionAlive(c *BaseConnection, done chan bool, interval time.Duration) {
- ticker := time.NewTicker(interval)
- defer func() {
- ticker.Stop()
- }()
+func keepConnectionAlive(c *BaseConnection, interval time.Duration) func() {
+ var timer *time.Timer
+ var closed atomic.Bool
- for {
- select {
- case <-done:
- return
- case <-ticker.C:
- c.UpdateLastActivity()
+ task := func() {
+ c.UpdateLastActivity()
+
+ if !closed.Load() {
+ timer.Reset(interval)
}
}
+
+ timer = time.AfterFunc(interval, task)
+
+ return func() {
+ closed.Store(true)
+ timer.Stop()
+ }
}
diff --git a/internal/common/connection_test.go b/internal/common/connection_test.go
index 8b9d44c2..0f426858 100644
--- a/internal/common/connection_test.go
+++ b/internal/common/connection_test.go
@@ -22,6 +22,7 @@ import (
"path"
"path/filepath"
"runtime"
+ "slices"
"strconv"
"testing"
"time"
@@ -197,25 +198,24 @@ func TestRecursiveRenameWalkError(t *testing.T) {
}
func TestCrossRenameFsErrors(t *testing.T) {
+ if runtime.GOOS == osWindows {
+ t.Skip("this test is not available on Windows")
+ }
fs := vfs.NewOsFs("", os.TempDir(), "", nil)
conn := NewBaseConnection("", ProtocolWebDAV, "", "", dataprovider.User{})
- res := conn.hasSpaceForCrossRename(fs, vfs.QuotaCheckResult{}, 1, "missingsource")
+ dirPath := filepath.Join(os.TempDir(), "d")
+ err := os.Mkdir(dirPath, os.ModePerm)
+ assert.NoError(t, err)
+ err = os.Chmod(dirPath, 0001)
+ assert.NoError(t, err)
+ srcInfo := vfs.NewFileInfo(filepath.Base(dirPath), true, 0, time.Now(), false)
+ res := conn.hasSpaceForCrossRename(fs, vfs.QuotaCheckResult{}, 1, dirPath, srcInfo)
assert.False(t, res)
- if runtime.GOOS != osWindows {
- dirPath := filepath.Join(os.TempDir(), "d")
- err := os.Mkdir(dirPath, os.ModePerm)
- assert.NoError(t, err)
- err = os.Chmod(dirPath, 0001)
- assert.NoError(t, err)
- res = conn.hasSpaceForCrossRename(fs, vfs.QuotaCheckResult{}, 1, dirPath)
- assert.False(t, res)
-
- err = os.Chmod(dirPath, os.ModePerm)
- assert.NoError(t, err)
- err = os.Remove(dirPath)
- assert.NoError(t, err)
- }
+ err = os.Chmod(dirPath, os.ModePerm)
+ assert.NoError(t, err)
+ err = os.Remove(dirPath)
+ assert.NoError(t, err)
}
func TestRenameVirtualFolders(t *testing.T) {
@@ -389,7 +389,7 @@ func TestErrorsMapping(t *testing.T) {
err := conn.GetFsError(fs, os.ErrNotExist)
if protocol == ProtocolSFTP {
assert.ErrorIs(t, err, sftp.ErrSSHFxNoSuchFile)
- } else if util.Contains(osErrorsProtocols, protocol) {
+ } else if slices.Contains(osErrorsProtocols, protocol) {
assert.EqualError(t, err, os.ErrNotExist.Error())
} else {
assert.EqualError(t, err, ErrNotExist.Error())
@@ -627,12 +627,11 @@ func TestErrorResolvePath(t *testing.T) {
func TestConnectionKeepAlive(t *testing.T) {
conn := NewBaseConnection("", ProtocolWebDAV, "", "", dataprovider.User{})
lastActivity := conn.GetLastActivity()
- done := make(chan bool)
- go func() {
- time.Sleep(200 * time.Millisecond)
- close(done)
- }()
- keepConnectionAlive(conn, done, 50*time.Millisecond)
+
+ stop := keepConnectionAlive(conn, 50*time.Millisecond)
+ defer stop()
+
+ time.Sleep(200 * time.Millisecond)
assert.Greater(t, conn.GetLastActivity(), lastActivity)
}
@@ -1047,6 +1046,37 @@ func TestFilePatterns(t *testing.T) {
require.Len(t, filtered, 1)
}
+func TestStatForOngoingTransfers(t *testing.T) {
+ user := dataprovider.User{
+ BaseUser: sdk.BaseUser{
+ Username: xid.New().String(),
+ Password: xid.New().String(),
+ HomeDir: filepath.Clean(os.TempDir()),
+ Status: 1,
+ Permissions: map[string][]string{
+ "/": {"*"},
+ },
+ },
+ }
+ fileName := "file.txt"
+ conn := NewBaseConnection(xid.New().String(), ProtocolSFTP, "", "", user)
+ fs := vfs.NewOsFs("", os.TempDir(), "", nil)
+ tr := NewBaseTransfer(nil, conn, nil, filepath.Join(os.TempDir(), fileName), filepath.Join(os.TempDir(), fileName),
+ fileName, TransferUpload, 0, 0, 0, 0, true, fs, dataprovider.TransferQuota{})
+ _, err := conn.DoStat("/file.txt", 0, false)
+ assert.NoError(t, err)
+ err = tr.Close()
+ assert.NoError(t, err)
+ tr = NewBaseTransfer(nil, conn, nil, filepath.Join(os.TempDir(), fileName), filepath.Join(os.TempDir(), fileName),
+ fileName, TransferDownload, 0, 0, 0, 0, true, fs, dataprovider.TransferQuota{})
+ _, err = conn.DoStat("/file.txt", 0, false)
+ assert.Error(t, err)
+ err = tr.Close()
+ assert.NoError(t, err)
+ err = conn.CloseFS()
+ assert.NoError(t, err)
+}
+
func TestListerAt(t *testing.T) {
dir := t.TempDir()
user := dataprovider.User{
@@ -1090,7 +1120,7 @@ func TestListerAt(t *testing.T) {
require.ErrorIs(t, err, io.EOF)
require.Len(t, files, 0)
_, err = lister.Next(-1)
- require.ErrorContains(t, err, "invalid limit")
+ require.ErrorContains(t, err, conn.GetGenericError(err).Error())
err = lister.Close()
require.NoError(t, err)
@@ -1134,8 +1164,8 @@ func TestListerAt(t *testing.T) {
require.Equal(t, 0, n)
lister, err = conn.ListDir("/")
require.NoError(t, err)
- lister.Add(vfs.NewFileInfo("..", true, 0, time.Unix(0, 0), false))
- lister.Add(vfs.NewFileInfo(".", true, 0, time.Unix(0, 0), false))
+ lister.Prepend(vfs.NewFileInfo("..", true, 0, time.Unix(0, 0), false))
+ lister.Prepend(vfs.NewFileInfo(".", true, 0, time.Unix(0, 0), false))
files = make([]os.FileInfo, 1)
n, err = lister.ListAt(files, 0)
require.NoError(t, err)
@@ -1159,3 +1189,348 @@ func TestListerAt(t *testing.T) {
err = lister.Close()
require.NoError(t, err)
}
+
+func TestGetFsAndResolvedPath(t *testing.T) {
+ homeDir := filepath.Join(os.TempDir(), "home_test")
+ localVdir := filepath.Join(os.TempDir(), "local_mount_test")
+
+ err := os.MkdirAll(homeDir, 0777)
+ require.NoError(t, err)
+ err = os.MkdirAll(localVdir, 0777)
+ require.NoError(t, err)
+
+ t.Cleanup(func() {
+ os.RemoveAll(homeDir)
+ os.RemoveAll(localVdir)
+ })
+
+ user := dataprovider.User{
+ BaseUser: sdk.BaseUser{
+ Username: xid.New().String(),
+ Status: 1,
+ HomeDir: homeDir,
+ },
+ VirtualFolders: []vfs.VirtualFolder{
+ {
+ BaseVirtualFolder: vfs.BaseVirtualFolder{
+ Name: "s3",
+ MappedPath: "",
+ FsConfig: vfs.Filesystem{
+ Provider: sdk.S3FilesystemProvider,
+ S3Config: vfs.S3FsConfig{
+ BaseS3FsConfig: sdk.BaseS3FsConfig{
+ Bucket: "my-test-bucket",
+ Region: "us-east-1",
+ },
+ },
+ },
+ },
+ VirtualPath: "/s3",
+ },
+ {
+ BaseVirtualFolder: vfs.BaseVirtualFolder{
+ Name: "local",
+ MappedPath: localVdir,
+ FsConfig: vfs.Filesystem{
+ Provider: sdk.LocalFilesystemProvider,
+ },
+ },
+ VirtualPath: "/local",
+ },
+ },
+ }
+
+ conn := NewBaseConnection(xid.New().String(), ProtocolSFTP, "", "", user)
+
+ tests := []struct {
+ name string
+ inputVirtualPath string
+ expectedFsType string
+ expectedPhyPath string // The resolved path on the target FS
+ expectedRelativePath string
+ }{
+ {
+ name: "Root File",
+ inputVirtualPath: "/file.txt",
+ expectedFsType: "osfs",
+ expectedPhyPath: filepath.Join(homeDir, "file.txt"),
+ expectedRelativePath: "/file.txt",
+ },
+ {
+ name: "Standard S3 File",
+ inputVirtualPath: "/s3/image.png",
+ expectedFsType: "S3Fs",
+ expectedPhyPath: "image.png",
+ expectedRelativePath: "/s3/image.png",
+ },
+ {
+ name: "Standard Local Mount File",
+ inputVirtualPath: "/local/config.json",
+ expectedFsType: "osfs",
+ expectedPhyPath: filepath.Join(localVdir, "config.json"),
+ expectedRelativePath: "/local/config.json",
+ },
+
+ {
+ name: "Backslash Separator -> Should hit S3",
+ inputVirtualPath: "\\s3\\doc.txt",
+ expectedFsType: "S3Fs",
+ expectedPhyPath: "doc.txt",
+ expectedRelativePath: "/s3/doc.txt",
+ },
+ {
+ name: "Mixed Separators -> Should hit Local Mount",
+ inputVirtualPath: "/local\\subdir/test.txt",
+ expectedFsType: "osfs",
+ expectedPhyPath: filepath.Join(localVdir, "subdir", "test.txt"),
+ expectedRelativePath: "/local/subdir/test.txt",
+ },
+ {
+ name: "Double Slash -> Should normalize and hit S3",
+ inputVirtualPath: "//s3//dir @1/data.csv",
+ expectedFsType: "S3Fs",
+ expectedPhyPath: "dir @1/data.csv",
+ expectedRelativePath: "/s3/dir @1/data.csv",
+ },
+
+ {
+ name: "Local Mount Traversal (Attempt to escape)",
+ inputVirtualPath: "/local/../../etc/passwd",
+ expectedFsType: "osfs",
+ expectedPhyPath: filepath.Join(homeDir, "/etc/passwd"),
+ expectedRelativePath: "/etc/passwd",
+ },
+ {
+ name: "Traversal Out of S3 (Valid)",
+ inputVirtualPath: "/s3/../../secret.txt",
+ expectedFsType: "osfs",
+ expectedPhyPath: filepath.Join(homeDir, "secret.txt"),
+ expectedRelativePath: "/secret.txt",
+ },
+ {
+ name: "Traversal Inside S3",
+ inputVirtualPath: "/s3/subdir/../image.png",
+ expectedFsType: "S3Fs",
+ expectedPhyPath: "image.png",
+ expectedRelativePath: "/s3/image.png",
+ },
+ {
+ name: "Mount Point Bypass -> Target Local Mount",
+ inputVirtualPath: "/s3\\..\\local\\secret.txt",
+ expectedFsType: "osfs",
+ expectedPhyPath: filepath.Join(localVdir, "secret.txt"),
+ expectedRelativePath: "/local/secret.txt",
+ },
+ {
+ name: "Dirty Relative Path (Your Case)",
+ inputVirtualPath: "test\\..\\..\\oops/file.txt",
+ expectedFsType: "osfs",
+ expectedPhyPath: filepath.Join(homeDir, "oops", "file.txt"),
+ expectedRelativePath: "/oops/file.txt",
+ },
+ {
+ name: "Relative Path targeting S3 (No leading slash)",
+ inputVirtualPath: "s3//sub/../image.png",
+ expectedFsType: "S3Fs",
+ expectedPhyPath: "image.png",
+ expectedRelativePath: "/s3/image.png",
+ },
+ {
+ name: "Windows Path starting with Backslash",
+ inputVirtualPath: "\\s3\\doc/dir\\doc.txt",
+ expectedFsType: "S3Fs",
+ expectedPhyPath: "doc/dir/doc.txt",
+ expectedRelativePath: "/s3/doc/dir/doc.txt",
+ },
+ {
+ name: "Filesystem Juggling (Relative)",
+ inputVirtualPath: "local/../s3/file.txt",
+ expectedFsType: "S3Fs",
+ expectedPhyPath: "file.txt",
+ expectedRelativePath: "/s3/file.txt",
+ },
+ {
+ name: "Triple Dot Filename (Valid Name)",
+ inputVirtualPath: "/...hidden/secret",
+ expectedFsType: "osfs",
+ expectedPhyPath: filepath.Join(homeDir, "...hidden", "secret"),
+ expectedRelativePath: "/...hidden/secret",
+ },
+ {
+ name: "Dot Slash Prefix",
+ inputVirtualPath: "./local/file.txt",
+ expectedFsType: "osfs",
+ expectedPhyPath: filepath.Join(localVdir, "file.txt"),
+ expectedRelativePath: "/local/file.txt",
+ },
+ {
+ name: "Root of Local Mount Exactly",
+ inputVirtualPath: "/local/",
+ expectedFsType: "osfs",
+ expectedPhyPath: localVdir,
+ expectedRelativePath: "/local",
+ },
+ {
+ name: "Root of S3 Mount Exactly",
+ inputVirtualPath: "/s3/",
+ expectedFsType: "S3Fs",
+ expectedPhyPath: "",
+ expectedRelativePath: "/s3",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // The input path is sanitized by the protocol handler
+ // implementations before reaching GetFsAndResolvedPath.
+ cleanInput := util.CleanPath(tc.inputVirtualPath)
+ fs, resolvedPath, err := conn.GetFsAndResolvedPath(cleanInput)
+ if assert.NoError(t, err, "did not expect error for path: %q, got: %v", tc.inputVirtualPath, err) {
+ assert.Contains(t, fs.Name(), tc.expectedFsType,
+ "routing error: input %q but expected fs %q, got %q", tc.inputVirtualPath, tc.expectedFsType, fs.Name())
+ assert.Equal(t, tc.expectedPhyPath, resolvedPath,
+ "resolution error: input %q resolved to %q expected %q", tc.inputVirtualPath, resolvedPath, tc.expectedPhyPath)
+ relativePath := fs.GetRelativePath(resolvedPath)
+ assert.Equal(t, tc.expectedRelativePath, relativePath,
+ "relative path error, input %q, got %q, expected %q", tc.inputVirtualPath, tc.expectedRelativePath, relativePath)
+ }
+ })
+ }
+}
+
+func TestOsFsGetRelativePath(t *testing.T) {
+ homeDir := filepath.Join(os.TempDir(), "home_test")
+ localVdir := filepath.Join(os.TempDir(), "local_mount_test")
+
+ err := os.MkdirAll(homeDir, 0777)
+ require.NoError(t, err)
+ err = os.MkdirAll(localVdir, 0777)
+ require.NoError(t, err)
+
+ t.Cleanup(func() {
+ os.RemoveAll(homeDir)
+ os.RemoveAll(localVdir)
+ })
+
+ user := dataprovider.User{
+ BaseUser: sdk.BaseUser{
+ Username: xid.New().String(),
+ Status: 1,
+ HomeDir: homeDir,
+ },
+ VirtualFolders: []vfs.VirtualFolder{
+ {
+ BaseVirtualFolder: vfs.BaseVirtualFolder{
+ Name: "local",
+ MappedPath: localVdir,
+ FsConfig: vfs.Filesystem{
+ Provider: sdk.LocalFilesystemProvider,
+ },
+ },
+ VirtualPath: "/local",
+ },
+ },
+ }
+
+ connID := xid.New().String()
+ rootFs, err := user.GetFilesystemForPath("/", connID)
+ require.NoError(t, err)
+
+ localFs, err := user.GetFilesystemForPath("/local", connID)
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ fs vfs.Fs
+ inputPath string // The physical path to reverse-map
+ expectedRel string // The expected virtual path
+ }{
+ {
+ name: "Root FS - Inside root",
+ fs: rootFs,
+ inputPath: filepath.Join(homeDir, "docs", "file.txt"),
+ expectedRel: "/docs/file.txt",
+ },
+ {
+ name: "Root FS - Exact root directory",
+ fs: rootFs,
+ inputPath: homeDir,
+ expectedRel: "/",
+ },
+ {
+ name: "Root FS - External absolute path (Jail to /)",
+ fs: rootFs,
+ inputPath: "/etc/passwd",
+ expectedRel: "/",
+ },
+ {
+ name: "Root FS - Traversal escape (Jail to /)",
+ fs: rootFs,
+ inputPath: filepath.Join(homeDir, "..", "escaped.txt"),
+ expectedRel: "/",
+ },
+ {
+ name: "Root FS - Valid file named with triple dots",
+ fs: rootFs,
+ inputPath: filepath.Join(homeDir, "..."),
+ expectedRel: "/...",
+ },
+ {
+ name: "Local FS - Up path in dir",
+ fs: rootFs,
+ inputPath: homeDir + "/../" + filepath.Base(homeDir) + "/dir/test.txt",
+ expectedRel: "/dir/test.txt",
+ },
+
+ {
+ name: "Local FS - Inside mount",
+ fs: localFs,
+ inputPath: filepath.Join(localVdir, "data", "config.json"),
+ expectedRel: "/local/data/config.json",
+ },
+ {
+ name: "Local FS - Exact mount directory",
+ fs: localFs,
+ inputPath: localVdir,
+ expectedRel: "/local",
+ },
+ {
+ name: "Local FS - External absolute path (Jail to /local)",
+ fs: localFs,
+ inputPath: "/var/log/syslog",
+ expectedRel: "/local",
+ },
+ {
+ name: "Local FS - Traversal escape (Jail to /local)",
+ fs: localFs,
+ inputPath: filepath.Join(localVdir, "..", "..", "etc", "passwd"),
+ expectedRel: "/local",
+ },
+ {
+ name: "Local FS - Partial prefix (Jail to /local)",
+ fs: localFs,
+ inputPath: localVdir + "_backup",
+ expectedRel: "/local",
+ },
+ {
+ name: "Local FS - Relative traversal matching virual dir",
+ fs: localFs,
+ inputPath: localVdir + "/../" + filepath.Base(localVdir) + "/dir/test.txt",
+ expectedRel: "/local/dir/test.txt",
+ },
+ {
+ name: "Local FS - Valid file starting with two dots",
+ fs: localFs,
+ inputPath: filepath.Join(localVdir, "..hidden_file.txt"),
+ expectedRel: "/local/..hidden_file.txt",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ actualRel := tc.fs.GetRelativePath(tc.inputPath)
+ assert.Equal(t, tc.expectedRel, actualRel,
+ "Failed mapping physical path %q on FS %q", tc.inputPath, tc.fs.Name())
+ })
+ }
+}
diff --git a/internal/common/dataretention.go b/internal/common/dataretention.go
index 4f0f9aca..cbde9521 100644
--- a/internal/common/dataretention.go
+++ b/internal/common/dataretention.go
@@ -15,44 +15,20 @@
package common
import (
- "bytes"
- "context"
- "encoding/json"
"errors"
"fmt"
"io"
- "net/http"
- "net/url"
"os"
- "os/exec"
"path"
- "path/filepath"
- "strings"
"sync"
"time"
- "github.com/wneessen/go-mail"
-
- "github.com/drakkan/sftpgo/v2/internal/command"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
- "github.com/drakkan/sftpgo/v2/internal/httpclient"
"github.com/drakkan/sftpgo/v2/internal/logger"
- "github.com/drakkan/sftpgo/v2/internal/smtp"
"github.com/drakkan/sftpgo/v2/internal/util"
"github.com/drakkan/sftpgo/v2/internal/vfs"
)
-// RetentionCheckNotification defines the supported notification methods for a retention check result
-type RetentionCheckNotification = string
-
-// Supported notification methods
-const (
- // notify results using the defined "data_retention_hook"
- RetentionCheckNotificationHook = "Hook"
- // notify results by email
- RetentionCheckNotificationEmail = "Email"
-)
-
var (
// RetentionChecks is the list of active retention checks
RetentionChecks ActiveRetentionChecks
@@ -74,14 +50,10 @@ func (c *ActiveRetentionChecks) Get(role string) []RetentionCheck {
if role == "" || role == check.Role {
foldersCopy := make([]dataprovider.FolderRetention, len(check.Folders))
copy(foldersCopy, check.Folders)
- notificationsCopy := make([]string, len(check.Notifications))
- copy(notificationsCopy, check.Notifications)
checks = append(checks, RetentionCheck{
- Username: check.Username,
- StartTime: check.StartTime,
- Notifications: notificationsCopy,
- Email: check.Email,
- Folders: foldersCopy,
+ Username: check.Username,
+ StartTime: check.StartTime,
+ Folders: foldersCopy,
})
}
}
@@ -150,54 +122,10 @@ type RetentionCheck struct {
StartTime int64 `json:"start_time"`
// affected folders
Folders []dataprovider.FolderRetention `json:"folders"`
- // how cleanup results will be notified
- Notifications []RetentionCheckNotification `json:"notifications,omitempty"`
- // email to use if the notification method is set to email
- Email string `json:"email,omitempty"`
- Role string `json:"-"`
+ Role string `json:"-"`
// Cleanup results
results []folderRetentionCheckResult `json:"-"`
- conn *BaseConnection
-}
-
-// Validate returns an error if the specified folders are not valid
-func (c *RetentionCheck) Validate() error {
- folderPaths := make(map[string]bool)
- nothingToDo := true
- for idx := range c.Folders {
- f := &c.Folders[idx]
- if err := f.Validate(); err != nil {
- return err
- }
- if f.Retention > 0 {
- nothingToDo = false
- }
- if _, ok := folderPaths[f.Path]; ok {
- return util.NewValidationError(fmt.Sprintf("duplicated folder path %q", f.Path))
- }
- folderPaths[f.Path] = true
- }
- if nothingToDo {
- return util.NewValidationError("nothing to delete!")
- }
- for _, notification := range c.Notifications {
- switch notification {
- case RetentionCheckNotificationEmail:
- if !smtp.IsEnabled() {
- return util.NewValidationError("in order to notify results via email you must configure an SMTP server")
- }
- if c.Email == "" {
- return util.NewValidationError("in order to notify results via email you must add a valid email address to your profile")
- }
- case RetentionCheckNotificationHook:
- if Config.DataRetentionHook == "" {
- return util.NewValidationError("in order to notify results via hook you must define a data_retention_hook")
- }
- default:
- return util.NewValidationError(fmt.Sprintf("invalid notification %q", notification))
- }
- }
- return nil
+ conn *BaseConnection `json:"-"`
}
func (c *RetentionCheck) updateUserPermissions() {
@@ -269,7 +197,7 @@ func (c *RetentionCheck) cleanupFolder(folderPath string, recursion int) error {
return nil
}
result.Error = fmt.Sprintf("unable to get lister for directory %q", folderPath)
- c.conn.Log(logger.LevelError, result.Error)
+ c.conn.Log(logger.LevelError, "%s", result.Error)
return err
}
defer lister.Close()
@@ -359,130 +287,13 @@ func (c *RetentionCheck) Start() error {
for _, folder := range c.Folders {
if folder.Retention > 0 {
if err := c.cleanupFolder(folder.Path, 0); err != nil {
- c.conn.Log(logger.LevelError, "retention check failed, unable to cleanup folder %q", folder.Path)
- c.sendNotifications(time.Since(startTime), err)
+ c.conn.Log(logger.LevelError, "retention check failed, unable to cleanup folder %q, elapsed: %s",
+ folder.Path, time.Since(startTime))
return err
}
}
}
- c.conn.Log(logger.LevelInfo, "retention check completed")
- c.sendNotifications(time.Since(startTime), nil)
+ c.conn.Log(logger.LevelInfo, "retention check completed, elapsed: %s", time.Since(startTime))
return nil
}
-
-func (c *RetentionCheck) sendNotifications(elapsed time.Duration, err error) {
- for _, notification := range c.Notifications {
- switch notification {
- case RetentionCheckNotificationEmail:
- c.sendEmailNotification(err) //nolint:errcheck
- case RetentionCheckNotificationHook:
- c.sendHookNotification(elapsed, err) //nolint:errcheck
- }
- }
-}
-
-func (c *RetentionCheck) sendEmailNotification(errCheck error) error {
- params := EventParams{}
- if len(c.results) > 0 || errCheck != nil {
- params.retentionChecks = append(params.retentionChecks, executedRetentionCheck{
- Username: c.conn.User.Username,
- ActionName: "Retention check",
- Results: c.results,
- })
- }
- var files []*mail.File
- f, err := params.getRetentionReportsAsMailAttachment()
- if err != nil {
- c.conn.Log(logger.LevelError, "unable to get retention report as mail attachment: %v", err)
- return err
- }
- f.Name = "retention-report.zip"
- files = append(files, f)
-
- startTime := time.Now()
- var subject string
- if errCheck == nil {
- subject = fmt.Sprintf("Successful retention check for user %q", c.conn.User.Username)
- } else {
- subject = fmt.Sprintf("Retention check failed for user %q", c.conn.User.Username)
- }
- body := "Further details attached."
- err = smtp.SendEmail([]string{c.Email}, nil, subject, body, smtp.EmailContentTypeTextPlain, files...)
- if err != nil {
- c.conn.Log(logger.LevelError, "unable to notify retention check result via email: %v, elapsed: %s", err,
- time.Since(startTime))
- return err
- }
- c.conn.Log(logger.LevelInfo, "retention check result successfully notified via email, elapsed: %s", time.Since(startTime))
- return nil
-}
-
-func (c *RetentionCheck) sendHookNotification(elapsed time.Duration, errCheck error) error {
- startNewHook()
- defer hookEnded()
-
- data := make(map[string]any)
- totalDeletedFiles := 0
- totalDeletedSize := int64(0)
- for _, result := range c.results {
- totalDeletedFiles += result.DeletedFiles
- totalDeletedSize += result.DeletedSize
- }
- data["username"] = c.conn.User.Username
- data["start_time"] = c.StartTime
- data["elapsed"] = elapsed.Milliseconds()
- if errCheck == nil {
- data["status"] = 1
- } else {
- data["status"] = 0
- }
- data["total_deleted_files"] = totalDeletedFiles
- data["total_deleted_size"] = totalDeletedSize
- data["details"] = c.results
- jsonData, _ := json.Marshal(data)
-
- startTime := time.Now()
-
- if strings.HasPrefix(Config.DataRetentionHook, "http") {
- var url *url.URL
- url, err := url.Parse(Config.DataRetentionHook)
- if err != nil {
- c.conn.Log(logger.LevelError, "invalid data retention hook %q: %v", Config.DataRetentionHook, err)
- return err
- }
- respCode := 0
-
- resp, err := httpclient.RetryablePost(url.String(), "application/json", bytes.NewBuffer(jsonData))
- if err == nil {
- respCode = resp.StatusCode
- resp.Body.Close()
-
- if respCode != http.StatusOK {
- err = errUnexpectedHTTResponse
- }
- }
-
- c.conn.Log(logger.LevelDebug, "notified result to URL: %q, status code: %v, elapsed: %v err: %v",
- url.Redacted(), respCode, time.Since(startTime), err)
-
- return err
- }
- if !filepath.IsAbs(Config.DataRetentionHook) {
- err := fmt.Errorf("invalid data retention hook %q", Config.DataRetentionHook)
- c.conn.Log(logger.LevelError, "%v", err)
- return err
- }
- timeout, env, args := command.GetConfig(Config.DataRetentionHook, command.HookDataRetention)
- ctx, cancel := context.WithTimeout(context.Background(), timeout)
- defer cancel()
-
- cmd := exec.CommandContext(ctx, Config.DataRetentionHook, args...)
- cmd.Env = append(env,
- fmt.Sprintf("SFTPGO_DATA_RETENTION_RESULT=%s", util.BytesToString(jsonData)))
- err := cmd.Run()
-
- c.conn.Log(logger.LevelDebug, "notified result using command: %q, elapsed: %s err: %v",
- Config.DataRetentionHook, time.Since(startTime), err)
- return err
-}
diff --git a/internal/common/dataretention_test.go b/internal/common/dataretention_test.go
index b0c17e3b..b3ce46df 100644
--- a/internal/common/dataretention_test.go
+++ b/internal/common/dataretention_test.go
@@ -15,228 +15,17 @@
package common
import (
- "errors"
"fmt"
- "os/exec"
- "runtime"
"testing"
- "time"
"github.com/sftpgo/sdk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
- "github.com/drakkan/sftpgo/v2/internal/smtp"
"github.com/drakkan/sftpgo/v2/internal/util"
)
-func TestRetentionValidation(t *testing.T) {
- check := RetentionCheck{}
- check.Folders = []dataprovider.FolderRetention{
- {
- Path: "/",
- Retention: -1,
- },
- }
- err := check.Validate()
- require.Error(t, err)
- assert.Contains(t, err.Error(), "invalid folder retention")
-
- check.Folders = []dataprovider.FolderRetention{
- {
- Path: "/ab/..",
- Retention: 0,
- },
- }
- err = check.Validate()
- require.Error(t, err)
- assert.Contains(t, err.Error(), "nothing to delete")
- assert.Equal(t, "/", check.Folders[0].Path)
-
- check.Folders = append(check.Folders, dataprovider.FolderRetention{
- Path: "/../..",
- Retention: 24,
- })
- err = check.Validate()
- require.Error(t, err)
- assert.Contains(t, err.Error(), `duplicated folder path "/"`)
-
- check.Folders = []dataprovider.FolderRetention{
- {
- Path: "/dir1",
- Retention: 48,
- },
- {
- Path: "/dir2",
- Retention: 96,
- },
- }
- err = check.Validate()
- assert.NoError(t, err)
- assert.Len(t, check.Notifications, 0)
- assert.Empty(t, check.Email)
-
- check.Notifications = []RetentionCheckNotification{RetentionCheckNotificationEmail}
- err = check.Validate()
- require.Error(t, err)
- assert.Contains(t, err.Error(), "you must configure an SMTP server")
-
- smtpCfg := smtp.Config{
- Host: "mail.example.com",
- Port: 25,
- From: "notification@example.com",
- TemplatesPath: "templates",
- }
- err = smtpCfg.Initialize(configDir, true)
- require.NoError(t, err)
-
- err = check.Validate()
- require.Error(t, err)
- assert.Contains(t, err.Error(), "you must add a valid email address")
-
- check.Email = "admin@example.com"
- err = check.Validate()
- assert.NoError(t, err)
-
- smtpCfg = smtp.Config{}
- err = smtpCfg.Initialize(configDir, true)
- require.NoError(t, err)
-
- check.Notifications = []RetentionCheckNotification{RetentionCheckNotificationHook}
- err = check.Validate()
- require.Error(t, err)
- assert.Contains(t, err.Error(), "data_retention_hook")
-
- check.Notifications = []string{"not valid"}
- err = check.Validate()
- require.Error(t, err)
- assert.Contains(t, err.Error(), "invalid notification")
-}
-
-func TestRetentionEmailNotifications(t *testing.T) {
- smtpCfg := smtp.Config{
- Host: "127.0.0.1",
- Port: 2525,
- From: "notification@example.com",
- TemplatesPath: "templates",
- }
- err := smtpCfg.Initialize(configDir, true)
- require.NoError(t, err)
-
- user := dataprovider.User{
- BaseUser: sdk.BaseUser{
- Username: "user1",
- },
- }
- user.Permissions = make(map[string][]string)
- user.Permissions["/"] = []string{dataprovider.PermAny}
- check := RetentionCheck{
- Notifications: []RetentionCheckNotification{RetentionCheckNotificationEmail},
- Email: "notification@example.com",
- results: []folderRetentionCheckResult{
- {
- Path: "/",
- Retention: 24,
- DeletedFiles: 10,
- DeletedSize: 32657,
- Elapsed: 10 * time.Second,
- },
- },
- }
- conn := NewBaseConnection("", "", "", "", user)
- conn.SetProtocol(ProtocolDataRetention)
- conn.ID = fmt.Sprintf("data_retention_%v", user.Username)
- check.conn = conn
- check.sendNotifications(1*time.Second, nil)
- err = check.sendEmailNotification(nil)
- assert.NoError(t, err)
- err = check.sendEmailNotification(errors.New("test error"))
- assert.NoError(t, err)
-
- check.results = nil
- err = check.sendEmailNotification(nil)
- if assert.Error(t, err) {
- assert.Contains(t, err.Error(), "no data retention report available")
- }
-
- smtpCfg.Port = 2626
- err = smtpCfg.Initialize(configDir, true)
- require.NoError(t, err)
- err = check.sendEmailNotification(nil)
- assert.Error(t, err)
- check.results = []folderRetentionCheckResult{
- {
- Path: "/",
- Retention: 24,
- DeletedFiles: 20,
- DeletedSize: 456789,
- Elapsed: 12 * time.Second,
- },
- }
-
- smtpCfg = smtp.Config{}
- err = smtpCfg.Initialize(configDir, true)
- require.NoError(t, err)
- err = check.sendEmailNotification(nil)
- assert.Error(t, err)
-}
-
-func TestRetentionHookNotifications(t *testing.T) {
- dataRetentionHook := Config.DataRetentionHook
-
- Config.DataRetentionHook = fmt.Sprintf("http://%v", httpAddr)
- user := dataprovider.User{
- BaseUser: sdk.BaseUser{
- Username: "user2",
- },
- }
- user.Permissions = make(map[string][]string)
- user.Permissions["/"] = []string{dataprovider.PermAny}
- check := RetentionCheck{
- Notifications: []RetentionCheckNotification{RetentionCheckNotificationHook},
- results: []folderRetentionCheckResult{
- {
- Path: "/",
- Retention: 24,
- DeletedFiles: 10,
- DeletedSize: 32657,
- Elapsed: 10 * time.Second,
- },
- },
- }
- conn := NewBaseConnection("", "", "", "", user)
- conn.SetProtocol(ProtocolDataRetention)
- conn.ID = fmt.Sprintf("data_retention_%v", user.Username)
- check.conn = conn
- check.sendNotifications(1*time.Second, nil)
- err := check.sendHookNotification(1*time.Second, nil)
- assert.NoError(t, err)
-
- Config.DataRetentionHook = fmt.Sprintf("http://%v/404", httpAddr)
- err = check.sendHookNotification(1*time.Second, nil)
- assert.ErrorIs(t, err, errUnexpectedHTTResponse)
-
- Config.DataRetentionHook = "http://foo\x7f.com/retention"
- err = check.sendHookNotification(1*time.Second, err)
- assert.Error(t, err)
-
- Config.DataRetentionHook = "relativepath"
- err = check.sendHookNotification(1*time.Second, err)
- assert.Error(t, err)
-
- if runtime.GOOS != osWindows {
- hookCmd, err := exec.LookPath("true")
- assert.NoError(t, err)
-
- Config.DataRetentionHook = hookCmd
- err = check.sendHookNotification(1*time.Second, err)
- assert.NoError(t, err)
- }
-
- Config.DataRetentionHook = dataRetentionHook
-}
-
func TestRetentionPermissionsAndGetFolder(t *testing.T) {
user := dataprovider.User{
BaseUser: sdk.BaseUser{
@@ -311,7 +100,6 @@ func TestRetentionCheckAddRemove(t *testing.T) {
Retention: 48,
},
},
- Notifications: []RetentionCheckNotification{RetentionCheckNotificationHook},
}
assert.NotNil(t, RetentionChecks.Add(check, &user))
checks := RetentionChecks.Get("")
@@ -321,8 +109,6 @@ func TestRetentionCheckAddRemove(t *testing.T) {
require.Len(t, checks[0].Folders, 1)
assert.Equal(t, check.Folders[0].Path, checks[0].Folders[0].Path)
assert.Equal(t, check.Folders[0].Retention, checks[0].Folders[0].Retention)
- require.Len(t, checks[0].Notifications, 1)
- assert.Equal(t, RetentionCheckNotificationHook, checks[0].Notifications[0])
assert.Nil(t, RetentionChecks.Add(check, &user))
assert.True(t, RetentionChecks.remove(username))
@@ -349,7 +135,6 @@ func TestRetentionCheckRole(t *testing.T) {
Retention: 48,
},
},
- Notifications: []RetentionCheckNotification{RetentionCheckNotificationHook},
}
assert.NotNil(t, RetentionChecks.Add(check, &user))
checks := RetentionChecks.Get("")
diff --git a/internal/common/defender.go b/internal/common/defender.go
index d3f38221..18ea0ea7 100644
--- a/internal/common/defender.go
+++ b/internal/common/defender.go
@@ -53,6 +53,7 @@ type Defender interface {
GetBanTime(ip string) (*time.Time, error)
GetScore(ip string) (int, error)
DeleteHost(ip string) bool
+ DelayLogin(err error)
}
// DefenderConfig defines the "defender" configuration
@@ -90,6 +91,16 @@ type DefenderConfig struct {
// to return when you request for the entire host list from the defender
EntriesSoftLimit int `json:"entries_soft_limit" mapstructure:"entries_soft_limit"`
EntriesHardLimit int `json:"entries_hard_limit" mapstructure:"entries_hard_limit"`
+ // Configuration to impose a delay between login attempts
+ LoginDelay LoginDelay `json:"login_delay" mapstructure:"login_delay"`
+}
+
+// LoginDelay defines the delays to impose between login attempts.
+type LoginDelay struct {
+ // The number of milliseconds to pause prior to allowing a successful login
+ Success int `json:"success" mapstructure:"success"`
+ // The number of milliseconds to pause prior to reporting a failed login
+ PasswordFailed int `json:"password_failed" mapstructure:"password_failed"`
}
type baseDefender struct {
@@ -163,6 +174,19 @@ func (d *baseDefender) logBan(ip, protocol string) {
Send()
}
+// DelayLogin applies the configured login delay.
+func (d *baseDefender) DelayLogin(err error) {
+ if err == nil {
+ if d.config.LoginDelay.Success > 0 {
+ time.Sleep(time.Duration(d.config.LoginDelay.Success) * time.Millisecond)
+ }
+ return
+ }
+ if d.config.LoginDelay.PasswordFailed > 0 {
+ time.Sleep(time.Duration(d.config.LoginDelay.PasswordFailed) * time.Millisecond)
+ }
+}
+
type hostEvent struct {
dateTime time.Time
score int
diff --git a/internal/common/defender_test.go b/internal/common/defender_test.go
index 9465a770..f7cb9ad4 100644
--- a/internal/common/defender_test.go
+++ b/internal/common/defender_test.go
@@ -435,6 +435,31 @@ func TestDefenderCleanup(t *testing.T) {
assert.Equal(t, 0, score)
}
+func TestDefenderDelay(t *testing.T) {
+ d := memoryDefender{
+ baseDefender: baseDefender{
+ config: &DefenderConfig{
+ ObservationTime: 1,
+ EntriesSoftLimit: 2,
+ EntriesHardLimit: 3,
+ LoginDelay: LoginDelay{
+ Success: 50,
+ PasswordFailed: 200,
+ },
+ },
+ },
+ }
+ startTime := time.Now()
+ d.DelayLogin(nil)
+ elapsed := time.Since(startTime)
+ assert.Less(t, elapsed, time.Millisecond*100)
+
+ startTime = time.Now()
+ d.DelayLogin(ErrInternalFailure)
+ elapsed = time.Since(startTime)
+ assert.Greater(t, elapsed, time.Millisecond*150)
+}
+
func TestDefenderConfig(t *testing.T) {
c := DefenderConfig{}
err := c.validate()
diff --git a/internal/common/defenderdb.go b/internal/common/defenderdb.go
index d60879b4..63995862 100644
--- a/internal/common/defenderdb.go
+++ b/internal/common/defenderdb.go
@@ -62,7 +62,7 @@ func (d *dbDefender) GetHost(ip string) (dataprovider.DefenderEntry, error) {
// and increase ban time if the IP is found.
// This method must be called as soon as the client connects
func (d *dbDefender) IsBanned(ip, protocol string) bool {
- if d.baseDefender.isBanned(ip, protocol) {
+ if d.isBanned(ip, protocol) {
return true
}
@@ -95,22 +95,22 @@ func (d *dbDefender) AddEvent(ip, protocol string, event HostEvent) bool {
return true
}
- score := d.baseDefender.getScore(event)
+ score := d.getScore(event)
host, err := dataprovider.AddDefenderEvent(ip, score, d.getStartObservationTime())
if err != nil {
return false
}
- d.baseDefender.logEvent(ip, protocol, event, host.Score)
+ d.logEvent(ip, protocol, event, host.Score)
if host.Score > d.config.Threshold {
- d.baseDefender.logBan(ip, protocol)
+ d.logBan(ip, protocol)
banTime := time.Now().Add(time.Duration(d.config.BanTime) * time.Minute)
err = dataprovider.SetDefenderBanTime(ip, util.GetTimeAsMsSinceEpoch(banTime))
if err == nil {
eventManager.handleIPBlockedEvent(EventParams{
Event: ipBlockedEventName,
IP: ip,
- Timestamp: time.Now().UnixNano(),
+ Timestamp: time.Now(),
Status: 1,
})
}
diff --git a/internal/common/defendermem.go b/internal/common/defendermem.go
index 6a908d04..0f59b37a 100644
--- a/internal/common/defendermem.go
+++ b/internal/common/defendermem.go
@@ -148,7 +148,7 @@ func (d *memoryDefender) IsBanned(ip, protocol string) bool {
defer d.RUnlock()
- return d.baseDefender.isBanned(ip, protocol)
+ return d.isBanned(ip, protocol)
}
// DeleteHost removes the specified IP from the defender lists
@@ -188,7 +188,7 @@ func (d *memoryDefender) AddEvent(ip, protocol string, event HostEvent) bool {
delete(d.banned, ip)
}
- score := d.baseDefender.getScore(event)
+ score := d.getScore(event)
ev := hostEvent{
dateTime: time.Now(),
@@ -207,25 +207,25 @@ func (d *memoryDefender) AddEvent(ip, protocol string, event HostEvent) bool {
idx++
}
}
- d.baseDefender.logEvent(ip, protocol, event, hs.TotalScore)
+ d.logEvent(ip, protocol, event, hs.TotalScore)
hs.Events = hs.Events[:idx]
if hs.TotalScore >= d.config.Threshold {
- d.baseDefender.logBan(ip, protocol)
+ d.logBan(ip, protocol)
d.banned[ip] = time.Now().Add(time.Duration(d.config.BanTime) * time.Minute)
delete(d.hosts, ip)
d.cleanupBanned()
eventManager.handleIPBlockedEvent(EventParams{
Event: ipBlockedEventName,
IP: ip,
- Timestamp: time.Now().UnixNano(),
+ Timestamp: time.Now(),
Status: 1,
})
} else {
d.hosts[ip] = hs
}
} else {
- d.baseDefender.logEvent(ip, protocol, event, ev.score)
+ d.logEvent(ip, protocol, event, ev.score)
d.hosts[ip] = hostScore{
TotalScore: ev.score,
Events: []hostEvent{ev},
diff --git a/internal/common/eventmanager.go b/internal/common/eventmanager.go
index de9cbf64..ca0f7494 100644
--- a/internal/common/eventmanager.go
+++ b/internal/common/eventmanager.go
@@ -21,6 +21,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "html"
"io"
"mime"
"mime/multipart"
@@ -31,6 +32,7 @@ import (
"os/exec"
"path"
"path/filepath"
+ "slices"
"strconv"
"strings"
"sync"
@@ -55,8 +57,9 @@ import (
const (
ipBlockedEventName = "IP Blocked"
maxAttachmentsSize = int64(10 * 1024 * 1024)
- objDataPlaceholder = "{{ObjectData}}"
- objDataPlaceholderString = "{{ObjectDataString}}"
+ objDataPlaceholder = "{{.ObjectData}}"
+ objDataPlaceholderString = "{{.ObjectDataString}}"
+ dateTimeMillisFormat = "2006-01-02T15:04:05.000"
)
// Supported IDP login events
@@ -69,6 +72,9 @@ var (
// eventManager handle the supported event rules actions
eventManager eventRulesContainer
multipartQuoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
+ fsEventsWithSize = []string{operationPreDelete, OperationPreUpload, operationDelete,
+ operationCopy, operationDownload, operationFirstUpload, operationFirstDownload,
+ operationUpload}
)
func init() {
@@ -88,11 +94,12 @@ func init() {
ObjectType: objectType,
IP: ip,
Role: role,
- Timestamp: time.Now().UnixNano(),
+ Timestamp: time.Now(),
Object: object,
}
if u, ok := object.(*dataprovider.User); ok {
p.Email = u.Email
+ p.Groups = u.Groups
} else if a, ok := object.(*dataprovider.Admin); ok {
p.Email = a.Email
}
@@ -271,7 +278,8 @@ func (r *eventRulesContainer) addUpdateRuleInternal(rule dataprovider.EventRule)
func (r *eventRulesContainer) loadRules() {
eventManagerLog(logger.LevelDebug, "loading updated rules")
modTime := util.GetTimeAsMsSinceEpoch(time.Now())
- rules, err := dataprovider.GetRecentlyUpdatedRules(r.getLastLoadTime())
+ lastLoadTime := r.getLastLoadTime()
+ rules, err := dataprovider.GetRecentlyUpdatedRules(lastLoadTime)
if err != nil {
eventManagerLog(logger.LevelError, "unable to load event rules: %v", err)
return
@@ -307,23 +315,26 @@ func (*eventRulesContainer) checkIPDLoginEventMatch(conditions *dataprovider.Eve
}
func (*eventRulesContainer) checkProviderEventMatch(conditions *dataprovider.EventConditions, params *EventParams) bool {
- if !util.Contains(conditions.ProviderEvents, params.Event) {
+ if !slices.Contains(conditions.ProviderEvents, params.Event) {
return false
}
if !checkEventConditionPatterns(params.Name, conditions.Options.Names) {
return false
}
+ if !checkEventGroupConditionPatterns(params.Groups, conditions.Options.GroupNames) {
+ return false
+ }
if !checkEventConditionPatterns(params.Role, conditions.Options.RoleNames) {
return false
}
- if len(conditions.Options.ProviderObjects) > 0 && !util.Contains(conditions.Options.ProviderObjects, params.ObjectType) {
+ if len(conditions.Options.ProviderObjects) > 0 && !slices.Contains(conditions.Options.ProviderObjects, params.ObjectType) {
return false
}
return true
}
func (*eventRulesContainer) checkFsEventMatch(conditions *dataprovider.EventConditions, params *EventParams) bool {
- if !util.Contains(conditions.FsEvents, params.Event) {
+ if !slices.Contains(conditions.FsEvents, params.Event) {
return false
}
if !checkEventConditionPatterns(params.Name, conditions.Options.Names) {
@@ -338,10 +349,10 @@ func (*eventRulesContainer) checkFsEventMatch(conditions *dataprovider.EventCond
if !checkEventConditionPatterns(params.VirtualPath, conditions.Options.FsPaths) {
return false
}
- if len(conditions.Options.Protocols) > 0 && !util.Contains(conditions.Options.Protocols, params.Protocol) {
+ if len(conditions.Options.Protocols) > 0 && !slices.Contains(conditions.Options.Protocols, params.Protocol) {
return false
}
- if params.Event == operationUpload || params.Event == operationDownload {
+ if slices.Contains(fsEventsWithSize, params.Event) {
if conditions.Options.MinFileSize > 0 {
if params.FileSize < conditions.Options.MinFileSize {
return false
@@ -555,7 +566,7 @@ type EventParams struct {
IP string
Role string
Email string
- Timestamp int64
+ Timestamp time.Time
UID string
IDPCustomFields *map[string]string
Object plugin.Renderer
@@ -639,7 +650,7 @@ func (p *EventParams) setBackupParams(backupPath string) {
p.FsPath = backupPath
p.ObjectName = filepath.Base(backupPath)
p.VirtualPath = "/" + p.ObjectName
- p.Timestamp = time.Now().UnixNano()
+ p.Timestamp = time.Now()
info, err := os.Stat(backupPath)
if err == nil {
p.FileSize = info.Size()
@@ -765,46 +776,70 @@ func (p *EventParams) getRetentionReportsAsMailAttachment() (*mail.File, error)
}, nil
}
-func (*EventParams) getStringReplacement(val string, jsonEscaped bool) string {
- if jsonEscaped {
+func (*EventParams) getStringReplacement(val string, escapeMode int) string {
+ switch escapeMode {
+ case 1:
return util.JSONEscape(val)
+ case 2:
+ return html.EscapeString(val)
+ default:
+ return val
}
- return val
}
-func (p *EventParams) getStringReplacements(addObjectData, jsonEscaped bool) []string {
+func (p *EventParams) getStringReplacements(addObjectData bool, escapeMode int) []string {
+ var dateTimeString string
+ if Config.TZ == "local" {
+ dateTimeString = p.Timestamp.Local().Format(dateTimeMillisFormat)
+ } else {
+ dateTimeString = p.Timestamp.UTC().Format(dateTimeMillisFormat)
+ }
+ year := dateTimeString[0:4]
+ month := dateTimeString[5:7]
+ day := dateTimeString[8:10]
+ hour := dateTimeString[11:13]
+ minute := dateTimeString[14:16]
+
replacements := []string{
- "{{Name}}", p.getStringReplacement(p.Name, jsonEscaped),
- "{{Event}}", p.Event,
- "{{Status}}", fmt.Sprintf("%d", p.Status),
- "{{VirtualPath}}", p.getStringReplacement(p.VirtualPath, jsonEscaped),
- "{{FsPath}}", p.getStringReplacement(p.FsPath, jsonEscaped),
- "{{VirtualTargetPath}}", p.getStringReplacement(p.VirtualTargetPath, jsonEscaped),
- "{{FsTargetPath}}", p.getStringReplacement(p.FsTargetPath, jsonEscaped),
- "{{ObjectName}}", p.getStringReplacement(p.ObjectName, jsonEscaped),
- "{{ObjectType}}", p.ObjectType,
- "{{FileSize}}", strconv.FormatInt(p.FileSize, 10),
- "{{Elapsed}}", strconv.FormatInt(p.Elapsed, 10),
- "{{Protocol}}", p.Protocol,
- "{{IP}}", p.IP,
- "{{Role}}", p.getStringReplacement(p.Role, jsonEscaped),
- "{{Email}}", p.getStringReplacement(p.Email, jsonEscaped),
- "{{Timestamp}}", strconv.FormatInt(p.Timestamp, 10),
- "{{StatusString}}", p.getStatusString(),
- "{{UID}}", p.getStringReplacement(p.UID, jsonEscaped),
- "{{Ext}}", p.getStringReplacement(p.Extension, jsonEscaped),
+ "{{.Name}}", p.getStringReplacement(p.Name, escapeMode),
+ "{{.Event}}", p.Event,
+ "{{.Status}}", fmt.Sprintf("%d", p.Status),
+ "{{.VirtualPath}}", p.getStringReplacement(p.VirtualPath, escapeMode),
+ "{{.EscapedVirtualPath}}", p.getStringReplacement(url.QueryEscape(p.VirtualPath), escapeMode),
+ "{{.FsPath}}", p.getStringReplacement(p.FsPath, escapeMode),
+ "{{.VirtualTargetPath}}", p.getStringReplacement(p.VirtualTargetPath, escapeMode),
+ "{{.FsTargetPath}}", p.getStringReplacement(p.FsTargetPath, escapeMode),
+ "{{.ObjectName}}", p.getStringReplacement(p.ObjectName, escapeMode),
+ "{{.ObjectBaseName}}", p.getStringReplacement(strings.TrimSuffix(p.ObjectName, p.Extension), escapeMode),
+ "{{.ObjectType}}", p.ObjectType,
+ "{{.FileSize}}", strconv.FormatInt(p.FileSize, 10),
+ "{{.Elapsed}}", strconv.FormatInt(p.Elapsed, 10),
+ "{{.Protocol}}", p.Protocol,
+ "{{.IP}}", p.IP,
+ "{{.Role}}", p.getStringReplacement(p.Role, escapeMode),
+ "{{.Email}}", p.getStringReplacement(p.Email, escapeMode),
+ "{{.Timestamp}}", strconv.FormatInt(p.Timestamp.UnixNano(), 10),
+ "{{.DateTime}}", dateTimeString,
+ "{{.Year}}", year,
+ "{{.Month}}", month,
+ "{{.Day}}", day,
+ "{{.Hour}}", hour,
+ "{{.Minute}}", minute,
+ "{{.StatusString}}", p.getStatusString(),
+ "{{.UID}}", p.getStringReplacement(p.UID, escapeMode),
+ "{{.Ext}}", p.getStringReplacement(p.Extension, escapeMode),
}
if p.VirtualPath != "" {
- replacements = append(replacements, "{{VirtualDirPath}}", p.getStringReplacement(path.Dir(p.VirtualPath), jsonEscaped))
+ replacements = append(replacements, "{{.VirtualDirPath}}", p.getStringReplacement(path.Dir(p.VirtualPath), escapeMode))
}
if p.VirtualTargetPath != "" {
- replacements = append(replacements, "{{VirtualTargetDirPath}}", p.getStringReplacement(path.Dir(p.VirtualTargetPath), jsonEscaped))
- replacements = append(replacements, "{{TargetName}}", p.getStringReplacement(path.Base(p.VirtualTargetPath), jsonEscaped))
+ replacements = append(replacements, "{{.VirtualTargetDirPath}}", p.getStringReplacement(path.Dir(p.VirtualTargetPath), escapeMode))
+ replacements = append(replacements, "{{.TargetName}}", p.getStringReplacement(path.Base(p.VirtualTargetPath), escapeMode))
}
if len(p.errors) > 0 {
- replacements = append(replacements, "{{ErrorString}}", p.getStringReplacement(strings.Join(p.errors, ", "), jsonEscaped))
+ replacements = append(replacements, "{{.ErrorString}}", p.getStringReplacement(strings.Join(p.errors, ", "), escapeMode))
} else {
- replacements = append(replacements, "{{ErrorString}}", "")
+ replacements = append(replacements, "{{.ErrorString}}", "")
}
replacements = append(replacements, objDataPlaceholder, "{}")
replacements = append(replacements, objDataPlaceholderString, "")
@@ -812,23 +847,23 @@ func (p *EventParams) getStringReplacements(addObjectData, jsonEscaped bool) []s
data, err := p.Object.RenderAsJSON(p.Event != operationDelete)
if err == nil {
dataString := util.BytesToString(data)
- replacements[len(replacements)-3] = p.getStringReplacement(dataString, false)
- replacements[len(replacements)-1] = p.getStringReplacement(dataString, true)
+ replacements[len(replacements)-3] = p.getStringReplacement(dataString, 0)
+ replacements[len(replacements)-1] = p.getStringReplacement(dataString, 1)
}
}
if p.IDPCustomFields != nil {
for k, v := range *p.IDPCustomFields {
- replacements = append(replacements, fmt.Sprintf("{{IDPField%s}}", k), p.getStringReplacement(v, jsonEscaped))
+ replacements = append(replacements, fmt.Sprintf("{{.IDPField%s}}", k), p.getStringReplacement(v, escapeMode))
}
}
- replacements = append(replacements, "{{Metadata}}", "{}")
- replacements = append(replacements, "{{MetadataString}}", "")
+ replacements = append(replacements, "{{.Metadata}}", "{}")
+ replacements = append(replacements, "{{.MetadataString}}", "")
if len(p.Metadata) > 0 {
data, err := json.Marshal(p.Metadata)
if err == nil {
dataString := util.BytesToString(data)
- replacements[len(replacements)-3] = p.getStringReplacement(dataString, false)
- replacements[len(replacements)-1] = p.getStringReplacement(dataString, true)
+ replacements[len(replacements)-3] = p.getStringReplacement(dataString, 0)
+ replacements[len(replacements)-1] = p.getStringReplacement(dataString, 1)
}
}
return replacements
@@ -908,10 +943,7 @@ func updateUserQuotaAfterFileWrite(conn *BaseConnection, virtualPath string, num
dataprovider.UpdateUserQuota(&conn.User, numFiles, fileSize, false) //nolint:errcheck
return
}
- dataprovider.UpdateVirtualFolderQuota(&vfolder.BaseVirtualFolder, numFiles, fileSize, false) //nolint:errcheck
- if vfolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&conn.User, numFiles, fileSize, false) //nolint:errcheck
- }
+ dataprovider.UpdateUserFolderQuota(&vfolder, &conn.User, numFiles, fileSize, false)
}
func checkWriterPermsAndQuota(conn *BaseConnection, virtualPath string, numFiles int, expectedSize, truncatedSize int64) error {
@@ -988,7 +1020,7 @@ func getFileWriter(conn *BaseConnection, virtualPath string, expectedSize int64)
return w, numFiles, truncatedSize, cancelFn, nil
}
-func addZipEntry(wr *zipWriterWrapper, conn *BaseConnection, entryPath, baseDir string, recursion int) error {
+func addZipEntry(wr *zipWriterWrapper, conn *BaseConnection, entryPath, baseDir string, info os.FileInfo, recursion int) error { //nolint:gocyclo
if entryPath == wr.Name {
// skip the archive itself
return nil
@@ -998,10 +1030,13 @@ func addZipEntry(wr *zipWriterWrapper, conn *BaseConnection, entryPath, baseDir
return util.ErrRecursionTooDeep
}
recursion++
- info, err := conn.DoStat(entryPath, 1, false)
- if err != nil {
- eventManagerLog(logger.LevelError, "unable to add zip entry %q, stat error: %v", entryPath, err)
- return err
+ var err error
+ if info == nil {
+ info, err = conn.DoStat(entryPath, 1, false)
+ if err != nil {
+ eventManagerLog(logger.LevelError, "unable to add zip entry %q, stat error: %v", entryPath, err)
+ return err
+ }
}
entryName, err := getZipEntryName(entryPath, baseDir)
if err != nil {
@@ -1039,7 +1074,7 @@ func addZipEntry(wr *zipWriterWrapper, conn *BaseConnection, entryPath, baseDir
}
for _, info := range contents {
fullPath := util.CleanPath(path.Join(entryPath, info.Name()))
- if err := addZipEntry(wr, conn, fullPath, baseDir, recursion); err != nil {
+ if err := addZipEntry(wr, conn, fullPath, baseDir, info, recursion); err != nil {
eventManagerLog(logger.LevelError, "unable to add zip entry: %v", err)
return err
}
@@ -1163,7 +1198,7 @@ func getMailAttachments(conn *BaseConnection, attachments []string, replacer *st
}
func replaceWithReplacer(input string, replacer *strings.Replacer) string {
- if !strings.Contains(input, "{{") {
+ if !strings.Contains(input, "{{.") {
return input
}
return replacer.Replace(input)
@@ -1250,7 +1285,7 @@ func getHTTPRuleActionEndpoint(c *dataprovider.EventActionHTTPConfig, replacer *
if err != nil {
return "", fmt.Errorf("invalid endpoint: %w", err)
}
- if strings.Contains(u.Path, "{{") {
+ if strings.Contains(u.Path, "{{.") {
pathComponents := strings.Split(u.Path, "/")
for idx := range pathComponents {
part := replaceWithReplacer(pathComponents[idx], replacer)
@@ -1284,7 +1319,7 @@ func writeHTTPPart(m *multipart.Writer, part dataprovider.HTTPPart, h textproto.
if part.Body != "" {
cType := h.Get("Content-Type")
if strings.Contains(strings.ToLower(cType), "application/json") {
- replacements := params.getStringReplacements(addObjectData, true)
+ replacements := params.getStringReplacements(addObjectData, 1)
jsonReplacer := strings.NewReplacer(replacements...)
_, err = partWriter.Write(util.StringToBytes(replaceWithReplacer(part.Body, jsonReplacer)))
} else {
@@ -1316,7 +1351,7 @@ func writeHTTPPart(m *multipart.Writer, part dataprovider.HTTPPart, h textproto.
return nil
}
-func getHTTPRuleActionBody(c *dataprovider.EventActionHTTPConfig, replacer *strings.Replacer,
+func getHTTPRuleActionBody(c *dataprovider.EventActionHTTPConfig, replacer *strings.Replacer, //nolint:gocyclo
cancel context.CancelFunc, user dataprovider.User, params *EventParams, addObjectData bool,
) (io.Reader, string, error) {
var body io.Reader
@@ -1332,7 +1367,7 @@ func getHTTPRuleActionBody(c *dataprovider.EventActionHTTPConfig, replacer *stri
return bytes.NewBuffer(data), "", nil
}
if c.HasJSONBody() {
- replacements := params.getStringReplacements(addObjectData, true)
+ replacements := params.getStringReplacements(addObjectData, 1)
jsonReplacer := strings.NewReplacer(replacements...)
return bytes.NewBufferString(replaceWithReplacer(c.Body, jsonReplacer)), "", nil
}
@@ -1345,8 +1380,7 @@ func getHTTPRuleActionBody(c *dataprovider.EventActionHTTPConfig, replacer *stri
var conn *BaseConnection
if user.Username != "" {
var err error
- user, err = getUserForEventAction(user)
- if err != nil {
+ if err := getUserForEventAction(&user); err != nil {
return body, "", err
}
connectionID := fmt.Sprintf("%s_%s", protocolEventAction, xid.New().String())
@@ -1362,6 +1396,9 @@ func getHTTPRuleActionBody(c *dataprovider.EventActionHTTPConfig, replacer *stri
go func() {
defer w.Close()
defer user.CloseFs() //nolint:errcheck
+ if conn != nil {
+ defer conn.CloseFS() //nolint:errcheck
+ }
for _, part := range c.Parts {
h := make(textproto.MIMEHeader)
@@ -1417,7 +1454,7 @@ func executeHTTPRuleAction(c dataprovider.EventActionHTTPConfig, params *EventPa
addObjectData = c.HasObjectData()
}
- replacements := params.getStringReplacements(addObjectData, false)
+ replacements := params.getStringReplacements(addObjectData, 0)
replacer := strings.NewReplacer(replacements...)
endpoint, err := getHTTPRuleActionEndpoint(&c, replacer)
if err != nil {
@@ -1467,7 +1504,7 @@ func executeHTTPRuleAction(c dataprovider.EventActionHTTPConfig, params *EventPa
if resp.StatusCode < http.StatusOK || resp.StatusCode > http.StatusNoContent {
if rb, err := io.ReadAll(io.LimitReader(resp.Body, 2048)); err == nil {
eventManagerLog(logger.LevelDebug, "error notification response from endpoint %q: %s",
- endpoint, util.BytesToString(rb))
+ endpoint, rb)
}
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
@@ -1476,6 +1513,9 @@ func executeHTTPRuleAction(c dataprovider.EventActionHTTPConfig, params *EventPa
}
func executeCommandRuleAction(c dataprovider.EventActionCommandConfig, params *EventParams) error {
+ if !dataprovider.IsActionCommandAllowed(c.Cmd) {
+ return fmt.Errorf("command %q is not allowed", c.Cmd)
+ }
addObjectData := false
if params.Object != nil {
for _, k := range c.EnvVars {
@@ -1485,7 +1525,7 @@ func executeCommandRuleAction(c dataprovider.EventActionCommandConfig, params *E
}
}
}
- replacements := params.getStringReplacements(addObjectData, false)
+ replacements := params.getStringReplacements(addObjectData, 0)
replacer := strings.NewReplacer(replacements...)
args := make([]string, 0, len(c.Args))
@@ -1499,7 +1539,7 @@ func executeCommandRuleAction(c dataprovider.EventActionCommandConfig, params *E
cmd := exec.CommandContext(ctx, c.Cmd, args...)
cmd.Env = []string{}
for _, keyVal := range c.EnvVars {
- if keyVal.Value == "$" {
+ if keyVal.Value == "$" && !strings.HasPrefix(strings.ToUpper(keyVal.Key), "SFTPGO_") {
val := os.Getenv(keyVal.Key)
if val == "" {
eventManagerLog(logger.LevelDebug, "empty value for environment variable %q", keyVal.Key)
@@ -1540,9 +1580,16 @@ func executeEmailRuleAction(c dataprovider.EventActionEmailConfig, params *Event
addObjectData = true
}
}
- replacements := params.getStringReplacements(addObjectData, false)
+ replacements := params.getStringReplacements(addObjectData, 0)
replacer := strings.NewReplacer(replacements...)
- body := replaceWithReplacer(c.Body, replacer)
+ var body string
+ if c.ContentType == 1 {
+ replacements := params.getStringReplacements(addObjectData, 2)
+ bodyReplacer := strings.NewReplacer(replacements...)
+ body = replaceWithReplacer(c.Body, bodyReplacer)
+ } else {
+ body = replaceWithReplacer(c.Body, replacer)
+ }
subject := replaceWithReplacer(c.Subject, replacer)
recipients := getEmailAddressesWithReplacer(c.Recipients, replacer)
bcc := getEmailAddressesWithReplacer(c.Bcc, replacer)
@@ -1565,8 +1612,7 @@ func executeEmailRuleAction(c dataprovider.EventActionEmailConfig, params *Event
if err != nil {
return err
}
- user, err = getUserForEventAction(user)
- if err != nil {
+ if err := getUserForEventAction(&user); err != nil {
return err
}
connectionID := fmt.Sprintf("%s_%s", protocolEventAction, xid.New().String())
@@ -1576,6 +1622,8 @@ func executeEmailRuleAction(c dataprovider.EventActionEmailConfig, params *Event
return fmt.Errorf("error getting email attachments, unable to check root fs for user %q: %w", user.Username, err)
}
conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
+ defer conn.CloseFS() //nolint:errcheck
+
res, err := getMailAttachments(conn, fileAttachments, replacer)
if err != nil {
return err
@@ -1591,11 +1639,11 @@ func executeEmailRuleAction(c dataprovider.EventActionEmailConfig, params *Event
return nil
}
-func getUserForEventAction(user dataprovider.User) (dataprovider.User, error) {
+func getUserForEventAction(user *dataprovider.User) error {
err := user.LoadAndApplyGroupSettings()
if err != nil {
eventManagerLog(logger.LevelError, "unable to get group for user %q: %+v", user.Username, err)
- return dataprovider.User{}, fmt.Errorf("unable to get groups for user %q", user.Username)
+ return fmt.Errorf("unable to get groups for user %q", user.Username)
}
user.UploadDataTransfer = 0
user.UploadBandwidth = 0
@@ -1606,7 +1654,7 @@ func getUserForEventAction(user dataprovider.User) (dataprovider.User, error) {
for k := range user.Permissions {
user.Permissions[k] = []string{dataprovider.PermAny}
}
- return user, nil
+ return nil
}
func replacePathsPlaceholders(paths []string, replacer *strings.Replacer) []string {
@@ -1626,17 +1674,18 @@ func executeDeleteFileFsAction(conn *BaseConnection, item string, info os.FileIn
}
func executeDeleteFsActionForUser(deletes []string, replacer *strings.Replacer, user dataprovider.User) error {
- user, err := getUserForEventAction(user)
- if err != nil {
+ if err := getUserForEventAction(&user); err != nil {
return err
}
connectionID := fmt.Sprintf("%s_%s", protocolEventAction, xid.New().String())
- err = user.CheckFsRoot(connectionID)
+ err := user.CheckFsRoot(connectionID)
defer user.CloseFs() //nolint:errcheck
if err != nil {
return fmt.Errorf("delete error, unable to check root fs for user %q: %w", user.Username, err)
}
conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
+ defer conn.CloseFS() //nolint:errcheck
+
for _, item := range replacePathsPlaceholders(deletes, replacer) {
info, err := conn.DoStat(item, 0, false)
if err != nil {
@@ -1694,17 +1743,18 @@ func executeDeleteFsRuleAction(deletes []string, replacer *strings.Replacer,
}
func executeMkDirsFsActionForUser(dirs []string, replacer *strings.Replacer, user dataprovider.User) error {
- user, err := getUserForEventAction(user)
- if err != nil {
+ if err := getUserForEventAction(&user); err != nil {
return err
}
connectionID := fmt.Sprintf("%s_%s", protocolEventAction, xid.New().String())
- err = user.CheckFsRoot(connectionID)
+ err := user.CheckFsRoot(connectionID)
defer user.CloseFs() //nolint:errcheck
if err != nil {
return fmt.Errorf("mkdir error, unable to check root fs for user %q: %w", user.Username, err)
}
conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
+ defer conn.CloseFS() //nolint:errcheck
+
for _, item := range replacePathsPlaceholders(dirs, replacer) {
if err = conn.CheckParentDirs(path.Dir(item)); err != nil {
return fmt.Errorf("unable to check parent dirs for %q, user %q: %w", item, user.Username, err)
@@ -1750,24 +1800,29 @@ func executeMkdirFsRuleAction(dirs []string, replacer *strings.Replacer,
return nil
}
-func executeRenameFsActionForUser(renames []dataprovider.KeyValue, replacer *strings.Replacer,
+func executeRenameFsActionForUser(renames []dataprovider.RenameConfig, replacer *strings.Replacer,
user dataprovider.User,
) error {
- user, err := getUserForEventAction(user)
- if err != nil {
+ if err := getUserForEventAction(&user); err != nil {
return err
}
connectionID := fmt.Sprintf("%s_%s", protocolEventAction, xid.New().String())
- err = user.CheckFsRoot(connectionID)
+ err := user.CheckFsRoot(connectionID)
defer user.CloseFs() //nolint:errcheck
if err != nil {
return fmt.Errorf("rename error, unable to check root fs for user %q: %w", user.Username, err)
}
conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
+ defer conn.CloseFS() //nolint:errcheck
+
for _, item := range renames {
source := util.CleanPath(replaceWithReplacer(item.Key, replacer))
target := util.CleanPath(replaceWithReplacer(item.Value, replacer))
- if err = conn.renameInternal(source, target, true); err != nil {
+ checks := 0
+ if item.UpdateModTime {
+ checks += vfs.CheckUpdateModTime
+ }
+ if err = conn.renameInternal(source, target, true, checks); err != nil {
return fmt.Errorf("unable to rename %q->%q, user %q: %w", source, target, user.Username, err)
}
eventManagerLog(logger.LevelDebug, "rename %q->%q ok, user %q", source, target, user.Username)
@@ -1775,21 +1830,22 @@ func executeRenameFsActionForUser(renames []dataprovider.KeyValue, replacer *str
return nil
}
-func executeCopyFsActionForUser(copy []dataprovider.KeyValue, replacer *strings.Replacer,
+func executeCopyFsActionForUser(keyVals []dataprovider.KeyValue, replacer *strings.Replacer,
user dataprovider.User,
) error {
- user, err := getUserForEventAction(user)
- if err != nil {
+ if err := getUserForEventAction(&user); err != nil {
return err
}
connectionID := fmt.Sprintf("%s_%s", protocolEventAction, xid.New().String())
- err = user.CheckFsRoot(connectionID)
+ err := user.CheckFsRoot(connectionID)
defer user.CloseFs() //nolint:errcheck
if err != nil {
return fmt.Errorf("copy error, unable to check root fs for user %q: %w", user.Username, err)
}
conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
- for _, item := range copy {
+ defer conn.CloseFS() //nolint:errcheck
+
+ for _, item := range keyVals {
source := util.CleanPath(replaceWithReplacer(item.Key, replacer))
target := util.CleanPath(replaceWithReplacer(item.Value, replacer))
if strings.HasSuffix(item.Key, "/") {
@@ -1809,17 +1865,18 @@ func executeCopyFsActionForUser(copy []dataprovider.KeyValue, replacer *strings.
func executeExistFsActionForUser(exist []string, replacer *strings.Replacer,
user dataprovider.User,
) error {
- user, err := getUserForEventAction(user)
- if err != nil {
+ if err := getUserForEventAction(&user); err != nil {
return err
}
connectionID := fmt.Sprintf("%s_%s", protocolEventAction, xid.New().String())
- err = user.CheckFsRoot(connectionID)
+ err := user.CheckFsRoot(connectionID)
defer user.CloseFs() //nolint:errcheck
if err != nil {
return fmt.Errorf("existence check error, unable to check root fs for user %q: %w", user.Username, err)
}
conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
+ defer conn.CloseFS() //nolint:errcheck
+
for _, item := range replacePathsPlaceholders(exist, replacer) {
if _, err = conn.DoStat(item, 0, false); err != nil {
return fmt.Errorf("error checking existence for path %q, user %q: %w", item, user.Username, err)
@@ -1829,7 +1886,7 @@ func executeExistFsActionForUser(exist []string, replacer *strings.Replacer,
return nil
}
-func executeRenameFsRuleAction(renames []dataprovider.KeyValue, replacer *strings.Replacer,
+func executeRenameFsRuleAction(renames []dataprovider.RenameConfig, replacer *strings.Replacer,
conditions dataprovider.ConditionOptions, params *EventParams,
) error {
users, err := params.getUsers()
@@ -1863,7 +1920,7 @@ func executeRenameFsRuleAction(renames []dataprovider.KeyValue, replacer *string
return nil
}
-func executeCopyFsRuleAction(copy []dataprovider.KeyValue, replacer *strings.Replacer,
+func executeCopyFsRuleAction(keyVals []dataprovider.KeyValue, replacer *strings.Replacer,
conditions dataprovider.ConditionOptions, params *EventParams,
) error {
users, err := params.getUsers()
@@ -1882,7 +1939,7 @@ func executeCopyFsRuleAction(copy []dataprovider.KeyValue, replacer *strings.Rep
}
}
executed++
- if err = executeCopyFsActionForUser(copy, replacer, user); err != nil {
+ if err = executeCopyFsActionForUser(keyVals, replacer, user); err != nil {
failures = append(failures, user.Username)
params.AddError(err)
}
@@ -1967,17 +2024,18 @@ func estimateZipSize(conn *BaseConnection, zipPath string, paths []string) (int6
func executeCompressFsActionForUser(c dataprovider.EventActionFsCompress, replacer *strings.Replacer,
user dataprovider.User,
) error {
- user, err := getUserForEventAction(user)
- if err != nil {
+ if err := getUserForEventAction(&user); err != nil {
return err
}
connectionID := fmt.Sprintf("%s_%s", protocolEventAction, xid.New().String())
- err = user.CheckFsRoot(connectionID)
+ err := user.CheckFsRoot(connectionID)
defer user.CloseFs() //nolint:errcheck
if err != nil {
return fmt.Errorf("compress error, unable to check root fs for user %q: %w", user.Username, err)
}
conn := NewBaseConnection(connectionID, protocolEventAction, "", "", user)
+ defer conn.CloseFS() //nolint:errcheck
+
name := util.CleanPath(replaceWithReplacer(c.Name, replacer))
conn.CheckParentDirs(path.Dir(name)) //nolint:errcheck
paths := make([]string, 0, len(c.Paths))
@@ -2011,7 +2069,7 @@ func executeCompressFsActionForUser(c dataprovider.EventActionFsCompress, replac
}
startTime := time.Now()
for _, item := range paths {
- if err := addZipEntry(zipWriter, conn, item, baseDir, 0); err != nil {
+ if err := addZipEntry(zipWriter, conn, item, baseDir, nil, 0); err != nil {
closeWriterAndUpdateQuota(writer, conn, name, "", numFiles, truncatedSize, err, operationUpload, startTime) //nolint:errcheck
return err
}
@@ -2096,7 +2154,7 @@ func executeFsRuleAction(c dataprovider.EventActionFilesystemConfig, conditions
params *EventParams,
) error {
addObjectData := false
- replacements := params.getStringReplacements(addObjectData, false)
+ replacements := params.getStringReplacements(addObjectData, 0)
replacer := strings.NewReplacer(replacements...)
switch c.Type {
case dataprovider.FilesystemActionRename:
@@ -2448,7 +2506,7 @@ func executePwdExpirationCheckForUser(user *dataprovider.User, config dataprovid
}
subject := "SFTPGo password expiration notification"
startTime := time.Now()
- if err := smtp.SendEmail([]string{user.Email}, nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
+ if err := smtp.SendEmail(user.GetEmailAddresses(), nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
eventManagerLog(logger.LevelError, "unable to notify password expiration for user %s: %v, elapsed: %s",
user.Username, err, time.Since(startTime))
return err
@@ -2496,7 +2554,7 @@ func executeAdminCheckAction(c *dataprovider.EventActionIDPAccountCheck, params
return nil, err
}
- replacements := params.getStringReplacements(false, true)
+ replacements := params.getStringReplacements(false, 1)
replacer := strings.NewReplacer(replacements...)
data := replaceWithReplacer(c.TemplateAdmin, replacer)
@@ -2505,19 +2563,57 @@ func executeAdminCheckAction(c *dataprovider.EventActionIDPAccountCheck, params
if err != nil {
return nil, err
}
- if newAdmin.Password == "" {
- newAdmin.Password = util.GenerateUniqueID()
- }
if exists {
eventManagerLog(logger.LevelDebug, "updating admin %q after IDP login", params.Name)
+ // Not sure if this makes sense, but it shouldn't hurt.
+ if newAdmin.Password == "" {
+ newAdmin.Password = admin.Password
+ }
+ newAdmin.Filters.TOTPConfig = admin.Filters.TOTPConfig
+ newAdmin.Filters.RecoveryCodes = admin.Filters.RecoveryCodes
err = dataprovider.UpdateAdmin(&newAdmin, dataprovider.ActionExecutorSystem, "", "")
} else {
eventManagerLog(logger.LevelDebug, "creating admin %q after IDP login", params.Name)
+ if newAdmin.Password == "" {
+ newAdmin.Password = util.GenerateUniqueID()
+ }
err = dataprovider.AddAdmin(&newAdmin, dataprovider.ActionExecutorSystem, "", "")
}
return &newAdmin, err
}
+func preserveUserProfile(user, newUser *dataprovider.User) {
+ if newUser.CanChangePassword() && user.Password != "" {
+ newUser.Password = user.Password
+ }
+ if newUser.CanManagePublicKeys() && len(user.PublicKeys) > 0 {
+ newUser.PublicKeys = user.PublicKeys
+ }
+ if newUser.CanManageTLSCerts() {
+ if len(user.Filters.TLSCerts) > 0 {
+ newUser.Filters.TLSCerts = user.Filters.TLSCerts
+ }
+ }
+ if newUser.CanChangeInfo() {
+ if user.Description != "" {
+ newUser.Description = user.Description
+ }
+ if user.Email != "" {
+ newUser.Email = user.Email
+ }
+ if len(user.Filters.AdditionalEmails) > 0 {
+ newUser.Filters.AdditionalEmails = user.Filters.AdditionalEmails
+ }
+ }
+ if newUser.CanChangeAPIKeyAuth() {
+ newUser.Filters.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth
+ }
+ newUser.Filters.RecoveryCodes = user.Filters.RecoveryCodes
+ newUser.Filters.TOTPConfig = user.Filters.TOTPConfig
+ newUser.LastPasswordChange = user.LastPasswordChange
+ newUser.SetEmptySecretsIfNil()
+}
+
func executeUserCheckAction(c *dataprovider.EventActionIDPAccountCheck, params *EventParams) (*dataprovider.User, error) {
user, err := dataprovider.UserExists(params.Name, "")
exists := err == nil
@@ -2528,7 +2624,7 @@ func executeUserCheckAction(c *dataprovider.EventActionIDPAccountCheck, params *
if err != nil && !errors.Is(err, util.ErrNotFound) {
return nil, err
}
- replacements := params.getStringReplacements(false, true)
+ replacements := params.getStringReplacements(false, 1)
replacer := strings.NewReplacer(replacements...)
data := replaceWithReplacer(c.TemplateUser, replacer)
@@ -2539,6 +2635,7 @@ func executeUserCheckAction(c *dataprovider.EventActionIDPAccountCheck, params *
}
if exists {
eventManagerLog(logger.LevelDebug, "updating user %q after IDP login", params.Name)
+ preserveUserProfile(&user, &newUser)
err = dataprovider.UpdateUser(&newUser, dataprovider.ActionExecutorSystem, "", "")
} else {
eventManagerLog(logger.LevelDebug, "creating user %q after IDP login", params.Name)
@@ -2551,9 +2648,14 @@ func executeUserCheckAction(c *dataprovider.EventActionIDPAccountCheck, params *
return &u, err
}
-func executeRuleAction(action dataprovider.BaseEventAction, params *EventParams,
+func executeRuleAction(action dataprovider.BaseEventAction, params *EventParams, //nolint:gocyclo
conditions dataprovider.ConditionOptions,
) error {
+ if len(conditions.EventStatuses) > 0 && !slices.Contains(conditions.EventStatuses, params.Status) {
+ eventManagerLog(logger.LevelDebug, "skipping action %s, event status %d does not match: %v",
+ action.Name, params.Status, conditions.EventStatuses)
+ return nil
+ }
var err error
switch action.Type {
@@ -2585,6 +2687,8 @@ func executeRuleAction(action dataprovider.BaseEventAction, params *EventParams,
err = executeUserExpirationCheckRuleAction(conditions, params)
case dataprovider.ActionTypeUserInactivityCheck:
err = executeUserInactivityCheckRuleAction(action.Options.UserInactivityConfig, conditions, params, time.Now())
+ case dataprovider.ActionTypeRotateLogs:
+ err = logger.RotateLogFile()
default:
err = fmt.Errorf("unsupported action type: %d", action.Type)
}
@@ -2742,6 +2846,16 @@ func (j *eventCronJob) getTask(rule *dataprovider.EventRule) (dataprovider.Task,
return dataprovider.Task{}, nil
}
+func (j *eventCronJob) getEventParams() EventParams {
+ return EventParams{
+ Event: "Schedule",
+ Name: j.ruleName,
+ Status: 1,
+ Timestamp: time.Now(),
+ updateStatusFromError: true,
+ }
+}
+
func (j *eventCronJob) Run() {
eventManagerLog(logger.LevelDebug, "executing scheduled rule %q", j.ruleName)
rule, err := dataprovider.EventRuleExists(j.ruleName)
@@ -2792,9 +2906,9 @@ func (j *eventCronJob) Run() {
}
}(task.Name)
- executeAsyncRulesActions([]dataprovider.EventRule{rule}, EventParams{Status: 1, updateStatusFromError: true})
+ executeAsyncRulesActions([]dataprovider.EventRule{rule}, j.getEventParams())
} else {
- executeAsyncRulesActions([]dataprovider.EventRule{rule}, EventParams{Status: 1, updateStatusFromError: true})
+ executeAsyncRulesActions([]dataprovider.EventRule{rule}, j.getEventParams())
}
eventManagerLog(logger.LevelDebug, "execution for scheduled rule %q finished", j.ruleName)
}
diff --git a/internal/common/eventmanager_test.go b/internal/common/eventmanager_test.go
index 47ddd08c..d350421e 100644
--- a/internal/common/eventmanager_test.go
+++ b/internal/common/eventmanager_test.go
@@ -800,6 +800,32 @@ func TestEventManagerErrors(t *testing.T) {
stopEventScheduler()
}
+func TestDateTimePlaceholder(t *testing.T) {
+ oldTZ := Config.TZ
+
+ Config.TZ = ""
+ dateTime := time.Now()
+ params := EventParams{
+ Timestamp: dateTime,
+ }
+ replacements := params.getStringReplacements(false, 0)
+ r := strings.NewReplacer(replacements...)
+ res := r.Replace("{{.DateTime}}")
+ assert.Equal(t, dateTime.UTC().Format(dateTimeMillisFormat), res)
+ res = r.Replace("{{.Year}}-{{.Month}}-{{.Day}}T{{.Hour}}:{{.Minute}}")
+ assert.Equal(t, dateTime.UTC().Format(dateTimeMillisFormat)[:16], res)
+
+ Config.TZ = "local"
+ replacements = params.getStringReplacements(false, 0)
+ r = strings.NewReplacer(replacements...)
+ res = r.Replace("{{.DateTime}}")
+ assert.Equal(t, dateTime.Local().Format(dateTimeMillisFormat), res)
+ res = r.Replace("{{.Year}}-{{.Month}}-{{.Day}}T{{.Hour}}:{{.Minute}}")
+ assert.Equal(t, dateTime.Local().Format(dateTimeMillisFormat)[:16], res)
+
+ Config.TZ = oldTZ
+}
+
func TestEventRuleActions(t *testing.T) {
actionName := "test rule action"
action := dataprovider.BaseEventAction{
@@ -819,7 +845,7 @@ func TestEventRuleActions(t *testing.T) {
HTTPConfig: dataprovider.EventActionHTTPConfig{
Endpoint: "http://foo\x7f.com/", // invalid URL
SkipTLSVerify: true,
- Body: `"data": "{{ObjectDataString}}"`,
+ Body: `"data": "{{.ObjectDataString}}"`,
Method: http.MethodPost,
QueryParameters: []dataprovider.KeyValue{
{
@@ -887,7 +913,7 @@ func TestEventRuleActions(t *testing.T) {
assert.Contains(t, getErrorString(err), "error getting user")
action.Options.HTTPConfig.Parts = nil
- action.Options.HTTPConfig.Body = "{{ObjectData}}"
+ action.Options.HTTPConfig.Body = "{{.ObjectData}}"
// test disk and transfer quota reset
username1 := "user1"
username2 := "user2"
@@ -1166,10 +1192,12 @@ func TestEventRuleActions(t *testing.T) {
action.Options = dataprovider.BaseEventActionOptions{
FsConfig: dataprovider.EventActionFilesystemConfig{
Type: dataprovider.FilesystemActionRename,
- Renames: []dataprovider.KeyValue{
+ Renames: []dataprovider.RenameConfig{
{
- Key: "/source",
- Value: "/target",
+ KeyValue: dataprovider.KeyValue{
+ Key: "/source",
+ Value: "/target",
+ },
},
},
},
@@ -1221,7 +1249,7 @@ func TestEventRuleActions(t *testing.T) {
Type: dataprovider.FilesystemActionCompress,
Compress: dataprovider.EventActionFsCompress{
Name: "test.zip",
- Paths: []string{"/{{VirtualPath}}"},
+ Paths: []string{"/{{.VirtualPath}}"},
},
},
}
@@ -1386,6 +1414,31 @@ func TestIDPAccountCheckRule(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, username, user.Username)
assert.Equal(t, 1, user.Status)
+ assert.Empty(t, user.Password)
+ assert.Len(t, user.PublicKeys, 0)
+ assert.Len(t, user.Filters.TLSCerts, 0)
+ assert.Empty(t, user.Email)
+ assert.Empty(t, user.Description)
+ // Update the profile attribute and make sure they are preserved
+ user.Password = "secret"
+ user.Email = "example@example.com"
+ user.Filters.AdditionalEmails = []string{"alias@example.com"}
+ user.Description = "some desc"
+ user.Filters.TLSCerts = []string{serverCert}
+ user.PublicKeys = []string{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC03jj0D+djk7pxIf/0OhrxrchJTRZklofJ1NoIu4752Sq02mdXmarMVsqJ1cAjV5LBVy3D1F5U6XW4rppkXeVtd04Pxb09ehtH0pRRPaoHHlALiJt8CoMpbKYMA8b3KXPPriGxgGomvtU2T2RMURSwOZbMtpsugfjYSWenyYX+VORYhylWnSXL961LTyC21ehd6d6QnW9G7E5hYMITMY9TuQZz3bROYzXiTsgN0+g6Hn7exFQp50p45StUMfV/SftCMdCxlxuyGny2CrN/vfjO7xxOo2uv7q1qm10Q46KPWJQv+pgZ/OfL+EDjy07n5QVSKHlbx+2nT4Q0EgOSQaCTYwn3YjtABfIxWwgAFdyj6YlPulCL22qU4MYhDcA6PSBwDdf8hvxBfvsiHdM+JcSHvv8/VeJhk6CmnZxGY0fxBupov27z3yEO8nAg8k+6PaUiW1MSUfuGMF/ktB8LOstXsEPXSszuyXiOv4DaryOXUiSn7bmRqKcEFlJusO6aZP0= nicola@p1"}
+ err = dataprovider.UpdateUser(user, "", "", "")
+ assert.NoError(t, err)
+
+ user, err = executeUserCheckAction(c, params)
+ assert.NoError(t, err)
+ assert.Equal(t, username, user.Username)
+ assert.Equal(t, 1, user.Status)
+ assert.NotEmpty(t, user.Password)
+ assert.Len(t, user.PublicKeys, 1)
+ assert.Len(t, user.Filters.TLSCerts, 1)
+ assert.NotEmpty(t, user.Email)
+ assert.Len(t, user.Filters.AdditionalEmails, 1)
+ assert.NotEmpty(t, user.Description)
err = dataprovider.DeleteUser(username, "", "", "")
assert.NoError(t, err)
@@ -1686,10 +1739,12 @@ func TestFilesystemActionErrors(t *testing.T) {
assert.NoError(t, err)
err = dataprovider.AddUser(&user, "", "", "")
assert.NoError(t, err)
- err = executeRenameFsActionForUser([]dataprovider.KeyValue{
+ err = executeRenameFsActionForUser([]dataprovider.RenameConfig{
{
- Key: "/p1",
- Value: "/p1",
+ KeyValue: dataprovider.KeyValue{
+ Key: "/p1",
+ Value: "/p1",
+ },
},
}, testReplacer, user)
if assert.Error(t, err) {
@@ -1700,10 +1755,12 @@ func TestFilesystemActionErrors(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
FsConfig: dataprovider.EventActionFilesystemConfig{
Type: dataprovider.FilesystemActionRename,
- Renames: []dataprovider.KeyValue{
+ Renames: []dataprovider.RenameConfig{
{
- Key: "/p2",
- Value: "/p2",
+ KeyValue: dataprovider.KeyValue{
+ Key: "/p2",
+ Value: "/p2",
+ },
},
},
},
@@ -1789,7 +1846,7 @@ func TestFilesystemActionErrors(t *testing.T) {
Writer: zip.NewWriter(bytes.NewBuffer(nil)),
Entries: map[string]bool{},
}
- err = addZipEntry(wr, conn, "/adir/sub/f.dat", "/adir/sub/sub", 0)
+ err = addZipEntry(wr, conn, "/adir/sub/f.dat", "/adir/sub/sub", nil, 0)
assert.Error(t, err)
assert.Contains(t, getErrorString(err), "is outside base dir")
}
@@ -1799,7 +1856,7 @@ func TestFilesystemActionErrors(t *testing.T) {
Writer: zip.NewWriter(bytes.NewBuffer(nil)),
Entries: map[string]bool{},
}
- err = addZipEntry(wr, conn, "/p1", "/", 2000)
+ err = addZipEntry(wr, conn, "/p1", "/", nil, 2000)
assert.ErrorIs(t, err, util.ErrRecursionTooDeep)
err = dataprovider.DeleteUser(username, "", "", "")
@@ -1897,12 +1954,27 @@ func TestScheduledActions(t *testing.T) {
backupsPath := filepath.Join(os.TempDir(), "backups")
err := os.RemoveAll(backupsPath)
assert.NoError(t, err)
+ now := time.Now().UTC().Format(dateTimeMillisFormat)
+ // The backup action sets the home directory to the backup path.
+ expectedDirPath := filepath.Join(backupsPath, fmt.Sprintf("%s_%s_%s", now[0:4], now[5:7], now[8:10]))
- action := &dataprovider.BaseEventAction{
- Name: "action",
+ action1 := &dataprovider.BaseEventAction{
+ Name: "action1",
Type: dataprovider.ActionTypeBackup,
}
- err = dataprovider.AddEventAction(action, "", "", "")
+ err = dataprovider.AddEventAction(action1, "", "", "")
+ assert.NoError(t, err)
+ action2 := &dataprovider.BaseEventAction{
+ Name: "action2",
+ Type: dataprovider.ActionTypeFilesystem,
+ Options: dataprovider.BaseEventActionOptions{
+ FsConfig: dataprovider.EventActionFilesystemConfig{
+ Type: dataprovider.FilesystemActionMkdirs,
+ MkDirs: []string{"{{.Year}}_{{.Month}}_{{.Day}}"},
+ },
+ },
+ }
+ err = dataprovider.AddEventAction(action2, "", "", "")
assert.NoError(t, err)
rule := &dataprovider.EventRule{
Name: "rule",
@@ -1921,10 +1993,16 @@ func TestScheduledActions(t *testing.T) {
Actions: []dataprovider.EventAction{
{
BaseEventAction: dataprovider.BaseEventAction{
- Name: action.Name,
+ Name: action1.Name,
},
Order: 1,
},
+ {
+ BaseEventAction: dataprovider.BaseEventAction{
+ Name: action2.Name,
+ },
+ Order: 2,
+ },
},
}
@@ -1939,9 +2017,10 @@ func TestScheduledActions(t *testing.T) {
job.Run()
assert.DirExists(t, backupsPath)
+ assert.DirExists(t, expectedDirPath)
- action.Type = dataprovider.ActionTypeEmail
- action.Options = dataprovider.BaseEventActionOptions{
+ action1.Type = dataprovider.ActionTypeEmail
+ action1.Options = dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"example@example.com"},
Subject: "test with attachments",
@@ -1949,16 +2028,19 @@ func TestScheduledActions(t *testing.T) {
Attachments: []string{"/file1.txt"},
},
}
- err = dataprovider.UpdateEventAction(action, "", "", "")
+ err = dataprovider.UpdateEventAction(action1, "", "", "")
assert.NoError(t, err)
job.Run() // action is not compatible with a scheduled rule
err = dataprovider.DeleteEventRule(rule.Name, "", "", "")
assert.NoError(t, err)
- err = dataprovider.DeleteEventAction(action.Name, "", "", "")
+ err = dataprovider.DeleteEventAction(action1.Name, "", "", "")
+ assert.NoError(t, err)
+ err = dataprovider.DeleteEventAction(action2.Name, "", "", "")
assert.NoError(t, err)
err = os.RemoveAll(backupsPath)
assert.NoError(t, err)
+
stopEventScheduler()
}
@@ -2077,11 +2159,11 @@ func TestWriteHTTPPartsError(t *testing.T) {
}
func TestReplacePathsPlaceholders(t *testing.T) {
- replacer := strings.NewReplacer("{{VirtualPath}}", "/path1")
- paths := []string{"{{VirtualPath}}", "/path1"}
+ replacer := strings.NewReplacer("{{.VirtualPath}}", "/path1")
+ paths := []string{"{{.VirtualPath}}", "/path1"}
paths = replacePathsPlaceholders(paths, replacer)
assert.Equal(t, []string{"/path1"}, paths)
- paths = []string{"{{VirtualPath}}", "/path2"}
+ paths = []string{"{{.VirtualPath}}", "/path2"}
paths = replacePathsPlaceholders(paths, replacer)
assert.Equal(t, []string{"/path1", "/path2"}, paths)
}
@@ -2174,7 +2256,7 @@ func TestOnDemandRule(t *testing.T) {
Recipients: []string{"example@example.org"},
Subject: "subject",
Body: "body",
- Attachments: []string{"/{{VirtualPath}}"},
+ Attachments: []string{"/{{.VirtualPath}}"},
},
},
}
@@ -2215,21 +2297,21 @@ func getErrorString(err error) string {
func TestHTTPEndpointWithPlaceholders(t *testing.T) {
c := dataprovider.EventActionHTTPConfig{
- Endpoint: "http://127.0.0.1:8080/base/url/{{Name}}/{{VirtualPath}}/upload",
+ Endpoint: "http://127.0.0.1:8080/base/url/{{.Name}}/{{.VirtualPath}}/upload",
QueryParameters: []dataprovider.KeyValue{
{
Key: "u",
- Value: "{{Name}}",
+ Value: "{{.Name}}",
},
{
Key: "p",
- Value: "{{VirtualPath}}",
+ Value: "{{.VirtualPath}}",
},
},
}
name := "uname"
vPath := "/a dir/@ file.txt"
- replacer := strings.NewReplacer("{{Name}}", name, "{{VirtualPath}}", vPath)
+ replacer := strings.NewReplacer("{{.Name}}", name, "{{.VirtualPath}}", vPath)
u, err := getHTTPRuleActionEndpoint(&c, replacer)
assert.NoError(t, err)
expected := "http://127.0.0.1:8080/base/url/" + url.PathEscape(name) + "/" + url.PathEscape(vPath) +
@@ -2249,9 +2331,9 @@ func TestMetadataReplacement(t *testing.T) {
"key": "value",
},
}
- replacements := params.getStringReplacements(false, false)
+ replacements := params.getStringReplacements(false, 0)
replacer := strings.NewReplacer(replacements...)
- reader, _, err := getHTTPRuleActionBody(&dataprovider.EventActionHTTPConfig{Body: "{{Metadata}} {{MetadataString}}"}, replacer, nil, dataprovider.User{}, params, false)
+ reader, _, err := getHTTPRuleActionBody(&dataprovider.EventActionHTTPConfig{Body: "{{.Metadata}} {{.MetadataString}}"}, replacer, nil, dataprovider.User{}, params, false)
require.NoError(t, err)
data, err := io.ReadAll(reader)
require.NoError(t, err)
diff --git a/internal/common/eventscheduler.go b/internal/common/eventscheduler.go
index 71315e81..762880f7 100644
--- a/internal/common/eventscheduler.go
+++ b/internal/common/eventscheduler.go
@@ -19,6 +19,8 @@ import (
"github.com/robfig/cron/v3"
+ "github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/util"
)
@@ -36,7 +38,15 @@ func stopEventScheduler() {
func startEventScheduler() {
stopEventScheduler()
- eventScheduler = cron.New(cron.WithLocation(time.UTC), cron.WithLogger(cron.DiscardLogger))
+ options := []cron.Option{
+ cron.WithLogger(cron.DiscardLogger),
+ }
+ if !dataprovider.UseLocalTime() {
+ eventManagerLog(logger.LevelDebug, "use UTC time for the scheduler")
+ options = append(options, cron.WithLocation(time.UTC))
+ }
+
+ eventScheduler = cron.New(options...)
eventManager.loadRules()
_, err := eventScheduler.AddFunc("@every 10m", eventManager.loadRules)
util.PanicOnError(err)
diff --git a/internal/common/protocol_test.go b/internal/common/protocol_test.go
index e8f590b2..8147e246 100644
--- a/internal/common/protocol_test.go
+++ b/internal/common/protocol_test.go
@@ -26,10 +26,12 @@ import (
"math"
"net"
"net/http"
+ "net/url"
"os"
"path"
"path/filepath"
"runtime"
+ "slices"
"strings"
"sync"
"testing"
@@ -1457,15 +1459,15 @@ func TestTruncateQuotaLimits(t *testing.T) {
expectedQuotaSize := int64(3)
fold, _, err := httpdtest.GetFolderByName(folder2.Name, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, expectedQuotaSize, fold.UsedQuotaSize)
- assert.Equal(t, expectedQuotaFiles, fold.UsedQuotaFiles)
+ assert.Equal(t, int64(0), fold.UsedQuotaSize)
+ assert.Equal(t, 0, fold.UsedQuotaFiles)
err = f.Close()
assert.NoError(t, err)
expectedQuotaFiles = 1
fold, _, err = httpdtest.GetFolderByName(folder2.Name, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, expectedQuotaSize, fold.UsedQuotaSize)
- assert.Equal(t, expectedQuotaFiles, fold.UsedQuotaFiles)
+ assert.Equal(t, int64(0), fold.UsedQuotaSize)
+ assert.Equal(t, 0, fold.UsedQuotaFiles)
user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles)
@@ -1777,8 +1779,8 @@ func TestVirtualFoldersQuotaValues(t *testing.T) {
assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize)
f, _, err := httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize, f.UsedQuotaSize)
@@ -1885,8 +1887,8 @@ func TestQuotaRenameInsideSameVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize)
f, _, err := httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 2, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
@@ -1910,8 +1912,8 @@ func TestQuotaRenameInsideSameVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 2, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
// rename a file inside vdir2, it isn't included inside user quota, so we have:
// - vdir1/dir1/testFileName.rename
// - vdir1/dir2/testFileName1
@@ -1929,8 +1931,8 @@ func TestQuotaRenameInsideSameVirtualFolder(t *testing.T) {
assert.Equal(t, 2, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 2, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
// rename a file inside vdir2 overwriting an existing, we now have:
// - vdir1/dir1/testFileName.rename
// - vdir1/dir2/testFileName1
@@ -1947,8 +1949,8 @@ func TestQuotaRenameInsideSameVirtualFolder(t *testing.T) {
assert.Equal(t, 1, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 2, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
// rename a file inside vdir1 overwriting an existing, we now have:
// - vdir1/dir1/testFileName.rename (initial testFileName1)
// - vdir2/dir1/testFileName.rename (initial testFileName1)
@@ -1960,8 +1962,8 @@ func TestQuotaRenameInsideSameVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize1, f.UsedQuotaSize)
@@ -1981,8 +1983,8 @@ func TestQuotaRenameInsideSameVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize1, f.UsedQuotaSize)
@@ -2087,8 +2089,8 @@ func TestQuotaRenameBetweenVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize, user.UsedQuotaSize)
f, _, err := httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize+testFileSize1+testFileSize1, f.UsedQuotaSize)
@@ -2106,8 +2108,8 @@ func TestQuotaRenameBetweenVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize*2, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize*2, f.UsedQuotaSize)
- assert.Equal(t, 2, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize1*2, f.UsedQuotaSize)
@@ -2124,8 +2126,8 @@ func TestQuotaRenameBetweenVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize1+testFileSize, f.UsedQuotaSize)
@@ -2141,8 +2143,8 @@ func TestQuotaRenameBetweenVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize, f.UsedQuotaSize)
@@ -2172,8 +2174,8 @@ func TestQuotaRenameBetweenVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize1*3+testFileSize*2, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize1*3+testFileSize*2, f.UsedQuotaSize)
- assert.Equal(t, 5, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, int64(0), f.UsedQuotaSize)
@@ -2187,8 +2189,8 @@ func TestQuotaRenameBetweenVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize1*2+testFileSize, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize1*2+testFileSize, f.UsedQuotaSize)
- assert.Equal(t, 3, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
@@ -2293,8 +2295,8 @@ func TestQuotaRenameFromVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize)
f, _, err := httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
@@ -2312,8 +2314,8 @@ func TestQuotaRenameFromVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize+testFileSize1+testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize, f.UsedQuotaSize)
@@ -2376,8 +2378,8 @@ func TestQuotaRenameFromVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize*3+testFileSize1*3, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 2, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, int64(0), f.UsedQuotaSize)
@@ -2497,8 +2499,8 @@ func TestQuotaRenameToVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize)
f, _, err := httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
// rename a file from user home dir to vdir2, vdir2 is not included in user quota so we have:
// - /vdir2/dir1/testFileName
// - /vdir1/dir1/testFileName1
@@ -2537,8 +2539,8 @@ func TestQuotaRenameToVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize, f.UsedQuotaSize)
@@ -2554,8 +2556,8 @@ func TestQuotaRenameToVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize1, f.UsedQuotaSize)
@@ -2577,8 +2579,8 @@ func TestQuotaRenameToVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize*2+testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize1, f.UsedQuotaSize)
@@ -2595,8 +2597,8 @@ func TestQuotaRenameToVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize*2+testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize*2+testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 3, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize1, f.UsedQuotaSize)
@@ -2621,8 +2623,8 @@ func TestQuotaRenameToVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize*2+testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize*2+testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 3, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize1*2+testFileSize, f.UsedQuotaSize)
@@ -3782,8 +3784,8 @@ func TestEventRule(t *testing.T) {
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"test1@example.com", "test2@example.com"},
Bcc: []string{"test3@example.com"},
- Subject: `New "{{Event}}" from "{{Name}}" status {{StatusString}}`,
- Body: "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}} Data: {{ObjectData}} {{ErrorString}}",
+ Subject: `New "{{.Event}}" from "{{.Name}}" status {{.StatusString}}`,
+ Body: "Fs path {{.FsPath}}, size: {{.FileSize}}, protocol: {{.Protocol}}, IP: {{.IP}} Data: {{.ObjectData}} {{.ErrorString}}",
},
},
}
@@ -3793,8 +3795,8 @@ func TestEventRule(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"failure@example.com"},
- Subject: `Failed "{{Event}}" from "{{Name}}"`,
- Body: "Fs path {{FsPath}}, protocol: {{Protocol}}, IP: {{IP}} {{ErrorString}}",
+ Subject: `Failed "{{.Event}}" from "{{.Name}}"`,
+ Body: "Fs path {{.FsPath}}, protocol: {{.Protocol}}, IP: {{.IP}} {{.ErrorString}}",
},
},
}
@@ -3814,6 +3816,7 @@ func TestEventRule(t *testing.T) {
Conditions: dataprovider.EventConditions{
FsEvents: []string{"upload"},
Options: dataprovider.ConditionOptions{
+ EventStatuses: []int{1},
FsPaths: []dataprovider.ConditionPattern{
{
Pattern: "/subdir/*.dat",
@@ -3925,6 +3928,11 @@ func TestEventRule(t *testing.T) {
err = os.WriteFile(uploadScriptPath, getUploadScriptContent(movedPath, "", 0), 0755)
assert.NoError(t, err)
+ dataprovider.EnabledActionCommands = []string{uploadScriptPath}
+ defer func() {
+ dataprovider.EnabledActionCommands = nil
+ }()
+
action1.Type = dataprovider.ActionTypeCommand
action1.Options = dataprovider.BaseEventActionOptions{
CmdConfig: dataprovider.EventActionCommandConfig{
@@ -3933,7 +3941,7 @@ func TestEventRule(t *testing.T) {
EnvVars: []dataprovider.KeyValue{
{
Key: "SFTPGO_ACTION_PATH",
- Value: "{{FsPath}}",
+ Value: "{{.FsPath}}",
},
{
Key: "CUSTOM_ENV_VAR",
@@ -3978,9 +3986,9 @@ func TestEventRule(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 3)
- assert.True(t, util.Contains(email.To, "test1@example.com"))
- assert.True(t, util.Contains(email.To, "test2@example.com"))
- assert.True(t, util.Contains(email.To, "test3@example.com"))
+ assert.True(t, slices.Contains(email.To, "test1@example.com"))
+ assert.True(t, slices.Contains(email.To, "test2@example.com"))
+ assert.True(t, slices.Contains(email.To, "test3@example.com"))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: New "upload" from "%s" status OK`, user.Username))
// test the failure action, we download a file that exceeds the transfer quota limit
err = writeSFTPFileNoCheck(path.Join("subdir1", testFileName), 1*1024*1024+65535, client)
@@ -3999,9 +4007,9 @@ func TestEventRule(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email = lastReceivedEmail.get()
assert.Len(t, email.To, 3)
- assert.True(t, util.Contains(email.To, "test1@example.com"))
- assert.True(t, util.Contains(email.To, "test2@example.com"))
- assert.True(t, util.Contains(email.To, "test3@example.com"))
+ assert.True(t, slices.Contains(email.To, "test1@example.com"))
+ assert.True(t, slices.Contains(email.To, "test2@example.com"))
+ assert.True(t, slices.Contains(email.To, "test3@example.com"))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: New "download" from "%s" status KO`, user.Username))
assert.Contains(t, email.Data, `"download" failed`)
assert.Contains(t, email.Data, common.ErrReadQuotaExceeded.Error())
@@ -4019,7 +4027,7 @@ func TestEventRule(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email = lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "failure@example.com"))
+ assert.True(t, slices.Contains(email.To, "failure@example.com"))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: Failed "upload" from "%s"`, user.Username))
assert.Contains(t, email.Data, fmt.Sprintf(`action %q failed`, action1.Name))
// now test the download rule
@@ -4036,13 +4044,13 @@ func TestEventRule(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email = lastReceivedEmail.get()
assert.Len(t, email.To, 3)
- assert.True(t, util.Contains(email.To, "test1@example.com"))
- assert.True(t, util.Contains(email.To, "test2@example.com"))
- assert.True(t, util.Contains(email.To, "test3@example.com"))
+ assert.True(t, slices.Contains(email.To, "test1@example.com"))
+ assert.True(t, slices.Contains(email.To, "test2@example.com"))
+ assert.True(t, slices.Contains(email.To, "test3@example.com"))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: New "download" from "%s"`, user.Username))
}
// test upload action command with arguments
- action1.Options.CmdConfig.Args = []string{"{{Event}}", "{{VirtualPath}}", "custom_arg"}
+ action1.Options.CmdConfig.Args = []string{"{{.Event}}", "{{.VirtualPath}}", "custom_arg"}
action1, _, err = httpdtest.UpdateEventAction(action1, http.StatusOK)
assert.NoError(t, err)
uploadLogFilePath := filepath.Join(os.TempDir(), "upload.log")
@@ -4079,9 +4087,9 @@ func TestEventRule(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 3)
- assert.True(t, util.Contains(email.To, "test1@example.com"))
- assert.True(t, util.Contains(email.To, "test2@example.com"))
- assert.True(t, util.Contains(email.To, "test3@example.com"))
+ assert.True(t, slices.Contains(email.To, "test1@example.com"))
+ assert.True(t, slices.Contains(email.To, "test2@example.com"))
+ assert.True(t, slices.Contains(email.To, "test3@example.com"))
assert.Contains(t, email.Data, `Subject: New "delete" from "admin"`)
_, err = httpdtest.RemoveEventRule(rule3, http.StatusOK)
assert.NoError(t, err)
@@ -4104,7 +4112,108 @@ func TestEventRule(t *testing.T) {
require.NoError(t, err)
}
-func TestEventRuleProviderEvents(t *testing.T) {
+func TestEventRuleStatues(t *testing.T) {
+ smtpCfg := smtp.Config{
+ Host: "127.0.0.1",
+ Port: 2525,
+ From: "notification@example.com",
+ TemplatesPath: "templates",
+ }
+ err := smtpCfg.Initialize(configDir, true)
+ require.NoError(t, err)
+
+ a1 := dataprovider.BaseEventAction{
+ Name: "a1",
+ Type: dataprovider.ActionTypeEmail,
+ Options: dataprovider.BaseEventActionOptions{
+ EmailConfig: dataprovider.EventActionEmailConfig{
+ Recipients: []string{"test6@example.com"},
+ Subject: `New "{{.Event}}" error`,
+ Body: "{{.ErrorString}}",
+ },
+ },
+ }
+ action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated)
+ assert.NoError(t, err)
+
+ r := dataprovider.EventRule{
+ Name: "rule",
+ Status: 1,
+ Trigger: dataprovider.EventTriggerFsEvent,
+ Conditions: dataprovider.EventConditions{
+ FsEvents: []string{"upload"},
+ Options: dataprovider.ConditionOptions{
+ EventStatuses: []int{3},
+ },
+ },
+ Actions: []dataprovider.EventAction{
+ {
+ BaseEventAction: dataprovider.BaseEventAction{
+ Name: action1.Name,
+ },
+ Order: 1,
+ },
+ },
+ }
+ rule, resp, err := httpdtest.AddEventRule(r, http.StatusCreated)
+ assert.NoError(t, err, string(resp))
+
+ u := getTestUser()
+ u.UploadDataTransfer = 1
+ u.DownloadDataTransfer = 1
+ user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+ assert.NoError(t, err)
+ conn, client, err := getSftpClient(user)
+ if assert.NoError(t, err) {
+ defer conn.Close()
+ defer client.Close()
+
+ testFileSize := int64(999999)
+ err = writeSFTPFile(testFileName, testFileSize, client)
+ assert.NoError(t, err)
+ f, err := client.Open(testFileName)
+ assert.NoError(t, err)
+ contents := make([]byte, testFileSize)
+ n, err := io.ReadFull(f, contents)
+ assert.NoError(t, err)
+ assert.Equal(t, int(testFileSize), n)
+ assert.Len(t, contents, int(testFileSize))
+ err = f.Close()
+ assert.NoError(t, err)
+
+ lastReceivedEmail.reset()
+ assert.Eventually(t, func() bool {
+ return lastReceivedEmail.get().From == ""
+ }, 600*time.Millisecond, 500*time.Millisecond)
+
+ err = writeSFTPFile(testFileName, testFileSize, client)
+ assert.Error(t, err)
+ lastReceivedEmail.reset()
+ assert.Eventually(t, func() bool {
+ return lastReceivedEmail.get().From != ""
+ }, 3000*time.Millisecond, 100*time.Millisecond)
+ email := lastReceivedEmail.get()
+ assert.Len(t, email.To, 1)
+ assert.True(t, slices.Contains(email.To, "test6@example.com"))
+ assert.Contains(t, email.Data, `Subject: New "upload" error`)
+ assert.Contains(t, email.Data, common.ErrQuotaExceeded.Error())
+ }
+
+ _, err = httpdtest.RemoveEventRule(rule, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+ err = os.RemoveAll(user.GetHomeDir())
+ assert.NoError(t, err)
+
+ smtpCfg = smtp.Config{}
+ err = smtpCfg.Initialize(configDir, true)
+ require.NoError(t, err)
+}
+
+func TestEventRuleDisabledCommand(t *testing.T) {
if runtime.GOOS == osWindows {
t.Skip("this test is not available on Windows")
}
@@ -4132,7 +4241,7 @@ func TestEventRuleProviderEvents(t *testing.T) {
EnvVars: []dataprovider.KeyValue{
{
Key: "SFTPGO_OBJECT_DATA",
- Value: "{{ObjectData}}",
+ Value: "{{.ObjectData}}",
},
},
},
@@ -4144,8 +4253,8 @@ func TestEventRuleProviderEvents(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"test3@example.com"},
- Subject: `New "{{Event}}" from "{{Name}}"`,
- Body: "Object name: {{ObjectName}} object type: {{ObjectType}} Data: {{ObjectData}}",
+ Subject: `New "{{.Event}}" from "{{.Name}}"`,
+ Body: "Object name: {{.ObjectName}} object type: {{.ObjectType}} Data: {{.ObjectData}}",
},
},
}
@@ -4156,8 +4265,157 @@ func TestEventRuleProviderEvents(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"failure@example.com"},
- Subject: `Failed "{{Event}}" from "{{Name}}"`,
- Body: "Object name: {{ObjectName}} object type: {{ObjectType}}, IP: {{IP}}",
+ Subject: `Failed "{{.Event}}" from "{{.Name}}"`,
+ Body: "Object name: {{.ObjectName}} object type: {{.ObjectType}}, IP: {{.IP}}",
+ },
+ },
+ }
+ _, _, err = httpdtest.AddEventAction(a1, http.StatusBadRequest)
+ assert.NoError(t, err)
+ // Enable the command to allow saving
+ dataprovider.EnabledActionCommands = []string{a1.Options.CmdConfig.Cmd}
+ action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated)
+ assert.NoError(t, err)
+ action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated)
+ assert.NoError(t, err)
+ action3, _, err := httpdtest.AddEventAction(a3, http.StatusCreated)
+ assert.NoError(t, err)
+
+ r := dataprovider.EventRule{
+ Name: "rule",
+ Status: 1,
+ Trigger: dataprovider.EventTriggerProviderEvent,
+ Conditions: dataprovider.EventConditions{
+ ProviderEvents: []string{"add"},
+ Options: dataprovider.ConditionOptions{
+ ProviderObjects: []string{"folder"},
+ },
+ },
+ Actions: []dataprovider.EventAction{
+ {
+ BaseEventAction: dataprovider.BaseEventAction{
+ Name: action1.Name,
+ },
+ Order: 1,
+ Options: dataprovider.EventActionOptions{
+ StopOnFailure: true,
+ },
+ },
+ {
+ BaseEventAction: dataprovider.BaseEventAction{
+ Name: action2.Name,
+ },
+ Order: 2,
+ },
+ {
+ BaseEventAction: dataprovider.BaseEventAction{
+ Name: action3.Name,
+ },
+ Order: 3,
+ Options: dataprovider.EventActionOptions{
+ IsFailureAction: true,
+ StopOnFailure: true,
+ },
+ },
+ },
+ }
+ rule, _, err := httpdtest.AddEventRule(r, http.StatusCreated)
+ assert.NoError(t, err)
+ // restrict command execution
+ dataprovider.EnabledActionCommands = nil
+
+ lastReceivedEmail.reset()
+ // create a folder to trigger the rule
+ folder := vfs.BaseVirtualFolder{
+ Name: "ftest failed command",
+ MappedPath: filepath.Join(os.TempDir(), "p"),
+ }
+ folder, _, err = httpdtest.AddFolder(folder, http.StatusCreated)
+ assert.NoError(t, err)
+
+ assert.NoFileExists(t, outPath)
+ assert.Eventually(t, func() bool {
+ return lastReceivedEmail.get().From != ""
+ }, 3000*time.Millisecond, 100*time.Millisecond)
+ email := lastReceivedEmail.get()
+ assert.Len(t, email.To, 1)
+ assert.True(t, slices.Contains(email.To, "failure@example.com"))
+ assert.Contains(t, email.Data, `Subject: Failed "add" from "admin"`)
+ assert.Contains(t, email.Data, fmt.Sprintf("Object name: %s object type: folder", folder.Name))
+ lastReceivedEmail.reset()
+
+ _, err = httpdtest.RemoveFolder(folder, http.StatusOK)
+ assert.NoError(t, err)
+
+ _, err = httpdtest.RemoveEventRule(rule, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveEventAction(action2, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveEventAction(action3, http.StatusOK)
+ assert.NoError(t, err)
+}
+
+func TestEventRuleProviderEvents(t *testing.T) {
+ if runtime.GOOS == osWindows {
+ t.Skip("this test is not available on Windows")
+ }
+ smtpCfg := smtp.Config{
+ Host: "127.0.0.1",
+ Port: 2525,
+ From: "notification@example.com",
+ TemplatesPath: "templates",
+ }
+ err := smtpCfg.Initialize(configDir, true)
+ require.NoError(t, err)
+
+ saveObjectScriptPath := filepath.Join(os.TempDir(), "provider.sh")
+ outPath := filepath.Join(os.TempDir(), "provider_out.json")
+ err = os.WriteFile(saveObjectScriptPath, getSaveProviderObjectScriptContent(outPath, 0), 0755)
+ assert.NoError(t, err)
+
+ dataprovider.EnabledActionCommands = []string{saveObjectScriptPath}
+ defer func() {
+ dataprovider.EnabledActionCommands = nil
+ }()
+
+ a1 := dataprovider.BaseEventAction{
+ Name: "a1",
+ Type: dataprovider.ActionTypeCommand,
+ Options: dataprovider.BaseEventActionOptions{
+ CmdConfig: dataprovider.EventActionCommandConfig{
+ Cmd: saveObjectScriptPath,
+ Timeout: 10,
+ EnvVars: []dataprovider.KeyValue{
+ {
+ Key: "SFTPGO_OBJECT_DATA",
+ Value: "{{.ObjectData}}",
+ },
+ },
+ },
+ },
+ }
+ a2 := dataprovider.BaseEventAction{
+ Name: "a2",
+ Type: dataprovider.ActionTypeEmail,
+ Options: dataprovider.BaseEventActionOptions{
+ EmailConfig: dataprovider.EventActionEmailConfig{
+ Recipients: []string{"test3@example.com"},
+ Subject: `New "{{.Event}}" from "{{.Name}}"`,
+ Body: "Object name: {{.ObjectName}} object type: {{.ObjectType}} Data: {{.ObjectData}}",
+ },
+ },
+ }
+
+ a3 := dataprovider.BaseEventAction{
+ Name: "a3",
+ Type: dataprovider.ActionTypeEmail,
+ Options: dataprovider.BaseEventActionOptions{
+ EmailConfig: dataprovider.EventActionEmailConfig{
+ Recipients: []string{"failure@example.com"},
+ Subject: `Failed "{{.Event}}" from "{{.Name}}"`,
+ Body: "Object name: {{.ObjectName}} object type: {{.ObjectType}}, IP: {{.IP}}",
},
},
}
@@ -4236,7 +4494,7 @@ func TestEventRuleProviderEvents(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "test3@example.com"))
+ assert.True(t, slices.Contains(email.To, "test3@example.com"))
assert.Contains(t, email.Data, `Subject: New "update" from "admin"`)
}
// now delete the script to generate an error
@@ -4251,7 +4509,7 @@ func TestEventRuleProviderEvents(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "failure@example.com"))
+ assert.True(t, slices.Contains(email.To, "failure@example.com"))
assert.Contains(t, email.Data, `Subject: Failed "update" from "admin"`)
assert.Contains(t, email.Data, fmt.Sprintf("Object name: %s object type: folder", folder.Name))
lastReceivedEmail.reset()
@@ -4300,10 +4558,12 @@ func TestEventRuleFsActions(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
FsConfig: dataprovider.EventActionFilesystemConfig{
Type: dataprovider.FilesystemActionRename,
- Renames: []dataprovider.KeyValue{
+ Renames: []dataprovider.RenameConfig{
{
- Key: "/{{VirtualDirPath}}/{{ObjectName}}",
- Value: "/{{ObjectName}}_renamed",
+ KeyValue: dataprovider.KeyValue{
+ Key: "/{{.VirtualDirPath}}/{{.ObjectName}}",
+ Value: "/{{.ObjectName}}_renamed",
+ },
},
},
},
@@ -4315,7 +4575,7 @@ func TestEventRuleFsActions(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
FsConfig: dataprovider.EventActionFilesystemConfig{
Type: dataprovider.FilesystemActionDelete,
- Deletes: []string{"/{{ObjectName}}_renamed"},
+ Deletes: []string{"/{{.ObjectName}}_renamed"},
},
},
}
@@ -4333,7 +4593,7 @@ func TestEventRuleFsActions(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
FsConfig: dataprovider.EventActionFilesystemConfig{
Type: dataprovider.FilesystemActionExist,
- Exist: []string{"/{{VirtualPath}}"},
+ Exist: []string{"/{{.VirtualPath}}"},
},
},
}
@@ -4561,6 +4821,80 @@ func TestEventRuleFsActions(t *testing.T) {
assert.NoError(t, err)
}
+func TestEventActionObjectBaseName(t *testing.T) {
+ a1 := dataprovider.BaseEventAction{
+ Name: "a1",
+ Type: dataprovider.ActionTypeFilesystem,
+ Options: dataprovider.BaseEventActionOptions{
+ FsConfig: dataprovider.EventActionFilesystemConfig{
+ Type: dataprovider.FilesystemActionRename,
+ Renames: []dataprovider.RenameConfig{
+ {
+ KeyValue: dataprovider.KeyValue{
+ Key: "/{{.VirtualDirPath}}/{{.ObjectName}}",
+ Value: "/{{.ObjectBaseName}}",
+ },
+ },
+ },
+ },
+ },
+ }
+ action1, resp, err := httpdtest.AddEventAction(a1, http.StatusCreated)
+ assert.NoError(t, err, string(resp))
+
+ r1 := dataprovider.EventRule{
+ Name: "r2",
+ Status: 1,
+ Trigger: dataprovider.EventTriggerFsEvent,
+ Conditions: dataprovider.EventConditions{
+ FsEvents: []string{"upload"},
+ },
+ Actions: []dataprovider.EventAction{
+ {
+ BaseEventAction: dataprovider.BaseEventAction{
+ Name: action1.Name,
+ },
+ Order: 1,
+ Options: dataprovider.EventActionOptions{
+ ExecuteSync: true,
+ },
+ },
+ },
+ }
+ rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated)
+ assert.NoError(t, err)
+
+ user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+ assert.NoError(t, err)
+ conn, client, err := getSftpClient(user)
+ if assert.NoError(t, err) {
+ defer conn.Close()
+ defer client.Close()
+
+ testDir := "test dir name"
+ err = client.Mkdir(testDir)
+ fileSize := int64(32768)
+ assert.NoError(t, err)
+ err = writeSFTPFileNoCheck(path.Join(testDir, testFileName), fileSize, client)
+ assert.NoError(t, err)
+
+ _, err = client.Stat(path.Join(testDir, testFileName))
+ assert.ErrorIs(t, err, os.ErrNotExist)
+
+ _, err = client.Stat(strings.TrimSuffix(testFileName, path.Ext(testFileName)))
+ assert.NoError(t, err)
+ }
+
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+ err = os.RemoveAll(user.GetHomeDir())
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
+ assert.NoError(t, err)
+}
+
func TestUploadEventRule(t *testing.T) {
smtpCfg := smtp.Config{
Host: "127.0.0.1",
@@ -4577,8 +4911,8 @@ func TestUploadEventRule(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"test1@example.com"},
- Subject: `New "{{Event}}" from "{{Name}}" status {{StatusString}}`,
- Body: "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}} Data: {{ObjectData}} {{ErrorString}}",
+ Subject: `New "{{.Event}}" from "{{.Name}}" status {{.StatusString}}`,
+ Body: "Fs path {{.FsPath}}, size: {{.FileSize}}, protocol: {{.Protocol}}, IP: {{.IP}} Data: {{.ObjectData}} {{.ErrorString}}",
},
},
}
@@ -4710,10 +5044,13 @@ func TestEventRulePreDelete(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
FsConfig: dataprovider.EventActionFilesystemConfig{
Type: dataprovider.FilesystemActionRename,
- Renames: []dataprovider.KeyValue{
+ Renames: []dataprovider.RenameConfig{
{
- Key: "/{{VirtualPath}}",
- Value: fmt.Sprintf("/%s/{{VirtualPath}}", movePath),
+ KeyValue: dataprovider.KeyValue{
+ Key: "/{{.VirtualPath}}",
+ Value: fmt.Sprintf("/%s/{{.VirtualPath}}", movePath),
+ },
+ UpdateModTime: true,
},
},
},
@@ -4767,59 +5104,83 @@ func TestEventRulePreDelete(t *testing.T) {
QuotaFiles: 1000,
},
}
- user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+ localUser, _, err := httpdtest.AddUser(u, http.StatusCreated)
+ assert.NoError(t, err)
+ u = getTestSFTPUser()
+ u.QuotaFiles = 1000
+ sftpUser, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
- conn, client, err := getSftpClient(user)
- if assert.NoError(t, err) {
- defer conn.Close()
- defer client.Close()
- testDir := "sub dir"
- err = client.MkdirAll(testDir)
- assert.NoError(t, err)
- err = writeSFTPFile(testFileName, 100, client)
- assert.NoError(t, err)
- err = writeSFTPFile(path.Join(testDir, testFileName), 100, client)
- assert.NoError(t, err)
- err = client.Remove(testFileName)
- assert.NoError(t, err)
- err = client.Remove(path.Join(testDir, testFileName))
- assert.NoError(t, err)
- // check files
- _, err = client.Stat(testFileName)
- assert.ErrorIs(t, err, os.ErrNotExist)
- _, err = client.Stat(path.Join(testDir, testFileName))
- assert.ErrorIs(t, err, os.ErrNotExist)
- _, err = client.Stat(path.Join("/", movePath, testFileName))
- assert.NoError(t, err)
- _, err = client.Stat(path.Join("/", movePath, testDir, testFileName))
- assert.NoError(t, err)
- // check quota
- user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
- assert.NoError(t, err)
- assert.Equal(t, 0, user.UsedQuotaFiles)
- assert.Equal(t, int64(0), user.UsedQuotaSize)
- folder, _, err := httpdtest.GetFolderByName(movePath, http.StatusOK)
- assert.NoError(t, err)
- assert.Equal(t, 2, folder.UsedQuotaFiles)
- assert.Equal(t, int64(200), folder.UsedQuotaSize)
- // pre-delete action is not executed in movePath
- err = client.Remove(path.Join("/", movePath, testFileName))
- assert.NoError(t, err)
- // check quota
- folder, _, err = httpdtest.GetFolderByName(movePath, http.StatusOK)
- assert.NoError(t, err)
- assert.Equal(t, 1, folder.UsedQuotaFiles)
- assert.Equal(t, int64(100), folder.UsedQuotaSize)
+ for _, user := range []dataprovider.User{localUser, sftpUser} {
+ conn, client, err := getSftpClient(user)
+ if assert.NoError(t, err) {
+ defer conn.Close()
+ defer client.Close()
+
+ testDir := "sub dir"
+ err = client.MkdirAll(testDir)
+ assert.NoError(t, err)
+ err = writeSFTPFile(testFileName, 100, client)
+ assert.NoError(t, err)
+ err = writeSFTPFile(path.Join(testDir, testFileName), 100, client)
+ assert.NoError(t, err)
+ modTime := time.Now().Add(-36 * time.Hour)
+ err = client.Chtimes(testFileName, modTime, modTime)
+ assert.NoError(t, err)
+ err = client.Remove(testFileName)
+ assert.NoError(t, err)
+ err = client.Remove(path.Join(testDir, testFileName))
+ assert.NoError(t, err)
+ // check files
+ _, err = client.Stat(testFileName)
+ assert.ErrorIs(t, err, os.ErrNotExist)
+ _, err = client.Stat(path.Join(testDir, testFileName))
+ assert.ErrorIs(t, err, os.ErrNotExist)
+ info, err := client.Stat(path.Join("/", movePath, testFileName))
+ assert.NoError(t, err)
+ diff := math.Abs(time.Until(info.ModTime()).Seconds())
+ assert.LessOrEqual(t, diff, float64(2))
+
+ _, err = client.Stat(path.Join("/", movePath, testDir, testFileName))
+ assert.NoError(t, err)
+ // check quota
+ user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+ assert.NoError(t, err)
+ if user.Username == localUser.Username {
+ assert.Equal(t, 0, user.UsedQuotaFiles)
+ assert.Equal(t, int64(0), user.UsedQuotaSize)
+ folder, _, err := httpdtest.GetFolderByName(movePath, http.StatusOK)
+ assert.NoError(t, err)
+ assert.Equal(t, 2, folder.UsedQuotaFiles)
+ assert.Equal(t, int64(200), folder.UsedQuotaSize)
+ } else {
+ assert.Equal(t, 1, user.UsedQuotaFiles)
+ assert.Equal(t, int64(100), user.UsedQuotaSize)
+ }
+ // pre-delete action is not executed in movePath
+ err = client.Remove(path.Join("/", movePath, testFileName))
+ assert.NoError(t, err)
+ if user.Username == localUser.Username {
+ // check quota
+ folder, _, err := httpdtest.GetFolderByName(movePath, http.StatusOK)
+ assert.NoError(t, err)
+ assert.Equal(t, 1, folder.UsedQuotaFiles)
+ assert.Equal(t, int64(100), folder.UsedQuotaSize)
+ err = os.RemoveAll(user.GetHomeDir())
+ assert.NoError(t, err)
+ }
+ }
}
_, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
assert.NoError(t, err)
_, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
assert.NoError(t, err)
- _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ _, err = httpdtest.RemoveUser(sftpUser, http.StatusOK)
assert.NoError(t, err)
- err = os.RemoveAll(user.GetHomeDir())
+ _, err = httpdtest.RemoveUser(localUser, http.StatusOK)
+ assert.NoError(t, err)
+ err = os.RemoveAll(localUser.GetHomeDir())
assert.NoError(t, err)
_, err = httpdtest.RemoveFolder(vfs.BaseVirtualFolder{Name: movePath}, http.StatusOK)
assert.NoError(t, err)
@@ -4847,10 +5208,12 @@ func TestEventRulePreDownloadUpload(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
FsConfig: dataprovider.EventActionFilesystemConfig{
Type: dataprovider.FilesystemActionRename,
- Renames: []dataprovider.KeyValue{
+ Renames: []dataprovider.RenameConfig{
{
- Key: "/missing source",
- Value: "/missing target",
+ KeyValue: dataprovider.KeyValue{
+ Key: "/missing source",
+ Value: "/missing target",
+ },
},
},
},
@@ -4954,6 +5317,11 @@ func TestEventActionCommandEnvVars(t *testing.T) {
envName := "MY_ENV"
uploadScriptPath := filepath.Join(os.TempDir(), "upload.sh")
+ dataprovider.EnabledActionCommands = []string{uploadScriptPath}
+ defer func() {
+ dataprovider.EnabledActionCommands = nil
+ }()
+
err := os.WriteFile(uploadScriptPath, getUploadScriptEnvContent(envName), 0755)
assert.NoError(t, err)
a1 := dataprovider.BaseEventAction{
@@ -5034,6 +5402,7 @@ func TestEventActionCommandEnvVars(t *testing.T) {
}
func TestFsActionCopy(t *testing.T) {
+ dirCopy := "/dircopy"
a1 := dataprovider.BaseEventAction{
Name: "a1",
Type: dataprovider.ActionTypeFilesystem,
@@ -5042,8 +5411,8 @@ func TestFsActionCopy(t *testing.T) {
Type: dataprovider.FilesystemActionCopy,
Copy: []dataprovider.KeyValue{
{
- Key: "/{{VirtualPath}}/",
- Value: "/dircopy/",
+ Key: "/{{.VirtualPath}}/",
+ Value: dirCopy + "/",
},
},
},
@@ -5073,7 +5442,29 @@ func TestFsActionCopy(t *testing.T) {
}
rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated)
assert.NoError(t, err)
+ g1 := dataprovider.Group{
+ BaseGroup: sdk.BaseGroup{
+ Name: "group1",
+ },
+ UserSettings: dataprovider.GroupUserSettings{
+ BaseGroupUserSettings: sdk.BaseGroupUserSettings{
+ Permissions: map[string][]string{
+ // Restrict permissions in copyPath to check that action
+ // will have full permissions anyway.
+ dirCopy: {dataprovider.PermListItems, dataprovider.PermDelete},
+ },
+ },
+ },
+ }
+ group1, resp, err := httpdtest.AddGroup(g1, http.StatusCreated)
+ assert.NoError(t, err, string(resp))
u := getTestUser()
+ u.Groups = []sdk.GroupMapping{
+ {
+ Name: group1.Name,
+ Type: sdk.GroupTypePrimary,
+ },
+ }
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
conn, client, err := getSftpClient(user)
@@ -5083,7 +5474,7 @@ func TestFsActionCopy(t *testing.T) {
err = writeSFTPFile(testFileName, 100, client)
assert.NoError(t, err)
- _, err = client.Stat(path.Join("dircopy", testFileName))
+ _, err = client.Stat(path.Join(dirCopy, testFileName))
assert.NoError(t, err)
action1.Options.FsConfig.Copy = []dataprovider.KeyValue{
@@ -5106,6 +5497,8 @@ func TestFsActionCopy(t *testing.T) {
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
+ _, err = httpdtest.RemoveGroup(group1, http.StatusOK)
+ assert.NoError(t, err)
}
func TestEventFsActionsGroupFilters(t *testing.T) {
@@ -5124,8 +5517,8 @@ func TestEventFsActionsGroupFilters(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"example@example.net"},
- Subject: `New "{{Event}}" from "{{Name}}" status {{StatusString}}`,
- Body: "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}} {{ErrorString}}",
+ Subject: `New "{{.Event}}" from "{{.Name}}" status {{.StatusString}}`,
+ Body: "Fs path {{.FsPath}}, size: {{.FileSize}}, protocol: {{.Protocol}}, IP: {{.IP}} {{.ErrorString}}",
},
},
}
@@ -5239,7 +5632,7 @@ func TestEventFsActionsGroupFilters(t *testing.T) {
require.NoError(t, err)
}
-func TestBackupAsAttachment(t *testing.T) {
+func TestEventProviderActionGroupFilters(t *testing.T) {
smtpCfg := smtp.Config{
Host: "127.0.0.1",
Port: 2525,
@@ -5251,6 +5644,138 @@ func TestBackupAsAttachment(t *testing.T) {
a1 := dataprovider.BaseEventAction{
Name: "a1",
+ Type: dataprovider.ActionTypeEmail,
+ Options: dataprovider.BaseEventActionOptions{
+ EmailConfig: dataprovider.EventActionEmailConfig{
+ Recipients: []string{"example@example.net"},
+ Subject: `New "{{.Event}}" from "{{.Name}}"`,
+ Body: "IP: {{.IP}}",
+ },
+ },
+ }
+ action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated)
+ assert.NoError(t, err)
+
+ r1 := dataprovider.EventRule{
+ Name: "rule1",
+ Status: 1,
+ Trigger: dataprovider.EventTriggerProviderEvent,
+ Conditions: dataprovider.EventConditions{
+ ProviderEvents: []string{"add", "update"},
+ Options: dataprovider.ConditionOptions{
+ GroupNames: []dataprovider.ConditionPattern{
+ {
+ Pattern: "group_*",
+ },
+ },
+ ProviderObjects: []string{"user"},
+ },
+ },
+ Actions: []dataprovider.EventAction{
+ {
+ BaseEventAction: dataprovider.BaseEventAction{
+ Name: action1.Name,
+ },
+ Order: 1,
+ },
+ },
+ }
+ rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated)
+ assert.NoError(t, err)
+
+ g1 := dataprovider.Group{
+ BaseGroup: sdk.BaseGroup{
+ Name: "agroup_1",
+ },
+ }
+ group1, _, err := httpdtest.AddGroup(g1, http.StatusCreated)
+ assert.NoError(t, err)
+
+ g2 := dataprovider.Group{
+ BaseGroup: sdk.BaseGroup{
+ Name: "group_2",
+ },
+ }
+ group2, _, err := httpdtest.AddGroup(g2, http.StatusCreated)
+ assert.NoError(t, err)
+
+ u := getTestUser()
+ u.Groups = []sdk.GroupMapping{
+ {
+ Name: group2.Name,
+ Type: sdk.GroupTypePrimary,
+ },
+ }
+
+ lastReceivedEmail.reset()
+ user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+ assert.NoError(t, err)
+ assert.Eventually(t, func() bool {
+ return lastReceivedEmail.get().From != ""
+ }, 1500*time.Millisecond, 100*time.Millisecond)
+ email := lastReceivedEmail.get()
+ assert.Len(t, email.To, 1)
+
+ user.Groups = []sdk.GroupMapping{
+ {
+ Name: group1.Name,
+ Type: sdk.GroupTypePrimary,
+ },
+ }
+
+ lastReceivedEmail.reset()
+ user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+ assert.NoError(t, err)
+ time.Sleep(300 * time.Millisecond)
+ email = lastReceivedEmail.get()
+ assert.Len(t, email.To, 0)
+
+ user.Groups = []sdk.GroupMapping{
+ {
+ Name: group2.Name,
+ Type: sdk.GroupTypePrimary,
+ },
+ }
+
+ lastReceivedEmail.reset()
+ user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+ assert.NoError(t, err)
+ assert.Eventually(t, func() bool {
+ return lastReceivedEmail.get().From != ""
+ }, 1500*time.Millisecond, 100*time.Millisecond)
+ email = lastReceivedEmail.get()
+ assert.Len(t, email.To, 1)
+
+ _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+ err = os.RemoveAll(user.GetHomeDir())
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveGroup(group1, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveGroup(group2, http.StatusOK)
+ assert.NoError(t, err)
+
+ smtpCfg = smtp.Config{}
+ err = smtpCfg.Initialize(configDir, true)
+ require.NoError(t, err)
+}
+
+func TestBackupAsAttachment(t *testing.T) {
+ smtpCfg := smtp.Config{
+ Host: "127.0.0.1",
+ Port: 2525,
+ From: "notification@example.com",
+ TemplatesPath: "templates",
+ }
+ err := smtpCfg.Initialize(configDir, true)
+ require.NoError(t, err)
+
+ a1 := dataprovider.BaseEventAction{
+ Name: "a1 with space",
Type: dataprovider.ActionTypeBackup,
}
a2 := dataprovider.BaseEventAction{
@@ -5259,9 +5784,9 @@ func TestBackupAsAttachment(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"test@example.com"},
- Subject: `"{{Event}} {{StatusString}}"`,
- Body: "Domain: {{Name}}",
- Attachments: []string{"/{{VirtualPath}}"},
+ Subject: `"{{.Event}} {{.StatusString}}"`,
+ Body: "Domain: {{.Name}}",
+ Attachments: []string{"/{{.VirtualPath}}"},
},
},
}
@@ -5297,7 +5822,7 @@ func TestBackupAsAttachment(t *testing.T) {
common.HandleCertificateEvent(common.EventParams{
Name: "example.com",
- Timestamp: time.Now().UnixNano(),
+ Timestamp: time.Now(),
Status: 1,
Event: renewalEvent,
})
@@ -5306,7 +5831,7 @@ func TestBackupAsAttachment(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "test@example.com"))
+ assert.True(t, slices.Contains(email.To, "test@example.com"))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "%s OK"`, renewalEvent))
assert.Contains(t, email.Data, `Domain: example.com`)
assert.Contains(t, email.Data, "Content-Type: application/json")
@@ -5340,11 +5865,11 @@ func TestEventActionHTTPMultipart(t *testing.T) {
Value: "application/json",
},
},
- Body: `{"FilePath": "{{VirtualPath}}"}`,
+ Body: `{"FilePath": "{{.VirtualPath}}"}`,
},
{
Name: "file",
- Filepath: "/{{VirtualPath}}",
+ Filepath: "/{{.VirtualPath}}",
},
},
},
@@ -5420,8 +5945,8 @@ func TestEventActionCompress(t *testing.T) {
FsConfig: dataprovider.EventActionFilesystemConfig{
Type: dataprovider.FilesystemActionCompress,
Compress: dataprovider.EventActionFsCompress{
- Name: "/{{VirtualPath}}.zip",
- Paths: []string{"/{{VirtualPath}}"},
+ Name: "/{{.VirtualPath}}.zip",
+ Paths: []string{"/{{.VirtualPath}}"},
},
},
},
@@ -5591,7 +6116,7 @@ func TestEventActionCompressQuotaErrors(t *testing.T) {
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"test@example.com"},
Subject: `"Compress failed"`,
- Body: "Error: {{ErrorString}}",
+ Body: "Error: {{.ErrorString}}",
},
},
}
@@ -5676,7 +6201,7 @@ func TestEventActionCompressQuotaErrors(t *testing.T) {
}, 3*time.Second, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "test@example.com"))
+ assert.True(t, slices.Contains(email.To, "test@example.com"))
assert.Contains(t, email.Data, `Subject: "Compress failed"`)
assert.Contains(t, email.Data, common.ErrQuotaExceeded.Error())
// update quota size so the user is already overquota
@@ -5691,7 +6216,7 @@ func TestEventActionCompressQuotaErrors(t *testing.T) {
}, 3*time.Second, 100*time.Millisecond)
email = lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "test@example.com"))
+ assert.True(t, slices.Contains(email.To, "test@example.com"))
assert.Contains(t, email.Data, `Subject: "Compress failed"`)
assert.Contains(t, email.Data, common.ErrQuotaExceeded.Error())
// remove the path to compress to trigger an error for size estimation
@@ -5705,7 +6230,7 @@ func TestEventActionCompressQuotaErrors(t *testing.T) {
}, 3*time.Second, 100*time.Millisecond)
email = lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "test@example.com"))
+ assert.True(t, slices.Contains(email.To, "test@example.com"))
assert.Contains(t, email.Data, `Subject: "Compress failed"`)
assert.Contains(t, email.Data, "unable to estimate archive size")
}
@@ -5735,8 +6260,8 @@ func TestEventActionCompressQuotaFolder(t *testing.T) {
FsConfig: dataprovider.EventActionFilesystemConfig{
Type: dataprovider.FilesystemActionCompress,
Compress: dataprovider.EventActionFsCompress{
- Name: "/{{VirtualPath}}.zip",
- Paths: []string{"/{{VirtualPath}}", testDir},
+ Name: "/{{.VirtualPath}}.zip",
+ Paths: []string{"/{{.VirtualPath}}", testDir},
},
},
},
@@ -5834,8 +6359,8 @@ func TestEventActionCompressQuotaFolder(t *testing.T) {
assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize)
vfolder, _, err := httpdtest.GetFolderByName(folderName, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, 2, vfolder.UsedQuotaFiles)
- assert.Equal(t, info.Size()+int64(len(testFileContent)), vfolder.UsedQuotaSize)
+ assert.Equal(t, 0, vfolder.UsedQuotaFiles)
+ assert.Equal(t, int64(0), vfolder.UsedQuotaSize)
}
}
@@ -5861,8 +6386,8 @@ func TestEventActionCompressErrors(t *testing.T) {
FsConfig: dataprovider.EventActionFilesystemConfig{
Type: dataprovider.FilesystemActionCompress,
Compress: dataprovider.EventActionFsCompress{
- Name: "/{{VirtualPath}}.zip",
- Paths: []string{"/{{VirtualPath}}.zip"}, // cannot compress itself
+ Name: "/{{.VirtualPath}}.zip",
+ Paths: []string{"/{{.VirtualPath}}.zip"}, // cannot compress itself
},
},
},
@@ -5924,7 +6449,7 @@ func TestEventActionCompressErrors(t *testing.T) {
// try to overwrite a directory
testDir := "/adir"
action1.Options.FsConfig.Compress.Name = testDir
- action1.Options.FsConfig.Compress.Paths = []string{"/{{VirtualPath}}"}
+ action1.Options.FsConfig.Compress.Paths = []string{"/{{.VirtualPath}}"}
_, _, err = httpdtest.UpdateEventAction(action1, http.StatusOK)
assert.NoError(t, err)
conn, client, err = getSftpClient(user)
@@ -5969,8 +6494,8 @@ func TestEventActionEmailAttachments(t *testing.T) {
FsConfig: dataprovider.EventActionFilesystemConfig{
Type: dataprovider.FilesystemActionCompress,
Compress: dataprovider.EventActionFsCompress{
- Name: "/archive/{{VirtualPath}}.zip",
- Paths: []string{"/{{VirtualPath}}"},
+ Name: "/archive/{{.VirtualPath}}.zip",
+ Paths: []string{"/{{.VirtualPath}}"},
},
},
},
@@ -5983,9 +6508,9 @@ func TestEventActionEmailAttachments(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"test@example.com"},
- Subject: `"{{Event}}" from "{{Name}}"`,
- Body: "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}}",
- Attachments: []string{"/archive/{{VirtualPath}}.zip"},
+ Subject: `"{{.Event}}" from "{{.Name}}"`,
+ Body: "Fs path {{.FsPath}}, size: {{.FileSize}}, protocol: {{.Protocol}}, IP: {{.IP}} {{.EscapedVirtualPath}}",
+ Attachments: []string{"/archive/{{.VirtualPath}}.zip"},
},
},
}
@@ -6041,8 +6566,9 @@ func TestEventActionEmailAttachments(t *testing.T) {
}, 1500*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "test@example.com"))
+ assert.True(t, slices.Contains(email.To, "test@example.com"))
assert.Contains(t, email.Data, `Subject: "upload" from`)
+ assert.Contains(t, email.Data, url.QueryEscape("/"+testFileName))
assert.Contains(t, email.Data, "Content-Disposition: attachment")
}
}
@@ -6103,8 +6629,8 @@ func TestEventActionsRetentionReports(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"test@example.com"},
- Subject: `"{{Event}}" from "{{Name}}"`,
- Body: "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}}",
+ Subject: `"{{.Event}}" from "{{.Name}}"`,
+ Body: "Fs path {{.FsPath}}, size: {{.FileSize}}, protocol: {{.Protocol}}, IP: {{.IP}}",
Attachments: []string{dataprovider.RetentionReportPlaceHolder},
},
},
@@ -6218,7 +6744,7 @@ func TestEventActionsRetentionReports(t *testing.T) {
email := lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "test@example.com"))
+ assert.True(t, slices.Contains(email.To, "test@example.com"))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "upload" from "%s"`, user.Username))
assert.Contains(t, email.Data, "Content-Disposition: attachment")
_, err = client.Stat(testDir)
@@ -6331,8 +6857,8 @@ func TestEventRuleFirstUploadDownloadActions(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"test@example.com"},
- Subject: `"{{Event}}" from "{{Name}}"`,
- Body: "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}}",
+ Subject: `"{{.Event}}" from "{{.Name}}"`,
+ Body: "Fs path {{.FsPath}}, size: {{.FileSize}}, protocol: {{.Protocol}}, IP: {{.IP}}",
},
},
}
@@ -6391,7 +6917,7 @@ func TestEventRuleFirstUploadDownloadActions(t *testing.T) {
}, 1500*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "test@example.com"))
+ assert.True(t, slices.Contains(email.To, "test@example.com"))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "first-upload" from "%s"`, user.Username))
lastReceivedEmail.reset()
// a new upload will not produce a new notification
@@ -6414,7 +6940,7 @@ func TestEventRuleFirstUploadDownloadActions(t *testing.T) {
}, 1500*time.Millisecond, 100*time.Millisecond)
email = lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "test@example.com"))
+ assert.True(t, slices.Contains(email.To, "test@example.com"))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "first-download" from "%s"`, user.Username))
// download again
lastReceivedEmail.reset()
@@ -6463,9 +6989,9 @@ func TestEventRuleRenameEvent(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"test@example.com"},
- Subject: `"{{Event}}" from "{{Name}}"`,
+ Subject: `"{{.Event}}" from "{{.Name}}"`,
ContentType: 1,
- Body: `
Fs path {{FsPath}}, Target path "{{VirtualTargetDirPath}}/{{TargetName}}", size: {{FileSize}}
`,
+ Body: `Fs path {{.FsPath}}, Name: {{.Name}}, Target path "{{.VirtualTargetDirPath}}/{{.TargetName}}", size: {{.FileSize}}
`,
},
},
}
@@ -6490,7 +7016,9 @@ func TestEventRuleRenameEvent(t *testing.T) {
rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated)
assert.NoError(t, err)
- user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+ u := getTestUser()
+ u.Username = "test & chars"
+ user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
conn, client, err := getSftpClient(user)
if assert.NoError(t, err) {
@@ -6510,10 +7038,11 @@ func TestEventRuleRenameEvent(t *testing.T) {
}, 1500*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "test@example.com"))
+ assert.True(t, slices.Contains(email.To, "test@example.com"))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "rename" from "%s"`, user.Username))
assert.Contains(t, email.Data, "Content-Type: text/html")
assert.Contains(t, email.Data, fmt.Sprintf("Target path %q", path.Join("/subdir", testFileName)))
+ assert.Contains(t, email.Data, "Name: test & chars,")
}
_, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
@@ -6541,12 +7070,12 @@ func TestEventRuleIDPLogin(t *testing.T) {
require.NoError(t, err)
lastReceivedEmail.reset()
- username := `test_"idp_"login`
+ username := `test_'idp_'login`
custom1 := `cust"oa"1`
u := map[string]any{
- "username": "{{Name}}",
+ "username": "{{.Name}}",
"status": 1,
- "home_dir": filepath.Join(os.TempDir(), "{{IDPFieldcustom1}}"),
+ "home_dir": filepath.Join(os.TempDir(), "{{.IDPFieldcustom1}}"),
"permissions": map[string][]string{
"/": {dataprovider.PermAny},
},
@@ -6554,7 +7083,7 @@ func TestEventRuleIDPLogin(t *testing.T) {
userTmpl, err := json.Marshal(u)
require.NoError(t, err)
a := map[string]any{
- "username": "{{Name}}",
+ "username": "{{.Name}}",
"status": 1,
"permissions": []string{dataprovider.PermAdminAny},
}
@@ -6580,8 +7109,8 @@ func TestEventRuleIDPLogin(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"test@example.com"},
- Subject: `"{{Event}} {{StatusString}}"`,
- Body: "{{Name}} Custom field: {{IDPFieldcustom1}}",
+ Subject: `"{{.Event}} {{.StatusString}}"`,
+ Body: "{{.Name}} Custom field: {{.IDPFieldcustom1}}",
},
},
}
@@ -6644,7 +7173,7 @@ func TestEventRuleIDPLogin(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "test@example.com"))
+ assert.True(t, slices.Contains(email.To, "test@example.com"))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "%s OK"`, common.IDPLoginUser))
assert.Contains(t, email.Data, username)
assert.Contains(t, email.Data, custom1)
@@ -6708,7 +7237,7 @@ func TestEventRuleIDPLogin(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email = lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "test@example.com"))
+ assert.True(t, slices.Contains(email.To, "test@example.com"))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "%s OK"`, common.IDPLoginAdmin))
assert.Contains(t, email.Data, username)
assert.Contains(t, email.Data, custom1)
@@ -6826,8 +7355,8 @@ func TestEventRuleEmailField(t *testing.T) {
Type: dataprovider.ActionTypeEmail,
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
- Recipients: []string{"{{Email}}"},
- Subject: `"{{Event}}" from "{{Name}}"`,
+ Recipients: []string{"{{.Email}}"},
+ Subject: `"{{.Event}}" from "{{.Name}}"`,
Body: "Sample email body",
},
},
@@ -6841,7 +7370,7 @@ func TestEventRuleEmailField(t *testing.T) {
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"failure@example.com"},
Subject: `"Failure`,
- Body: "{{ErrorString}}",
+ Body: "{{.ErrorString}}",
},
},
}
@@ -6868,6 +7397,9 @@ func TestEventRuleEmailField(t *testing.T) {
Trigger: dataprovider.EventTriggerProviderEvent,
Conditions: dataprovider.EventConditions{
ProviderEvents: []string{"add"},
+ Options: dataprovider.ConditionOptions{
+ ProviderObjects: []string{"user"},
+ },
},
Actions: []dataprovider.EventAction{
{
@@ -6900,7 +7432,7 @@ func TestEventRuleEmailField(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, user.Email))
+ assert.True(t, slices.Contains(email.To, user.Email))
assert.Contains(t, email.Data, `Subject: "add" from "admin"`)
// if we add a user without email the notification will fail
@@ -6914,7 +7446,7 @@ func TestEventRuleEmailField(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email = lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "failure@example.com"))
+ assert.True(t, slices.Contains(email.To, "failure@example.com"))
assert.Contains(t, email.Data, `no recipient addresses set`)
conn, client, err := getSftpClient(user)
@@ -6931,7 +7463,7 @@ func TestEventRuleEmailField(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, user.Email))
+ assert.True(t, slices.Contains(email.To, user.Email))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "mkdir" from "%s"`, user.Username))
}
@@ -6972,9 +7504,9 @@ func TestEventRuleCertificate(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"test@example.com"},
- Subject: `"{{Event}} {{StatusString}}"`,
+ Subject: `"{{.Event}} {{.StatusString}}"`,
ContentType: 0,
- Body: "Domain: {{Name}} Timestamp: {{Timestamp}} {{ErrorString}}",
+ Body: "Domain: {{.Name}} Timestamp: {{.Timestamp}} {{.ErrorString}} Date time: {{.DateTime}}",
},
},
}
@@ -7029,7 +7561,7 @@ func TestEventRuleCertificate(t *testing.T) {
common.HandleCertificateEvent(common.EventParams{
Name: "example.com",
- Timestamp: time.Now().UnixNano(),
+ Timestamp: time.Now(),
Status: 1,
Event: renewalEvent,
})
@@ -7038,15 +7570,16 @@ func TestEventRuleCertificate(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "test@example.com"))
+ assert.True(t, slices.Contains(email.To, "test@example.com"))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "%s OK"`, renewalEvent))
assert.Contains(t, email.Data, "Content-Type: text/plain")
assert.Contains(t, email.Data, `Domain: example.com Timestamp`)
lastReceivedEmail.reset()
+ dateTime := time.Now()
params := common.EventParams{
Name: "example.com",
- Timestamp: time.Now().UnixNano(),
+ Timestamp: dateTime,
Status: 2,
Event: renewalEvent,
}
@@ -7058,9 +7591,10 @@ func TestEventRuleCertificate(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email = lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "test@example.com"))
+ assert.True(t, slices.Contains(email.To, "test@example.com"))
assert.Contains(t, email.Data, fmt.Sprintf(`Subject: "%s KO"`, renewalEvent))
assert.Contains(t, email.Data, `Domain: example.com Timestamp`)
+ assert.Contains(t, email.Data, dateTime.UTC().Format("2006-01-02T15:04:05.000"))
assert.Contains(t, email.Data, errRenew.Error())
_, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
@@ -7074,7 +7608,7 @@ func TestEventRuleCertificate(t *testing.T) {
// ignored no more certificate rules
common.HandleCertificateEvent(common.EventParams{
Name: "example.com",
- Timestamp: time.Now().UnixNano(),
+ Timestamp: time.Now(),
Status: 1,
Event: renewalEvent,
})
@@ -7110,8 +7644,8 @@ func TestEventRuleIPBlocked(t *testing.T) {
Options: dataprovider.BaseEventActionOptions{
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"test3@example.com", "test4@example.com"},
- Subject: `New "{{Event}}"`,
- Body: "IP: {{IP}} Timestamp: {{Timestamp}}",
+ Subject: `New "{{.Event}}"`,
+ Body: "IP: {{.IP}} Timestamp: {{.Timestamp}}",
},
},
}
@@ -7184,8 +7718,8 @@ func TestEventRuleIPBlocked(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 2)
- assert.True(t, util.Contains(email.To, "test3@example.com"))
- assert.True(t, util.Contains(email.To, "test4@example.com"))
+ assert.True(t, slices.Contains(email.To, "test3@example.com"))
+ assert.True(t, slices.Contains(email.To, "test4@example.com"))
assert.Contains(t, email.Data, `Subject: New "IP Blocked"`)
err = dataprovider.DeleteEventRule(rule1.Name, "", "", "")
@@ -7209,6 +7743,103 @@ func TestEventRuleIPBlocked(t *testing.T) {
assert.NoError(t, err)
}
+func TestEventRuleRotateLog(t *testing.T) {
+ smtpCfg := smtp.Config{
+ Host: "127.0.0.1",
+ Port: 2525,
+ From: "notification@example.com",
+ TemplatesPath: "templates",
+ }
+ err := smtpCfg.Initialize(configDir, true)
+ require.NoError(t, err)
+
+ user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+ assert.NoError(t, err)
+
+ a1 := dataprovider.BaseEventAction{
+ Name: "a1",
+ Type: dataprovider.ActionTypeRotateLogs,
+ }
+ action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated)
+ assert.NoError(t, err)
+ a2 := dataprovider.BaseEventAction{
+ Name: "a2",
+ Type: dataprovider.ActionTypeEmail,
+ Options: dataprovider.BaseEventActionOptions{
+ EmailConfig: dataprovider.EventActionEmailConfig{
+ Recipients: []string{"success@example.net"},
+ Subject: `OK`,
+ Body: "OK action",
+ },
+ },
+ }
+ action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated)
+ assert.NoError(t, err)
+
+ r1 := dataprovider.EventRule{
+ Name: "rule1",
+ Status: 1,
+ Trigger: dataprovider.EventTriggerFsEvent,
+ Conditions: dataprovider.EventConditions{
+ FsEvents: []string{"mkdir"},
+ Options: dataprovider.ConditionOptions{
+ Names: []dataprovider.ConditionPattern{
+ {
+ Pattern: user.Username,
+ },
+ },
+ },
+ },
+ Actions: []dataprovider.EventAction{
+ {
+ BaseEventAction: dataprovider.BaseEventAction{
+ Name: action1.Name,
+ },
+ Order: 1,
+ },
+ {
+ BaseEventAction: dataprovider.BaseEventAction{
+ Name: action2.Name,
+ },
+ Order: 2,
+ },
+ },
+ }
+ rule1, resp, err := httpdtest.AddEventRule(r1, http.StatusCreated)
+ assert.NoError(t, err, string(resp))
+ conn, client, err := getSftpClient(user)
+ if assert.NoError(t, err) {
+ defer conn.Close()
+ defer client.Close()
+
+ lastReceivedEmail.reset()
+ err := client.Mkdir("just a test dir")
+ assert.NoError(t, err)
+ // just check that the action is executed
+ assert.Eventually(t, func() bool {
+ return lastReceivedEmail.get().From != ""
+ }, 1500*time.Millisecond, 100*time.Millisecond)
+ email := lastReceivedEmail.get()
+ assert.Len(t, email.To, 1)
+ assert.Contains(t, email.To, "success@example.net")
+ }
+
+ _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveEventAction(action2, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+ err = os.RemoveAll(user.GetHomeDir())
+ assert.NoError(t, err)
+
+ smtpCfg = smtp.Config{}
+ err = smtpCfg.Initialize(configDir, true)
+ require.NoError(t, err)
+}
+
func TestEventRuleInactivityCheck(t *testing.T) {
smtpCfg := smtp.Config{
Host: "127.0.0.1",
@@ -7476,6 +8107,7 @@ func TestEventRulePasswordExpiration(t *testing.T) {
_, _, err = httpdtest.UpdateEventRule(rule1, http.StatusOK)
assert.NoError(t, err)
user.Email = "user@example.net"
+ user.Filters.AdditionalEmails = []string{"additional@example.net"}
_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
assert.NoError(t, err)
conn, client, err = getSftpClient(user)
@@ -7491,8 +8123,9 @@ func TestEventRulePasswordExpiration(t *testing.T) {
return lastReceivedEmail.get().From != ""
}, 1500*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
- assert.Len(t, email.To, 1)
+ assert.Len(t, email.To, 2)
assert.Contains(t, email.To, user.Email)
+ assert.Contains(t, email.To, user.Filters.AdditionalEmails[0])
assert.Contains(t, email.Data, "your SFTPGo password expires in 5 days")
err = client.RemoveDirectory(dirName)
assert.NoError(t, err)
@@ -7713,7 +8346,12 @@ func TestRetentionAPI(t *testing.T) {
DeleteEmptyDirs: true,
},
}
- _, err = httpdtest.StartRetentionCheck(user.Username, folderRetention, http.StatusAccepted)
+ check := common.RetentionCheck{
+ Folders: folderRetention,
+ }
+ c := common.RetentionChecks.Add(check, &user)
+ assert.NotNil(t, c)
+ err = c.Start()
assert.NoError(t, err)
assert.Eventually(t, func() bool {
@@ -7726,7 +8364,7 @@ func TestRetentionAPI(t *testing.T) {
err = client.Chtimes(uploadPath, time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour))
assert.NoError(t, err)
- _, err = httpdtest.StartRetentionCheck(user.Username, folderRetention, http.StatusAccepted)
+ err = c.Start()
assert.NoError(t, err)
assert.Eventually(t, func() bool {
@@ -7744,11 +8382,13 @@ func TestRetentionAPI(t *testing.T) {
err = writeSFTPFile(uploadPath, 32, client)
assert.NoError(t, err)
- folderRetention[0].DeleteEmptyDirs = false
+ check.Folders[0].DeleteEmptyDirs = false
err = client.Chtimes(uploadPath, time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour))
assert.NoError(t, err)
- _, err = httpdtest.StartRetentionCheck(user.Username, folderRetention, http.StatusAccepted)
+ c = common.RetentionChecks.Add(check, &user)
+ assert.NotNil(t, c)
+ err = c.Start()
assert.NoError(t, err)
assert.Eventually(t, func() bool {
@@ -7765,6 +8405,8 @@ func TestRetentionAPI(t *testing.T) {
assert.NoError(t, err)
err = client.Chtimes(uploadPath, time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour))
assert.NoError(t, err)
+ conn.Close()
+ client.Close()
}
// remove delete permissions to the user, it will be automatically granted
@@ -7775,9 +8417,6 @@ func TestRetentionAPI(t *testing.T) {
conn, client, err = getSftpClient(user)
if assert.NoError(t, err) {
- defer conn.Close()
- defer client.Close()
-
innerUploadFilePath := path.Join("/"+testDir, testDir, testFileName)
err = client.Mkdir(path.Join(testDir, testDir))
assert.NoError(t, err)
@@ -7802,7 +8441,12 @@ func TestRetentionAPI(t *testing.T) {
Retention: 0,
},
}
- _, err = httpdtest.StartRetentionCheck(user.Username, folderRetention, http.StatusAccepted)
+ check := common.RetentionCheck{
+ Folders: folderRetention,
+ }
+ c := common.RetentionChecks.Add(check, &user)
+ assert.NotNil(t, c)
+ err = c.Start()
assert.NoError(t, err)
assert.Eventually(t, func() bool {
@@ -7823,7 +8467,12 @@ func TestRetentionAPI(t *testing.T) {
},
}
- _, err = httpdtest.StartRetentionCheck(user.Username, folderRetention, http.StatusAccepted)
+ check = common.RetentionCheck{
+ Folders: folderRetention,
+ }
+ c = common.RetentionChecks.Add(check, &user)
+ assert.NotNil(t, c)
+ err = c.Start()
assert.NoError(t, err)
assert.Eventually(t, func() bool {
@@ -7832,6 +8481,8 @@ func TestRetentionAPI(t *testing.T) {
_, err = client.Stat(innerUploadFilePath)
assert.ErrorIs(t, err, os.ErrNotExist)
+ conn.Close()
+ client.Close()
}
// finally test some errors removing files or folders
if runtime.GOOS != osWindows {
@@ -7857,8 +8508,13 @@ func TestRetentionAPI(t *testing.T) {
},
}
- _, err = httpdtest.StartRetentionCheck(user.Username, folderRetention, http.StatusAccepted)
- assert.NoError(t, err)
+ check := common.RetentionCheck{
+ Folders: folderRetention,
+ }
+ c := common.RetentionChecks.Add(check, &user)
+ assert.NotNil(t, c)
+ err = c.Start()
+ assert.ErrorIs(t, err, os.ErrPermission)
assert.Eventually(t, func() bool {
return len(common.RetentionChecks.Get("")) == 0
@@ -7867,8 +8523,10 @@ func TestRetentionAPI(t *testing.T) {
err = os.Chmod(dirPath, 0555)
assert.NoError(t, err)
- _, err = httpdtest.StartRetentionCheck(user.Username, folderRetention, http.StatusAccepted)
- assert.NoError(t, err)
+ c = common.RetentionChecks.Add(check, &user)
+ assert.NotNil(t, c)
+ err = c.Start()
+ assert.ErrorIs(t, err, os.ErrPermission)
assert.Eventually(t, func() bool {
return len(common.RetentionChecks.Get("")) == 0
@@ -7877,7 +8535,12 @@ func TestRetentionAPI(t *testing.T) {
err = os.Chmod(dirPath, os.ModePerm)
assert.NoError(t, err)
- _, err = httpdtest.StartRetentionCheck(user.Username, folderRetention, http.StatusAccepted)
+ check = common.RetentionCheck{
+ Folders: folderRetention,
+ }
+ c = common.RetentionChecks.Add(check, &user)
+ assert.NotNil(t, c)
+ err = c.Start()
assert.NoError(t, err)
assert.Eventually(t, func() bool {
@@ -7891,6 +8554,93 @@ func TestRetentionAPI(t *testing.T) {
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
+
+ assert.Eventually(t, func() bool {
+ return common.Connections.GetClientConnections() == 0
+ }, 1*time.Second, 50*time.Millisecond)
+}
+
+func TestPerUserTransferLimits(t *testing.T) {
+ oldMaxPerHostConns := common.Config.MaxPerHostConnections
+
+ common.Config.MaxPerHostConnections = 2
+
+ u := getTestUser()
+ u.UploadBandwidth = 32
+ user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+ if !assert.NoError(t, err) {
+ printLatestLogs(20)
+ }
+ conn, client, err := getSftpClient(user)
+ if assert.NoError(t, err) {
+ defer conn.Close()
+ defer client.Close()
+
+ var wg sync.WaitGroup
+ numErrors := 0
+ for i := 0; i <= 2; i++ {
+ wg.Add(1)
+ go func(counter int) {
+ defer wg.Done()
+
+ time.Sleep(20 * time.Millisecond)
+ err := writeSFTPFile(fmt.Sprintf("%s_%d", testFileName, counter), 64*1024, client)
+ if err != nil {
+ numErrors++
+ }
+ }(i)
+ }
+ wg.Wait()
+
+ assert.Equal(t, 1, numErrors)
+ }
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+ err = os.RemoveAll(user.GetHomeDir())
+ assert.NoError(t, err)
+
+ common.Config.MaxPerHostConnections = oldMaxPerHostConns
+}
+
+func TestMaxSessionsSameConnection(t *testing.T) {
+ u := getTestUser()
+ u.UploadBandwidth = 32
+ u.MaxSessions = 2
+ user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+ assert.NoError(t, err)
+ conn, client, err := getSftpClient(user)
+ if assert.NoError(t, err) {
+ defer conn.Close()
+ defer client.Close()
+
+ var wg sync.WaitGroup
+ numErrors := 0
+ for i := 0; i <= 2; i++ {
+ wg.Add(1)
+ go func(counter int) {
+ defer wg.Done()
+
+ var err error
+ if counter < 2 {
+ err = writeSFTPFile(fmt.Sprintf("%s_%d", testFileName, counter), 64*1024, client)
+ } else {
+ // wait for the transfers to start
+ time.Sleep(50 * time.Millisecond)
+ _, _, err = getSftpClient(user)
+ }
+ if err != nil {
+ numErrors++
+ }
+ }(i)
+ }
+
+ wg.Wait()
+ assert.Equal(t, 1, numErrors)
+ }
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+ err = os.RemoveAll(user.GetHomeDir())
+ assert.NoError(t, err)
}
func TestRenameDir(t *testing.T) {
@@ -8260,7 +9010,7 @@ func TestSFTPLoopError(t *testing.T) {
}, 3000*time.Millisecond, 100*time.Millisecond)
email := lastReceivedEmail.get()
assert.Len(t, email.To, 1)
- assert.True(t, util.Contains(email.To, "failure@example.com"))
+ assert.True(t, slices.Contains(email.To, "failure@example.com"))
assert.Contains(t, email.Data, `Subject: Failed action`)
user1.VirtualFolders[0].FsConfig.SFTPConfig.Password = kms.NewPlainSecret(defaultPassword)
@@ -8934,9 +9684,8 @@ func TestHTTPFs(t *testing.T) {
func TestProxyProtocol(t *testing.T) {
resp, err := httpclient.Get(fmt.Sprintf("http://%v", httpProxyAddr))
- if assert.NoError(t, err) {
- defer resp.Body.Close()
- assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
+ if !assert.Error(t, err) {
+ resp.Body.Close()
}
}
@@ -9218,7 +9967,7 @@ func printLatestLogs(maxNumberOfLines int) {
return
}
for _, line := range lines {
- logger.DebugToConsole(line)
+ logger.DebugToConsole("%s", line)
}
}
diff --git a/internal/common/ratelimiter.go b/internal/common/ratelimiter.go
index 1caa2ebf..85d88092 100644
--- a/internal/common/ratelimiter.go
+++ b/internal/common/ratelimiter.go
@@ -17,6 +17,7 @@ package common
import (
"errors"
"fmt"
+ "slices"
"sort"
"sync"
"sync/atomic"
@@ -94,7 +95,7 @@ func (r *RateLimiterConfig) validate() error {
}
r.Protocols = util.RemoveDuplicates(r.Protocols, true)
for _, protocol := range r.Protocols {
- if !util.Contains(rateLimiterProtocolValues, protocol) {
+ if !slices.Contains(rateLimiterProtocolValues, protocol) {
return fmt.Errorf("invalid protocol %q", protocol)
}
}
diff --git a/internal/common/tlsutils.go b/internal/common/tlsutils.go
index c604728d..a7dfb0c6 100644
--- a/internal/common/tlsutils.go
+++ b/internal/common/tlsutils.go
@@ -25,6 +25,7 @@ import (
"math/rand"
"os"
"path/filepath"
+ "slices"
"sync"
"github.com/drakkan/sftpgo/v2/internal/logger"
@@ -96,7 +97,7 @@ func (m *CertManager) loadCertificates() error {
}
logger.Debug(m.logSender, "", "TLS certificate %q successfully loaded, id %v", keyPair.Cert, keyPair.ID)
certs[keyPair.ID] = &newCert
- if !util.Contains(m.monitorList, keyPair.Cert) {
+ if !slices.Contains(m.monitorList, keyPair.Cert) {
m.monitorList = append(m.monitorList, keyPair.Cert)
}
}
@@ -190,7 +191,7 @@ func (m *CertManager) LoadCRLs() error {
logger.Debug(m.logSender, "", "CRL %q successfully loaded", revocationList)
crls = append(crls, crl)
- if !util.Contains(m.monitorList, revocationList) {
+ if !slices.Contains(m.monitorList, revocationList) {
m.monitorList = append(m.monitorList, revocationList)
}
}
diff --git a/internal/common/transfer.go b/internal/common/transfer.go
index 9bf354fb..df9638b1 100644
--- a/internal/common/transfer.go
+++ b/internal/common/transfer.go
@@ -35,7 +35,7 @@ var (
)
// BaseTransfer contains protocols common transfer details for an upload or a download.
-type BaseTransfer struct { //nolint:maligned
+type BaseTransfer struct {
ID int64
BytesSent atomic.Int64
BytesReceived atomic.Int64
@@ -329,6 +329,21 @@ func (t *BaseTransfer) getUploadFileSize() (int64, int, error) {
var fileSize int64
var deletedFiles int
+ switch dataprovider.GetQuotaTracking() {
+ case 0:
+ return fileSize, deletedFiles, errors.New("quota tracking disabled")
+ case 2:
+ if !t.Connection.User.HasQuotaRestrictions() {
+ vfolder, err := t.Connection.User.GetVirtualFolderForPath(path.Dir(t.requestPath))
+ if err != nil {
+ return fileSize, deletedFiles, errors.New("quota tracking disabled for this user")
+ }
+ if vfolder.IsIncludedInUserQuota() {
+ return fileSize, deletedFiles, errors.New("quota tracking disabled for this user and folder included in user quota")
+ }
+ }
+ }
+
info, err := t.Fs.Stat(t.fsPath)
if err == nil {
fileSize = info.Size()
@@ -352,6 +367,9 @@ func (t *BaseTransfer) checkUploadOutsideHomeDir(err error) int {
if err == nil {
return 0
}
+ if t.ErrTransfer == nil {
+ t.ErrTransfer = err
+ }
if Config.TempPath == "" {
return 0
}
@@ -391,7 +409,7 @@ func (t *BaseTransfer) Close() error {
t.effectiveFsPath, err)
} else if t.isAtomicUpload() {
if t.ErrTransfer == nil || Config.UploadMode&UploadModeAtomicWithResume != 0 {
- _, _, err = t.Fs.Rename(t.effectiveFsPath, t.fsPath)
+ _, _, err = t.Fs.Rename(t.effectiveFsPath, t.fsPath, 0)
t.Connection.Log(logger.LevelDebug, "atomic upload completed, rename: %q -> %q, error: %v",
t.effectiveFsPath, t.fsPath, err)
// the file must be removed if it is uploaded to a path outside the home dir and cannot be renamed
@@ -410,7 +428,8 @@ func (t *BaseTransfer) Close() error {
var uploadFileSize int64
if t.transferType == TransferDownload {
logger.TransferLog(downloadLogSender, t.fsPath, elapsed, t.BytesSent.Load(), t.Connection.User.Username,
- t.Connection.ID, t.Connection.protocol, t.Connection.localAddr, t.Connection.remoteAddr, t.ftpMode)
+ t.Connection.ID, t.Connection.protocol, t.Connection.localAddr, t.Connection.remoteAddr, t.ftpMode,
+ t.ErrTransfer)
ExecuteActionNotification(t.Connection, operationDownload, t.fsPath, t.requestPath, "", "", "", //nolint:errcheck
t.BytesSent.Load(), t.ErrTransfer, elapsed, t.metadata)
} else {
@@ -431,7 +450,8 @@ func (t *BaseTransfer) Close() error {
t.updateQuota(numFiles, uploadFileSize)
t.updateTimes()
logger.TransferLog(uploadLogSender, t.fsPath, elapsed, t.BytesReceived.Load(), t.Connection.User.Username,
- t.Connection.ID, t.Connection.protocol, t.Connection.localAddr, t.Connection.remoteAddr, t.ftpMode)
+ t.Connection.ID, t.Connection.protocol, t.Connection.localAddr, t.Connection.remoteAddr, t.ftpMode,
+ t.ErrTransfer)
}
if t.ErrTransfer != nil {
t.Connection.Log(logger.LevelError, "transfer error: %v, path: %q", t.ErrTransfer, t.fsPath)
@@ -516,11 +536,8 @@ func (t *BaseTransfer) updateQuota(numFiles int, fileSize int64) bool {
if t.transferType == TransferUpload && (numFiles != 0 || sizeDiff != 0) {
vfolder, err := t.Connection.User.GetVirtualFolderForPath(path.Dir(t.requestPath))
if err == nil {
- dataprovider.UpdateVirtualFolderQuota(&vfolder.BaseVirtualFolder, numFiles, //nolint:errcheck
+ dataprovider.UpdateUserFolderQuota(&vfolder, &t.Connection.User, numFiles,
sizeDiff, false)
- if vfolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&t.Connection.User, numFiles, sizeDiff, false) //nolint:errcheck
- }
} else {
dataprovider.UpdateUserQuota(&t.Connection.User, numFiles, sizeDiff, false) //nolint:errcheck
}
diff --git a/internal/common/transfer_test.go b/internal/common/transfer_test.go
index 5324484b..1bf0dbe1 100644
--- a/internal/common/transfer_test.go
+++ b/internal/common/transfer_test.go
@@ -306,8 +306,9 @@ func TestRemovePartialCryptoFile(t *testing.T) {
require.NoError(t, err)
u := dataprovider.User{
BaseUser: sdk.BaseUser{
- Username: "test",
- HomeDir: os.TempDir(),
+ Username: "test",
+ HomeDir: os.TempDir(),
+ QuotaFiles: 1000000,
},
}
conn := NewBaseConnection(fs.ConnectionID(), ProtocolSFTP, "", "", u)
@@ -323,6 +324,9 @@ func TestRemovePartialCryptoFile(t *testing.T) {
assert.Equal(t, int64(0), size)
assert.Equal(t, 1, deletedFiles)
assert.NoFileExists(t, testFile)
+ err = transfer.Close()
+ assert.Error(t, err)
+ assert.Len(t, conn.GetTransfers(), 0)
}
func TestFTPMode(t *testing.T) {
@@ -434,6 +438,11 @@ func TestTransferQuota(t *testing.T) {
}
err = transfer.CheckWrite()
assert.True(t, conn.IsQuotaExceededError(err))
+
+ err = transfer.Close()
+ assert.NoError(t, err)
+ assert.Len(t, conn.GetTransfers(), 0)
+ assert.Equal(t, int32(0), Connections.GetTotalTransfers())
}
func TestUploadOutsideHomeRenameError(t *testing.T) {
diff --git a/internal/common/transferschecker_test.go b/internal/common/transferschecker_test.go
index 443e438e..0528de61 100644
--- a/internal/common/transferschecker_test.go
+++ b/internal/common/transferschecker_test.go
@@ -250,6 +250,7 @@ func TestTransfersCheckerDiskQuota(t *testing.T) {
Connections.Remove(fakeConn5.GetID())
stats := Connections.GetStats("")
assert.Len(t, stats, 0)
+ assert.Equal(t, int32(0), Connections.GetTotalTransfers())
err = dataprovider.DeleteUser(user.Username, "", "", "")
assert.NoError(t, err)
@@ -368,11 +369,16 @@ func TestTransferCheckerTransferQuota(t *testing.T) {
if assert.Error(t, transfer4.errAbort) {
assert.Contains(t, transfer4.errAbort.Error(), ErrReadQuotaExceeded.Error())
}
+ err = transfer3.Close()
+ assert.NoError(t, err)
+ err = transfer4.Close()
+ assert.NoError(t, err)
Connections.Remove(fakeConn3.GetID())
Connections.Remove(fakeConn4.GetID())
stats := Connections.GetStats("")
assert.Len(t, stats, 0)
+ assert.Equal(t, int32(0), Connections.GetTotalTransfers())
err = dataprovider.DeleteUser(user.Username, "", "", "")
assert.NoError(t, err)
diff --git a/internal/config/config.go b/internal/config/config.go
index 97a0da7b..58863c6d 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -20,9 +20,11 @@ import (
"fmt"
"os"
"path/filepath"
+ "slices"
"strconv"
"strings"
+ kmsplugin "github.com/sftpgo/sdk/plugin/kms"
"github.com/spf13/viper"
"github.com/subosito/gotenv"
@@ -91,30 +93,35 @@ var (
TLSCipherSuites: nil,
Protocols: nil,
Prefix: "",
+ ProxyMode: 0,
ProxyAllowed: nil,
ClientIPProxyHeader: "",
ClientIPHeaderDepth: 0,
DisableWWWAuthHeader: false,
}
defaultHTTPDBinding = httpd.Binding{
- Address: "",
- Port: 8080,
- EnableWebAdmin: true,
- EnableWebClient: true,
- EnableRESTAPI: true,
- EnabledLoginMethods: 0,
- EnableHTTPS: false,
- CertificateFile: "",
- CertificateKeyFile: "",
- MinTLSVersion: 12,
- ClientAuthType: 0,
- TLSCipherSuites: nil,
- Protocols: nil,
- ProxyAllowed: nil,
- ClientIPProxyHeader: "",
- ClientIPHeaderDepth: 0,
- HideLoginURL: 0,
- RenderOpenAPI: true,
+ Address: "",
+ Port: 8080,
+ EnableWebAdmin: true,
+ EnableWebClient: true,
+ EnableRESTAPI: true,
+ EnabledLoginMethods: 0,
+ DisabledLoginMethods: 0,
+ EnableHTTPS: false,
+ CertificateFile: "",
+ CertificateKeyFile: "",
+ MinTLSVersion: 12,
+ ClientAuthType: 0,
+ TLSCipherSuites: nil,
+ Protocols: nil,
+ ProxyMode: 0,
+ ProxyAllowed: nil,
+ ClientIPProxyHeader: "",
+ ClientIPHeaderDepth: 0,
+ HideLoginURL: 0,
+ RenderOpenAPI: true,
+ BaseURL: "",
+ Languages: []string{"en"},
OIDC: httpd.OIDC{
ClientID: "",
ClientSecret: "",
@@ -130,20 +137,23 @@ var (
Debug: false,
},
Security: httpd.SecurityConf{
- Enabled: false,
- AllowedHosts: nil,
- AllowedHostsAreRegex: false,
- HostsProxyHeaders: nil,
- HTTPSRedirect: false,
- HTTPSHost: "",
- HTTPSProxyHeaders: nil,
- STSSeconds: 0,
- STSIncludeSubdomains: false,
- STSPreload: false,
- ContentTypeNosniff: false,
- ContentSecurityPolicy: "",
- PermissionsPolicy: "",
- CrossOriginOpenerPolicy: "",
+ Enabled: false,
+ AllowedHosts: nil,
+ AllowedHostsAreRegex: false,
+ HostsProxyHeaders: nil,
+ HTTPSRedirect: false,
+ HTTPSHost: "",
+ HTTPSProxyHeaders: nil,
+ STSSeconds: 0,
+ STSIncludeSubdomains: false,
+ STSPreload: false,
+ ContentTypeNosniff: false,
+ ContentSecurityPolicy: "",
+ PermissionsPolicy: "",
+ CrossOriginOpenerPolicy: "",
+ CrossOriginResourcePolicy: "",
+ CrossOriginEmbedderPolicy: "",
+ CacheControl: "",
},
Branding: httpd.Branding{},
}
@@ -208,7 +218,6 @@ func Init() {
ProxySkipped: []string{},
PostConnectHook: "",
PostDisconnectHook: "",
- DataRetentionHook: "",
MaxTotalConnections: 0,
MaxPerHostConnections: 20,
AllowListStatus: 0,
@@ -226,13 +235,21 @@ func Init() {
ObservationTime: 30,
EntriesSoftLimit: 100,
EntriesHardLimit: 150,
+ LoginDelay: common.LoginDelay{
+ Success: 0,
+ PasswordFailed: 1000,
+ },
},
RateLimitersConfig: []common.RateLimiterConfig{defaultRateLimiter},
Umask: "",
ServerVersion: "",
+ TZ: "",
Metadata: common.MetadataConfig{
Read: 0,
},
+ EventManager: common.EventManagerConfig{
+ EnabledCommands: []string{},
+ },
},
ACME: acme.Configuration{
Email: "",
@@ -262,6 +279,8 @@ func Init() {
PublicKeyAlgorithms: []string{},
TrustedUserCAKeys: []string{},
RevokedUserCertsFile: "",
+ OPKSSHPath: "",
+ OPKSSHChecksum: "",
LoginBannerFile: "",
EnabledSSHCommands: []string{},
KeyboardInteractiveAuthentication: true,
@@ -390,6 +409,9 @@ func Init() {
SigningPassphrase: "",
SigningPassphraseFile: "",
TokenValidation: 0,
+ CookieLifetime: 20,
+ ShareCookieLifetime: 120,
+ JWTLifetime: 20,
MaxUploadFileSize: 0,
Cors: httpd.CorsConfig{
Enabled: false,
@@ -568,6 +590,16 @@ func SetPluginsConfig(config []plugin.Config) {
globalConf.PluginsConfig = config
}
+// HasKMSPlugin returns true if at least one KMS plugin is configured.
+func HasKMSPlugin() bool {
+ for _, c := range globalConf.PluginsConfig {
+ if c.Type == kmsplugin.PluginName {
+ return true
+ }
+ }
+ return false
+}
+
// GetMFAConfig returns multi-factor authentication config
func GetMFAConfig() mfa.Config {
return globalConf.MFAConfig
@@ -614,7 +646,6 @@ func getRedactedGlobalConf() globalConfig {
conf.Common.StartupHook = util.GetRedactedURL(conf.Common.StartupHook)
conf.Common.PostConnectHook = util.GetRedactedURL(conf.Common.PostConnectHook)
conf.Common.PostDisconnectHook = util.GetRedactedURL(conf.Common.PostDisconnectHook)
- conf.Common.DataRetentionHook = util.GetRedactedURL(conf.Common.DataRetentionHook)
conf.SFTPD.KeyboardInteractiveHook = util.GetRedactedURL(conf.SFTPD.KeyboardInteractiveHook)
conf.HTTPDConfig.SigningPassphrase = getRedactedPassword(conf.HTTPDConfig.SigningPassphrase)
conf.HTTPDConfig.Setup.InstallationCode = getRedactedPassword(conf.HTTPDConfig.Setup.InstallationCode)
@@ -631,6 +662,16 @@ func getRedactedGlobalConf() globalConfig {
binding.OIDC.ClientSecret = getRedactedPassword(binding.OIDC.ClientSecret)
conf.HTTPDConfig.Bindings = append(conf.HTTPDConfig.Bindings, binding)
}
+ conf.KMSConfig.Secrets.MasterKeyString = getRedactedPassword(conf.KMSConfig.Secrets.MasterKeyString)
+ conf.PluginsConfig = nil
+ for _, plugin := range globalConf.PluginsConfig {
+ var args []string
+ for _, arg := range plugin.Args {
+ args = append(args, getRedactedPassword(arg))
+ }
+ plugin.Args = args
+ conf.PluginsConfig = append(conf.PluginsConfig, plugin)
+ }
return conf
}
@@ -701,7 +742,7 @@ func checkOverrideDefaultSettings() {
}
}
- if util.Contains(viper.AllKeys(), "mfa.totp") {
+ if slices.Contains(viper.AllKeys(), "mfa.totp") {
globalConf.MFAConfig.TOTP = nil
}
}
@@ -787,6 +828,12 @@ func resetInvalidConfigs() {
logger.WarnToConsole("Non-fatal configuration error: %v", warn)
}
}
+ if globalConf.Common.RenameMode < 0 || globalConf.Common.RenameMode > 1 {
+ warn := fmt.Sprintf("invalid rename mode %d, reset to 0", globalConf.Common.RenameMode)
+ globalConf.Common.RenameMode = 0
+ logger.Warn(logSender, "", "Non-fatal configuration error: %v", warn)
+ logger.WarnToConsole("Non-fatal configuration error: %v", warn)
+ }
}
func loadBindingsFromEnv() {
@@ -859,13 +906,13 @@ func getRateLimitersFromEnv(idx int) {
isSet = true
}
- burst, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_COMMON__RATE_LIMITERS__%v__BURST", idx), 0)
+ burst, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_COMMON__RATE_LIMITERS__%v__BURST", idx), 32)
if ok {
rtlConfig.Burst = int(burst)
isSet = true
}
- rtlType, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_COMMON__RATE_LIMITERS__%v__TYPE", idx), 0)
+ rtlType, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_COMMON__RATE_LIMITERS__%v__TYPE", idx), 32)
if ok {
rtlConfig.Type = int(rtlType)
isSet = true
@@ -883,13 +930,13 @@ func getRateLimitersFromEnv(idx int) {
isSet = true
}
- softLimit, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_COMMON__RATE_LIMITERS__%v__ENTRIES_SOFT_LIMIT", idx), 0)
+ softLimit, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_COMMON__RATE_LIMITERS__%v__ENTRIES_SOFT_LIMIT", idx), 32)
if ok {
rtlConfig.EntriesSoftLimit = int(softLimit)
isSet = true
}
- hardLimit, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_COMMON__RATE_LIMITERS__%v__ENTRIES_HARD_LIMIT", idx), 0)
+ hardLimit, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_COMMON__RATE_LIMITERS__%v__ENTRIES_HARD_LIMIT", idx), 32)
if ok {
rtlConfig.EntriesHardLimit = int(hardLimit)
isSet = true
@@ -925,7 +972,7 @@ func getKMSPluginFromEnv(idx int, pluginConfig *plugin.Config) bool {
func getAuthPluginFromEnv(idx int, pluginConfig *plugin.Config) bool {
isSet := false
- authScope, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_PLUGINS__%v__AUTH_OPTIONS__SCOPE", idx), 0)
+ authScope, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_PLUGINS__%v__AUTH_OPTIONS__SCOPE", idx), 32)
if ok {
pluginConfig.AuthOptions.Scope = int(authScope)
isSet = true
@@ -970,13 +1017,13 @@ func getNotifierPluginFromEnv(idx int, pluginConfig *plugin.Config) bool {
}
}
- notifierRetryMaxTime, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_PLUGINS__%v__NOTIFIER_OPTIONS__RETRY_MAX_TIME", idx), 0)
+ notifierRetryMaxTime, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_PLUGINS__%v__NOTIFIER_OPTIONS__RETRY_MAX_TIME", idx), 32)
if ok {
pluginConfig.NotifierOptions.RetryMaxTime = int(notifierRetryMaxTime)
isSet = true
}
- notifierRetryQueueMaxSize, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_PLUGINS__%v__NOTIFIER_OPTIONS__RETRY_QUEUE_MAX_SIZE", idx), 0)
+ notifierRetryQueueMaxSize, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_PLUGINS__%v__NOTIFIER_OPTIONS__RETRY_QUEUE_MAX_SIZE", idx), 32)
if ok {
pluginConfig.NotifierOptions.RetryQueueMaxSize = int(notifierRetryQueueMaxSize)
isSet = true
@@ -1064,7 +1111,7 @@ func getSFTPDBindindFromEnv(idx int) {
isSet := false
- port, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_SFTPD__BINDINGS__%v__PORT", idx), 0)
+ port, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_SFTPD__BINDINGS__%v__PORT", idx), 32)
if ok {
binding.Port = int(port)
isSet = true
@@ -1151,19 +1198,13 @@ func getFTPDBindingSecurityFromEnv(idx int, binding *ftpd.Binding) bool {
isSet = true
}
- tlsMode, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__TLS_MODE", idx), 0)
+ tlsMode, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__TLS_MODE", idx), 32)
if ok {
binding.TLSMode = int(tlsMode)
isSet = true
}
- tlsSessionReuse, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__TLS_SESSION_REUSE", idx), 0)
- if ok {
- binding.TLSSessionReuse = int(tlsSessionReuse)
- isSet = true
- }
-
- tlsVer, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__MIN_TLS_VERSION", idx), 0)
+ tlsVer, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__MIN_TLS_VERSION", idx), 32)
if ok {
binding.MinTLSVersion = int(tlsVer)
isSet = true
@@ -1175,30 +1216,24 @@ func getFTPDBindingSecurityFromEnv(idx int, binding *ftpd.Binding) bool {
isSet = true
}
- clientAuthType, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__CLIENT_AUTH_TYPE", idx), 0)
+ clientAuthType, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__CLIENT_AUTH_TYPE", idx), 32)
if ok {
binding.ClientAuthType = int(clientAuthType)
isSet = true
}
- pasvSecurity, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__PASSIVE_CONNECTIONS_SECURITY", idx), 0)
+ pasvSecurity, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__PASSIVE_CONNECTIONS_SECURITY", idx), 32)
if ok {
binding.PassiveConnectionsSecurity = int(pasvSecurity)
isSet = true
}
- activeSecurity, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__ACTIVE_CONNECTIONS_SECURITY", idx), 0)
+ activeSecurity, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__ACTIVE_CONNECTIONS_SECURITY", idx), 32)
if ok {
binding.ActiveConnectionsSecurity = int(activeSecurity)
isSet = true
}
- ignoreASCIITransferType, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%d__IGNORE_ASCII_TRANSFER_TYPE", idx), 0)
- if ok {
- binding.IgnoreASCIITransferType = int(ignoreASCIITransferType)
- isSet = true
- }
-
return isSet
}
@@ -1206,7 +1241,7 @@ func getFTPDBindingFromEnv(idx int) {
binding := getDefaultFTPDBinding(idx)
isSet := false
- port, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__PORT", idx), 0)
+ port, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_FTPD__BINDINGS__%v__PORT", idx), 32)
if ok {
binding.Port = int(port)
isSet = true
@@ -1286,13 +1321,13 @@ func getWebDAVBindingHTTPSConfigsFromEnv(idx int, binding *webdavd.Binding) bool
isSet = true
}
- tlsVer, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_WEBDAVD__BINDINGS__%v__MIN_TLS_VERSION", idx), 0)
+ tlsVer, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_WEBDAVD__BINDINGS__%v__MIN_TLS_VERSION", idx), 32)
if ok {
binding.MinTLSVersion = int(tlsVer)
isSet = true
}
- clientAuthType, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_WEBDAVD__BINDINGS__%v__CLIENT_AUTH_TYPE", idx), 0)
+ clientAuthType, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_WEBDAVD__BINDINGS__%v__CLIENT_AUTH_TYPE", idx), 32)
if ok {
binding.ClientAuthType = int(clientAuthType)
isSet = true
@@ -1316,6 +1351,12 @@ func getWebDAVBindingHTTPSConfigsFromEnv(idx int, binding *webdavd.Binding) bool
func getWebDAVDBindingProxyConfigsFromEnv(idx int, binding *webdavd.Binding) bool {
isSet := false
+ proxyMode, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_WEBDAVD__BINDINGS__%v__PROXY_MODE", idx), 32)
+ if ok {
+ binding.ProxyMode = int(proxyMode)
+ isSet = true
+ }
+
proxyAllowed, ok := lookupStringListFromEnv(fmt.Sprintf("SFTPGO_WEBDAVD__BINDINGS__%v__PROXY_ALLOWED", idx))
if ok {
binding.ProxyAllowed = proxyAllowed
@@ -1328,7 +1369,7 @@ func getWebDAVDBindingProxyConfigsFromEnv(idx int, binding *webdavd.Binding) boo
isSet = true
}
- clientIPHeaderDepth, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_WEBDAVD__BINDINGS__%v__CLIENT_IP_HEADER_DEPTH", idx), 0)
+ clientIPHeaderDepth, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_WEBDAVD__BINDINGS__%v__CLIENT_IP_HEADER_DEPTH", idx), 32)
if ok {
binding.ClientIPHeaderDepth = int(clientIPHeaderDepth)
isSet = true
@@ -1366,7 +1407,7 @@ func getWebDAVDBindingFromEnv(idx int) {
isSet := false
- port, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_WEBDAVD__BINDINGS__%v__PORT", idx), 0)
+ port, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_WEBDAVD__BINDINGS__%v__PORT", idx), 32)
if ok {
binding.Port = int(port)
isSet = true
@@ -1527,9 +1568,33 @@ func getHTTPDSecurityConfFromEnv(idx int) (httpd.SecurityConf, bool) { //nolint:
isSet = true
}
- crossOriginOpenedPolicy, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__CROSS_ORIGIN_OPENER_POLICY", idx))
+ crossOriginOpenerPolicy, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__CROSS_ORIGIN_OPENER_POLICY", idx))
if ok {
- result.CrossOriginOpenerPolicy = crossOriginOpenedPolicy
+ result.CrossOriginOpenerPolicy = crossOriginOpenerPolicy
+ isSet = true
+ }
+
+ crossOriginResourcePolicy, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__CROSS_ORIGIN_RESOURCE_POLICY", idx))
+ if ok {
+ result.CrossOriginResourcePolicy = crossOriginResourcePolicy
+ isSet = true
+ }
+
+ crossOriginEmbedderPolicy, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__CROSS_ORIGIN_EMBEDDER_POLICY", idx))
+ if ok {
+ result.CrossOriginEmbedderPolicy = crossOriginEmbedderPolicy
+ isSet = true
+ }
+
+ referredPolicy, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__REFERRER_POLICY", idx))
+ if ok {
+ result.ReferrerPolicy = referredPolicy
+ isSet = true
+ }
+
+ cacheControl, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__CACHE_CONTROL", idx))
+ if ok {
+ result.CacheControl = cacheControl
isSet = true
}
@@ -1645,12 +1710,6 @@ func getHTTPDUIBrandingFromEnv(prefix string, branding httpd.UIBranding) (httpd.
isSet = true
}
- loginImagePath, ok := os.LookupEnv(fmt.Sprintf("%s__LOGIN_IMAGE_PATH", prefix))
- if ok {
- branding.LoginImagePath = loginImagePath
- isSet = true
- }
-
disclaimerName, ok := os.LookupEnv(fmt.Sprintf("%s__DISCLAIMER_NAME", prefix))
if ok {
branding.DisclaimerName = disclaimerName
@@ -1737,6 +1796,12 @@ func getHTTPDNestedObjectsFromEnv(idx int, binding *httpd.Binding) bool {
func getHTTPDBindingProxyConfigsFromEnv(idx int, binding *httpd.Binding) bool {
isSet := false
+ proxyMode, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__PROXY_MODE", idx), 32)
+ if ok {
+ binding.ProxyMode = int(proxyMode)
+ isSet = true
+ }
+
proxyAllowed, ok := lookupStringListFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__PROXY_ALLOWED", idx))
if ok {
binding.ProxyAllowed = proxyAllowed
@@ -1749,7 +1814,7 @@ func getHTTPDBindingProxyConfigsFromEnv(idx int, binding *httpd.Binding) bool {
isSet = true
}
- clientIPHeaderDepth, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__CLIENT_IP_HEADER_DEPTH", idx), 0)
+ clientIPHeaderDepth, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__CLIENT_IP_HEADER_DEPTH", idx), 32)
if ok {
binding.ClientIPHeaderDepth = int(clientIPHeaderDepth)
isSet = true
@@ -1762,7 +1827,7 @@ func getHTTPDBindingFromEnv(idx int) { //nolint:gocyclo
binding := getDefaultHTTPBinding(idx)
isSet := false
- port, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__PORT", idx), 0)
+ port, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__PORT", idx), 32)
if ok {
binding.Port = int(port)
isSet = true
@@ -1804,31 +1869,49 @@ func getHTTPDBindingFromEnv(idx int) { //nolint:gocyclo
isSet = true
}
- enabledLoginMethods, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__ENABLED_LOGIN_METHODS", idx), 0)
+ enabledLoginMethods, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__ENABLED_LOGIN_METHODS", idx), 32)
if ok {
binding.EnabledLoginMethods = int(enabledLoginMethods)
isSet = true
}
+ disabledLoginMethods, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__DISABLED_LOGIN_METHODS", idx), 32)
+ if ok {
+ binding.DisabledLoginMethods = int(disabledLoginMethods)
+ isSet = true
+ }
+
renderOpenAPI, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__RENDER_OPENAPI", idx))
if ok {
binding.RenderOpenAPI = renderOpenAPI
isSet = true
}
+ baseURL, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%d__BASE_URL", idx))
+ if ok {
+ binding.BaseURL = baseURL
+ isSet = true
+ }
+
+ languages, ok := lookupStringListFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%d__LANGUAGES", idx))
+ if ok {
+ binding.Languages = languages
+ isSet = true
+ }
+
enableHTTPS, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__ENABLE_HTTPS", idx))
if ok {
binding.EnableHTTPS = enableHTTPS
isSet = true
}
- tlsVer, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__MIN_TLS_VERSION", idx), 0)
+ tlsVer, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__MIN_TLS_VERSION", idx), 32)
if ok {
binding.MinTLSVersion = int(tlsVer)
isSet = true
}
- clientAuthType, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__CLIENT_AUTH_TYPE", idx), 0)
+ clientAuthType, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__CLIENT_AUTH_TYPE", idx), 32)
if ok {
binding.ClientAuthType = int(clientAuthType)
isSet = true
@@ -1850,7 +1933,7 @@ func getHTTPDBindingFromEnv(idx int) { //nolint:gocyclo
isSet = true
}
- hideLoginURL, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__HIDE_LOGIN_URL", idx), 0)
+ hideLoginURL, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__HIDE_LOGIN_URL", idx), 32)
if ok {
binding.HideLoginURL = int(hideLoginURL)
isSet = true
@@ -1939,7 +2022,7 @@ func getCommandConfigsFromEnv(idx int) {
cfg.Path = path
}
- timeout, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_COMMAND__COMMANDS__%v__TIMEOUT", idx), 0)
+ timeout, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_COMMAND__COMMANDS__%v__TIMEOUT", idx), 32)
if ok {
cfg.Timeout = int(timeout)
}
@@ -1978,7 +2061,6 @@ func setViperDefaults() {
viper.SetDefault("common.proxy_skipped", globalConf.Common.ProxySkipped)
viper.SetDefault("common.post_connect_hook", globalConf.Common.PostConnectHook)
viper.SetDefault("common.post_disconnect_hook", globalConf.Common.PostDisconnectHook)
- viper.SetDefault("common.data_retention_hook", globalConf.Common.DataRetentionHook)
viper.SetDefault("common.max_total_connections", globalConf.Common.MaxTotalConnections)
viper.SetDefault("common.max_per_host_connections", globalConf.Common.MaxPerHostConnections)
viper.SetDefault("common.allowlist_status", globalConf.Common.AllowListStatus)
@@ -1995,9 +2077,13 @@ func setViperDefaults() {
viper.SetDefault("common.defender.observation_time", globalConf.Common.DefenderConfig.ObservationTime)
viper.SetDefault("common.defender.entries_soft_limit", globalConf.Common.DefenderConfig.EntriesSoftLimit)
viper.SetDefault("common.defender.entries_hard_limit", globalConf.Common.DefenderConfig.EntriesHardLimit)
+ viper.SetDefault("common.defender.login_delay.success", globalConf.Common.DefenderConfig.LoginDelay.Success)
+ viper.SetDefault("common.defender.login_delay.password_failed", globalConf.Common.DefenderConfig.LoginDelay.PasswordFailed)
viper.SetDefault("common.umask", globalConf.Common.Umask)
viper.SetDefault("common.server_version", globalConf.Common.ServerVersion)
+ viper.SetDefault("common.tz", globalConf.Common.TZ)
viper.SetDefault("common.metadata.read", globalConf.Common.Metadata.Read)
+ viper.SetDefault("common.event_manager.enabled_commands", globalConf.Common.EventManager.EnabledCommands)
viper.SetDefault("acme.email", globalConf.ACME.Email)
viper.SetDefault("acme.key_type", globalConf.ACME.KeyType)
viper.SetDefault("acme.certs_path", globalConf.ACME.CertsPath)
@@ -2018,6 +2104,8 @@ func setViperDefaults() {
viper.SetDefault("sftpd.public_key_algorithms", globalConf.SFTPD.PublicKeyAlgorithms)
viper.SetDefault("sftpd.trusted_user_ca_keys", globalConf.SFTPD.TrustedUserCAKeys)
viper.SetDefault("sftpd.revoked_user_certs_file", globalConf.SFTPD.RevokedUserCertsFile)
+ viper.SetDefault("sftpd.opkssh_path", globalConf.SFTPD.OPKSSHPath)
+ viper.SetDefault("sftpd.opkssh_checksum", globalConf.SFTPD.OPKSSHChecksum)
viper.SetDefault("sftpd.login_banner_file", globalConf.SFTPD.LoginBannerFile)
viper.SetDefault("sftpd.enabled_ssh_commands", sftpd.GetDefaultSSHCommands())
viper.SetDefault("sftpd.keyboard_interactive_authentication", globalConf.SFTPD.KeyboardInteractiveAuthentication)
@@ -2109,6 +2197,9 @@ func setViperDefaults() {
viper.SetDefault("httpd.signing_passphrase", globalConf.HTTPDConfig.SigningPassphrase)
viper.SetDefault("httpd.signing_passphrase_file", globalConf.HTTPDConfig.SigningPassphraseFile)
viper.SetDefault("httpd.token_validation", globalConf.HTTPDConfig.TokenValidation)
+ viper.SetDefault("httpd.cookie_lifetime", globalConf.HTTPDConfig.CookieLifetime)
+ viper.SetDefault("httpd.share_cookie_lifetime", globalConf.HTTPDConfig.ShareCookieLifetime)
+ viper.SetDefault("httpd.jwt_lifetime", globalConf.HTTPDConfig.JWTLifetime)
viper.SetDefault("httpd.max_upload_file_size", globalConf.HTTPDConfig.MaxUploadFileSize)
viper.SetDefault("httpd.cors.enabled", globalConf.HTTPDConfig.Cors.Enabled)
viper.SetDefault("httpd.cors.allowed_origins", globalConf.HTTPDConfig.Cors.AllowedOrigins)
@@ -2182,7 +2273,7 @@ func lookupStringListFromEnv(envName string) ([]string, bool) {
value, ok := os.LookupEnv(envName)
if ok {
var result []string
- for _, v := range strings.Split(value, ",") {
+ for v := range strings.SplitSeq(value, ",") {
val := strings.TrimSpace(v)
if val != "" {
result = append(result, val)
diff --git a/internal/config/config_darwin.go b/internal/config/config_darwin.go
index 04a31d48..808c1cd3 100644
--- a/internal/config/config_darwin.go
+++ b/internal/config/config_darwin.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build darwin
-// +build darwin
package config
diff --git a/internal/config/config_fallback.go b/internal/config/config_fallback.go
index 5bf27b46..f841410a 100644
--- a/internal/config/config_fallback.go
+++ b/internal/config/config_fallback.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build !linux && !darwin
-// +build !linux,!darwin
package config
diff --git a/internal/config/config_linux.go b/internal/config/config_linux.go
index ef19d34c..21e6eed5 100644
--- a/internal/config/config_linux.go
+++ b/internal/config/config_linux.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build linux
-// +build linux
package config
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 17fc4702..668d6769 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -19,6 +19,7 @@ import (
"encoding/json"
"os"
"path/filepath"
+ "slices"
"testing"
"github.com/sftpgo/sdk/kms"
@@ -36,7 +37,6 @@ import (
"github.com/drakkan/sftpgo/v2/internal/plugin"
"github.com/drakkan/sftpgo/v2/internal/sftpd"
"github.com/drakkan/sftpgo/v2/internal/smtp"
- "github.com/drakkan/sftpgo/v2/internal/util"
"github.com/drakkan/sftpgo/v2/internal/webdavd"
)
@@ -243,6 +243,26 @@ func TestInvalidInstallationHint(t *testing.T) {
assert.NoError(t, err)
}
+func TestInvalidRenameMode(t *testing.T) {
+ reset()
+
+ confName := tempConfigName + ".json"
+ configFilePath := filepath.Join(configDir, confName)
+ commonConfig := config.GetCommonConfig()
+ commonConfig.RenameMode = 10
+ c := make(map[string]any)
+ c["common"] = commonConfig
+ jsonConf, err := json.Marshal(c)
+ assert.NoError(t, err)
+ err = os.WriteFile(configFilePath, jsonConf, os.ModePerm)
+ assert.NoError(t, err)
+ err = config.LoadConfig(configDir, confName)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, config.GetCommonConfig().RenameMode)
+ err = os.Remove(configFilePath)
+ assert.NoError(t, err)
+}
+
func TestDefenderProviderDriver(t *testing.T) {
if config.GetProviderConf().Driver != dataprovider.SQLiteDataProviderName {
t.Skip("this test is not supported with the current database provider")
@@ -323,6 +343,18 @@ func TestSetGetConfig(t *testing.T) {
if assert.Len(t, config.GetPluginsConfig(), 1) {
assert.Equal(t, pluginConf[0].Type, config.GetPluginsConfig()[0].Type)
}
+ assert.False(t, config.HasKMSPlugin())
+ pluginConf = []plugin.Config{
+ {
+ Type: "notifier",
+ },
+ {
+ Type: "kms",
+ },
+ }
+ config.SetPluginsConfig(pluginConf)
+ assert.Len(t, config.GetPluginsConfig(), 2)
+ assert.True(t, config.HasKMSPlugin())
}
func TestServiceToStart(t *testing.T) {
@@ -508,7 +540,7 @@ func TestOverrideSliceValues(t *testing.T) {
c = make(map[string]any)
c["httpd"] = httpd.Conf{
- Bindings: []httpd.Binding{},
+ Bindings: nil,
}
jsonConf, err = json.Marshal(c)
assert.NoError(t, err)
@@ -679,8 +711,8 @@ func TestPluginsFromEnv(t *testing.T) {
pluginConf := pluginsConf[0]
require.Equal(t, "notifier", pluginConf.Type)
require.Len(t, pluginConf.NotifierOptions.FsEvents, 2)
- require.True(t, util.Contains(pluginConf.NotifierOptions.FsEvents, "upload"))
- require.True(t, util.Contains(pluginConf.NotifierOptions.FsEvents, "download"))
+ require.True(t, slices.Contains(pluginConf.NotifierOptions.FsEvents, "upload"))
+ require.True(t, slices.Contains(pluginConf.NotifierOptions.FsEvents, "download"))
require.Len(t, pluginConf.NotifierOptions.ProviderEvents, 2)
require.Equal(t, "add", pluginConf.NotifierOptions.ProviderEvents[0])
require.Equal(t, "update", pluginConf.NotifierOptions.ProviderEvents[1])
@@ -729,8 +761,8 @@ func TestPluginsFromEnv(t *testing.T) {
pluginConf = pluginsConf[0]
require.Equal(t, "notifier", pluginConf.Type)
require.Len(t, pluginConf.NotifierOptions.FsEvents, 2)
- require.True(t, util.Contains(pluginConf.NotifierOptions.FsEvents, "upload"))
- require.True(t, util.Contains(pluginConf.NotifierOptions.FsEvents, "download"))
+ require.True(t, slices.Contains(pluginConf.NotifierOptions.FsEvents, "upload"))
+ require.True(t, slices.Contains(pluginConf.NotifierOptions.FsEvents, "download"))
require.Len(t, pluginConf.NotifierOptions.ProviderEvents, 2)
require.Equal(t, "add", pluginConf.NotifierOptions.ProviderEvents[0])
require.Equal(t, "update", pluginConf.NotifierOptions.ProviderEvents[1])
@@ -787,8 +819,8 @@ func TestRateLimitersFromEnv(t *testing.T) {
require.Equal(t, 2, limiters[0].Type)
protocols := limiters[0].Protocols
require.Len(t, protocols, 2)
- require.True(t, util.Contains(protocols, common.ProtocolFTP))
- require.True(t, util.Contains(protocols, common.ProtocolSSH))
+ require.True(t, slices.Contains(protocols, common.ProtocolFTP))
+ require.True(t, slices.Contains(protocols, common.ProtocolSSH))
require.True(t, limiters[0].GenerateDefenderEvents)
require.Equal(t, 50, limiters[0].EntriesSoftLimit)
require.Equal(t, 100, limiters[0].EntriesHardLimit)
@@ -799,10 +831,10 @@ func TestRateLimitersFromEnv(t *testing.T) {
require.Equal(t, 2, limiters[1].Type)
protocols = limiters[1].Protocols
require.Len(t, protocols, 4)
- require.True(t, util.Contains(protocols, common.ProtocolFTP))
- require.True(t, util.Contains(protocols, common.ProtocolSSH))
- require.True(t, util.Contains(protocols, common.ProtocolWebDAV))
- require.True(t, util.Contains(protocols, common.ProtocolHTTP))
+ require.True(t, slices.Contains(protocols, common.ProtocolFTP))
+ require.True(t, slices.Contains(protocols, common.ProtocolSSH))
+ require.True(t, slices.Contains(protocols, common.ProtocolWebDAV))
+ require.True(t, slices.Contains(protocols, common.ProtocolHTTP))
require.False(t, limiters[1].GenerateDefenderEvents)
require.Equal(t, 100, limiters[1].EntriesSoftLimit)
require.Equal(t, 150, limiters[1].EntriesHardLimit)
@@ -910,7 +942,6 @@ func TestFTPDBindingsFromEnv(t *testing.T) {
os.Setenv("SFTPGO_FTPD__BINDINGS__0__PORT", "2200")
os.Setenv("SFTPGO_FTPD__BINDINGS__0__APPLY_PROXY_CONFIG", "f")
os.Setenv("SFTPGO_FTPD__BINDINGS__0__TLS_MODE", "2")
- os.Setenv("SFTPGO_FTPD__BINDINGS__0__TLS_SESSION_REUSE", "1")
os.Setenv("SFTPGO_FTPD__BINDINGS__0__FORCE_PASSIVE_IP", "127.0.1.2")
os.Setenv("SFTPGO_FTPD__BINDINGS__0__PASSIVE_IP_OVERRIDES__0__IP", "172.16.1.1")
os.Setenv("SFTPGO_FTPD__BINDINGS__0__PASSIVE_HOST", "127.0.1.3")
@@ -935,7 +966,6 @@ func TestFTPDBindingsFromEnv(t *testing.T) {
os.Unsetenv("SFTPGO_FTPD__BINDINGS__0__PORT")
os.Unsetenv("SFTPGO_FTPD__BINDINGS__0__APPLY_PROXY_CONFIG")
os.Unsetenv("SFTPGO_FTPD__BINDINGS__0__TLS_MODE")
- os.Unsetenv("SFTPGO_FTPD__BINDINGS__0__TLS_SESSION_REUSE")
os.Unsetenv("SFTPGO_FTPD__BINDINGS__0__FORCE_PASSIVE_IP")
os.Unsetenv("SFTPGO_FTPD__BINDINGS__0__PASSIVE_IP_OVERRIDES__0__IP")
os.Unsetenv("SFTPGO_FTPD__BINDINGS__0__PASSIVE_HOST")
@@ -964,7 +994,6 @@ func TestFTPDBindingsFromEnv(t *testing.T) {
require.Equal(t, "127.0.0.1", bindings[0].Address)
require.False(t, bindings[0].ApplyProxyConfig)
require.Equal(t, 2, bindings[0].TLSMode)
- require.Equal(t, 1, bindings[0].TLSSessionReuse)
require.Equal(t, 12, bindings[0].MinTLSVersion)
require.Equal(t, "127.0.1.2", bindings[0].ForcePassiveIP)
require.Len(t, bindings[0].PassiveIPOverrides, 0)
@@ -976,12 +1005,10 @@ func TestFTPDBindingsFromEnv(t *testing.T) {
require.False(t, bindings[0].Debug)
require.Equal(t, 1, bindings[0].PassiveConnectionsSecurity)
require.Equal(t, 0, bindings[0].ActiveConnectionsSecurity)
- require.Equal(t, 0, bindings[0].IgnoreASCIITransferType)
require.Equal(t, 2203, bindings[1].Port)
require.Equal(t, "127.0.1.1", bindings[1].Address)
require.True(t, bindings[1].ApplyProxyConfig) // default value
require.Equal(t, 1, bindings[1].TLSMode)
- require.Equal(t, 0, bindings[1].TLSSessionReuse)
require.Equal(t, 13, bindings[1].MinTLSVersion)
require.Equal(t, "127.0.1.1", bindings[1].ForcePassiveIP)
require.Empty(t, bindings[1].PassiveHost)
@@ -994,7 +1021,6 @@ func TestFTPDBindingsFromEnv(t *testing.T) {
require.Nil(t, bindings[1].TLSCipherSuites)
require.Equal(t, 0, bindings[1].PassiveConnectionsSecurity)
require.Equal(t, 1, bindings[1].ActiveConnectionsSecurity)
- require.Equal(t, 1, bindings[1].IgnoreASCIITransferType)
require.True(t, bindings[1].Debug)
require.Equal(t, "cert.crt", bindings[1].CertificateFile)
require.Equal(t, "cert.key", bindings[1].CertificateKeyFile)
@@ -1074,6 +1100,7 @@ func TestWebDAVBindingsFromEnv(t *testing.T) {
os.Setenv("SFTPGO_WEBDAVD__BINDINGS__1__ENABLE_HTTPS", "0")
os.Setenv("SFTPGO_WEBDAVD__BINDINGS__1__TLS_CIPHER_SUITES", "TLS_RSA_WITH_AES_128_CBC_SHA ")
os.Setenv("SFTPGO_WEBDAVD__BINDINGS__1__TLS_PROTOCOLS", "http/1.1 ")
+ os.Setenv("SFTPGO_WEBDAVD__BINDINGS__1__PROXY_MODE", "1")
os.Setenv("SFTPGO_WEBDAVD__BINDINGS__1__PROXY_ALLOWED", "192.168.10.1")
os.Setenv("SFTPGO_WEBDAVD__BINDINGS__1__CLIENT_IP_PROXY_HEADER", "X-Forwarded-For")
os.Setenv("SFTPGO_WEBDAVD__BINDINGS__1__CLIENT_IP_HEADER_DEPTH", "2")
@@ -1093,6 +1120,7 @@ func TestWebDAVBindingsFromEnv(t *testing.T) {
os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__1__ENABLE_HTTPS")
os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__1__TLS_CIPHER_SUITES")
os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__1__TLS_PROTOCOLS")
+ os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__1__PROXY_MODE")
os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__1__PROXY_ALLOWED")
os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__1__CLIENT_IP_PROXY_HEADER")
os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__1__CLIENT_IP_HEADER_DEPTH")
@@ -1117,6 +1145,7 @@ func TestWebDAVBindingsFromEnv(t *testing.T) {
require.Equal(t, 12, bindings[0].MinTLSVersion)
require.Len(t, bindings[0].TLSCipherSuites, 0)
require.Len(t, bindings[0].Protocols, 0)
+ require.Equal(t, 0, bindings[0].ProxyMode)
require.Empty(t, bindings[0].Prefix)
require.Equal(t, 0, bindings[0].ClientIPHeaderDepth)
require.False(t, bindings[0].DisableWWWAuthHeader)
@@ -1129,6 +1158,7 @@ func TestWebDAVBindingsFromEnv(t *testing.T) {
require.Equal(t, "TLS_RSA_WITH_AES_128_CBC_SHA", bindings[1].TLSCipherSuites[0])
require.Len(t, bindings[1].Protocols, 1)
assert.Equal(t, "http/1.1", bindings[1].Protocols[0])
+ require.Equal(t, 1, bindings[1].ProxyMode)
require.Equal(t, "192.168.10.1", bindings[1].ProxyAllowed[0])
require.Equal(t, "X-Forwarded-For", bindings[1].ClientIPProxyHeader)
require.Equal(t, 2, bindings[1].ClientIPHeaderDepth)
@@ -1139,6 +1169,7 @@ func TestWebDAVBindingsFromEnv(t *testing.T) {
require.True(t, bindings[2].EnableHTTPS)
require.Equal(t, 13, bindings[2].MinTLSVersion)
require.Equal(t, 1, bindings[2].ClientAuthType)
+ require.Equal(t, 0, bindings[2].ProxyMode)
require.Nil(t, bindings[2].TLSCipherSuites)
require.Equal(t, "/dav2", bindings[2].Prefix)
require.Equal(t, "webdav.crt", bindings[2].CertificateFile)
@@ -1167,12 +1198,16 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_WEB_CLIENT", "0")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_REST_API", "0")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLED_LOGIN_METHODS", "3")
+ os.Setenv("SFTPGO_HTTPD__BINDINGS__2__DISABLED_LOGIN_METHODS", "12")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__RENDER_OPENAPI", "0")
+ os.Setenv("SFTPGO_HTTPD__BINDINGS__2__BASE_URL", "https://example.com")
+ os.Setenv("SFTPGO_HTTPD__BINDINGS__2__LANGUAGES", "en,es")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_HTTPS", "1 ")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__MIN_TLS_VERSION", "13")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__CLIENT_AUTH_TYPE", "1")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__TLS_CIPHER_SUITES", " TLS_AES_256_GCM_SHA384 , TLS_CHACHA20_POLY1305_SHA256")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__TLS_PROTOCOLS", "h2, http/1.1")
+ os.Setenv("SFTPGO_HTTPD__BINDINGS__2__PROXY_MODE", "1")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__PROXY_ALLOWED", " 192.168.9.1 , 172.16.25.0/24")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__CLIENT_IP_PROXY_HEADER", "X-Real-IP")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__CLIENT_IP_HEADER_DEPTH", "2")
@@ -1203,11 +1238,14 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CONTENT_SECURITY_POLICY", "script-src $NONCE")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__PERMISSIONS_POLICY", "fullscreen=(), geolocation=()")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CROSS_ORIGIN_OPENER_POLICY", "same-origin")
+ os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CROSS_ORIGIN_RESOURCE_POLICY", "same-site")
+ os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CROSS_ORIGIN_EMBEDDER_POLICY", "require-corp")
+ os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CACHE_CONTROL", "private")
+ os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__REFERRER_POLICY", "no-referrer")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__EXTRA_CSS__0__PATH", "path1")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__EXTRA_CSS__1__PATH", "path2")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__BRANDING__WEB_ADMIN__FAVICON_PATH", "favicon.ico")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__BRANDING__WEB_CLIENT__LOGO_PATH", "logo.png")
- os.Setenv("SFTPGO_HTTPD__BINDINGS__2__BRANDING__WEB_ADMIN__LOGIN_IMAGE_PATH", "login_image.png")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__BRANDING__WEB_CLIENT__DISCLAIMER_NAME", "disclaimer")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__BRANDING__WEB_ADMIN__DISCLAIMER_PATH", "disclaimer.html")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__BRANDING__WEB_CLIENT__DEFAULT_CSS", "default.css")
@@ -1234,10 +1272,14 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_WEB_CLIENT")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_REST_API")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLED_LOGIN_METHODS")
+ os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__DISABLED_LOGIN_METHODS")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__RENDER_OPENAPI")
+ os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__BASE_URL")
+ os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__LANGUAGES")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__CLIENT_AUTH_TYPE")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__TLS_CIPHER_SUITES")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__TLS_PROTOCOLS")
+ os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__PROXY_MODE")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__PROXY_ALLOWED")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__CLIENT_IP_PROXY_HEADER")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__CLIENT_IP_HEADER_DEPTH")
@@ -1268,11 +1310,14 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CONTENT_SECURITY_POLICY")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__PERMISSIONS_POLICY")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CROSS_ORIGIN_OPENER_POLICY")
+ os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CROSS_ORIGIN_RESOURCE_POLICY")
+ os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CROSS_ORIGIN_EMBEDDER_POLICY")
+ os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CACHE_CONTROL")
+ os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__REFERRER_POLICY")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__EXTRA_CSS__0__PATH")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__EXTRA_CSS__1__PATH")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__BRANDING__WEB_ADMIN__FAVICON_PATH")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__BRANDING__WEB_CLIENT__LOGO_PATH")
- os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__BRANDING__WEB_ADMIN__LOGIN_IMAGE_PATH")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__BRANDING__WEB_CLIENT__DISCLAIMER_NAME")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__BRANDING__WEB_ADMIN__DISCLAIMER_PATH")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__BRANDING__WEB_CLIENT__DEFAULT_CSS")
@@ -1294,8 +1339,13 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
require.True(t, bindings[0].EnableWebClient)
require.True(t, bindings[0].EnableRESTAPI)
require.Equal(t, 0, bindings[0].EnabledLoginMethods)
+ require.Equal(t, 0, bindings[0].DisabledLoginMethods)
require.True(t, bindings[0].RenderOpenAPI)
+ require.Empty(t, bindings[0].BaseURL)
+ require.Len(t, bindings[0].Languages, 1)
+ assert.Contains(t, bindings[0].Languages, "en")
require.Len(t, bindings[0].TLSCipherSuites, 1)
+ require.Equal(t, 0, bindings[0].ProxyMode)
require.Empty(t, bindings[0].OIDC.ConfigURL)
require.Equal(t, "TLS_AES_128_GCM_SHA256", bindings[0].TLSCipherSuites[0])
require.Equal(t, 0, bindings[0].HideLoginURL)
@@ -1304,6 +1354,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
require.Len(t, bindings[0].OIDC.Scopes, 3)
require.False(t, bindings[0].OIDC.InsecureSkipSignatureCheck)
require.False(t, bindings[0].OIDC.Debug)
+ require.Empty(t, bindings[0].Security.ReferrerPolicy)
require.Equal(t, 8000, bindings[1].Port)
require.Equal(t, "127.0.0.1", bindings[1].Address)
require.False(t, bindings[1].EnableHTTPS)
@@ -1312,7 +1363,11 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
require.True(t, bindings[1].EnableWebClient)
require.True(t, bindings[1].EnableRESTAPI)
require.Equal(t, 0, bindings[1].EnabledLoginMethods)
+ require.Equal(t, 0, bindings[1].DisabledLoginMethods)
require.True(t, bindings[1].RenderOpenAPI)
+ require.Empty(t, bindings[1].BaseURL)
+ require.Len(t, bindings[1].Languages, 1)
+ assert.Contains(t, bindings[1].Languages, "en")
require.Nil(t, bindings[1].TLSCipherSuites)
require.Equal(t, 1, bindings[1].HideLoginURL)
require.Empty(t, bindings[1].OIDC.ClientID)
@@ -1322,6 +1377,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
require.False(t, bindings[1].Security.Enabled)
require.Equal(t, "Web Admin", bindings[1].Branding.WebAdmin.Name)
require.Equal(t, "WebClient", bindings[1].Branding.WebClient.ShortName)
+ require.Equal(t, 0, bindings[1].ProxyMode)
require.Equal(t, 0, bindings[1].ClientIPHeaderDepth)
require.Equal(t, 9000, bindings[2].Port)
require.Equal(t, "127.0.1.1", bindings[2].Address)
@@ -1331,7 +1387,12 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
require.False(t, bindings[2].EnableWebClient)
require.False(t, bindings[2].EnableRESTAPI)
require.Equal(t, 3, bindings[2].EnabledLoginMethods)
+ require.Equal(t, 12, bindings[2].DisabledLoginMethods)
require.False(t, bindings[2].RenderOpenAPI)
+ require.Equal(t, "https://example.com", bindings[2].BaseURL)
+ require.Len(t, bindings[2].Languages, 2)
+ assert.Contains(t, bindings[2].Languages, "en")
+ assert.Contains(t, bindings[2].Languages, "es")
require.Equal(t, 1, bindings[2].ClientAuthType)
require.Len(t, bindings[2].TLSCipherSuites, 2)
require.Equal(t, "TLS_AES_256_GCM_SHA384", bindings[2].TLSCipherSuites[0])
@@ -1339,6 +1400,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
require.Len(t, bindings[2].Protocols, 2)
require.Equal(t, "h2", bindings[2].Protocols[0])
require.Equal(t, "http/1.1", bindings[2].Protocols[1])
+ require.Equal(t, 1, bindings[2].ProxyMode)
require.Len(t, bindings[2].ProxyAllowed, 2)
require.Equal(t, "192.168.9.1", bindings[2].ProxyAllowed[0])
require.Equal(t, "172.16.25.0/24", bindings[2].ProxyAllowed[1])
@@ -1378,9 +1440,12 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
require.Equal(t, "script-src $NONCE", bindings[2].Security.ContentSecurityPolicy)
require.Equal(t, "fullscreen=(), geolocation=()", bindings[2].Security.PermissionsPolicy)
require.Equal(t, "same-origin", bindings[2].Security.CrossOriginOpenerPolicy)
+ require.Equal(t, "same-site", bindings[2].Security.CrossOriginResourcePolicy)
+ require.Equal(t, "require-corp", bindings[2].Security.CrossOriginEmbedderPolicy)
+ require.Equal(t, "private", bindings[2].Security.CacheControl)
+ require.Equal(t, "no-referrer", bindings[2].Security.ReferrerPolicy)
require.Equal(t, "favicon.ico", bindings[2].Branding.WebAdmin.FaviconPath)
require.Equal(t, "logo.png", bindings[2].Branding.WebClient.LogoPath)
- require.Equal(t, "login_image.png", bindings[2].Branding.WebAdmin.LoginImagePath)
require.Equal(t, "disclaimer", bindings[2].Branding.WebClient.DisclaimerName)
require.Equal(t, "disclaimer.html", bindings[2].Branding.WebAdmin.DisclaimerPath)
require.Equal(t, []string{"default.css"}, bindings[2].Branding.WebClient.DefaultCSS)
diff --git a/internal/dataprovider/actions.go b/internal/dataprovider/actions.go
index 066b7bf7..b61e5b0a 100644
--- a/internal/dataprovider/actions.go
+++ b/internal/dataprovider/actions.go
@@ -21,6 +21,7 @@ import (
"net/url"
"os/exec"
"path/filepath"
+ "slices"
"strings"
"time"
@@ -78,8 +79,8 @@ func executeAction(operation, executor, ip, objectType, objectName, role string,
if config.Actions.Hook == "" {
return
}
- if !util.Contains(config.Actions.ExecuteOn, operation) ||
- !util.Contains(config.Actions.ExecuteFor, objectType) {
+ if !slices.Contains(config.Actions.ExecuteOn, operation) ||
+ !slices.Contains(config.Actions.ExecuteFor, objectType) {
return
}
@@ -141,14 +142,14 @@ func executeNotificationCommand(operation, executor, ip, objectType, objectName,
cmd := exec.CommandContext(ctx, config.Actions.Hook, args...)
cmd.Env = append(env,
- fmt.Sprintf("SFTPGO_PROVIDER_ACTION=%vs", operation),
+ fmt.Sprintf("SFTPGO_PROVIDER_ACTION=%s", operation),
fmt.Sprintf("SFTPGO_PROVIDER_OBJECT_TYPE=%s", objectType),
fmt.Sprintf("SFTPGO_PROVIDER_OBJECT_NAME=%s", objectName),
fmt.Sprintf("SFTPGO_PROVIDER_USERNAME=%s", executor),
fmt.Sprintf("SFTPGO_PROVIDER_IP=%s", ip),
fmt.Sprintf("SFTPGO_PROVIDER_ROLE=%s", role),
fmt.Sprintf("SFTPGO_PROVIDER_TIMESTAMP=%d", util.GetTimeAsMsSinceEpoch(time.Now())),
- fmt.Sprintf("SFTPGO_PROVIDER_OBJECT=%s", util.BytesToString(objectAsJSON)))
+ fmt.Sprintf("SFTPGO_PROVIDER_OBJECT=%s", objectAsJSON))
startTime := time.Now()
err := cmd.Run()
diff --git a/internal/dataprovider/admin.go b/internal/dataprovider/admin.go
index b88cbe00..dea4a20b 100644
--- a/internal/dataprovider/admin.go
+++ b/internal/dataprovider/admin.go
@@ -20,6 +20,7 @@ import (
"fmt"
"net"
"os"
+ "slices"
"strconv"
"strings"
@@ -44,19 +45,12 @@ const (
PermAdminViewConnections = "view_conns"
PermAdminCloseConnections = "close_conns"
PermAdminViewServerStatus = "view_status"
- PermAdminManageAdmins = "manage_admins"
PermAdminManageGroups = "manage_groups"
PermAdminManageFolders = "manage_folders"
- PermAdminManageAPIKeys = "manage_apikeys"
PermAdminQuotaScans = "quota_scans"
- PermAdminManageSystem = "manage_system"
PermAdminManageDefender = "manage_defender"
PermAdminViewDefender = "view_defender"
- PermAdminRetentionChecks = "retention_checks"
PermAdminViewEvents = "view_events"
- PermAdminManageEventRules = "manage_event_rules"
- PermAdminManageRoles = "manage_roles"
- PermAdminManageIPLists = "manage_ip_lists"
PermAdminDisableMFA = "disable_mfa"
)
@@ -72,12 +66,9 @@ const (
var (
validAdminPerms = []string{PermAdminAny, PermAdminAddUsers, PermAdminChangeUsers, PermAdminDeleteUsers,
PermAdminViewUsers, PermAdminManageFolders, PermAdminManageGroups, PermAdminViewConnections,
- PermAdminCloseConnections, PermAdminViewServerStatus, PermAdminManageAdmins, PermAdminManageRoles,
- PermAdminManageEventRules, PermAdminManageAPIKeys, PermAdminQuotaScans, PermAdminManageSystem,
- PermAdminManageDefender, PermAdminViewDefender, PermAdminManageIPLists, PermAdminRetentionChecks,
- PermAdminViewEvents, PermAdminDisableMFA}
- forbiddenPermsForRoleAdmins = []string{PermAdminAny, PermAdminManageAdmins, PermAdminManageSystem,
- PermAdminManageEventRules, PermAdminManageIPLists, PermAdminManageRoles}
+ PermAdminCloseConnections, PermAdminViewServerStatus, PermAdminQuotaScans,
+ PermAdminManageDefender, PermAdminViewDefender, PermAdminViewEvents, PermAdminDisableMFA}
+ forbiddenPermsForRoleAdmins = []string{PermAdminAny}
)
// AdminTOTPConfig defines the time-based one time password configuration
@@ -96,7 +87,7 @@ func (c *AdminTOTPConfig) validate(username string) error {
if c.ConfigName == "" {
return util.NewValidationError("totp: config name is mandatory")
}
- if !util.Contains(mfa.GetAvailableTOTPConfigNames(), c.ConfigName) {
+ if !slices.Contains(mfa.GetAvailableTOTPConfigNames(), c.ConfigName) {
return util.NewValidationError(fmt.Sprintf("totp: config name %q not found", c.ConfigName))
}
if c.Secret.IsEmpty() {
@@ -265,12 +256,7 @@ type Admin struct {
// Last login as unix timestamp in milliseconds
LastLogin int64 `json:"last_login"`
// Role name. If set the admin can only administer users with the same role.
- // Role admins cannot have the following permissions:
- // - manage_admins
- // - manage_apikeys
- // - manage_system
- // - manage_event_rules
- // - manage_roles
+ // Role admins cannot be super administrators
Role string `json:"role,omitempty"`
}
@@ -337,22 +323,18 @@ func (a *Admin) validatePermissions() error {
util.I18nErrorPermissionsRequired,
)
}
- if util.Contains(a.Permissions, PermAdminAny) {
+ if slices.Contains(a.Permissions, PermAdminAny) {
a.Permissions = []string{PermAdminAny}
}
for _, perm := range a.Permissions {
- if !util.Contains(validAdminPerms, perm) {
+ if !slices.Contains(validAdminPerms, perm) {
return util.NewValidationError(fmt.Sprintf("invalid permission: %q", perm))
}
if a.Role != "" {
- if util.Contains(forbiddenPermsForRoleAdmins, perm) {
- deniedPerms := strings.Join(forbiddenPermsForRoleAdmins, ",")
+ if slices.Contains(forbiddenPermsForRoleAdmins, perm) {
return util.NewI18nError(
- util.NewValidationError(fmt.Sprintf("a role admin cannot have the following permissions: %q", deniedPerms)),
+ util.NewValidationError("a role admin cannot be a super admin"),
util.I18nErrorRoleAdminPerms,
- util.I18nErrorArgs(map[string]any{
- "val": deniedPerms,
- }),
)
}
}
@@ -382,11 +364,24 @@ func (a *Admin) validateGroups() error {
return nil
}
-func (a *Admin) validate() error {
+func (a *Admin) applyNamingRules() {
+ a.Username = config.convertName(a.Username)
+ a.Role = config.convertName(a.Role)
+ for idx := range a.Groups {
+ a.Groups[idx].Name = config.convertName(a.Groups[idx].Name)
+ }
+}
+
+func (a *Admin) validate() error { //nolint:gocyclo
a.SetEmptySecretsIfNil()
+ a.applyNamingRules()
+ a.Password = strings.TrimSpace(a.Password)
if a.Username == "" {
return util.NewI18nError(util.NewValidationError("username is mandatory"), util.I18nErrorUsernameRequired)
}
+ if !util.IsNameValid(a.Username) {
+ return util.NewI18nError(errInvalidInput, util.I18nErrorInvalidInput)
+ }
if err := checkReservedUsernames(a.Username); err != nil {
return util.NewI18nError(err, util.I18nErrorReservedUsername)
}
@@ -499,7 +494,7 @@ func (a *Admin) checkUserAndPass(password, ip string) error {
if err := a.CanLogin(ip); err != nil {
return err
}
- if a.Password == "" || password == "" {
+ if a.Password == "" || strings.TrimSpace(password) == "" {
return errors.New("credentials cannot be null or empty")
}
match, err := a.CheckPassword(password)
@@ -559,10 +554,20 @@ func (a *Admin) SetNilSecretsIfEmpty() {
// HasPermission returns true if the admin has the specified permission
func (a *Admin) HasPermission(perm string) bool {
- if util.Contains(a.Permissions, PermAdminAny) {
+ if slices.Contains(a.Permissions, PermAdminAny) {
return true
}
- return util.Contains(a.Permissions, perm)
+ return slices.Contains(a.Permissions, perm)
+}
+
+// HasPermissions returns true if the admin has all the specified permissions
+func (a *Admin) HasPermissions(perms ...string) bool {
+ for _, perm := range perms {
+ if !a.HasPermission(perm) {
+ return false
+ }
+ }
+ return len(perms) > 0
}
// GetAllowedIPAsString returns the allowed IP as comma separated string
diff --git a/internal/dataprovider/apikey.go b/internal/dataprovider/apikey.go
index b6c8499d..b7424dd6 100644
--- a/internal/dataprovider/apikey.go
+++ b/internal/dataprovider/apikey.go
@@ -148,6 +148,9 @@ func (k *APIKey) validate() error {
if k.Name == "" {
return util.NewValidationError("name is mandatory")
}
+ if !util.IsNameValid(k.Name) {
+ return util.NewI18nError(errInvalidInput, util.I18nErrorInvalidInput)
+ }
if k.Scope != APIKeyScopeAdmin && k.Scope != APIKeyScopeUser {
return util.NewValidationError(fmt.Sprintf("invalid scope: %v", k.Scope))
}
diff --git a/internal/dataprovider/bolt.go b/internal/dataprovider/bolt.go
index 01ad0b3e..28adb25f 100644
--- a/internal/dataprovider/bolt.go
+++ b/internal/dataprovider/bolt.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build !nobolt
-// +build !nobolt
package dataprovider
@@ -25,10 +24,13 @@ import (
"fmt"
"net/netip"
"path/filepath"
+ "slices"
"sort"
+ "strconv"
"time"
bolt "go.etcd.io/bbolt"
+ bolterrors "go.etcd.io/bbolt/errors"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/util"
@@ -37,7 +39,7 @@ import (
)
const (
- boltDatabaseVersion = 29
+ boltDatabaseVersion = 34
)
var (
@@ -181,6 +183,50 @@ func (p *BoltProvider) updateAPIKeyLastUse(keyID string) error {
})
}
+func (p *BoltProvider) getAdminSignature(username string) (string, error) {
+ var updatedAt int64
+ err := p.dbHandle.View(func(tx *bolt.Tx) error {
+ bucket, err := p.getAdminsBucket(tx)
+ if err != nil {
+ return err
+ }
+ u := bucket.Get([]byte(username))
+ var admin Admin
+ err = json.Unmarshal(u, &admin)
+ if err != nil {
+ return err
+ }
+ updatedAt = admin.UpdatedAt
+ return nil
+ })
+ if err != nil {
+ return "", err
+ }
+ return strconv.FormatInt(updatedAt, 10), nil
+}
+
+func (p *BoltProvider) getUserSignature(username string) (string, error) {
+ var updatedAt int64
+ err := p.dbHandle.View(func(tx *bolt.Tx) error {
+ bucket, err := p.getUsersBucket(tx)
+ if err != nil {
+ return err
+ }
+ u := bucket.Get([]byte(username))
+ var user User
+ err = json.Unmarshal(u, &user)
+ if err != nil {
+ return err
+ }
+ updatedAt = user.UpdatedAt
+ return nil
+ })
+ if err != nil {
+ return "", err
+ }
+ return strconv.FormatInt(updatedAt, 10), nil
+}
+
func (p *BoltProvider) setUpdatedAt(username string) {
p.dbHandle.Update(func(tx *bolt.Tx) error { //nolint:errcheck
bucket, err := p.getUsersBucket(tx)
@@ -400,9 +446,6 @@ func (p *BoltProvider) addAdmin(admin *Admin) error {
admin.LastLogin = 0
admin.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
admin.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
- sort.Slice(admin.Groups, func(i, j int) bool {
- return admin.Groups[i].Name < admin.Groups[j].Name
- })
for idx := range admin.Groups {
err = p.addAdminToGroupMapping(admin.Username, admin.Groups[idx].Name, groupBucket)
if err != nil {
@@ -461,9 +504,6 @@ func (p *BoltProvider) updateAdmin(admin *Admin) error {
if err = p.addAdminToRole(admin.Username, admin.Role, rolesBucket); err != nil {
return err
}
- sort.Slice(admin.Groups, func(i, j int) bool {
- return admin.Groups[i].Name < admin.Groups[j].Name
- })
for idx := range admin.Groups {
err = p.addAdminToGroupMapping(admin.Username, admin.Groups[idx].Name, groupBucket)
if err != nil {
@@ -675,18 +715,12 @@ func (p *BoltProvider) addUser(user *User) error {
if err := p.addUserToRole(user.Username, user.Role, rolesBucket); err != nil {
return err
}
- sort.Slice(user.VirtualFolders, func(i, j int) bool {
- return user.VirtualFolders[i].Name < user.VirtualFolders[j].Name
- })
for idx := range user.VirtualFolders {
err = p.addRelationToFolderMapping(user.VirtualFolders[idx].Name, user, nil, foldersBucket)
if err != nil {
return err
}
}
- sort.Slice(user.Groups, func(i, j int) bool {
- return user.Groups[i].Name < user.Groups[j].Name
- })
for idx := range user.Groups {
err = p.addUserToGroupMapping(user.Username, user.Groups[idx].Name, groupBucket)
if err != nil {
@@ -1458,9 +1492,6 @@ func (p *BoltProvider) addGroup(group *Group) error {
group.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
group.Users = nil
group.Admins = nil
- sort.Slice(group.VirtualFolders, func(i, j int) bool {
- return group.VirtualFolders[i].Name < group.VirtualFolders[j].Name
- })
for idx := range group.VirtualFolders {
err = p.addRelationToFolderMapping(group.VirtualFolders[idx].Name, nil, group, foldersBucket)
if err != nil {
@@ -1503,9 +1534,6 @@ func (p *BoltProvider) updateGroup(group *Group) error {
return err
}
}
- sort.Slice(group.VirtualFolders, func(i, j int) bool {
- return group.VirtualFolders[i].Name < group.VirtualFolders[j].Name
- })
for idx := range group.VirtualFolders {
err = p.addRelationToFolderMapping(group.VirtualFolders[idx].Name, nil, group, foldersBucket)
if err != nil {
@@ -2088,11 +2116,11 @@ func (p *BoltProvider) addSharedSession(_ Session) error {
return ErrNotImplemented
}
-func (p *BoltProvider) deleteSharedSession(_ string) error {
+func (p *BoltProvider) deleteSharedSession(_ string, _ SessionType) error {
return ErrNotImplemented
}
-func (p *BoltProvider) getSharedSession(_ string) (Session, error) {
+func (p *BoltProvider) getSharedSession(_ string, _ SessionType) (Session, error) {
return Session{}, ErrNotImplemented
}
@@ -3134,15 +3162,16 @@ func (p *BoltProvider) migrateDatabase() error {
case version == boltDatabaseVersion:
providerLog(logger.LevelDebug, "bolt database is up to date, current version: %d", version)
return ErrNoInitRequired
- case version < 28:
- err = fmt.Errorf("database schema version %d is too old, please see the upgrading docs", version)
+ case version < 33:
+ err = errSchemaVersionTooOld(version)
providerLog(logger.LevelError, "%v", err)
logger.ErrorToConsole("%v", err)
return err
- case version == 28:
- logger.InfoToConsole("updating database schema version: %d -> 29", version)
- providerLog(logger.LevelInfo, "updating database schema version: %d -> 29", version)
- return updateBoltDatabaseVersion(p.dbHandle, 29)
+ case version == 33:
+ logger.InfoToConsole("updating database schema version: %d -> 34", version)
+ providerLog(logger.LevelInfo, "updating database schema version: %d -> 34", version)
+ return updateBoltDatabaseVersion(p.dbHandle, 34)
+
default:
if version > boltDatabaseVersion {
providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@@ -3155,7 +3184,7 @@ func (p *BoltProvider) migrateDatabase() error {
}
}
-func (p *BoltProvider) revertDatabase(targetVersion int) error { //nolint:gocyclo
+func (p *BoltProvider) revertDatabase(targetVersion int) error {
dbVersion, err := getBoltDatabaseVersion(p.dbHandle)
if err != nil {
return err
@@ -3164,10 +3193,11 @@ func (p *BoltProvider) revertDatabase(targetVersion int) error { //nolint:gocycl
return errors.New("current version match target version, nothing to do")
}
switch dbVersion.Version {
- case 29:
- logger.InfoToConsole("downgrading database schema version: %d -> 28", dbVersion.Version)
- providerLog(logger.LevelInfo, "downgrading database schema version: %d -> 28", dbVersion.Version)
- return updateBoltDatabaseVersion(p.dbHandle, 28)
+ case 34:
+ logger.InfoToConsole("downgrading database schema version: %d -> 33", dbVersion.Version)
+ providerLog(logger.LevelInfo, "downgrading database schema version: %d -> 33", dbVersion.Version)
+ return updateBoltDatabaseVersion(p.dbHandle, 33)
+
default:
return fmt.Errorf("database schema version not handled: %v", dbVersion.Version)
}
@@ -3177,7 +3207,7 @@ func (p *BoltProvider) resetDatabase() error {
return p.dbHandle.Update(func(tx *bolt.Tx) error {
for _, bucketName := range boltBuckets {
err := tx.DeleteBucket(bucketName)
- if err != nil && !errors.Is(err, bolt.ErrBucketNotFound) {
+ if err != nil && !errors.Is(err, bolterrors.ErrBucketNotFound) {
return fmt.Errorf("unable to remove bucket %v: %w", bucketName, err)
}
}
@@ -3328,7 +3358,7 @@ func (p *BoltProvider) addAdminToRole(username, roleName string, bucket *bolt.Bu
if err != nil {
return err
}
- if !util.Contains(role.Admins, username) {
+ if !slices.Contains(role.Admins, username) {
role.Admins = append(role.Admins, username)
buf, err := json.Marshal(role)
if err != nil {
@@ -3353,7 +3383,7 @@ func (p *BoltProvider) removeAdminFromRole(username, roleName string, bucket *bo
if err != nil {
return err
}
- if util.Contains(role.Admins, username) {
+ if slices.Contains(role.Admins, username) {
var admins []string
for _, admin := range role.Admins {
if admin != username {
@@ -3383,7 +3413,7 @@ func (p *BoltProvider) addUserToRole(username, roleName string, bucket *bolt.Buc
if err != nil {
return err
}
- if !util.Contains(role.Users, username) {
+ if !slices.Contains(role.Users, username) {
role.Users = append(role.Users, username)
buf, err := json.Marshal(role)
if err != nil {
@@ -3408,7 +3438,7 @@ func (p *BoltProvider) removeUserFromRole(username, roleName string, bucket *bol
if err != nil {
return err
}
- if util.Contains(role.Users, username) {
+ if slices.Contains(role.Users, username) {
var users []string
for _, user := range role.Users {
if user != username {
@@ -3436,7 +3466,7 @@ func (p *BoltProvider) addRuleToActionMapping(ruleName, actionName string, bucke
if err != nil {
return err
}
- if !util.Contains(action.Rules, ruleName) {
+ if !slices.Contains(action.Rules, ruleName) {
action.Rules = append(action.Rules, ruleName)
buf, err := json.Marshal(action)
if err != nil {
@@ -3458,7 +3488,7 @@ func (p *BoltProvider) removeRuleFromActionMapping(ruleName, actionName string,
if err != nil {
return err
}
- if util.Contains(action.Rules, ruleName) {
+ if slices.Contains(action.Rules, ruleName) {
var rules []string
for _, r := range action.Rules {
if r != ruleName {
@@ -3485,7 +3515,7 @@ func (p *BoltProvider) addUserToGroupMapping(username, groupname string, bucket
if err != nil {
return err
}
- if !util.Contains(group.Users, username) {
+ if !slices.Contains(group.Users, username) {
group.Users = append(group.Users, username)
buf, err := json.Marshal(group)
if err != nil {
@@ -3530,7 +3560,7 @@ func (p *BoltProvider) addAdminToGroupMapping(username, groupname string, bucket
if err != nil {
return err
}
- if !util.Contains(group.Admins, username) {
+ if !slices.Contains(group.Admins, username) {
group.Admins = append(group.Admins, username)
buf, err := json.Marshal(group)
if err != nil {
@@ -3601,11 +3631,11 @@ func (p *BoltProvider) addRelationToFolderMapping(folderName string, user *User,
return err
}
updated := false
- if user != nil && !util.Contains(folder.Users, user.Username) {
+ if user != nil && !slices.Contains(folder.Users, user.Username) {
folder.Users = append(folder.Users, user.Username)
updated = true
}
- if group != nil && !util.Contains(folder.Groups, group.Name) {
+ if group != nil && !slices.Contains(folder.Groups, group.Name) {
folder.Groups = append(folder.Groups, group.Name)
updated = true
}
@@ -3691,18 +3721,12 @@ func (p *BoltProvider) updateUserRelations(tx *bolt.Tx, user *User, oldUser User
if err = p.removeUserFromRole(oldUser.Username, oldUser.Role, rolesBucket); err != nil {
return err
}
- sort.Slice(user.VirtualFolders, func(i, j int) bool {
- return user.VirtualFolders[i].Name < user.VirtualFolders[j].Name
- })
for idx := range user.VirtualFolders {
err = p.addRelationToFolderMapping(user.VirtualFolders[idx].Name, user, nil, foldersBucket)
if err != nil {
return err
}
}
- sort.Slice(user.Groups, func(i, j int) bool {
- return user.Groups[i].Name < user.Groups[j].Name
- })
for idx := range user.Groups {
err = p.addUserToGroupMapping(user.Username, user.Groups[idx].Name, groupsBucket)
if err != nil {
@@ -3899,7 +3923,7 @@ func getBoltDatabaseVersion(dbHandle *bolt.DB) (schemaVersion, error) {
v := bucket.Get(dbVersionKey)
if v == nil {
dbVersion = schemaVersion{
- Version: 28,
+ Version: 33,
}
return nil
}
diff --git a/internal/dataprovider/bolt_disabled.go b/internal/dataprovider/bolt_disabled.go
index 0116314d..0ec5030a 100644
--- a/internal/dataprovider/bolt_disabled.go
+++ b/internal/dataprovider/bolt_disabled.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build nobolt
-// +build nobolt
package dataprovider
diff --git a/internal/dataprovider/configs.go b/internal/dataprovider/configs.go
index a68abf72..22a9c5a4 100644
--- a/internal/dataprovider/configs.go
+++ b/internal/dataprovider/configs.go
@@ -15,8 +15,12 @@
package dataprovider
import (
+ "bytes"
"encoding/json"
"fmt"
+ "image/png"
+ "net/url"
+ "slices"
"golang.org/x/crypto/ssh"
@@ -28,18 +32,18 @@ import (
// Supported values for host keys, KEXs, ciphers, MACs
var (
supportedHostKeyAlgos = []string{ssh.KeyAlgoRSA}
- supportedPublicKeyAlgos = []string{ssh.KeyAlgoRSA, ssh.InsecureKeyAlgoDSA}
+ supportedPublicKeyAlgos = []string{ssh.KeyAlgoRSA, ssh.InsecureKeyAlgoDSA} //nolint:staticcheck
supportedKexAlgos = []string{
ssh.KeyExchangeDH16SHA512, ssh.InsecureKeyExchangeDH14SHA1, ssh.InsecureKeyExchangeDH1SHA1,
ssh.InsecureKeyExchangeDHGEXSHA1,
}
supportedCiphers = []string{
- ssh.InsecureCipherAES128CBC, ssh.InsecureCipherAES192CBC, ssh.InsecureCipherAES256CBC,
+ ssh.InsecureCipherAES128CBC,
ssh.InsecureCipherTripleDESCBC,
}
supportedMACs = []string{
ssh.HMACSHA512ETM, ssh.HMACSHA512,
- ssh.InsecureHMACSHA1, ssh.InsecureHMACSHA196,
+ ssh.HMACSHA1, ssh.InsecureHMACSHA196,
}
)
@@ -102,7 +106,7 @@ func (c *SFTPDConfigs) validate() error {
if algo == ssh.CertAlgoRSAv01 {
continue
}
- if !util.Contains(supportedHostKeyAlgos, algo) {
+ if !slices.Contains(supportedHostKeyAlgos, algo) {
return util.NewValidationError(fmt.Sprintf("unsupported host key algorithm %q", algo))
}
hostKeyAlgos = append(hostKeyAlgos, algo)
@@ -113,24 +117,27 @@ func (c *SFTPDConfigs) validate() error {
if algo == "diffie-hellman-group18-sha512" || algo == ssh.KeyExchangeDHGEXSHA256 {
continue
}
- if !util.Contains(supportedKexAlgos, algo) {
+ if !slices.Contains(supportedKexAlgos, algo) {
return util.NewValidationError(fmt.Sprintf("unsupported KEX algorithm %q", algo))
}
kexAlgos = append(kexAlgos, algo)
}
c.KexAlgorithms = kexAlgos
for _, cipher := range c.Ciphers {
- if !util.Contains(supportedCiphers, cipher) {
+ if slices.Contains([]string{"aes192-cbc", "aes256-cbc"}, cipher) {
+ continue
+ }
+ if !slices.Contains(supportedCiphers, cipher) {
return util.NewValidationError(fmt.Sprintf("unsupported cipher %q", cipher))
}
}
for _, mac := range c.MACs {
- if !util.Contains(supportedMACs, mac) {
+ if !slices.Contains(supportedMACs, mac) {
return util.NewValidationError(fmt.Sprintf("unsupported MAC algorithm %q", mac))
}
}
for _, algo := range c.PublicKeyAlgos {
- if !util.Contains(supportedPublicKeyAlgos, algo) {
+ if !slices.Contains(supportedPublicKeyAlgos, algo) {
return util.NewValidationError(fmt.Sprintf("unsupported public key algorithm %q", algo))
}
}
@@ -193,19 +200,19 @@ func (c *SMTPOAuth2) validate() error {
if c.ClientID == "" {
return util.NewI18nError(
util.NewValidationError("smtp oauth2: client id is required"),
- util.I18nErrorSMTPClientIDRequired,
+ util.I18nErrorClientIDRequired,
)
}
- if c.ClientSecret == nil {
+ if c.ClientSecret == nil || c.ClientSecret.IsEmpty() {
return util.NewI18nError(
util.NewValidationError("smtp oauth2: client secret is required"),
- util.I18nErrorSMTPClientSecretRequired,
+ util.I18nErrorClientSecretRequired,
)
}
- if c.RefreshToken == nil {
+ if c.RefreshToken == nil || c.RefreshToken.IsEmpty() {
return util.NewI18nError(
util.NewValidationError("smtp oauth2: refresh token is required"),
- util.I18nErrorSMTPRefreshTokenRequired,
+ util.I18nErrorRefreshTokenRequired,
)
}
if err := validateSMTPSecret(c.ClientSecret, "oauth2 client secret"); err != nil {
@@ -305,6 +312,27 @@ func (c *SMTPConfigs) TryDecrypt() error {
return nil
}
+func (c *SMTPConfigs) prepareForRendering() {
+ if c.Password != nil {
+ c.Password.Hide()
+ if c.Password.IsEmpty() {
+ c.Password = nil
+ }
+ }
+ if c.OAuth2.ClientSecret != nil {
+ c.OAuth2.ClientSecret.Hide()
+ if c.OAuth2.ClientSecret.IsEmpty() {
+ c.OAuth2.ClientSecret = nil
+ }
+ }
+ if c.OAuth2.RefreshToken != nil {
+ c.OAuth2.RefreshToken.Hide()
+ if c.OAuth2.RefreshToken.IsEmpty() {
+ c.OAuth2.RefreshToken = nil
+ }
+ }
+}
+
func (c *SMTPConfigs) getACopy() *SMTPConfigs {
var password *kms.Secret
if c.Password != nil {
@@ -387,13 +415,137 @@ func (c *ACMEConfigs) getACopy() *ACMEConfigs {
}
}
+// BrandingConfig defines the branding configuration
+type BrandingConfig struct {
+ Name string `json:"name"`
+ ShortName string `json:"short_name"`
+ Logo []byte `json:"logo"`
+ Favicon []byte `json:"favicon"`
+ DisclaimerName string `json:"disclaimer_name"`
+ DisclaimerURL string `json:"disclaimer_url"`
+}
+
+func (c *BrandingConfig) isEmpty() bool {
+ if c.Name != "" {
+ return false
+ }
+ if c.ShortName != "" {
+ return false
+ }
+ if len(c.Logo) > 0 {
+ return false
+ }
+ if len(c.Favicon) > 0 {
+ return false
+ }
+ if c.DisclaimerName != "" && c.DisclaimerURL != "" {
+ return false
+ }
+ return true
+}
+
+func (*BrandingConfig) validatePNG(b []byte, maxWidth, maxHeight int) error {
+ if len(b) == 0 {
+ return nil
+ }
+ // DecodeConfig is more efficient, but I'm not sure if this would lead to
+ // accepting invalid images in some edge cases and performance does not
+ // matter here.
+ img, err := png.Decode(bytes.NewBuffer(b))
+ if err != nil {
+ return util.NewI18nError(
+ util.NewValidationError("invalid PNG image"),
+ util.I18nErrorInvalidPNG,
+ )
+ }
+ bounds := img.Bounds()
+ if bounds.Dx() > maxWidth || bounds.Dy() > maxHeight {
+ return util.NewI18nError(
+ util.NewValidationError("invalid PNG image size"),
+ util.I18nErrorInvalidPNGSize,
+ )
+ }
+ return nil
+}
+
+func (c *BrandingConfig) validateDisclaimerURL() error {
+ if c.DisclaimerURL == "" {
+ return nil
+ }
+ u, err := url.Parse(c.DisclaimerURL)
+ if err != nil {
+ return util.NewI18nError(
+ util.NewValidationError("invalid disclaimer URL"),
+ util.I18nErrorInvalidDisclaimerURL,
+ )
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return util.NewI18nError(
+ util.NewValidationError("invalid disclaimer URL scheme"),
+ util.I18nErrorInvalidDisclaimerURL,
+ )
+ }
+ return nil
+}
+
+func (c *BrandingConfig) validate() error {
+ if err := c.validateDisclaimerURL(); err != nil {
+ return err
+ }
+ if err := c.validatePNG(c.Logo, 512, 512); err != nil {
+ return err
+ }
+ return c.validatePNG(c.Favicon, 256, 256)
+}
+
+func (c *BrandingConfig) getACopy() BrandingConfig {
+ logo := make([]byte, len(c.Logo))
+ copy(logo, c.Logo)
+ favicon := make([]byte, len(c.Favicon))
+ copy(favicon, c.Favicon)
+
+ return BrandingConfig{
+ Name: c.Name,
+ ShortName: c.ShortName,
+ Logo: logo,
+ Favicon: favicon,
+ DisclaimerName: c.DisclaimerName,
+ DisclaimerURL: c.DisclaimerURL,
+ }
+}
+
+// BrandingConfigs defines the branding configuration for WebAdmin and WebClient UI
+type BrandingConfigs struct {
+ WebAdmin BrandingConfig
+ WebClient BrandingConfig
+}
+
+func (c *BrandingConfigs) isEmpty() bool {
+ return c.WebAdmin.isEmpty() && c.WebClient.isEmpty()
+}
+
+func (c *BrandingConfigs) validate() error {
+ if err := c.WebAdmin.validate(); err != nil {
+ return err
+ }
+ return c.WebClient.validate()
+}
+
+func (c *BrandingConfigs) getACopy() *BrandingConfigs {
+ return &BrandingConfigs{
+ WebAdmin: c.WebAdmin.getACopy(),
+ WebClient: c.WebClient.getACopy(),
+ }
+}
+
// Configs allows to set configuration keys disabled by default without
// modifying the config file or setting env vars
type Configs struct {
- SFTPD *SFTPDConfigs `json:"sftpd,omitempty"`
- SMTP *SMTPConfigs `json:"smtp,omitempty"`
- ACME *ACMEConfigs `json:"acme,omitempty"`
- UpdatedAt int64 `json:"updated_at,omitempty"`
+ SFTPD *SFTPDConfigs `json:"sftpd,omitempty"`
+ SMTP *SMTPConfigs `json:"smtp,omitempty"`
+ ACME *ACMEConfigs `json:"acme,omitempty"`
+ Branding *BrandingConfigs `json:"branding,omitempty"`
+ UpdatedAt int64 `json:"updated_at,omitempty"`
}
func (c *Configs) validate() error {
@@ -412,6 +564,11 @@ func (c *Configs) validate() error {
return err
}
}
+ if c.Branding != nil {
+ if err := c.Branding.validate(); err != nil {
+ return err
+ }
+ }
return nil
}
@@ -428,25 +585,11 @@ func (c *Configs) PrepareForRendering() {
if c.ACME != nil && c.ACME.isEmpty() {
c.ACME = nil
}
+ if c.Branding != nil && c.Branding.isEmpty() {
+ c.Branding = nil
+ }
if c.SMTP != nil {
- if c.SMTP.Password != nil {
- c.SMTP.Password.Hide()
- if c.SMTP.Password.IsEmpty() {
- c.SMTP.Password = nil
- }
- }
- if c.SMTP.OAuth2.ClientSecret != nil {
- c.SMTP.OAuth2.ClientSecret.Hide()
- if c.SMTP.OAuth2.ClientSecret.IsEmpty() {
- c.SMTP.OAuth2.ClientSecret = nil
- }
- }
- if c.SMTP.OAuth2.RefreshToken != nil {
- c.SMTP.OAuth2.RefreshToken.Hide()
- if c.SMTP.OAuth2.RefreshToken.IsEmpty() {
- c.SMTP.OAuth2.RefreshToken = nil
- }
- }
+ c.SMTP.prepareForRendering()
}
}
@@ -470,6 +613,9 @@ func (c *Configs) SetNilsToEmpty() {
if c.ACME == nil {
c.ACME = &ACMEConfigs{}
}
+ if c.Branding == nil {
+ c.Branding = &BrandingConfigs{}
+ }
}
// RenderAsJSON implements the renderer interface used within plugins
@@ -498,6 +644,9 @@ func (c *Configs) getACopy() Configs {
if c.ACME != nil {
result.ACME = c.ACME.getACopy()
}
+ if c.Branding != nil {
+ result.Branding = c.Branding.getACopy()
+ }
result.UpdatedAt = c.UpdatedAt
return result
}
diff --git a/internal/dataprovider/dataprovider.go b/internal/dataprovider/dataprovider.go
index 16d1dada..7ecbbe2d 100644
--- a/internal/dataprovider/dataprovider.go
+++ b/internal/dataprovider/dataprovider.go
@@ -44,6 +44,7 @@ import (
"path/filepath"
"regexp"
"runtime"
+ "slices"
"strconv"
"strings"
"sync"
@@ -89,7 +90,7 @@ const (
CockroachDataProviderName = "cockroachdb"
// DumpVersion defines the version for the dump.
// For restore/load we support the current version and the previous one
- DumpVersion = 16
+ DumpVersion = 17
argonPwdPrefix = "$argon2id$"
bcryptPwdPrefix = "$2a$"
@@ -111,7 +112,6 @@ const (
operationDelete = "delete"
sqlPrefixValidChars = "abcdefghijklmnopqrstuvwxyz_0123456789"
maxHookResponseSize = 1048576 // 1MB
- iso8601UTCFormat = "2006-01-02T15:04:05Z"
)
// Supported algorithms for hashing passwords.
@@ -187,6 +187,8 @@ var (
ErrDuplicatedKey = errors.New("duplicated key not allowed")
// ErrForeignKeyViolated occurs when there is a foreign key constraint violation
ErrForeignKeyViolated = errors.New("violates foreign key constraint")
+ errInvalidInput = util.NewValidationError("Invalid input. Slashes (/ ), colons (:), control characters, and reserved system names are not allowed")
+ tz = ""
isAdminCreated atomic.Bool
validTLSUsernames = []string{string(sdk.TLSUsernameNone), string(sdk.TLSUsernameCN)}
config Config
@@ -209,6 +211,7 @@ var (
sqlTableAdmins string
sqlTableAPIKeys string
sqlTableShares string
+ sqlTableSharesGroupsMapping string
sqlTableDefenderHosts string
sqlTableDefenderEvents string
sqlTableActiveTransfers string
@@ -243,6 +246,7 @@ func initSQLTables() {
sqlTableAdmins = "admins"
sqlTableAPIKeys = "api_keys"
sqlTableShares = "shares"
+ sqlTableSharesGroupsMapping = "shares_groups_mapping"
sqlTableDefenderHosts = "defender_hosts"
sqlTableDefenderEvents = "defender_events"
sqlTableActiveTransfers = "active_transfers"
@@ -518,7 +522,7 @@ type Config struct {
// GetShared returns the provider share mode.
// This method is called before the provider is initialized
func (c *Config) GetShared() int {
- if !util.Contains(sharedProviders, c.Driver) {
+ if !slices.Contains(sharedProviders, c.Driver) {
return 0
}
return c.IsShared
@@ -590,6 +594,16 @@ func (c *Config) doBackup() (string, error) {
return outputFile, nil
}
+// SetTZ sets the configured timezone.
+func SetTZ(val string) {
+ tz = val
+}
+
+// UseLocalTime returns true if local time should be used instead of UTC.
+func UseLocalTime() bool {
+ return tz == "local"
+}
+
// ExecuteBackup executes a backup
func ExecuteBackup() (string, error) {
return config.doBackup()
@@ -759,6 +773,8 @@ type Provider interface {
updateLastLogin(username string) error
updateAdminLastLogin(username string) error
setUpdatedAt(username string)
+ getAdminSignature(username string) (string, error)
+ getUserSignature(username string) (string, error)
getFolders(limit, offset int, order string, minimal bool) ([]vfs.BaseVirtualFolder, error)
getFolderByName(name string) (vfs.BaseVirtualFolder, error)
addFolder(folder *vfs.BaseVirtualFolder) error
@@ -810,8 +826,8 @@ type Provider interface {
cleanupActiveTransfers(before time.Time) error
getActiveTransfers(from time.Time) ([]ActiveTransfer, error)
addSharedSession(session Session) error
- deleteSharedSession(key string) error
- getSharedSession(key string) (Session, error)
+ deleteSharedSession(key string, sessionType SessionType) error
+ getSharedSession(key string, sessionType SessionType) (Session, error)
cleanupSharedSessions(sessionType SessionType, before int64) error
getEventActions(limit, offset int, order string, minimal bool) ([]BaseEventAction, error)
dumpEventActions() ([]BaseEventAction, error)
@@ -874,7 +890,7 @@ func SetTempPath(fsPath string) {
}
func checkSharedMode() {
- if !util.Contains(sharedProviders, config.Driver) {
+ if !slices.Contains(sharedProviders, config.Driver) {
config.IsShared = 0
}
}
@@ -929,12 +945,13 @@ func checkDatabase(checkAdmins bool) error {
if config.UpdateMode == 0 {
err := provider.initializeDatabase()
if err != nil && err != ErrNoInitRequired {
- logger.WarnToConsole("Unable to initialize data provider: %v", err)
- providerLog(logger.LevelError, "Unable to initialize data provider: %v", err)
+ logger.WarnToConsole("unable to initialize data provider: %v", err)
+ providerLog(logger.LevelError, "unable to initialize data provider: %v", err)
return err
}
if err == nil {
- logger.DebugToConsole("Data provider successfully initialized")
+ logger.DebugToConsole("data provider successfully initialized")
+ providerLog(logger.LevelInfo, "data provider successfully initialized")
}
err = provider.migrateDatabase()
if err != nil && err != ErrNoInitRequired {
@@ -1040,6 +1057,7 @@ func validateSQLTablesPrefix() error {
sqlTableAdmins = config.SQLTablesPrefix + sqlTableAdmins
sqlTableAPIKeys = config.SQLTablesPrefix + sqlTableAPIKeys
sqlTableShares = config.SQLTablesPrefix + sqlTableShares
+ sqlTableSharesGroupsMapping = config.SQLTablesPrefix + sqlTableSharesGroupsMapping
sqlTableDefenderEvents = config.SQLTablesPrefix + sqlTableDefenderEvents
sqlTableDefenderHosts = config.SQLTablesPrefix + sqlTableDefenderHosts
sqlTableActiveTransfers = config.SQLTablesPrefix + sqlTableActiveTransfers
@@ -1061,12 +1079,12 @@ func validateSQLTablesPrefix() error {
"api keys %q shares %q defender hosts %q defender events %q transfers %q groups %q "+
"users groups mapping %q admins groups mapping %q groups folders mapping %q shared sessions %q "+
"schema version %q events actions %q events rules %q rules actions mapping %q tasks %q nodes %q roles %q"+
- "ip lists %q configs %q",
+ "ip lists %q share groups mapping %q configs %q",
sqlTableUsers, sqlTableFolders, sqlTableUsersFoldersMapping, sqlTableAdmins, sqlTableAPIKeys,
sqlTableShares, sqlTableDefenderHosts, sqlTableDefenderEvents, sqlTableActiveTransfers, sqlTableGroups,
sqlTableUsersGroupsMapping, sqlTableAdminsGroupsMapping, sqlTableGroupsFoldersMapping, sqlTableSharedSessions,
sqlTableSchemaVersion, sqlTableEventsActions, sqlTableEventsRules, sqlTableRulesActionsMapping,
- sqlTableTasks, sqlTableNodes, sqlTableRoles, sqlTableIPLists, sqlTableConfigs)
+ sqlTableTasks, sqlTableNodes, sqlTableRoles, sqlTableIPLists, sqlTableSharesGroupsMapping, sqlTableConfigs)
}
return nil
}
@@ -1503,6 +1521,15 @@ func UpdateUserQuota(user *User, filesAdd int, sizeAdd int64, reset bool) error
return nil
}
+// UpdateUserFolderQuota updates the quota for the given user and virtual folder.
+func UpdateUserFolderQuota(folder *vfs.VirtualFolder, user *User, filesAdd int, sizeAdd int64, reset bool) {
+ if folder.IsIncludedInUserQuota() {
+ UpdateUserQuota(user, filesAdd, sizeAdd, reset) //nolint:errcheck
+ return
+ }
+ UpdateVirtualFolderQuota(&folder.BaseVirtualFolder, filesAdd, sizeAdd, reset) //nolint:errcheck
+}
+
// UpdateVirtualFolderQuota updates the quota for the given virtual folder adding filesAdd and sizeAdd.
// If reset is true filesAdd and sizeAdd indicates the total files and the total size instead of the difference.
func UpdateVirtualFolderQuota(vfolder *vfs.BaseVirtualFolder, filesAdd int, sizeAdd int64, reset bool) error {
@@ -1693,7 +1720,7 @@ func IPListEntryExists(ipOrNet string, listType IPListType) (IPListEntry, error)
// GetIPListEntries returns the IP list entries applying the specified criteria and search limit
func GetIPListEntries(listType IPListType, filter, from, order string, limit int) ([]IPListEntry, error) {
- if !util.Contains(supportedIPListType, listType) {
+ if !slices.Contains(supportedIPListType, listType) {
return nil, util.NewValidationError(fmt.Sprintf("invalid list type %d", listType))
}
return provider.getIPListEntries(listType, filter, from, order, limit)
@@ -2064,6 +2091,20 @@ func UserExists(username, role string) (User, error) {
return provider.userExists(username, role)
}
+// GetAdminSignature returns the signature for the admin with the specified
+// username.
+func GetAdminSignature(username string) (string, error) {
+ username = config.convertName(username)
+ return provider.getAdminSignature(username)
+}
+
+// GetUserSignature returns the signature for the user with the specified
+// username.
+func GetUserSignature(username string) (string, error) {
+ username = config.convertName(username)
+ return provider.getUserSignature(username)
+}
+
// GetUserWithGroupSettings tries to return the user with the specified username
// loading also the group settings
func GetUserWithGroupSettings(username, role string) (User, error) {
@@ -2106,9 +2147,6 @@ func UpdateUserPassword(username, plainPwd, executor, ipAddress, role string) er
return err
}
userCopy := user.getACopy()
- if err := userCopy.LoadAndApplyGroupSettings(); err != nil {
- return err
- }
userCopy.Password = plainPwd
if err := createUserPasswordHash(&userCopy); err != nil {
return err
@@ -2206,8 +2244,8 @@ func AddSharedSession(session Session) error {
}
// DeleteSharedSession deletes the session with the specified key
-func DeleteSharedSession(key string) error {
- err := provider.deleteSharedSession(key)
+func DeleteSharedSession(key string, sessionType SessionType) error {
+ err := provider.deleteSharedSession(key, sessionType)
if err != nil {
providerLog(logger.LevelError, "unable to add shared session, key %q, err: %v", key, err)
}
@@ -2215,8 +2253,8 @@ func DeleteSharedSession(key string) error {
}
// GetSharedSession retrieves the session with the specified key
-func GetSharedSession(key string) (Session, error) {
- return provider.getSharedSession(key)
+func GetSharedSession(key string, sessionType SessionType) (Session, error) {
+ return provider.getSharedSession(key, sessionType)
}
// CleanupSharedSessions removes the shared session with the specified type and
@@ -2352,7 +2390,7 @@ func GetFolders(limit, offset int, order string, minimal bool) ([]vfs.BaseVirtua
}
func dumpUsers(data *BackupData, scopes []string) error {
- if len(scopes) == 0 || util.Contains(scopes, DumpScopeUsers) {
+ if len(scopes) == 0 || slices.Contains(scopes, DumpScopeUsers) {
users, err := provider.dumpUsers()
if err != nil {
return err
@@ -2363,7 +2401,7 @@ func dumpUsers(data *BackupData, scopes []string) error {
}
func dumpFolders(data *BackupData, scopes []string) error {
- if len(scopes) == 0 || util.Contains(scopes, DumpScopeFolders) {
+ if len(scopes) == 0 || slices.Contains(scopes, DumpScopeFolders) {
folders, err := provider.dumpFolders()
if err != nil {
return err
@@ -2374,7 +2412,7 @@ func dumpFolders(data *BackupData, scopes []string) error {
}
func dumpGroups(data *BackupData, scopes []string) error {
- if len(scopes) == 0 || util.Contains(scopes, DumpScopeGroups) {
+ if len(scopes) == 0 || slices.Contains(scopes, DumpScopeGroups) {
groups, err := provider.dumpGroups()
if err != nil {
return err
@@ -2385,7 +2423,7 @@ func dumpGroups(data *BackupData, scopes []string) error {
}
func dumpAdmins(data *BackupData, scopes []string) error {
- if len(scopes) == 0 || util.Contains(scopes, DumpScopeAdmins) {
+ if len(scopes) == 0 || slices.Contains(scopes, DumpScopeAdmins) {
admins, err := provider.dumpAdmins()
if err != nil {
return err
@@ -2396,7 +2434,7 @@ func dumpAdmins(data *BackupData, scopes []string) error {
}
func dumpAPIKeys(data *BackupData, scopes []string) error {
- if len(scopes) == 0 || util.Contains(scopes, DumpScopeAPIKeys) {
+ if len(scopes) == 0 || slices.Contains(scopes, DumpScopeAPIKeys) {
apiKeys, err := provider.dumpAPIKeys()
if err != nil {
return err
@@ -2407,7 +2445,7 @@ func dumpAPIKeys(data *BackupData, scopes []string) error {
}
func dumpShares(data *BackupData, scopes []string) error {
- if len(scopes) == 0 || util.Contains(scopes, DumpScopeShares) {
+ if len(scopes) == 0 || slices.Contains(scopes, DumpScopeShares) {
shares, err := provider.dumpShares()
if err != nil {
return err
@@ -2418,7 +2456,7 @@ func dumpShares(data *BackupData, scopes []string) error {
}
func dumpActions(data *BackupData, scopes []string) error {
- if len(scopes) == 0 || util.Contains(scopes, DumpScopeActions) {
+ if len(scopes) == 0 || slices.Contains(scopes, DumpScopeActions) {
actions, err := provider.dumpEventActions()
if err != nil {
return err
@@ -2429,7 +2467,7 @@ func dumpActions(data *BackupData, scopes []string) error {
}
func dumpRules(data *BackupData, scopes []string) error {
- if len(scopes) == 0 || util.Contains(scopes, DumpScopeRules) {
+ if len(scopes) == 0 || slices.Contains(scopes, DumpScopeRules) {
rules, err := provider.dumpEventRules()
if err != nil {
return err
@@ -2440,7 +2478,7 @@ func dumpRules(data *BackupData, scopes []string) error {
}
func dumpRoles(data *BackupData, scopes []string) error {
- if len(scopes) == 0 || util.Contains(scopes, DumpScopeRoles) {
+ if len(scopes) == 0 || slices.Contains(scopes, DumpScopeRoles) {
roles, err := provider.dumpRoles()
if err != nil {
return err
@@ -2451,7 +2489,7 @@ func dumpRoles(data *BackupData, scopes []string) error {
}
func dumpIPLists(data *BackupData, scopes []string) error {
- if len(scopes) == 0 || util.Contains(scopes, DumpScopeIPLists) {
+ if len(scopes) == 0 || slices.Contains(scopes, DumpScopeIPLists) {
ipLists, err := provider.dumpIPListEntries()
if err != nil {
return err
@@ -2462,7 +2500,7 @@ func dumpIPLists(data *BackupData, scopes []string) error {
}
func dumpConfigs(data *BackupData, scopes []string) error {
- if len(scopes) == 0 || util.Contains(scopes, DumpScopeConfigs) {
+ if len(scopes) == 0 || slices.Contains(scopes, DumpScopeConfigs) {
configs, err := provider.getConfigs()
if err != nil {
return err
@@ -2519,6 +2557,17 @@ func DumpData(scopes []string) (BackupData, error) {
func ParseDumpData(data []byte) (BackupData, error) {
var dump BackupData
err := json.Unmarshal(data, &dump)
+ if err != nil {
+ return dump, err
+ }
+ if dump.Version < 17 {
+ providerLog(logger.LevelInfo, "updating placeholders for actions restored from dump version %d", dump.Version)
+ eventActions, err := updateEventActionPlaceholders(dump.EventActions)
+ if err != nil {
+ return dump, fmt.Errorf("unable to update event action placeholders for dump version %d: %w", dump.Version, err)
+ }
+ dump.EventActions = eventActions
+ }
return dump, err
}
@@ -2568,9 +2617,8 @@ func createProvider(basePath string) error {
return initializeBoltProvider(basePath)
case MemoryDataProviderName:
if err := initializeMemoryProvider(basePath); err != nil {
- msg := fmt.Sprintf("provider initialized but data loading failed: %v", err)
- logger.Warn(logSender, "", msg)
- logger.WarnToConsole(msg)
+ logger.Warn(logSender, "", "provider initialized but data loading failed: %v", err)
+ logger.WarnToConsole("provider initialized but data loading failed: %v", err)
}
return nil
default:
@@ -2684,7 +2732,7 @@ func validateUserGroups(user *User) error {
groupNames := make(map[string]bool)
for _, g := range user.Groups {
- if g.Type < sdk.GroupTypePrimary && g.Type > sdk.GroupTypeMembership {
+ if g.Type < sdk.GroupTypePrimary || g.Type > sdk.GroupTypeMembership {
return util.NewValidationError(fmt.Sprintf("invalid group type: %v", g.Type))
}
if g.Type == sdk.GroupTypePrimary {
@@ -2715,6 +2763,7 @@ func validateAssociatedVirtualFolders(vfolders []vfs.VirtualFolder) ([]vfs.Virtu
folderNames := make(map[string]bool)
for _, v := range vfolders {
+ v.Name = config.convertName(v.Name)
if v.VirtualPath == "" {
return nil, util.NewI18nError(
util.NewValidationError("mount/virtual path is mandatory"),
@@ -2766,7 +2815,7 @@ func validateUserTOTPConfig(c *UserTOTPConfig, username string) error {
if c.ConfigName == "" {
return util.NewValidationError("totp: config name is mandatory")
}
- if !util.Contains(mfa.GetAvailableTOTPConfigNames(), c.ConfigName) {
+ if !slices.Contains(mfa.GetAvailableTOTPConfigNames(), c.ConfigName) {
return util.NewValidationError(fmt.Sprintf("totp: config name %q not found", c.ConfigName))
}
if c.Secret.IsEmpty() {
@@ -2782,7 +2831,7 @@ func validateUserTOTPConfig(c *UserTOTPConfig, username string) error {
return util.NewValidationError("totp: specify at least one protocol")
}
for _, protocol := range c.Protocols {
- if !util.Contains(MFAProtocols, protocol) {
+ if !slices.Contains(MFAProtocols, protocol) {
return util.NewValidationError(fmt.Sprintf("totp: invalid protocol %q", protocol))
}
}
@@ -2815,7 +2864,7 @@ func validateUserPermissions(permsToCheck map[string][]string) (map[string][]str
return permissions, util.NewValidationError("invalid permissions")
}
for _, p := range perms {
- if !util.Contains(ValidPerms, p) {
+ if !slices.Contains(ValidPerms, p) {
return permissions, util.NewValidationError(fmt.Sprintf("invalid permission: %q", p))
}
}
@@ -2829,7 +2878,7 @@ func validateUserPermissions(permsToCheck map[string][]string) (map[string][]str
if dir != cleanedDir && cleanedDir == "/" {
return permissions, util.NewValidationError(fmt.Sprintf("cannot set permissions for invalid subdirectory: %q is an alias for \"/\"", dir))
}
- if util.Contains(perms, PermAny) {
+ if slices.Contains(perms, PermAny) {
permissions[cleanedDir] = []string{PermAny}
} else {
permissions[cleanedDir] = util.RemoveDuplicates(perms, false)
@@ -2870,11 +2919,18 @@ func validatePublicKeys(user *User) error {
util.I18nErrorPubKeyInvalid,
)
}
+ if out.Type() == ssh.InsecureKeyAlgoDSA { //nolint:staticcheck
+ providerLog(logger.LevelError, "dsa public key not accepted, position: %d", idx)
+ return util.NewI18nError(
+ util.NewValidationError(fmt.Sprintf("DSA key format is insecure and it is not allowed for key at position %d", idx)),
+ util.I18nErrorKeyInsecure,
+ )
+ }
if k, ok := out.(ssh.CryptoPublicKey); ok {
cryptoKey := k.CryptoPublicKey()
if rsaKey, ok := cryptoKey.(*rsa.PublicKey); ok {
if size := rsaKey.N.BitLen(); size < 2048 {
- providerLog(logger.LevelError, "rsa key with size %d not accepted, minimum 2048", size)
+ providerLog(logger.LevelError, "rsa key with size %d at position %d not accepted, minimum 2048", size, idx)
return util.NewI18nError(
util.NewValidationError(fmt.Sprintf("invalid size %d for rsa key at position %d, minimum 2048",
size, idx)),
@@ -2905,7 +2961,7 @@ func validateFiltersPatternExtensions(baseFilters *sdk.BaseUserFilters) error {
util.I18nErrorFilePatternPathInvalid,
)
}
- if util.Contains(filteredPaths, cleanedPath) {
+ if slices.Contains(filteredPaths, cleanedPath) {
return util.NewI18nError(
util.NewValidationError(fmt.Sprintf("duplicate file patterns filter for path %q", f.Path)),
util.I18nErrorFilePatternDuplicated,
@@ -3024,13 +3080,13 @@ func validateFilterProtocols(filters *sdk.BaseUserFilters) error {
return util.NewValidationError("invalid denied_protocols")
}
for _, p := range filters.DeniedProtocols {
- if !util.Contains(ValidProtocols, p) {
+ if !slices.Contains(ValidProtocols, p) {
return util.NewValidationError(fmt.Sprintf("invalid denied protocol %q", p))
}
}
for _, p := range filters.TwoFactorAuthProtocols {
- if !util.Contains(MFAProtocols, p) {
+ if !slices.Contains(MFAProtocols, p) {
return util.NewValidationError(fmt.Sprintf("invalid two factor protocol %q", p))
}
}
@@ -3086,7 +3142,7 @@ func validateBaseFilters(filters *sdk.BaseUserFilters) error {
return util.NewValidationError("invalid denied_login_methods")
}
for _, loginMethod := range filters.DeniedLoginMethods {
- if !util.Contains(ValidLoginMethods, loginMethod) {
+ if !slices.Contains(ValidLoginMethods, loginMethod) {
return util.NewValidationError(fmt.Sprintf("invalid login method: %q", loginMethod))
}
}
@@ -3094,7 +3150,7 @@ func validateBaseFilters(filters *sdk.BaseUserFilters) error {
return err
}
if filters.TLSUsername != "" {
- if !util.Contains(validTLSUsernames, string(filters.TLSUsername)) {
+ if !slices.Contains(validTLSUsernames, string(filters.TLSUsername)) {
return util.NewValidationError(fmt.Sprintf("invalid TLS username: %q", filters.TLSUsername))
}
}
@@ -3104,7 +3160,7 @@ func validateBaseFilters(filters *sdk.BaseUserFilters) error {
}
filters.TLSCerts = certs
for _, opts := range filters.WebClient {
- if !util.Contains(sdk.WebClientOptions, opts) {
+ if !slices.Contains(sdk.WebClientOptions, opts) {
return util.NewValidationError(fmt.Sprintf("invalid web client options %q", opts))
}
}
@@ -3172,19 +3228,19 @@ func validateAccessTimeFilters(filters *sdk.BaseUserFilters) error {
}
func validateCombinedUserFilters(user *User) error {
- if user.Filters.TOTPConfig.Enabled && util.Contains(user.Filters.WebClient, sdk.WebClientMFADisabled) {
+ if user.Filters.TOTPConfig.Enabled && slices.Contains(user.Filters.WebClient, sdk.WebClientMFADisabled) {
return util.NewI18nError(
util.NewValidationError("two-factor authentication cannot be disabled for a user with an active configuration"),
util.I18nErrorDisableActive2FA,
)
}
- if user.Filters.RequirePasswordChange && util.Contains(user.Filters.WebClient, sdk.WebClientPasswordChangeDisabled) {
+ if user.Filters.RequirePasswordChange && slices.Contains(user.Filters.WebClient, sdk.WebClientPasswordChangeDisabled) {
return util.NewI18nError(
util.NewValidationError("you cannot require password change and at the same time disallow it"),
util.I18nErrorPwdChangeConflict,
)
}
- if len(user.Filters.TwoFactorAuthProtocols) > 0 && util.Contains(user.Filters.WebClient, sdk.WebClientMFADisabled) {
+ if len(user.Filters.TwoFactorAuthProtocols) > 0 && slices.Contains(user.Filters.WebClient, sdk.WebClientMFADisabled) {
return util.NewI18nError(
util.NewValidationError("you cannot require two-factor authentication and at the same time disallow it"),
util.I18nError2FAConflict,
@@ -3193,19 +3249,37 @@ func validateCombinedUserFilters(user *User) error {
return nil
}
-func validateBaseParams(user *User) error {
- if user.Username == "" {
- return util.NewI18nError(util.NewValidationError("username is mandatory"), util.I18nErrorUsernameRequired)
- }
- if err := checkReservedUsernames(user.Username); err != nil {
- return util.NewI18nError(err, util.I18nErrorReservedUsername)
- }
+func validateEmails(user *User) error {
if user.Email != "" && !util.IsEmailValid(user.Email) {
return util.NewI18nError(
util.NewValidationError(fmt.Sprintf("email %q is not valid", user.Email)),
util.I18nErrorInvalidEmail,
)
}
+ for _, email := range user.Filters.AdditionalEmails {
+ if !util.IsEmailValid(email) {
+ return util.NewI18nError(
+ util.NewValidationError(fmt.Sprintf("email %q is not valid", email)),
+ util.I18nErrorInvalidEmail,
+ )
+ }
+ }
+ return nil
+}
+
+func validateBaseParams(user *User) error {
+ if user.Username == "" {
+ return util.NewI18nError(util.NewValidationError("username is mandatory"), util.I18nErrorUsernameRequired)
+ }
+ if !util.IsNameValid(user.Username) {
+ return util.NewI18nError(errInvalidInput, util.I18nErrorInvalidInput)
+ }
+ if err := checkReservedUsernames(user.Username); err != nil {
+ return util.NewI18nError(err, util.I18nErrorReservedUsername)
+ }
+ if err := validateEmails(user); err != nil {
+ return err
+ }
if config.NamingRules&1 == 0 && !usernameRegex.MatchString(user.Username) {
return util.NewI18nError(
util.NewValidationError(fmt.Sprintf("username %q is not valid, the following characters are allowed: a-zA-Z0-9-_.~", user.Username)),
@@ -3266,6 +3340,19 @@ func hashPlainPassword(plainPwd string) (string, error) {
func createUserPasswordHash(user *User) error {
if user.Password != "" && !user.IsPasswordHashed() {
+ for _, g := range user.Groups {
+ if g.Type == sdk.GroupTypePrimary {
+ group, err := GroupExists(g.Name)
+ if err != nil {
+ return errors.New("unable to load group password policies")
+ }
+ if minEntropy := group.UserSettings.Filters.PasswordStrength; minEntropy > 0 {
+ if err := passwordvalidator.Validate(user.Password, float64(minEntropy)); err != nil {
+ return util.NewI18nError(util.NewValidationError(err.Error()), util.I18nErrorPasswordComplexity)
+ }
+ }
+ }
+ }
if minEntropy := user.getMinPasswordEntropy(); minEntropy > 0 {
if err := passwordvalidator.Validate(user.Password, minEntropy); err != nil {
return util.NewI18nError(util.NewValidationError(err.Error()), util.I18nErrorPasswordComplexity)
@@ -3288,6 +3375,9 @@ func ValidateFolder(folder *vfs.BaseVirtualFolder) error {
if folder.Name == "" {
return util.NewI18nError(util.NewValidationError("folder name is mandatory"), util.I18nErrorNameRequired)
}
+ if !util.IsNameValid(folder.Name) {
+ return util.NewI18nError(errInvalidInput, util.I18nErrorInvalidInput)
+ }
if config.NamingRules&1 == 0 && !usernameRegex.MatchString(folder.Name) {
return util.NewI18nError(
util.NewValidationError(fmt.Sprintf("folder name %q is not valid, the following characters are allowed: a-zA-Z0-9-_.~", folder.Name)),
@@ -3317,6 +3407,7 @@ func ValidateUser(user *User) error {
user.OIDCCustomFields = nil
user.HasPassword = false
user.SetEmptySecretsIfNil()
+ user.applyNamingRules()
buildUserHomeDir(user)
if err := validateBaseParams(user); err != nil {
return err
@@ -3466,7 +3557,7 @@ func checkUserAndPass(user *User, password, ip, protocol string) (User, error) {
if err != nil {
return *user, ErrInvalidCredentials
}
- if user.Password == "" || password == "" {
+ if user.Password == "" || strings.TrimSpace(password) == "" {
return *user, errors.New("credentials cannot be null or empty")
}
if !user.Filters.Hooks.CheckPasswordDisabled {
@@ -3505,7 +3596,7 @@ func checkUserPasscode(user *User, password, protocol string) (string, error) {
if user.Filters.TOTPConfig.Enabled {
switch protocol {
case protocolFTP:
- if util.Contains(user.Filters.TOTPConfig.Protocols, protocol) {
+ if slices.Contains(user.Filters.TOTPConfig.Protocols, protocol) {
// the TOTP passcode has six digits
pwdLen := len(password)
if pwdLen < 7 {
@@ -3641,7 +3732,8 @@ func comparePbkdf2PasswordAndHash(password, hashedPassword string) (bool, error)
}
func getSSLMode() string {
- if config.Driver == PGSQLDataProviderName || config.Driver == CockroachDataProviderName {
+ switch config.Driver {
+ case PGSQLDataProviderName, CockroachDataProviderName:
switch config.SSLMode {
case 0:
return "disable"
@@ -3656,7 +3748,7 @@ func getSSLMode() string {
case 5:
return "allow"
}
- } else if config.Driver == MySQLDataProviderName {
+ case MySQLDataProviderName:
if config.requireCustomTLSForMySQL() {
return "custom"
}
@@ -3711,7 +3803,7 @@ func doBuiltinKeyboardInteractiveAuth(user *User, client ssh.KeyboardInteractive
if err := user.LoadAndApplyGroupSettings(); err != nil {
return 0, err
}
- hasSecondFactor := user.Filters.TOTPConfig.Enabled && util.Contains(user.Filters.TOTPConfig.Protocols, protocolSSH)
+ hasSecondFactor := user.Filters.TOTPConfig.Enabled && slices.Contains(user.Filters.TOTPConfig.Protocols, protocolSSH)
if !isPartialAuth || !hasSecondFactor {
answers, err := client("", "", []string{"Password: "}, []bool{false})
if err != nil {
@@ -3729,7 +3821,7 @@ func doBuiltinKeyboardInteractiveAuth(user *User, client ssh.KeyboardInteractive
}
func checkKeyboardInteractiveSecondFactor(user *User, client ssh.KeyboardInteractiveChallenge, protocol string) (int, error) {
- if !user.Filters.TOTPConfig.Enabled || !util.Contains(user.Filters.TOTPConfig.Protocols, protocolSSH) {
+ if !user.Filters.TOTPConfig.Enabled || !slices.Contains(user.Filters.TOTPConfig.Protocols, protocolSSH) {
return 1, nil
}
err := user.Filters.TOTPConfig.Secret.TryDecrypt()
@@ -3853,7 +3945,7 @@ func getKeyboardInteractiveAnswers(client ssh.KeyboardInteractiveChallenge, resp
}
if len(answers) == 1 && response.CheckPwd > 0 {
if response.CheckPwd == 2 {
- if !user.Filters.TOTPConfig.Enabled || !util.Contains(user.Filters.TOTPConfig.Protocols, protocolSSH) {
+ if !user.Filters.TOTPConfig.Enabled || !slices.Contains(user.Filters.TOTPConfig.Protocols, protocolSSH) {
providerLog(logger.LevelInfo, "keyboard interactive auth error: unable to check TOTP passcode, TOTP is not enabled for user %q",
user.Username)
return answers, errors.New("TOTP not enabled for SSH protocol")
@@ -4059,7 +4151,7 @@ func getPasswordHookResponse(username, password, ip, protocol string) ([]byte, e
fmt.Sprintf("SFTPGO_AUTHD_IP=%s", ip),
fmt.Sprintf("SFTPGO_AUTHD_PROTOCOL=%s", protocol),
)
- return cmd.Output()
+ return getCmdOutput(cmd, "check_password_hook")
}
func executeCheckPasswordHook(username, password, ip, protocol string) (checkPasswordResponse, error) {
@@ -4115,15 +4207,17 @@ func getPreLoginHookResponse(loginMethod, ip, protocol string, userAsJSON []byte
cmd := exec.CommandContext(ctx, config.PreLoginHook, args...)
cmd.Env = append(env,
- fmt.Sprintf("SFTPGO_LOGIND_USER=%s", util.BytesToString(userAsJSON)),
+ fmt.Sprintf("SFTPGO_LOGIND_USER=%s", userAsJSON),
fmt.Sprintf("SFTPGO_LOGIND_METHOD=%s", loginMethod),
fmt.Sprintf("SFTPGO_LOGIND_IP=%s", ip),
fmt.Sprintf("SFTPGO_LOGIND_PROTOCOL=%s", protocol),
)
- return cmd.Output()
+ return getCmdOutput(cmd, "pre_login_hook")
}
func executePreLoginHook(username, loginMethod, ip, protocol string, oidcTokenFields *map[string]any) (User, error) {
+ var user User
+
u, mergedUser, userAsJSON, err := getUserAndJSONForHook(username, oidcTokenFields)
if err != nil {
return u, err
@@ -4146,55 +4240,41 @@ func executePreLoginHook(username, loginMethod, ip, protocol string, oidcTokenFi
}
return u, nil
}
-
- userID := u.ID
- userUsedQuotaSize := u.UsedQuotaSize
- userUsedQuotaFiles := u.UsedQuotaFiles
- userUsedDownloadTransfer := u.UsedDownloadDataTransfer
- userUsedUploadTransfer := u.UsedUploadDataTransfer
- userLastQuotaUpdate := u.LastQuotaUpdate
- userLastLogin := u.LastLogin
- userFirstDownload := u.FirstDownload
- userFirstUpload := u.FirstUpload
- userLastPwdChange := u.LastPasswordChange
- userCreatedAt := u.CreatedAt
- totpConfig := u.Filters.TOTPConfig
- recoveryCodes := u.Filters.RecoveryCodes
- err = json.Unmarshal(out, &u)
+ err = json.Unmarshal(out, &user)
if err != nil {
- return u, fmt.Errorf("invalid pre-login hook response %q, error: %v", util.BytesToString(out), err)
+ return u, fmt.Errorf("invalid pre-login hook response %q, error: %v", out, err)
}
- u.ID = userID
- u.UsedQuotaSize = userUsedQuotaSize
- u.UsedQuotaFiles = userUsedQuotaFiles
- u.UsedUploadDataTransfer = userUsedUploadTransfer
- u.UsedDownloadDataTransfer = userUsedDownloadTransfer
- u.LastQuotaUpdate = userLastQuotaUpdate
- u.LastLogin = userLastLogin
- u.LastPasswordChange = userLastPwdChange
- u.FirstDownload = userFirstDownload
- u.FirstUpload = userFirstUpload
- u.CreatedAt = userCreatedAt
- if userID == 0 {
- err = provider.addUser(&u)
- } else {
- u.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+ if u.ID > 0 {
+ user.ID = u.ID
+ user.UsedQuotaSize = u.UsedQuotaSize
+ user.UsedQuotaFiles = u.UsedQuotaFiles
+ user.UsedUploadDataTransfer = u.UsedUploadDataTransfer
+ user.UsedDownloadDataTransfer = u.UsedDownloadDataTransfer
+ user.LastQuotaUpdate = u.LastQuotaUpdate
+ user.LastLogin = u.LastLogin
+ user.LastPasswordChange = u.LastPasswordChange
+ user.FirstDownload = u.FirstDownload
+ user.FirstUpload = u.FirstUpload
// preserve TOTP config and recovery codes
- u.Filters.TOTPConfig = totpConfig
- u.Filters.RecoveryCodes = recoveryCodes
- err = provider.updateUser(&u)
- if err == nil {
- webDAVUsersCache.swap(&u, "")
+ user.Filters.TOTPConfig = u.Filters.TOTPConfig
+ user.Filters.RecoveryCodes = u.Filters.RecoveryCodes
+ if err := provider.updateUser(&user); err != nil {
+ return u, err
+ }
+ } else {
+ if err := provider.addUser(&user); err != nil {
+ return u, err
}
}
+ user, err = provider.userExists(user.Username, "")
if err != nil {
return u, err
}
- providerLog(logger.LevelDebug, "user %q added/updated from pre-login hook response, id: %d", username, userID)
- if userID == 0 {
- return provider.userExists(username, "")
+ providerLog(logger.LevelDebug, "user %q added/updated from pre-login hook response, id: %d", username, u.ID)
+ if u.ID > 0 {
+ webDAVUsersCache.swap(&user, "")
}
- return u, nil
+ return user, nil
}
// ExecutePostLoginHook executes the post login hook if defined
@@ -4257,7 +4337,7 @@ func ExecutePostLoginHook(user *User, loginMethod, ip, protocol string, err erro
cmd := exec.CommandContext(ctx, config.PostLoginHook, args...)
cmd.Env = append(env,
- fmt.Sprintf("SFTPGO_LOGIND_USER=%s", util.BytesToString(userAsJSON)),
+ fmt.Sprintf("SFTPGO_LOGIND_USER=%s", userAsJSON),
fmt.Sprintf("SFTPGO_LOGIND_IP=%s", ip),
fmt.Sprintf("SFTPGO_LOGIND_METHOD=%s", loginMethod),
fmt.Sprintf("SFTPGO_LOGIND_STATUS=%s", status),
@@ -4326,7 +4406,7 @@ func getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip,
cmd := exec.CommandContext(ctx, config.ExternalAuthHook, args...)
cmd.Env = append(env,
fmt.Sprintf("SFTPGO_AUTHD_USERNAME=%s", username),
- fmt.Sprintf("SFTPGO_AUTHD_USER=%s", util.BytesToString(userAsJSON)),
+ fmt.Sprintf("SFTPGO_AUTHD_USER=%s", userAsJSON),
fmt.Sprintf("SFTPGO_AUTHD_IP=%s", ip),
fmt.Sprintf("SFTPGO_AUTHD_PASSWORD=%s", password),
fmt.Sprintf("SFTPGO_AUTHD_PUBLIC_KEY=%s", pkey),
@@ -4334,7 +4414,7 @@ func getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip,
fmt.Sprintf("SFTPGO_AUTHD_TLS_CERT=%s", strings.ReplaceAll(tlsCert, "\n", "\\n")),
fmt.Sprintf("SFTPGO_AUTHD_KEYBOARD_INTERACTIVE=%v", keyboardInteractive))
- return cmd.Output()
+ return getCmdOutput(cmd, "external_auth_hook")
}
func updateUserFromExtAuthResponse(user *User, password, pkey string) {
@@ -4442,7 +4522,7 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv
// preserve TOTP config and recovery codes
user.Filters.TOTPConfig = u.Filters.TOTPConfig
user.Filters.RecoveryCodes = u.Filters.RecoveryCodes
- err = provider.updateUser(&user)
+ user, err = updateUserAfterExternalAuth(&user)
if err == nil {
if protocol != protocolWebDAV {
webDAVUsersCache.swap(&user, password)
@@ -4514,7 +4594,7 @@ func doPluginAuth(username, password string, pubKey []byte, ip, protocol string,
// preserve TOTP config and recovery codes
user.Filters.TOTPConfig = u.Filters.TOTPConfig
user.Filters.RecoveryCodes = u.Filters.RecoveryCodes
- err = provider.updateUser(&user)
+ user, err = updateUserAfterExternalAuth(&user)
if err == nil {
if protocol != protocolWebDAV {
webDAVUsersCache.swap(&user, password)
@@ -4530,6 +4610,13 @@ func doPluginAuth(username, password string, pubKey []byte, ip, protocol string,
return provider.userExists(user.Username, "")
}
+func updateUserAfterExternalAuth(user *User) (User, error) {
+ if err := provider.updateUser(user); err != nil {
+ return *user, err
+ }
+ return provider.userExists(user.Username, "")
+}
+
func getUserForHook(username string, oidcTokenFields *map[string]any) (User, User, error) {
u, err := provider.userExists(username, "")
if err != nil {
@@ -4601,6 +4688,59 @@ func isExternalAuthConfigured(loginMethod string) bool {
}
}
+func replaceTemplateVars(input string) string {
+ var result strings.Builder
+ i := 0
+ for i < len(input) {
+ if i+2 <= len(input) && input[i:i+2] == "{{" {
+ if i+2 < len(input) {
+ nextChar := input[i+2]
+ if nextChar == ' ' || nextChar == '.' || nextChar == '-' {
+ // Don't replace if followed by space, dot or minus.
+ result.WriteString("{{")
+ i += 2
+ continue
+ }
+ }
+
+ // Find the closing "}}"
+ closing := strings.Index(input[i:], "}}")
+ if closing != -1 {
+ // Replace with {{. only if it's a proper template variable.
+ result.WriteString("{{.")
+ result.WriteString(input[i+2 : i+closing])
+ result.WriteString("}}")
+ i += closing + 2
+ continue
+ }
+ }
+ result.WriteByte(input[i])
+ i++
+ }
+ return result.String()
+}
+
+func updateEventActionPlaceholders(actions []BaseEventAction) ([]BaseEventAction, error) {
+ var result []BaseEventAction
+
+ for _, action := range actions {
+ options, err := json.Marshal(action.Options)
+ if err != nil {
+ return nil, err
+ }
+ convertedOptions := replaceTemplateVars(string(options))
+ var opts BaseEventActionOptions
+ err = json.Unmarshal([]byte(convertedOptions), &opts)
+ if err != nil {
+ return nil, err
+ }
+ action.Options = opts
+ result = append(result, action)
+ }
+
+ return result, nil
+}
+
func getConfigPath(name, configDir string) string {
if !util.IsFileInputValid(name) {
return ""
@@ -4612,12 +4752,47 @@ func getConfigPath(name, configDir string) string {
}
func checkReservedUsernames(username string) error {
- if util.Contains(reservedUsers, username) {
+ if slices.Contains(reservedUsers, username) {
return util.NewValidationError("this username is reserved")
}
return nil
}
+func errSchemaVersionTooOld(version int) error {
+ return fmt.Errorf("database schema version %d is too old, please see the upgrading docs: https://docs.sftpgo.com/latest/data-provider/#upgrading", version)
+}
+
+func getCmdOutput(cmd *exec.Cmd, sender string) ([]byte, error) {
+ var stdout bytes.Buffer
+ cmd.Stdout = &stdout
+
+ stderr, err := cmd.StderrPipe()
+ if err != nil {
+ return nil, err
+ }
+
+ err = cmd.Start()
+ if err != nil {
+ return nil, err
+ }
+
+ scanner := bufio.NewScanner(stderr)
+
+ go func() {
+ for scanner.Scan() {
+ if out := scanner.Text(); out != "" {
+ logger.Log(logger.LevelWarn, sender, "", "%s", out)
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ logger.Log(logger.LevelError, sender, "", "error reading stderr: %v", err)
+ }
+ }()
+
+ err = cmd.Wait()
+ return stdout.Bytes(), err
+}
+
func providerLog(level logger.LogLevel, format string, v ...any) {
logger.Log(level, logSender, "", format, v...)
}
diff --git a/internal/dataprovider/eventrule.go b/internal/dataprovider/eventrule.go
index 2780860c..959f874c 100644
--- a/internal/dataprovider/eventrule.go
+++ b/internal/dataprovider/eventrule.go
@@ -23,6 +23,7 @@ import (
"net/http"
"path"
"path/filepath"
+ "slices"
"strings"
"time"
@@ -49,17 +50,21 @@ const (
ActionTypeUserExpirationCheck
ActionTypeIDPAccountCheck
ActionTypeUserInactivityCheck
+ ActionTypeRotateLogs
)
var (
supportedEventActions = []int{ActionTypeHTTP, ActionTypeCommand, ActionTypeEmail, ActionTypeFilesystem,
ActionTypeBackup, ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset,
- ActionTypeDataRetentionCheck, ActionTypePasswordExpirationCheck,
- ActionTypeUserExpirationCheck, ActionTypeUserInactivityCheck, ActionTypeIDPAccountCheck}
+ ActionTypeDataRetentionCheck, ActionTypePasswordExpirationCheck, ActionTypeUserExpirationCheck,
+ ActionTypeUserInactivityCheck, ActionTypeIDPAccountCheck, ActionTypeRotateLogs}
+ // EnabledActionCommands defines the system commands that can be executed via EventManager,
+ // an empty list means that no command is allowed to be executed.
+ EnabledActionCommands []string
)
func isActionTypeValid(action int) bool {
- return util.Contains(supportedEventActions, action)
+ return slices.Contains(supportedEventActions, action)
}
func getActionTypeAsString(action int) string {
@@ -88,6 +93,8 @@ func getActionTypeAsString(action int) string {
return util.I18nActionTypeUserInactivityCheck
case ActionTypeIDPAccountCheck:
return util.I18nActionTypeIDPCheck
+ case ActionTypeRotateLogs:
+ return util.I18nActionTypeRotateLogs
default:
return util.I18nActionTypeCommand
}
@@ -112,7 +119,7 @@ var (
)
func isEventTriggerValid(trigger int) bool {
- return util.Contains(supportedEventTriggers, trigger)
+ return slices.Contains(supportedEventTriggers, trigger)
}
func getTriggerTypeAsString(trigger int) string {
@@ -166,7 +173,7 @@ var (
)
func isFilesystemActionValid(value int) bool {
- return util.Contains(supportedFsActions, value)
+ return slices.Contains(supportedFsActions, value)
}
func getFsActionTypeAsString(value int) string {
@@ -335,7 +342,7 @@ func (c *EventActionHTTPConfig) validateMultiparts() error {
)
}
for _, k := range c.Headers {
- if strings.ToLower(k.Key) == "content-type" {
+ if strings.EqualFold(k.Key, "content-type") {
return util.NewI18nError(
util.NewValidationError("content type is automatically set for multipart requests"),
util.I18nErrorMultipartCType,
@@ -377,7 +384,7 @@ func (c *EventActionHTTPConfig) validate(additionalData string) error {
return util.NewValidationError(fmt.Sprintf("could not encrypt HTTP password: %v", err))
}
}
- if !util.Contains(SupportedHTTPActionMethods, c.Method) {
+ if !slices.Contains(SupportedHTTPActionMethods, c.Method) {
return util.NewValidationError(fmt.Sprintf("unsupported HTTP method: %s", c.Method))
}
for _, kv := range c.QueryParameters {
@@ -398,11 +405,11 @@ func (c *EventActionHTTPConfig) GetContext() (context.Context, context.CancelFun
// HasObjectData returns true if the {{ObjectData}} placeholder is defined
func (c *EventActionHTTPConfig) HasObjectData() bool {
- if strings.Contains(c.Body, "{{ObjectData}}") {
+ if strings.Contains(c.Body, "{{ObjectData}}") || strings.Contains(c.Body, "{{ObjectDataString}}") {
return true
}
for _, part := range c.Parts {
- if strings.Contains(part.Body, "{{ObjectData}}") {
+ if strings.Contains(part.Body, "{{ObjectData}}") || strings.Contains(part.Body, "{{ObjectDataString}}") {
return true
}
}
@@ -446,6 +453,11 @@ func (c *EventActionHTTPConfig) GetHTTPClient() *http.Client {
return client
}
+// IsActionCommandAllowed returns true if the specified command is allowed
+func IsActionCommandAllowed(cmd string) bool {
+ return slices.Contains(EnabledActionCommands, cmd)
+}
+
// EventActionCommandConfig defines the configuration for a command event target
type EventActionCommandConfig struct {
Cmd string `json:"cmd,omitempty"`
@@ -458,6 +470,9 @@ func (c *EventActionCommandConfig) validate() error {
if c.Cmd == "" {
return util.NewI18nError(util.NewValidationError("command is required"), util.I18nErrorCommandRequired)
}
+ if !IsActionCommandAllowed(c.Cmd) {
+ return util.NewValidationError(fmt.Sprintf("command %q is not allowed", c.Cmd))
+ }
if !filepath.IsAbs(c.Cmd) {
return util.NewI18nError(
util.NewValidationError("invalid command, it must be an absolute path"),
@@ -656,12 +671,21 @@ func (c *EventActionFsCompress) validate() error {
return nil
}
+// RenameConfig defines the configuration for a filesystem rename
+type RenameConfig struct {
+ // key is the source and target the value
+ KeyValue
+ // This setting only applies to storage providers that support
+ // changing modification times.
+ UpdateModTime bool `json:"update_modtime,omitempty"`
+}
+
// EventActionFilesystemConfig defines the configuration for filesystem actions
type EventActionFilesystemConfig struct {
// Filesystem actions, see the above enum
Type int `json:"type,omitempty"`
- // files/dirs to rename, key is the source and target the value
- Renames []KeyValue `json:"renames,omitempty"`
+ // files/dirs to rename
+ Renames []RenameConfig `json:"renames,omitempty"`
// directories to create
MkDirs []string `json:"mkdirs,omitempty"`
// files/dirs to delete
@@ -702,9 +726,9 @@ func (c *EventActionFilesystemConfig) validateRenames() error {
if len(c.Renames) == 0 {
return util.NewI18nError(util.NewValidationError("no path to rename specified"), util.I18nErrorPathRequired)
}
- for idx, kv := range c.Renames {
- key := strings.TrimSpace(kv.Key)
- value := strings.TrimSpace(kv.Value)
+ for idx, cfg := range c.Renames {
+ key := strings.TrimSpace(cfg.Key)
+ value := strings.TrimSpace(cfg.Value)
if key == "" || value == "" {
return util.NewValidationError("invalid paths to rename")
}
@@ -722,9 +746,12 @@ func (c *EventActionFilesystemConfig) validateRenames() error {
util.I18nErrorRootNotAllowed,
)
}
- c.Renames[idx] = KeyValue{
- Key: key,
- Value: value,
+ c.Renames[idx] = RenameConfig{
+ KeyValue: KeyValue{
+ Key: key,
+ Value: value,
+ },
+ UpdateModTime: cfg.UpdateModTime,
}
}
return nil
@@ -888,7 +915,7 @@ func (c *EventActionFilesystemConfig) getACopy() EventActionFilesystemConfig {
return EventActionFilesystemConfig{
Type: c.Type,
- Renames: cloneKeyValues(c.Renames),
+ Renames: cloneRenameConfigs(c.Renames),
MkDirs: mkdirs,
Deletes: deletes,
Exist: exist,
@@ -1226,6 +1253,15 @@ func (a *BaseEventAction) validate() error {
if a.Name == "" {
return util.NewI18nError(util.NewValidationError("name is mandatory"), util.I18nErrorNameRequired)
}
+ if !util.IsNameValid(a.Name) {
+ return util.NewI18nError(errInvalidInput, util.I18nErrorInvalidInput)
+ }
+ if config.NamingRules&1 == 0 && !usernameRegex.MatchString(a.Name) {
+ return util.NewI18nError(
+ util.NewValidationError(fmt.Sprintf("name %q is not valid, the following characters are allowed: a-zA-Z0-9-_.~", a.Name)),
+ util.I18nErrorInvalidUser,
+ )
+ }
if !isActionTypeValid(a.Type) {
return util.NewValidationError(fmt.Sprintf("invalid action type: %d", a.Type))
}
@@ -1277,7 +1313,7 @@ func (a *EventAction) validateAssociation(trigger int, fsEvents []string) error
}
if trigger == EventTriggerFsEvent {
for _, ev := range fsEvents {
- if !util.Contains(allowedSyncFsEvents, ev) {
+ if !slices.Contains(allowedSyncFsEvents, ev) {
return util.NewI18nError(
util.NewValidationError("sync execution is only supported for upload and pre-* events"),
util.I18nErrorEvSyncUnsupportedFs,
@@ -1320,6 +1356,7 @@ type ConditionOptions struct {
ProviderObjects []string `json:"provider_objects,omitempty"`
MinFileSize int64 `json:"min_size,omitempty"`
MaxFileSize int64 `json:"max_size,omitempty"`
+ EventStatuses []int `json:"event_statuses,omitempty"`
// allow to execute scheduled tasks concurrently from multiple instances
ConcurrentExecution bool `json:"concurrent_execution,omitempty"`
}
@@ -1329,6 +1366,8 @@ func (f *ConditionOptions) getACopy() ConditionOptions {
copy(protocols, f.Protocols)
providerObjects := make([]string, len(f.ProviderObjects))
copy(providerObjects, f.ProviderObjects)
+ statuses := make([]int, len(f.EventStatuses))
+ copy(statuses, f.EventStatuses)
return ConditionOptions{
Names: cloneConditionPatterns(f.Names),
@@ -1339,10 +1378,20 @@ func (f *ConditionOptions) getACopy() ConditionOptions {
ProviderObjects: providerObjects,
MinFileSize: f.MinFileSize,
MaxFileSize: f.MaxFileSize,
+ EventStatuses: statuses,
ConcurrentExecution: f.ConcurrentExecution,
}
}
+func (f *ConditionOptions) validateStatuses() error {
+ for _, status := range f.EventStatuses {
+ if status < 0 || status > 3 {
+ return util.NewValidationError(fmt.Sprintf("invalid event_status %d", status))
+ }
+ }
+ return nil
+}
+
func (f *ConditionOptions) validate() error {
if err := validateConditionPatterns(f.Names); err != nil {
return err
@@ -1358,12 +1407,12 @@ func (f *ConditionOptions) validate() error {
}
for _, p := range f.Protocols {
- if !util.Contains(SupportedRuleConditionProtocols, p) {
+ if !slices.Contains(SupportedRuleConditionProtocols, p) {
return util.NewValidationError(fmt.Sprintf("unsupported rule condition protocol: %q", p))
}
}
for _, p := range f.ProviderObjects {
- if !util.Contains(SupporteRuleConditionProviderObjects, p) {
+ if !slices.Contains(SupporteRuleConditionProviderObjects, p) {
return util.NewValidationError(fmt.Sprintf("unsupported provider object: %q", p))
}
}
@@ -1373,6 +1422,9 @@ func (f *ConditionOptions) validate() error {
util.ByteCountSI(f.MaxFileSize), util.ByteCountSI(f.MinFileSize)))
}
}
+ if err := f.validateStatuses(); err != nil {
+ return err
+ }
if config.IsShared == 0 {
f.ConcurrentExecution = false
}
@@ -1465,16 +1517,16 @@ func (c *EventConditions) validate(trigger int) error {
)
}
for _, ev := range c.FsEvents {
- if !util.Contains(SupportedFsEvents, ev) {
+ if !slices.Contains(SupportedFsEvents, ev) {
return util.NewValidationError(fmt.Sprintf("unsupported fs event: %q", ev))
}
}
case EventTriggerProviderEvent:
c.FsEvents = nil
c.Schedules = nil
- c.Options.GroupNames = nil
c.Options.FsPaths = nil
c.Options.Protocols = nil
+ c.Options.EventStatuses = nil
c.Options.MinFileSize = 0
c.Options.MaxFileSize = 0
c.IDPLoginEvent = 0
@@ -1485,7 +1537,7 @@ func (c *EventConditions) validate(trigger int) error {
)
}
for _, ev := range c.ProviderEvents {
- if !util.Contains(SupportedProviderEvents, ev) {
+ if !slices.Contains(SupportedProviderEvents, ev) {
return util.NewValidationError(fmt.Sprintf("unsupported provider event: %q", ev))
}
}
@@ -1494,6 +1546,7 @@ func (c *EventConditions) validate(trigger int) error {
c.ProviderEvents = nil
c.Options.FsPaths = nil
c.Options.Protocols = nil
+ c.Options.EventStatuses = nil
c.Options.MinFileSize = 0
c.Options.MaxFileSize = 0
c.Options.ProviderObjects = nil
@@ -1509,6 +1562,7 @@ func (c *EventConditions) validate(trigger int) error {
c.Options.RoleNames = nil
c.Options.FsPaths = nil
c.Options.Protocols = nil
+ c.Options.EventStatuses = nil
c.Options.MinFileSize = 0
c.Options.MaxFileSize = 0
c.Schedules = nil
@@ -1518,6 +1572,7 @@ func (c *EventConditions) validate(trigger int) error {
c.ProviderEvents = nil
c.Options.FsPaths = nil
c.Options.Protocols = nil
+ c.Options.EventStatuses = nil
c.Options.MinFileSize = 0
c.Options.MaxFileSize = 0
c.Options.ProviderObjects = nil
@@ -1531,10 +1586,11 @@ func (c *EventConditions) validate(trigger int) error {
c.Options.RoleNames = nil
c.Options.FsPaths = nil
c.Options.Protocols = nil
+ c.Options.EventStatuses = nil
c.Options.MinFileSize = 0
c.Options.MaxFileSize = 0
c.Schedules = nil
- if !util.Contains(supportedIDPLoginEvents, c.IDPLoginEvent) {
+ if !slices.Contains(supportedIDPLoginEvents, c.IDPLoginEvent) {
return util.NewValidationError(fmt.Sprintf("invalid Identity Provider login event %d", c.IDPLoginEvent))
}
default:
@@ -1544,6 +1600,7 @@ func (c *EventConditions) validate(trigger int) error {
c.Options.RoleNames = nil
c.Options.FsPaths = nil
c.Options.Protocols = nil
+ c.Options.EventStatuses = nil
c.Options.MinFileSize = 0
c.Options.MaxFileSize = 0
c.Schedules = nil
@@ -1624,10 +1681,19 @@ func (r *EventRule) isStatusValid() bool {
return r.Status >= 0 && r.Status <= 1
}
-func (r *EventRule) validate() error {
+func (r *EventRule) validate() error { //nolint:gocyclo
if r.Name == "" {
return util.NewI18nError(util.NewValidationError("name is mandatory"), util.I18nErrorNameRequired)
}
+ if !util.IsNameValid(r.Name) {
+ return util.NewI18nError(errInvalidInput, util.I18nErrorInvalidInput)
+ }
+ if config.NamingRules&1 == 0 && !usernameRegex.MatchString(r.Name) {
+ return util.NewI18nError(
+ util.NewValidationError(fmt.Sprintf("name %q is not valid, the following characters are allowed: a-zA-Z0-9-_.~", r.Name)),
+ util.I18nErrorInvalidUser,
+ )
+ }
if !r.isStatusValid() {
return util.NewValidationError(fmt.Sprintf("invalid event rule status: %d", r.Status))
}
@@ -1687,7 +1753,7 @@ func (r *EventRule) validateMandatorySyncActions() error {
return nil
}
for _, ev := range r.Conditions.FsEvents {
- if util.Contains(mandatorySyncFsEvents, ev) {
+ if slices.Contains(mandatorySyncFsEvents, ev) {
return util.NewI18nError(
util.NewValidationError(fmt.Sprintf("event %q requires at least a sync action", ev)),
util.I18nErrorRuleSyncActionRequired,
@@ -1705,7 +1771,7 @@ func (r *EventRule) checkIPBlockedAndCertificateActions() error {
ActionTypeDataRetentionCheck, ActionTypeFilesystem, ActionTypePasswordExpirationCheck,
ActionTypeUserExpirationCheck}
for _, action := range r.Actions {
- if util.Contains(unavailableActions, action.Type) {
+ if slices.Contains(unavailableActions, action.Type) {
return fmt.Errorf("action %q, type %q is not supported for event trigger %q",
action.Name, getActionTypeAsString(action.Type), getTriggerTypeAsString(r.Trigger))
}
@@ -1721,7 +1787,7 @@ func (r *EventRule) checkProviderEventActions(providerObjectType string) error {
ActionTypeDataRetentionCheck, ActionTypeFilesystem,
ActionTypePasswordExpirationCheck, ActionTypeUserExpirationCheck}
for _, action := range r.Actions {
- if util.Contains(userSpecificActions, action.Type) && providerObjectType != actionObjectUser {
+ if slices.Contains(userSpecificActions, action.Type) && providerObjectType != actionObjectUser {
return fmt.Errorf("action %q, type %q is only supported for provider user events",
action.Name, getActionTypeAsString(action.Type))
}
@@ -1829,6 +1895,20 @@ func (r *EventRule) RenderAsJSON(reload bool) ([]byte, error) {
return json.Marshal(r)
}
+func cloneRenameConfigs(renames []RenameConfig) []RenameConfig {
+ res := make([]RenameConfig, 0, len(renames))
+ for _, c := range renames {
+ res = append(res, RenameConfig{
+ KeyValue: KeyValue{
+ Key: c.Key,
+ Value: c.Value,
+ },
+ UpdateModTime: c.UpdateModTime,
+ })
+ }
+ return res
+}
+
func cloneKeyValues(keyVals []KeyValue) []KeyValue {
res := make([]KeyValue, 0, len(keyVals))
for _, kv := range keyVals {
diff --git a/internal/dataprovider/group.go b/internal/dataprovider/group.go
index 8577efda..fa09fda4 100644
--- a/internal/dataprovider/group.go
+++ b/internal/dataprovider/group.go
@@ -132,11 +132,22 @@ func (g *Group) hasRedactedSecret() bool {
return g.UserSettings.FsConfig.HasRedactedSecret()
}
+func (g *Group) applyNamingRules() {
+ g.Name = config.convertName(g.Name)
+ for idx := range g.VirtualFolders {
+ g.VirtualFolders[idx].Name = config.convertName(g.VirtualFolders[idx].Name)
+ }
+}
+
func (g *Group) validate() error {
g.SetEmptySecretsIfNil()
+ g.applyNamingRules()
if g.Name == "" {
return util.NewI18nError(util.NewValidationError("name is mandatory"), util.I18nErrorNameRequired)
}
+ if !util.IsNameValid(g.Name) {
+ return util.NewI18nError(errInvalidInput, util.I18nErrorInvalidInput)
+ }
if config.NamingRules&1 == 0 && !usernameRegex.MatchString(g.Name) {
return util.NewI18nError(
util.NewValidationError(fmt.Sprintf("name %q is not valid, the following characters are allowed: a-zA-Z0-9-_.~", g.Name)),
diff --git a/internal/dataprovider/iplist.go b/internal/dataprovider/iplist.go
index 739ef472..bc3b5b99 100644
--- a/internal/dataprovider/iplist.go
+++ b/internal/dataprovider/iplist.go
@@ -19,6 +19,7 @@ import (
"fmt"
"net"
"net/netip"
+ "slices"
"strings"
"sync"
"sync/atomic"
@@ -85,7 +86,7 @@ var (
// CheckIPListType returns an error if the provided IP list type is not valid
func CheckIPListType(t IPListType) error {
- if !util.Contains(supportedIPListType, t) {
+ if !slices.Contains(supportedIPListType, t) {
return util.NewValidationError(fmt.Sprintf("invalid list type %d", t))
}
return nil
@@ -417,6 +418,10 @@ func (l *IPList) IsListed(ip, protocol string) (bool, int, error) {
l.mu.RLock()
defer l.mu.RUnlock()
+ if l.Ranges.Len() == 0 {
+ return false, 0, nil
+ }
+
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
return false, 0, fmt.Errorf("invalid IP %s", ip)
diff --git a/internal/dataprovider/memory.go b/internal/dataprovider/memory.go
index 05d72352..e8595291 100644
--- a/internal/dataprovider/memory.go
+++ b/internal/dataprovider/memory.go
@@ -22,7 +22,9 @@ import (
"net/netip"
"os"
"path/filepath"
+ "slices"
"sort"
+ "strconv"
"sync"
"time"
@@ -206,6 +208,32 @@ func (p *MemoryProvider) updateAPIKeyLastUse(keyID string) error {
return nil
}
+func (p *MemoryProvider) getAdminSignature(username string) (string, error) {
+ p.dbHandle.Lock()
+ defer p.dbHandle.Unlock()
+ if p.dbHandle.isClosed {
+ return "", errMemoryProviderClosed
+ }
+ admin, err := p.adminExistsInternal(username)
+ if err != nil {
+ return "", err
+ }
+ return strconv.FormatInt(admin.UpdatedAt, 10), nil
+}
+
+func (p *MemoryProvider) getUserSignature(username string) (string, error) {
+ p.dbHandle.Lock()
+ defer p.dbHandle.Unlock()
+ if p.dbHandle.isClosed {
+ return "", errMemoryProviderClosed
+ }
+ user, err := p.userExistsInternal(username)
+ if err != nil {
+ return "", err
+ }
+ return strconv.FormatInt(user.UpdatedAt, 10), nil
+}
+
func (p *MemoryProvider) setUpdatedAt(username string) {
p.dbHandle.Lock()
defer p.dbHandle.Unlock()
@@ -348,9 +376,6 @@ func (p *MemoryProvider) addUser(user *User) error {
if err := p.addUserToRole(user.Username, user.Role); err != nil {
return err
}
- sort.Slice(user.Groups, func(i, j int) bool {
- return user.Groups[i].Name < user.Groups[j].Name
- })
var mappedGroups []string
for idx := range user.Groups {
if err = p.addUserToGroupMapping(user.Username, user.Groups[idx].Name); err != nil {
@@ -362,9 +387,6 @@ func (p *MemoryProvider) addUser(user *User) error {
}
mappedGroups = append(mappedGroups, user.Groups[idx].Name)
}
- sort.Slice(user.VirtualFolders, func(i, j int) bool {
- return user.VirtualFolders[i].Name < user.VirtualFolders[j].Name
- })
var mappedFolders []string
for idx := range user.VirtualFolders {
if err = p.addUserToFolderMapping(user.Username, user.VirtualFolders[idx].Name); err != nil {
@@ -410,9 +432,6 @@ func (p *MemoryProvider) updateUser(user *User) error { //nolint:gocyclo
for idx := range u.Groups {
p.removeUserFromGroupMapping(u.Username, u.Groups[idx].Name)
}
- sort.Slice(user.Groups, func(i, j int) bool {
- return user.Groups[i].Name < user.Groups[j].Name
- })
for idx := range user.Groups {
if err = p.addUserToGroupMapping(user.Username, user.Groups[idx].Name); err != nil {
// try to add old mapping
@@ -428,9 +447,6 @@ func (p *MemoryProvider) updateUser(user *User) error { //nolint:gocyclo
for _, oldFolder := range u.VirtualFolders {
p.removeRelationFromFolderMapping(oldFolder.Name, u.Username, "")
}
- sort.Slice(user.VirtualFolders, func(i, j int) bool {
- return user.VirtualFolders[i].Name < user.VirtualFolders[j].Name
- })
for idx := range user.VirtualFolders {
if err = p.addUserToFolderMapping(user.Username, user.VirtualFolders[idx].Name); err != nil {
// try to add old mapping
@@ -743,9 +759,6 @@ func (p *MemoryProvider) addAdmin(admin *Admin) error {
return err
}
var mappedAdmins []string
- sort.Slice(admin.Groups, func(i, j int) bool {
- return admin.Groups[i].Name < admin.Groups[j].Name
- })
for idx := range admin.Groups {
if err = p.addAdminToGroupMapping(admin.Username, admin.Groups[idx].Name); err != nil {
// try to remove group mapping
@@ -788,9 +801,6 @@ func (p *MemoryProvider) updateAdmin(admin *Admin) error {
for idx := range a.Groups {
p.removeAdminFromGroupMapping(a.Username, a.Groups[idx].Name)
}
- sort.Slice(admin.Groups, func(i, j int) bool {
- return admin.Groups[i].Name < admin.Groups[j].Name
- })
for idx := range admin.Groups {
if err = p.addAdminToGroupMapping(admin.Username, admin.Groups[idx].Name); err != nil {
// try to add old mapping
@@ -1054,9 +1064,6 @@ func (p *MemoryProvider) addGroup(group *Group) error {
group.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
group.Users = nil
group.Admins = nil
- sort.Slice(group.VirtualFolders, func(i, j int) bool {
- return group.VirtualFolders[i].Name < group.VirtualFolders[j].Name
- })
var mappedFolders []string
for idx := range group.VirtualFolders {
if err = p.addGroupToFolderMapping(group.Name, group.VirtualFolders[idx].Name); err != nil {
@@ -1090,9 +1097,6 @@ func (p *MemoryProvider) updateGroup(group *Group) error {
for _, oldFolder := range g.VirtualFolders {
p.removeRelationFromFolderMapping(oldFolder.Name, "", g.Name)
}
- sort.Slice(group.VirtualFolders, func(i, j int) bool {
- return group.VirtualFolders[i].Name < group.VirtualFolders[j].Name
- })
for idx := range group.VirtualFolders {
if err = p.addGroupToFolderMapping(group.Name, group.VirtualFolders[idx].Name); err != nil {
// try to add old mapping
@@ -1210,7 +1214,7 @@ func (p *MemoryProvider) addRuleToActionMapping(ruleName, actionName string) err
if err != nil {
return util.NewGenericError(fmt.Sprintf("action %q does not exist", actionName))
}
- if !util.Contains(a.Rules, ruleName) {
+ if !slices.Contains(a.Rules, ruleName) {
a.Rules = append(a.Rules, ruleName)
p.dbHandle.actions[actionName] = a
}
@@ -1223,7 +1227,7 @@ func (p *MemoryProvider) removeRuleFromActionMapping(ruleName, actionName string
providerLog(logger.LevelWarn, "action %q does not exist, cannot remove from mapping", actionName)
return
}
- if util.Contains(a.Rules, ruleName) {
+ if slices.Contains(a.Rules, ruleName) {
var rules []string
for _, r := range a.Rules {
if r != ruleName {
@@ -1240,7 +1244,7 @@ func (p *MemoryProvider) addAdminToGroupMapping(username, groupname string) erro
if err != nil {
return err
}
- if !util.Contains(g.Admins, username) {
+ if !slices.Contains(g.Admins, username) {
g.Admins = append(g.Admins, username)
p.dbHandle.groups[groupname] = g
}
@@ -1283,7 +1287,7 @@ func (p *MemoryProvider) addUserToGroupMapping(username, groupname string) error
if err != nil {
return err
}
- if !util.Contains(g.Users, username) {
+ if !slices.Contains(g.Users, username) {
g.Users = append(g.Users, username)
p.dbHandle.groups[groupname] = g
}
@@ -1313,7 +1317,7 @@ func (p *MemoryProvider) addAdminToRole(username, role string) error {
if err != nil {
return fmt.Errorf("%w: role %q does not exist", ErrForeignKeyViolated, role)
}
- if !util.Contains(r.Admins, username) {
+ if !slices.Contains(r.Admins, username) {
r.Admins = append(r.Admins, username)
p.dbHandle.roles[role] = r
}
@@ -1347,7 +1351,7 @@ func (p *MemoryProvider) addUserToRole(username, role string) error {
if err != nil {
return fmt.Errorf("%w: role %q does not exist", ErrForeignKeyViolated, role)
}
- if !util.Contains(r.Users, username) {
+ if !slices.Contains(r.Users, username) {
r.Users = append(r.Users, username)
p.dbHandle.roles[role] = r
}
@@ -1378,7 +1382,7 @@ func (p *MemoryProvider) addUserToFolderMapping(username, foldername string) err
if err != nil {
return util.NewGenericError(fmt.Sprintf("unable to get folder %q: %v", foldername, err))
}
- if !util.Contains(f.Users, username) {
+ if !slices.Contains(f.Users, username) {
f.Users = append(f.Users, username)
p.dbHandle.vfolders[foldername] = f
}
@@ -1390,7 +1394,7 @@ func (p *MemoryProvider) addGroupToFolderMapping(name, foldername string) error
if err != nil {
return util.NewGenericError(fmt.Sprintf("unable to get folder %q: %v", foldername, err))
}
- if !util.Contains(f.Groups, name) {
+ if !slices.Contains(f.Groups, name) {
f.Groups = append(f.Groups, name)
p.dbHandle.vfolders[foldername] = f
}
@@ -2089,11 +2093,11 @@ func (p *MemoryProvider) addSharedSession(_ Session) error {
return ErrNotImplemented
}
-func (p *MemoryProvider) deleteSharedSession(_ string) error {
+func (p *MemoryProvider) deleteSharedSession(_ string, _ SessionType) error {
return ErrNotImplemented
}
-func (p *MemoryProvider) getSharedSession(_ string) (Session, error) {
+func (p *MemoryProvider) getSharedSession(_ string, _ SessionType) (Session, error) {
return Session{}, ErrNotImplemented
}
diff --git a/internal/dataprovider/mysql.go b/internal/dataprovider/mysql.go
index 659637d9..f6be87b0 100644
--- a/internal/dataprovider/mysql.go
+++ b/internal/dataprovider/mysql.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build !nomysql
-// +build !nomysql
package dataprovider
@@ -39,11 +38,11 @@ import (
const (
mysqlResetSQL = "DROP TABLE IF EXISTS `{{api_keys}}` CASCADE;" +
- "DROP TABLE IF EXISTS `{{folders_mapping}}` CASCADE;" +
"DROP TABLE IF EXISTS `{{users_folders_mapping}}` CASCADE;" +
"DROP TABLE IF EXISTS `{{users_groups_mapping}}` CASCADE;" +
"DROP TABLE IF EXISTS `{{admins_groups_mapping}}` CASCADE;" +
"DROP TABLE IF EXISTS `{{groups_folders_mapping}}` CASCADE;" +
+ "DROP TABLE IF EXISTS `{{shares_groups_mapping}}` CASCADE;" +
"DROP TABLE IF EXISTS `{{admins}}` CASCADE;" +
"DROP TABLE IF EXISTS `{{folders}}` CASCADE;" +
"DROP TABLE IF EXISTS `{{shares}}` CASCADE;" +
@@ -84,8 +83,8 @@ const (
"CREATE TABLE `{{groups}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, " +
"`name` varchar(255) NOT NULL UNIQUE, `description` varchar(512) NULL, `created_at` bigint NOT NULL, " +
"`updated_at` bigint NOT NULL, `user_settings` longtext NULL);" +
- "CREATE TABLE `{{shared_sessions}}` (`key` varchar(128) NOT NULL PRIMARY KEY, " +
- "`data` longtext NOT NULL, `type` integer NOT NULL, `timestamp` bigint NOT NULL);" +
+ "CREATE TABLE `{{shared_sessions}}` (`key` varchar(128) NOT NULL, `type` integer NOT NULL, `data` longtext NOT NULL, " +
+ "`timestamp` bigint NOT NULL, PRIMARY KEY (`key`, `type`));" +
"CREATE TABLE `{{users}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `username` varchar(255) NOT NULL UNIQUE, " +
"`status` integer NOT NULL, `expiration_date` bigint NOT NULL, `description` varchar(512) NULL, `password` longtext NULL, " +
"`public_keys` longtext NULL, `home_dir` longtext NOT NULL, `uid` bigint NOT NULL, `gid` bigint NOT NULL, " +
@@ -95,38 +94,41 @@ const (
"`last_login` bigint NOT NULL, `filters` longtext NULL, `filesystem` longtext NULL, `additional_info` longtext NULL, " +
"`created_at` bigint NOT NULL, `updated_at` bigint NOT NULL, `email` varchar(255) NULL, " +
"`upload_data_transfer` integer NOT NULL, `download_data_transfer` integer NOT NULL, " +
- "`total_data_transfer` integer NOT NULL, `used_upload_data_transfer` integer NOT NULL, " +
- "`used_download_data_transfer` integer NOT NULL, `deleted_at` bigint NOT NULL, `first_download` bigint NOT NULL, " +
+ "`total_data_transfer` integer NOT NULL, `used_upload_data_transfer` bigint NOT NULL, " +
+ "`used_download_data_transfer` bigint NOT NULL, `deleted_at` bigint NOT NULL, `first_download` bigint NOT NULL, " +
"`first_upload` bigint NOT NULL, `last_password_change` bigint NOT NULL, `role_id` integer NULL);" +
"CREATE TABLE `{{groups_folders_mapping}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, " +
"`group_id` integer NOT NULL, `folder_id` integer NOT NULL, " +
- "`virtual_path` longtext NOT NULL, `quota_size` bigint NOT NULL, `quota_files` integer NOT NULL);" +
+ "`virtual_path` longtext NOT NULL, `quota_size` bigint NOT NULL, `quota_files` integer NOT NULL, `sort_order` integer NOT NULL);" +
"CREATE TABLE `{{users_groups_mapping}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, " +
- "`user_id` integer NOT NULL, `group_id` integer NOT NULL, `group_type` integer NOT NULL);" +
+ "`user_id` integer NOT NULL, `group_id` integer NOT NULL, `group_type` integer NOT NULL, `sort_order` integer NOT NULL);" +
"CREATE TABLE `{{users_folders_mapping}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `virtual_path` longtext NOT NULL, " +
- "`quota_size` bigint NOT NULL, `quota_files` integer NOT NULL, `folder_id` integer NOT NULL, `user_id` integer NOT NULL);" +
+ "`quota_size` bigint NOT NULL, `quota_files` integer NOT NULL, `folder_id` integer NOT NULL, `user_id` integer NOT NULL, `sort_order` integer NOT NULL);" +
"ALTER TABLE `{{users_folders_mapping}}` ADD CONSTRAINT `{{prefix}}unique_user_folder_mapping` " +
"UNIQUE (`user_id`, `folder_id`);" +
"ALTER TABLE `{{users_folders_mapping}}` ADD CONSTRAINT `{{prefix}}users_folders_mapping_user_id_fk_users_id` " +
"FOREIGN KEY (`user_id`) REFERENCES `{{users}}` (`id`) ON DELETE CASCADE;" +
"ALTER TABLE `{{users_folders_mapping}}` ADD CONSTRAINT `{{prefix}}users_folders_mapping_folder_id_fk_folders_id` " +
"FOREIGN KEY (`folder_id`) REFERENCES `{{folders}}` (`id`) ON DELETE CASCADE;" +
+ "CREATE INDEX `{{prefix}}users_folders_mapping_sort_order_idx` ON `{{users_folders_mapping}}` (`sort_order`);" +
"ALTER TABLE `{{users_groups_mapping}}` ADD CONSTRAINT `{{prefix}}unique_user_group_mapping` UNIQUE (`user_id`, `group_id`);" +
"ALTER TABLE `{{groups_folders_mapping}}` ADD CONSTRAINT `{{prefix}}unique_group_folder_mapping` UNIQUE (`group_id`, `folder_id`);" +
"ALTER TABLE `{{users_groups_mapping}}` ADD CONSTRAINT `{{prefix}}users_groups_mapping_group_id_fk_groups_id` " +
"FOREIGN KEY (`group_id`) REFERENCES `{{groups}}` (`id`) ON DELETE NO ACTION;" +
"ALTER TABLE `{{users_groups_mapping}}` ADD CONSTRAINT `{{prefix}}users_groups_mapping_user_id_fk_users_id` " +
"FOREIGN KEY (`user_id`) REFERENCES `{{users}}` (`id`) ON DELETE CASCADE; " +
+ "CREATE INDEX `{{prefix}}users_groups_mapping_sort_order_idx` ON `{{users_groups_mapping}}` (`sort_order`);" +
"ALTER TABLE `{{groups_folders_mapping}}` ADD CONSTRAINT `{{prefix}}groups_folders_mapping_folder_id_fk_folders_id` " +
"FOREIGN KEY (`folder_id`) REFERENCES `{{folders}}` (`id`) ON DELETE CASCADE;" +
"ALTER TABLE `{{groups_folders_mapping}}` ADD CONSTRAINT `{{prefix}}groups_folders_mapping_group_id_fk_groups_id` " +
"FOREIGN KEY (`group_id`) REFERENCES `{{groups}}` (`id`) ON DELETE CASCADE;" +
+ "CREATE INDEX `{{prefix}}groups_folders_mapping_sort_order_idx` ON `{{groups_folders_mapping}}` (`sort_order`); " +
"CREATE TABLE `{{shares}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, " +
"`share_id` varchar(60) NOT NULL UNIQUE, `name` varchar(255) NOT NULL, `description` varchar(512) NULL, " +
"`scope` integer NOT NULL, `paths` longtext NOT NULL, `created_at` bigint NOT NULL, " +
"`updated_at` bigint NOT NULL, `last_use_at` bigint NOT NULL, `expires_at` bigint NOT NULL, " +
"`password` longtext NULL, `max_tokens` integer NOT NULL, `used_tokens` integer NOT NULL, " +
- "`allow_from` longtext NULL, `user_id` integer NOT NULL);" +
+ "`allow_from` longtext NULL, `options` longtext NULL, `user_id` integer NOT NULL);" +
"ALTER TABLE `{{shares}}` ADD CONSTRAINT `{{prefix}}shares_user_id_fk_users_id` " +
"FOREIGN KEY (`user_id`) REFERENCES `{{users}}` (`id`) ON DELETE CASCADE;" +
"CREATE TABLE `{{api_keys}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` varchar(255) NOT NULL, `key_id` varchar(50) NOT NULL UNIQUE," +
@@ -150,13 +152,14 @@ const (
"ALTER TABLE `{{rules_actions_mapping}}` ADD CONSTRAINT `{{prefix}}rules_actions_mapping_action_id_fk_events_targets_id` " +
"FOREIGN KEY (`action_id`) REFERENCES `{{events_actions}}` (`id`) ON DELETE NO ACTION;" +
"CREATE TABLE `{{admins_groups_mapping}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, " +
- " `admin_id` integer NOT NULL, `group_id` integer NOT NULL, `options` longtext NOT NULL);" +
+ " `admin_id` integer NOT NULL, `group_id` integer NOT NULL, `options` longtext NOT NULL, `sort_order` integer NOT NULL);" +
"ALTER TABLE `{{admins_groups_mapping}}` ADD CONSTRAINT `{{prefix}}unique_admin_group_mapping` " +
"UNIQUE (`admin_id`, `group_id`);" +
"ALTER TABLE `{{admins_groups_mapping}}` ADD CONSTRAINT `{{prefix}}admins_groups_mapping_admin_id_fk_admins_id` " +
"FOREIGN KEY (`admin_id`) REFERENCES `{{admins}}` (`id`) ON DELETE CASCADE;" +
"ALTER TABLE `{{admins_groups_mapping}}` ADD CONSTRAINT `{{prefix}}admins_groups_mapping_group_id_fk_groups_id` " +
"FOREIGN KEY (`group_id`) REFERENCES `{{groups}}` (`id`) ON DELETE CASCADE;" +
+ "CREATE INDEX `{{prefix}}admins_groups_mapping_sort_order_idx` ON `{{admins_groups_mapping}}` (`sort_order`); " +
"CREATE TABLE `{{nodes}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, " +
"`name` varchar(255) NOT NULL UNIQUE, `data` longtext NOT NULL, `created_at` bigint NOT NULL, " +
"`updated_at` bigint NOT NULL);" +
@@ -193,11 +196,17 @@ const (
"CREATE INDEX `{{prefix}}ip_lists_updated_at_idx` ON `{{ip_lists}}` (`updated_at`);" +
"CREATE INDEX `{{prefix}}ip_lists_deleted_at_idx` ON `{{ip_lists}}` (`deleted_at`);" +
"CREATE INDEX `{{prefix}}ip_lists_first_last_idx` ON `{{ip_lists}}` (`first`, `last`);" +
- "INSERT INTO {{schema_version}} (version) VALUES (28);"
- mysqlV29SQL = "ALTER TABLE `{{users}}` MODIFY `used_download_data_transfer` bigint NOT NULL;" +
- "ALTER TABLE `{{users}}` MODIFY `used_upload_data_transfer` bigint NOT NULL;"
- mysqlV29DownSQL = "ALTER TABLE `{{users}}` MODIFY `used_upload_data_transfer` integer NOT NULL;" +
- "ALTER TABLE `{{users}}` MODIFY `used_download_data_transfer` integer NOT NULL;"
+ "INSERT INTO {{schema_version}} (version) VALUES (33);"
+ mysqlV34SQL = "CREATE TABLE `{{shares_groups_mapping}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY," +
+ "`share_id` integer NOT NULL, `group_id` integer NOT NULL, `permissions` integer NOT NULL," +
+ "`sort_order` integer NOT NULL," +
+ "CONSTRAINT `{{prefix}}unique_share_group_mapping` UNIQUE (`share_id`, `group_id`)," +
+ "CONSTRAINT `{{prefix}}shares_groups_mapping_share_id_fk` FOREIGN KEY (`share_id`) REFERENCES `{{shares}}` (`id`) ON DELETE CASCADE," +
+ "CONSTRAINT `{{prefix}}shares_groups_mapping_group_id_fk` FOREIGN KEY (`group_id`) REFERENCES `{{groups}}` (`id`) ON DELETE CASCADE); " +
+ "CREATE INDEX `{{prefix}}shares_groups_mapping_sort_order_idx` ON `{{shares_groups_mapping}}` (`sort_order`); " +
+ "CREATE INDEX `{{prefix}}shares_groups_mapping_share_id_idx` ON `{{shares_groups_mapping}}` (`share_id`); " +
+ "CREATE INDEX `{{prefix}}shares_groups_mapping_group_id_idx` ON `{{shares_groups_mapping}}` (`group_id`);"
+ mysqlV34DownSQL = "DROP TABLE IF EXISTS `{{shares_groups_mapping}}`;"
)
// MySQLProvider defines the auth provider for MySQL/MariaDB database
@@ -329,6 +338,14 @@ func (p *MySQLProvider) getUsedQuota(username string) (int, int64, int64, int64,
return sqlCommonGetUsedQuota(username, p.dbHandle)
}
+func (p *MySQLProvider) getAdminSignature(username string) (string, error) {
+ return sqlCommonGetAdminSignature(username, p.dbHandle)
+}
+
+func (p *MySQLProvider) getUserSignature(username string) (string, error) {
+ return sqlCommonGetUserSignature(username, p.dbHandle)
+}
+
func (p *MySQLProvider) setUpdatedAt(username string) {
sqlCommonSetUpdatedAt(username, p.dbHandle)
}
@@ -583,12 +600,12 @@ func (p *MySQLProvider) addSharedSession(session Session) error {
return sqlCommonAddSession(session, p.dbHandle)
}
-func (p *MySQLProvider) deleteSharedSession(key string) error {
- return sqlCommonDeleteSession(key, p.dbHandle)
+func (p *MySQLProvider) deleteSharedSession(key string, sessionType SessionType) error {
+ return sqlCommonDeleteSession(key, sessionType, p.dbHandle)
}
-func (p *MySQLProvider) getSharedSession(key string) (Session, error) {
- return sqlCommonGetSession(key, p.dbHandle)
+func (p *MySQLProvider) getSharedSession(key string, sessionType SessionType) (Session, error) {
+ return sqlCommonGetSession(key, sessionType, p.dbHandle)
}
func (p *MySQLProvider) cleanupSharedSessions(sessionType SessionType, before int64) error {
@@ -776,11 +793,11 @@ func (p *MySQLProvider) initializeDatabase() error {
if errors.Is(err, sql.ErrNoRows) {
return errSchemaVersionEmpty
}
- logger.InfoToConsole("creating initial database schema, version 28")
- providerLog(logger.LevelInfo, "creating initial database schema, version 28")
+ logger.InfoToConsole("creating initial database schema, version 33")
+ providerLog(logger.LevelInfo, "creating initial database schema, version 33")
initialSQL := sqlReplaceAll(mysqlInitialSQL)
- return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, strings.Split(initialSQL, ";"), 28, true)
+ return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, strings.Split(initialSQL, ";"), 33, true)
}
func (p *MySQLProvider) migrateDatabase() error {
@@ -793,13 +810,13 @@ func (p *MySQLProvider) migrateDatabase() error {
case version == sqlDatabaseVersion:
providerLog(logger.LevelDebug, "sql database is up to date, current version: %d", version)
return ErrNoInitRequired
- case version < 28:
- err = fmt.Errorf("database schema version %d is too old, please see the upgrading docs", version)
+ case version < 33:
+ err = errSchemaVersionTooOld(version)
providerLog(logger.LevelError, "%v", err)
logger.ErrorToConsole("%v", err)
return err
- case version == 28:
- return updateMySQLDatabaseFrom28To29(p.dbHandle)
+ case version == 33:
+ return updateMySQLDatabaseFromV33(p.dbHandle)
default:
if version > sqlDatabaseVersion {
providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@@ -822,8 +839,8 @@ func (p *MySQLProvider) revertDatabase(targetVersion int) error {
}
switch dbVersion.Version {
- case 29:
- return downgradeMySQLDatabaseFrom29To28(p.dbHandle)
+ case 34:
+ return downgradeMySQLDatabaseFromV34(p.dbHandle)
default:
return fmt.Errorf("database schema version not handled: %d", dbVersion.Version)
}
@@ -862,18 +879,29 @@ func (p *MySQLProvider) normalizeError(err error, fieldType int) error {
return err
}
-func updateMySQLDatabaseFrom28To29(dbHandle *sql.DB) error {
- logger.InfoToConsole("updating database schema version: 28 -> 29")
- providerLog(logger.LevelInfo, "updating database schema version: 28 -> 29")
-
- sql := strings.ReplaceAll(mysqlV29SQL, "{{users}}", sqlTableUsers)
- return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 29, true)
+func updateMySQLDatabaseFromV33(dbHandle *sql.DB) error {
+ return updateMySQLDatabaseFrom33To34(dbHandle)
}
-func downgradeMySQLDatabaseFrom29To28(dbHandle *sql.DB) error {
- logger.InfoToConsole("downgrading database schema version: 29 -> 28")
- providerLog(logger.LevelInfo, "downgrading database schema version: 29 -> 28")
-
- sql := strings.ReplaceAll(mysqlV29DownSQL, "{{users}}", sqlTableUsers)
- return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 28, false)
+func downgradeMySQLDatabaseFromV34(dbHandle *sql.DB) error {
+ return downgradeMySQLDatabaseFrom34To33(dbHandle)
+}
+
+func updateMySQLDatabaseFrom33To34(dbHandle *sql.DB) error {
+ logger.InfoToConsole("updating database schema version: 33 -> 34")
+ providerLog(logger.LevelInfo, "updating database schema version: 33 -> 34")
+
+ sql := strings.ReplaceAll(mysqlV34SQL, "{{prefix}}", config.SQLTablesPrefix)
+ sql = strings.ReplaceAll(sql, "{{shares}}", sqlTableShares)
+ sql = strings.ReplaceAll(sql, "{{shares_groups_mapping}}", sqlTableSharesGroupsMapping)
+ sql = strings.ReplaceAll(sql, "{{groups}}", sqlTableGroups)
+ return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 34, true)
+}
+
+func downgradeMySQLDatabaseFrom34To33(dbHandle *sql.DB) error {
+ logger.InfoToConsole("downgrading database schema version: 34 -> 33")
+ providerLog(logger.LevelInfo, "downgrading database schema version: 34 -> 33")
+
+ sql := strings.ReplaceAll(mysqlV34DownSQL, "{{shares_groups_mapping}}", sqlTableSharesGroupsMapping)
+ return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 33, false)
}
diff --git a/internal/dataprovider/mysql_disabled.go b/internal/dataprovider/mysql_disabled.go
index c6c09ded..203092b2 100644
--- a/internal/dataprovider/mysql_disabled.go
+++ b/internal/dataprovider/mysql_disabled.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build nomysql
-// +build nomysql
package dataprovider
diff --git a/internal/dataprovider/node.go b/internal/dataprovider/node.go
index a1c8b2c4..4b8fc803 100644
--- a/internal/dataprovider/node.go
+++ b/internal/dataprovider/node.go
@@ -17,21 +17,21 @@ package dataprovider
import (
"bytes"
"context"
+ "crypto/sha256"
+ "encoding/hex"
"encoding/json"
"errors"
"fmt"
- "hash/fnv"
"io"
"net/http"
"strconv"
"strings"
"time"
- "github.com/lestrrat-go/jwx/v2/jwa"
- "github.com/lestrrat-go/jwx/v2/jwt"
- "github.com/rs/xid"
+ "github.com/go-jose/go-jose/v4"
"github.com/drakkan/sftpgo/v2/internal/httpclient"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/kms"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/util"
@@ -45,7 +45,8 @@ const (
const (
// NodeTokenHeader defines the header to use for the node auth token
- NodeTokenHeader = "X-SFTPGO-Node"
+ NodeTokenHeader = "X-SFTPGO-Node"
+ nodeTokenAudience = "node"
)
var (
@@ -99,7 +100,7 @@ func (n *NodeData) validate() error {
if n.Proto != NodeProtoHTTP && n.Proto != NodeProtoHTTPS {
return util.NewValidationError(fmt.Sprintf("invalid node proto: %s", n.Proto))
}
- n.Key = kms.NewPlainSecret(util.BytesToString(util.GenerateRandomBytes(32)))
+ n.Key = kms.NewPlainSecret(util.GenerateOpaqueString())
n.Key.SetAdditionalData(n.Host)
if err := n.Key.Encrypt(); err != nil {
return fmt.Errorf("unable to encrypt node key: %w", err)
@@ -108,12 +109,12 @@ func (n *NodeData) validate() error {
}
func (n *NodeData) getNodeName() string {
- h := fnv.New64a()
+ h := sha256.New()
var b bytes.Buffer
- b.WriteString(fmt.Sprintf("%s:%d", n.Host, n.Port))
+ fmt.Fprintf(&b, "%s:%d", n.Host, n.Port)
h.Write(b.Bytes())
- return strconv.FormatUint(h.Sum64(), 10)
+ return hex.EncodeToString(h.Sum(nil))
}
// Node defines a cluster node
@@ -131,33 +132,26 @@ func (n *Node) validate() error {
return n.Data.validate()
}
-func (n *Node) authenticate(token string) (string, string, error) {
+func (n *Node) authenticate(token string) (*jwt.Claims, error) {
if err := n.Data.Key.TryDecrypt(); err != nil {
providerLog(logger.LevelError, "unable to decrypt node key: %v", err)
- return "", "", err
+ return nil, err
}
if token == "" {
- return "", "", ErrInvalidCredentials
+ return nil, ErrInvalidCredentials
}
- t, err := jwt.Parse([]byte(token), jwt.WithKey(jwa.HS256, []byte(n.Data.Key.GetPayload())), jwt.WithValidate(true))
+ claims, err := jwt.VerifyTokenWithKey(token, []jose.SignatureAlgorithm{jose.HS256}, []byte(n.Data.Key.GetPayload()))
if err != nil {
- return "", "", fmt.Errorf("unable to parse and validate token: %v", err)
+ return nil, fmt.Errorf("unable to parse and validate token: %v", err)
}
- var adminUsername, role string
- if admin, ok := t.Get("admin"); ok {
- if val, ok := admin.(string); ok && val != "" {
- adminUsername = val
- }
+ if claims.Username == "" {
+ return nil, errors.New("no admin username associated with node token")
}
- if adminUsername == "" {
- return "", "", errors.New("no admin username associated with node token")
+ if !claims.Audience.Contains(nodeTokenAudience) {
+ return nil, errors.New("invalid node token audience")
}
- if r, ok := t.Get("role"); ok {
- if val, ok := r.(string); ok && val != "" {
- role = val
- }
- }
- return adminUsername, role, nil
+
+ return claims, nil
}
// getBaseURL returns the base URL for this node
@@ -174,35 +168,37 @@ func (n *Node) getBaseURL() string {
}
// generateAuthToken generates a new auth token
-func (n *Node) generateAuthToken(username, role string) (string, error) {
+func (n *Node) generateAuthToken(username, role string, permissions []string) (string, error) {
if err := n.Data.Key.TryDecrypt(); err != nil {
return "", fmt.Errorf("unable to decrypt node key: %w", err)
}
- now := time.Now().UTC()
-
- t := jwt.New()
- t.Set("admin", username) //nolint:errcheck
- t.Set("role", role) //nolint:errcheck
- t.Set(jwt.JwtIDKey, xid.New().String()) //nolint:errcheck
- t.Set(jwt.NotBeforeKey, now.Add(-30*time.Second)) //nolint:errcheck
- t.Set(jwt.ExpirationKey, now.Add(1*time.Minute)) //nolint:errcheck
-
- payload, err := jwt.Sign(t, jwt.WithKey(jwa.HS256, []byte(n.Data.Key.GetPayload())))
+ signer, err := jwt.NewSigner(jose.HS256, []byte(n.Data.Key.GetPayload()))
+ if err != nil {
+ return "", fmt.Errorf("unable to create signer: %w", err)
+ }
+ claims := &jwt.Claims{
+ Username: username,
+ Role: role,
+ Permissions: permissions,
+ }
+ claims.Audience = []string{nodeTokenAudience}
+ claims.SetExpiry(time.Now().Add(1 * time.Minute))
+ payload, err := signer.Sign(claims)
if err != nil {
return "", fmt.Errorf("unable to sign authentication token: %w", err)
}
- return util.BytesToString(payload), nil
+ return payload, nil
}
func (n *Node) prepareRequest(ctx context.Context, username, role, relativeURL, method string,
- body io.Reader,
+ permissions []string, body io.Reader,
) (*http.Request, error) {
url := fmt.Sprintf("%s%s", n.getBaseURL(), relativeURL)
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
- token, err := n.generateAuthToken(username, role)
+ token, err := n.generateAuthToken(username, role, permissions)
if err != nil {
return nil, err
}
@@ -212,11 +208,11 @@ func (n *Node) prepareRequest(ctx context.Context, username, role, relativeURL,
// SendGetRequest sends an HTTP GET request to this node.
// The responseHolder must be a pointer
-func (n *Node) SendGetRequest(username, role, relativeURL string, responseHolder any) error {
+func (n *Node) SendGetRequest(username, role, relativeURL string, permissions []string, responseHolder any) error {
ctx, cancel := context.WithTimeout(context.Background(), nodeReqTimeout)
defer cancel()
- req, err := n.prepareRequest(ctx, username, role, relativeURL, http.MethodGet, nil)
+ req, err := n.prepareRequest(ctx, username, role, relativeURL, http.MethodGet, permissions, nil)
if err != nil {
return err
}
@@ -244,11 +240,11 @@ func (n *Node) SendGetRequest(username, role, relativeURL string, responseHolder
}
// SendDeleteRequest sends an HTTP DELETE request to this node
-func (n *Node) SendDeleteRequest(username, role, relativeURL string) error {
+func (n *Node) SendDeleteRequest(username, role, relativeURL string, permissions []string) error {
ctx, cancel := context.WithTimeout(context.Background(), nodeReqTimeout)
defer cancel()
- req, err := n.prepareRequest(ctx, username, role, relativeURL, http.MethodDelete, nil)
+ req, err := n.prepareRequest(ctx, username, role, relativeURL, http.MethodDelete, permissions, nil)
if err != nil {
return err
}
@@ -268,9 +264,9 @@ func (n *Node) SendDeleteRequest(username, role, relativeURL string) error {
}
// AuthenticateNodeToken check the validity of the provided token
-func AuthenticateNodeToken(token string) (string, string, error) {
+func AuthenticateNodeToken(token string) (*jwt.Claims, error) {
if currentNode == nil {
- return "", "", errNoClusterNodes
+ return nil, errNoClusterNodes
}
return currentNode.authenticate(token)
}
diff --git a/internal/dataprovider/pgsql.go b/internal/dataprovider/pgsql.go
index 3e7d69d2..c4f7c93a 100644
--- a/internal/dataprovider/pgsql.go
+++ b/internal/dataprovider/pgsql.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build !nopgsql
-// +build !nopgsql
package dataprovider
@@ -24,6 +23,7 @@ import (
"errors"
"fmt"
"net"
+ "slices"
"strconv"
"strings"
"time"
@@ -40,11 +40,11 @@ import (
const (
pgsqlResetSQL = `DROP TABLE IF EXISTS "{{api_keys}}" CASCADE;
-DROP TABLE IF EXISTS "{{folders_mapping}}" CASCADE;
DROP TABLE IF EXISTS "{{users_folders_mapping}}" CASCADE;
DROP TABLE IF EXISTS "{{users_groups_mapping}}" CASCADE;
DROP TABLE IF EXISTS "{{admins_groups_mapping}}" CASCADE;
DROP TABLE IF EXISTS "{{groups_folders_mapping}}" CASCADE;
+DROP TABLE IF EXISTS "{{shares_groups_mapping}}" CASCADE;
DROP TABLE IF EXISTS "{{admins}}" CASCADE;
DROP TABLE IF EXISTS "{{folders}}" CASCADE;
DROP TABLE IF EXISTS "{{shares}}" CASCADE;
@@ -85,8 +85,8 @@ CREATE TABLE "{{folders}}" ("id" integer NOT NULL PRIMARY KEY GENERATED ALWAYS A
"filesystem" text NULL);
CREATE TABLE "{{groups}}" ("id" integer NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY, "name" varchar(255) NOT NULL UNIQUE,
"description" varchar(512) NULL, "created_at" bigint NOT NULL, "updated_at" bigint NOT NULL, "user_settings" text NULL);
-CREATE TABLE "{{shared_sessions}}" ("key" varchar(128) NOT NULL PRIMARY KEY,
-"data" text NOT NULL, "type" integer NOT NULL, "timestamp" bigint NOT NULL);
+CREATE TABLE "{{shared_sessions}}" ("key" varchar(128) NOT NULL, "type" integer NOT NULL,
+"data" text NOT NULL, "timestamp" bigint NOT NULL, PRIMARY KEY ("key", "type"));
CREATE TABLE "{{users}}" ("id" integer NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY, "username" varchar(255) NOT NULL UNIQUE, "status" integer NOT NULL,
"expiration_date" bigint NOT NULL, "description" varchar(512) NULL, "password" text NULL, "public_keys" text NULL,
"home_dir" text NOT NULL, "uid" bigint NOT NULL, "gid" bigint NOT NULL, "max_sessions" integer NOT NULL,
@@ -95,14 +95,14 @@ CREATE TABLE "{{users}}" ("id" integer NOT NULL PRIMARY KEY GENERATED ALWAYS AS
"download_bandwidth" integer NOT NULL, "last_login" bigint NOT NULL, "filters" text NULL, "filesystem" text NULL,
"additional_info" text NULL, "created_at" bigint NOT NULL, "updated_at" bigint NOT NULL, "email" varchar(255) NULL,
"upload_data_transfer" integer NOT NULL, "download_data_transfer" integer NOT NULL, "total_data_transfer" integer NOT NULL,
-"used_upload_data_transfer" integer NOT NULL, "used_download_data_transfer" integer NOT NULL, "deleted_at" bigint NOT NULL,
+"used_upload_data_transfer" bigint NOT NULL, "used_download_data_transfer" bigint NOT NULL, "deleted_at" bigint NOT NULL,
"first_download" bigint NOT NULL, "first_upload" bigint NOT NULL, "last_password_change" bigint NOT NULL, "role_id" integer NULL);
CREATE TABLE "{{groups_folders_mapping}}" ("id" integer NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY, "group_id" integer NOT NULL,
-"folder_id" integer NOT NULL, "virtual_path" text NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL);
+"folder_id" integer NOT NULL, "virtual_path" text NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "sort_order" integer NOT NULL);
CREATE TABLE "{{users_groups_mapping}}" ("id" integer NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY, "user_id" integer NOT NULL,
-"group_id" integer NOT NULL, "group_type" integer NOT NULL);
+"group_id" integer NOT NULL, "group_type" integer NOT NULL, "sort_order" integer NOT NULL);
CREATE TABLE "{{users_folders_mapping}}" ("id" integer NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY, "virtual_path" text NOT NULL,
-"quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "folder_id" integer NOT NULL, "user_id" integer NOT NULL);
+"quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "sort_order" integer NOT NULL, "folder_id" integer NOT NULL, "user_id" integer NOT NULL);
ALTER TABLE "{{users_folders_mapping}}" ADD CONSTRAINT "{{prefix}}unique_user_folder_mapping" UNIQUE ("user_id", "folder_id");
ALTER TABLE "{{users_folders_mapping}}" ADD CONSTRAINT "{{prefix}}users_folders_mapping_folder_id_fk_folders_id"
FOREIGN KEY ("folder_id") REFERENCES "{{folders}}" ("id") MATCH SIMPLE ON UPDATE NO ACTION ON DELETE CASCADE;
@@ -112,7 +112,7 @@ CREATE TABLE "{{shares}}" ("id" integer NOT NULL PRIMARY KEY GENERATED ALWAYS AS
"share_id" varchar(60) NOT NULL UNIQUE, "name" varchar(255) NOT NULL, "description" varchar(512) NULL,
"scope" integer NOT NULL, "paths" text NOT NULL, "created_at" bigint NOT NULL, "updated_at" bigint NOT NULL,
"last_use_at" bigint NOT NULL, "expires_at" bigint NOT NULL, "password" text NULL,
-"max_tokens" integer NOT NULL, "used_tokens" integer NOT NULL, "allow_from" text NULL,
+"max_tokens" integer NOT NULL, "used_tokens" integer NOT NULL, "allow_from" text NULL, "options" text NULL,
"user_id" integer NOT NULL);
ALTER TABLE "{{shares}}" ADD CONSTRAINT "{{prefix}}shares_user_id_fk_users_id" FOREIGN KEY ("user_id")
REFERENCES "{{users}}" ("id") MATCH SIMPLE ON UPDATE NO ACTION ON DELETE CASCADE;
@@ -132,12 +132,14 @@ FOREIGN KEY ("group_id") REFERENCES "{{groups}}" ("id") MATCH SIMPLE ON UPDATE N
CREATE INDEX "{{prefix}}users_groups_mapping_user_id_idx" ON "{{users_groups_mapping}}" ("user_id");
ALTER TABLE "{{users_groups_mapping}}" ADD CONSTRAINT "{{prefix}}users_groups_mapping_user_id_fk_users_id"
FOREIGN KEY ("user_id") REFERENCES "{{users}}" ("id") MATCH SIMPLE ON UPDATE NO ACTION ON DELETE CASCADE;
+CREATE INDEX "{{prefix}}users_groups_mapping_sort_order_idx" ON "{{users_groups_mapping}}" ("sort_order");
CREATE INDEX "{{prefix}}groups_folders_mapping_folder_id_idx" ON "{{groups_folders_mapping}}" ("folder_id");
ALTER TABLE "{{groups_folders_mapping}}" ADD CONSTRAINT "{{prefix}}groups_folders_mapping_folder_id_fk_folders_id"
FOREIGN KEY ("folder_id") REFERENCES "{{folders}}" ("id") MATCH SIMPLE ON UPDATE NO ACTION ON DELETE CASCADE;
CREATE INDEX "{{prefix}}groups_folders_mapping_group_id_idx" ON "{{groups_folders_mapping}}" ("group_id");
ALTER TABLE "{{groups_folders_mapping}}" ADD CONSTRAINT "{{prefix}}groups_folders_mapping_group_id_fk_groups_id"
FOREIGN KEY ("group_id") REFERENCES "{{groups}}" ("id") MATCH SIMPLE ON UPDATE NO ACTION ON DELETE CASCADE;
+CREATE INDEX "{{prefix}}groups_folders_mapping_sort_order_idx" ON "{{groups_folders_mapping}}" ("sort_order");
CREATE TABLE "{{events_rules}}" ("id" integer NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY, "name" varchar(255) NOT NULL UNIQUE,
"status" integer NOT NULL, "description" varchar(512) NULL, "created_at" bigint NOT NULL, "updated_at" bigint NOT NULL,
"trigger" integer NOT NULL, "conditions" text NOT NULL, "deleted_at" bigint NOT NULL);
@@ -153,7 +155,7 @@ FOREIGN KEY ("rule_id") REFERENCES "{{events_rules}}" ("id") MATCH SIMPLE ON UPD
ALTER TABLE "{{rules_actions_mapping}}" ADD CONSTRAINT "{{prefix}}rules_actions_mapping_action_id_fk_events_targets_id"
FOREIGN KEY ("action_id") REFERENCES "{{events_actions}}" ("id") MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION;
CREATE TABLE "{{admins_groups_mapping}}" ("id" integer NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
-"admin_id" integer NOT NULL, "group_id" integer NOT NULL, "options" text NOT NULL);
+"admin_id" integer NOT NULL, "group_id" integer NOT NULL, "options" text NOT NULL, "sort_order" integer NOT NULL);
ALTER TABLE "{{admins_groups_mapping}}" ADD CONSTRAINT "{{prefix}}unique_admin_group_mapping" UNIQUE ("admin_id", "group_id");
ALTER TABLE "{{admins_groups_mapping}}" ADD CONSTRAINT "{{prefix}}admins_groups_mapping_admin_id_fk_admins_id"
FOREIGN KEY ("admin_id") REFERENCES "{{admins}}" ("id") MATCH SIMPLE ON UPDATE NO ACTION ON DELETE CASCADE;
@@ -176,6 +178,7 @@ CREATE TABLE "{{configs}}" ("id" integer NOT NULL PRIMARY KEY GENERATED ALWAYS A
INSERT INTO {{configs}} (configs) VALUES ('{}');
CREATE INDEX "{{prefix}}users_folders_mapping_folder_id_idx" ON "{{users_folders_mapping}}" ("folder_id");
CREATE INDEX "{{prefix}}users_folders_mapping_user_id_idx" ON "{{users_folders_mapping}}" ("user_id");
+CREATE INDEX "{{prefix}}users_folders_mapping_sort_order_idx" ON "{{users_folders_mapping}}" ("sort_order");
CREATE INDEX "{{prefix}}api_keys_admin_id_idx" ON "{{api_keys}}" ("admin_id");
CREATE INDEX "{{prefix}}api_keys_user_id_idx" ON "{{api_keys}}" ("user_id");
CREATE INDEX "{{prefix}}users_updated_at_idx" ON "{{users}}" ("updated_at");
@@ -198,6 +201,7 @@ CREATE INDEX "{{prefix}}rules_actions_mapping_action_id_idx" ON "{{rules_actions
CREATE INDEX "{{prefix}}rules_actions_mapping_order_idx" ON "{{rules_actions_mapping}}" ("order");
CREATE INDEX "{{prefix}}admins_groups_mapping_admin_id_idx" ON "{{admins_groups_mapping}}" ("admin_id");
CREATE INDEX "{{prefix}}admins_groups_mapping_group_id_idx" ON "{{admins_groups_mapping}}" ("group_id");
+CREATE INDEX "{{prefix}}admins_groups_mapping_sort_order_idx" ON "{{admins_groups_mapping}}" ("sort_order");
CREATE INDEX "{{prefix}}admins_role_id_idx" ON "{{admins}}" ("role_id");
CREATE INDEX "{{prefix}}users_role_id_idx" ON "{{users}}" ("role_id");
CREATE INDEX "{{prefix}}ip_lists_type_idx" ON "{{ip_lists}}" ("type");
@@ -205,16 +209,24 @@ CREATE INDEX "{{prefix}}ip_lists_ipornet_idx" ON "{{ip_lists}}" ("ipornet");
CREATE INDEX "{{prefix}}ip_lists_updated_at_idx" ON "{{ip_lists}}" ("updated_at");
CREATE INDEX "{{prefix}}ip_lists_deleted_at_idx" ON "{{ip_lists}}" ("deleted_at");
CREATE INDEX "{{prefix}}ip_lists_first_last_idx" ON "{{ip_lists}}" ("first", "last");
-INSERT INTO {{schema_version}} (version) VALUES (28);
+INSERT INTO {{schema_version}} (version) VALUES (33);
`
// not supported in CockroachDB
ipListsLikeIndex = `CREATE INDEX "{{prefix}}ip_lists_ipornet_like_idx" ON "{{ip_lists}}" ("ipornet" varchar_pattern_ops);`
- pgsqlV29SQL = `ALTER TABLE "{{users}}" ALTER COLUMN "used_download_data_transfer" TYPE bigint;
-ALTER TABLE "{{users}}" ALTER COLUMN "used_upload_data_transfer" TYPE bigint;
-`
- pgsqlV29DownSQL = `ALTER TABLE "{{users}}" ALTER COLUMN "used_upload_data_transfer" TYPE integer;
-ALTER TABLE "{{users}}" ALTER COLUMN "used_download_data_transfer" TYPE integer;
+ pgsqlV34SQL = `CREATE TABLE "{{shares_groups_mapping}}" (
+"id" integer NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
+"share_id" integer NOT NULL,
+"group_id" integer NOT NULL,
+"permissions" integer NOT NULL,
+"sort_order" integer NOT NULL,
+CONSTRAINT "{{prefix}}unique_share_group_mapping" UNIQUE ("share_id", "group_id"),
+CONSTRAINT "{{prefix}}shares_groups_mapping_share_id_fk" FOREIGN KEY ("share_id") REFERENCES "{{shares}}"("id") ON DELETE CASCADE,
+CONSTRAINT "{{prefix}}shares_groups_mapping_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "{{groups}}"("id") ON DELETE CASCADE);
+CREATE INDEX "{{prefix}}shares_groups_mapping_sort_order_idx" ON "{{shares_groups_mapping}}" ("sort_order");
+CREATE INDEX "{{prefix}}shares_groups_mapping_share_id_idx" ON "{{shares_groups_mapping}}" ("share_id");
+CREATE INDEX "{{prefix}}shares_groups_mapping_group_id_idx" ON "{{shares_groups_mapping}}" ("group_id");
`
+ pgsqlV34DownSQL = `DROP TABLE IF EXISTS "{{shares_groups_mapping}}";`
)
var (
@@ -274,7 +286,7 @@ func getPGSQLHostsAndPorts(configHost string, configPort int) (string, string) {
defaultPort = "5432"
}
- for _, hostport := range strings.Split(configHost, ",") {
+ for hostport := range strings.SplitSeq(configHost, ",") {
hostport = strings.TrimSpace(hostport)
if hostport == "" {
continue
@@ -311,7 +323,7 @@ func getPGSQLConnectionString(redactedPwd bool) string {
if config.DisableSNI {
connectionString += " sslsni=0"
}
- if util.Contains(pgSQLTargetSessionAttrs, config.TargetSessionAttrs) {
+ if slices.Contains(pgSQLTargetSessionAttrs, config.TargetSessionAttrs) {
connectionString += fmt.Sprintf(" target_session_attrs='%s'", config.TargetSessionAttrs)
}
} else {
@@ -348,6 +360,14 @@ func (p *PGSQLProvider) getUsedQuota(username string) (int, int64, int64, int64,
return sqlCommonGetUsedQuota(username, p.dbHandle)
}
+func (p *PGSQLProvider) getAdminSignature(username string) (string, error) {
+ return sqlCommonGetAdminSignature(username, p.dbHandle)
+}
+
+func (p *PGSQLProvider) getUserSignature(username string) (string, error) {
+ return sqlCommonGetUserSignature(username, p.dbHandle)
+}
+
func (p *PGSQLProvider) setUpdatedAt(username string) {
sqlCommonSetUpdatedAt(username, p.dbHandle)
}
@@ -602,12 +622,12 @@ func (p *PGSQLProvider) addSharedSession(session Session) error {
return sqlCommonAddSession(session, p.dbHandle)
}
-func (p *PGSQLProvider) deleteSharedSession(key string) error {
- return sqlCommonDeleteSession(key, p.dbHandle)
+func (p *PGSQLProvider) deleteSharedSession(key string, sessionType SessionType) error {
+ return sqlCommonDeleteSession(key, sessionType, p.dbHandle)
}
-func (p *PGSQLProvider) getSharedSession(key string) (Session, error) {
- return sqlCommonGetSession(key, p.dbHandle)
+func (p *PGSQLProvider) getSharedSession(key string, sessionType SessionType) (Session, error) {
+ return sqlCommonGetSession(key, sessionType, p.dbHandle)
}
func (p *PGSQLProvider) cleanupSharedSessions(sessionType SessionType, before int64) error {
@@ -795,8 +815,8 @@ func (p *PGSQLProvider) initializeDatabase() error {
if errors.Is(err, sql.ErrNoRows) {
return errSchemaVersionEmpty
}
- logger.InfoToConsole("creating initial database schema, version 28")
- providerLog(logger.LevelInfo, "creating initial database schema, version 28")
+ logger.InfoToConsole("creating initial database schema, version 33")
+ providerLog(logger.LevelInfo, "creating initial database schema, version 33")
var initialSQL string
if config.Driver == CockroachDataProviderName {
initialSQL = sqlReplaceAll(pgsqlInitial)
@@ -805,10 +825,10 @@ func (p *PGSQLProvider) initializeDatabase() error {
initialSQL = sqlReplaceAll(pgsqlInitial + ipListsLikeIndex)
}
- return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{initialSQL}, 28, true)
+ return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{initialSQL}, 33, true)
}
-func (p *PGSQLProvider) migrateDatabase() error { //nolint:dupl
+func (p *PGSQLProvider) migrateDatabase() error {
dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle, true)
if err != nil {
return err
@@ -818,13 +838,13 @@ func (p *PGSQLProvider) migrateDatabase() error { //nolint:dupl
case version == sqlDatabaseVersion:
providerLog(logger.LevelDebug, "sql database is up to date, current version: %d", version)
return ErrNoInitRequired
- case version < 28:
- err = fmt.Errorf("database schema version %d is too old, please see the upgrading docs", version)
+ case version < 33:
+ err = errSchemaVersionTooOld(version)
providerLog(logger.LevelError, "%v", err)
logger.ErrorToConsole("%v", err)
return err
- case version == 28:
- return updatePGSQLDatabaseFrom28To29(p.dbHandle)
+ case version == 33:
+ return updatePGSQLDatabaseFromV33(p.dbHandle)
default:
if version > sqlDatabaseVersion {
providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@@ -847,8 +867,8 @@ func (p *PGSQLProvider) revertDatabase(targetVersion int) error {
}
switch dbVersion.Version {
- case 29:
- return downgradePGSQLDatabaseFrom29To28(p.dbHandle)
+ case 34:
+ return downgradePGSQLDatabaseFromV34(p.dbHandle)
default:
return fmt.Errorf("database schema version not handled: %d", dbVersion.Version)
}
@@ -887,18 +907,29 @@ func (p *PGSQLProvider) normalizeError(err error, fieldType int) error {
return err
}
-func updatePGSQLDatabaseFrom28To29(dbHandle *sql.DB) error {
- logger.InfoToConsole("updating database schema version: 28 -> 29")
- providerLog(logger.LevelInfo, "updating database schema version: 28 -> 29")
-
- sql := strings.ReplaceAll(pgsqlV29SQL, "{{users}}", sqlTableUsers)
- return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 29, true)
+func updatePGSQLDatabaseFromV33(dbHandle *sql.DB) error {
+ return updatePGSQLDatabaseFrom33To34(dbHandle)
}
-func downgradePGSQLDatabaseFrom29To28(dbHandle *sql.DB) error {
- logger.InfoToConsole("downgrading database schema version: 29 -> 28")
- providerLog(logger.LevelInfo, "downgrading database schema version: 29 -> 28")
-
- sql := strings.ReplaceAll(pgsqlV29DownSQL, "{{users}}", sqlTableUsers)
- return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 28, false)
+func downgradePGSQLDatabaseFromV34(dbHandle *sql.DB) error {
+ return downgradePGSQLDatabaseFrom34To33(dbHandle)
+}
+
+func updatePGSQLDatabaseFrom33To34(dbHandle *sql.DB) error {
+ logger.InfoToConsole("updating database schema version: 33 -> 34")
+ providerLog(logger.LevelInfo, "updating database schema version: 33 -> 34")
+
+ sql := strings.ReplaceAll(pgsqlV34SQL, "{{prefix}}", config.SQLTablesPrefix)
+ sql = strings.ReplaceAll(sql, "{{shares}}", sqlTableShares)
+ sql = strings.ReplaceAll(sql, "{{shares_groups_mapping}}", sqlTableSharesGroupsMapping)
+ sql = strings.ReplaceAll(sql, "{{groups}}", sqlTableGroups)
+ return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 34, true)
+}
+
+func downgradePGSQLDatabaseFrom34To33(dbHandle *sql.DB) error {
+ logger.InfoToConsole("downgrading database schema version: 34 -> 33")
+ providerLog(logger.LevelInfo, "downgrading database schema version: 34 -> 33")
+
+ sql := strings.ReplaceAll(pgsqlV34DownSQL, "{{shares_groups_mapping}}", sqlTableSharesGroupsMapping)
+ return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 33, false)
}
diff --git a/internal/dataprovider/pgsql_disabled.go b/internal/dataprovider/pgsql_disabled.go
index a561c5c9..899b5380 100644
--- a/internal/dataprovider/pgsql_disabled.go
+++ b/internal/dataprovider/pgsql_disabled.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build nopgsql
-// +build nopgsql
package dataprovider
diff --git a/internal/dataprovider/role.go b/internal/dataprovider/role.go
index dffe0321..5f1154e0 100644
--- a/internal/dataprovider/role.go
+++ b/internal/dataprovider/role.go
@@ -57,6 +57,9 @@ func (r *Role) validate() error {
if r.Name == "" {
return util.NewI18nError(util.NewValidationError("name is mandatory"), util.I18nErrorNameRequired)
}
+ if !util.IsNameValid(r.Name) {
+ return util.NewI18nError(errInvalidInput, util.I18nErrorInvalidInput)
+ }
if len(r.Name) > 255 {
return util.NewValidationError("name is too long, 255 is the maximum length allowed")
}
diff --git a/internal/dataprovider/scheduler.go b/internal/dataprovider/scheduler.go
index 71e9ead8..085066a5 100644
--- a/internal/dataprovider/scheduler.go
+++ b/internal/dataprovider/scheduler.go
@@ -84,11 +84,14 @@ func addScheduledCacheUpdates() error {
func checkDataprovider() {
if currentNode != nil {
- if err := provider.updateNodeTimestamp(); err != nil {
+ err := provider.updateNodeTimestamp()
+ if err != nil {
providerLog(logger.LevelError, "unable to update node timestamp: %v", err)
} else {
providerLog(logger.LevelDebug, "node timestamp updated")
}
+ metric.UpdateDataProviderAvailability(err)
+ return
}
err := provider.checkAvailability()
if err != nil {
diff --git a/internal/dataprovider/share.go b/internal/dataprovider/share.go
index 7e8a4798..6d2881b0 100644
--- a/internal/dataprovider/share.go
+++ b/internal/dataprovider/share.go
@@ -89,6 +89,11 @@ func (s *Share) GetAllowedFromAsString() string {
return strings.Join(s.AllowFrom, ",")
}
+// IsPasswordHashed returns true if the password is hashed
+func (s *Share) IsPasswordHashed() bool {
+ return util.IsStringPrefixInSlice(s.Password, hashPwdPrefixes)
+}
+
func (s *Share) getACopy() Share {
allowFrom := make([]string, len(s.AllowFrom))
copy(allowFrom, s.AllowFrom)
@@ -141,7 +146,7 @@ func (s *Share) HasRedactedPassword() bool {
func (s *Share) hashPassword() error {
if s.Password != "" && !util.IsStringPrefixInSlice(s.Password, internalHashPwdPrefixes) {
- user, err := UserExists(s.Username, "")
+ user, err := GetUserWithGroupSettings(s.Username, "")
if err != nil {
return util.NewGenericError(fmt.Sprintf("unable to validate user: %v", err))
}
@@ -201,13 +206,16 @@ func (s *Share) validatePaths() error {
return nil
}
-func (s *Share) validate() error {
+func (s *Share) validate() error { //nolint:gocyclo
if s.ShareID == "" {
return util.NewValidationError("share_id is mandatory")
}
if s.Name == "" {
return util.NewI18nError(util.NewValidationError("name is mandatory"), util.I18nErrorNameRequired)
}
+ if !util.IsNameValid(s.Name) {
+ return util.NewI18nError(errInvalidInput, util.I18nErrorInvalidInput)
+ }
if s.Scope < ShareScopeRead || s.Scope > ShareScopeReadWrite {
return util.NewI18nError(util.NewValidationError(fmt.Sprintf("invalid scope: %v", s.Scope)), util.I18nErrorShareScope)
}
diff --git a/internal/dataprovider/sqlcommon.go b/internal/dataprovider/sqlcommon.go
index 3a5e5c1c..768adb31 100644
--- a/internal/dataprovider/sqlcommon.go
+++ b/internal/dataprovider/sqlcommon.go
@@ -23,6 +23,7 @@ import (
"fmt"
"net/netip"
"runtime/debug"
+ "strconv"
"strings"
"time"
@@ -35,7 +36,7 @@ import (
)
const (
- sqlDatabaseVersion = 29
+ sqlDatabaseVersion = 34
defaultSQLQueryTimeout = 10 * time.Second
longSQLQueryTimeout = 60 * time.Second
)
@@ -70,6 +71,7 @@ func sqlReplaceAll(sql string) string {
sql = strings.ReplaceAll(sql, "{{groups_folders_mapping}}", sqlTableGroupsFoldersMapping)
sql = strings.ReplaceAll(sql, "{{api_keys}}", sqlTableAPIKeys)
sql = strings.ReplaceAll(sql, "{{shares}}", sqlTableShares)
+ sql = strings.ReplaceAll(sql, "{{shares_groups_mapping}}", sqlTableSharesGroupsMapping)
sql = strings.ReplaceAll(sql, "{{defender_events}}", sqlTableDefenderEvents)
sql = strings.ReplaceAll(sql, "{{defender_hosts}}", sqlTableDefenderHosts)
sql = strings.ReplaceAll(sql, "{{active_transfers}}", sqlTableActiveTransfers)
@@ -1248,6 +1250,32 @@ func sqlCommonUpdateQuota(username string, filesAdd int, sizeAdd int64, reset bo
return err
}
+func sqlCommonGetAdminSignature(username string, dbHandle *sql.DB) (string, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
+ defer cancel()
+
+ q := getAdminSignatureQuery()
+ var updatedAt int64
+ err := dbHandle.QueryRowContext(ctx, q, username).Scan(&updatedAt)
+ if err != nil {
+ return "", err
+ }
+ return strconv.FormatInt(updatedAt, 10), nil
+}
+
+func sqlCommonGetUserSignature(username string, dbHandle *sql.DB) (string, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
+ defer cancel()
+
+ q := getUserSignatureQuery()
+ var updatedAt int64
+ err := dbHandle.QueryRowContext(ctx, q, username).Scan(&updatedAt)
+ if err != nil {
+ return "", err
+ }
+ return strconv.FormatInt(updatedAt, 10), nil
+}
+
func sqlCommonGetUsedQuota(username string, dbHandle *sql.DB) (int, int64, int64, int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
defer cancel()
@@ -2496,9 +2524,9 @@ func sqlCommonClearUserGroupMapping(ctx context.Context, user *User, dbHandle sq
return err
}
-func sqlCommonAddUserFolderMapping(ctx context.Context, user *User, folder *vfs.VirtualFolder, dbHandle sqlQuerier) error {
+func sqlCommonAddUserFolderMapping(ctx context.Context, user *User, folder *vfs.VirtualFolder, sortOrder int, dbHandle sqlQuerier) error {
q := getAddUserFolderMappingQuery()
- _, err := dbHandle.ExecContext(ctx, q, folder.VirtualPath, folder.QuotaSize, folder.QuotaFiles, folder.Name, user.Username)
+ _, err := dbHandle.ExecContext(ctx, q, folder.VirtualPath, folder.QuotaSize, folder.QuotaFiles, folder.Name, user.Username, sortOrder)
return err
}
@@ -2508,27 +2536,29 @@ func sqlCommonClearAdminGroupMapping(ctx context.Context, admin *Admin, dbHandle
return err
}
-func sqlCommonAddGroupFolderMapping(ctx context.Context, group *Group, folder *vfs.VirtualFolder, dbHandle sqlQuerier) error {
+func sqlCommonAddGroupFolderMapping(ctx context.Context, group *Group, folder *vfs.VirtualFolder, sortOrder int,
+ dbHandle sqlQuerier,
+) error {
q := getAddGroupFolderMappingQuery()
- _, err := dbHandle.ExecContext(ctx, q, folder.VirtualPath, folder.QuotaSize, folder.QuotaFiles, folder.Name, group.Name)
+ _, err := dbHandle.ExecContext(ctx, q, folder.VirtualPath, folder.QuotaSize, folder.QuotaFiles, folder.Name, group.Name, sortOrder)
return err
}
-func sqlCommonAddUserGroupMapping(ctx context.Context, username, groupName string, groupType int, dbHandle sqlQuerier) error {
+func sqlCommonAddUserGroupMapping(ctx context.Context, username, groupName string, groupType, sortOrder int, dbHandle sqlQuerier) error {
q := getAddUserGroupMappingQuery()
- _, err := dbHandle.ExecContext(ctx, q, username, groupName, groupType)
+ _, err := dbHandle.ExecContext(ctx, q, username, groupName, groupType, sortOrder)
return err
}
func sqlCommonAddAdminGroupMapping(ctx context.Context, username, groupName string, mappingOptions AdminGroupMappingOptions,
- dbHandle sqlQuerier,
+ sortOrder int, dbHandle sqlQuerier,
) error {
options, err := json.Marshal(mappingOptions)
if err != nil {
return err
}
q := getAddAdminGroupMappingQuery()
- _, err = dbHandle.ExecContext(ctx, q, username, groupName, options)
+ _, err = dbHandle.ExecContext(ctx, q, username, groupName, options, sortOrder)
return err
}
@@ -2539,7 +2569,7 @@ func generateGroupVirtualFoldersMapping(ctx context.Context, group *Group, dbHan
}
for idx := range group.VirtualFolders {
vfolder := &group.VirtualFolders[idx]
- err = sqlCommonAddGroupFolderMapping(ctx, group, vfolder, dbHandle)
+ err = sqlCommonAddGroupFolderMapping(ctx, group, vfolder, idx, dbHandle)
if err != nil {
return err
}
@@ -2554,7 +2584,7 @@ func generateUserVirtualFoldersMapping(ctx context.Context, user *User, dbHandle
}
for idx := range user.VirtualFolders {
vfolder := &user.VirtualFolders[idx]
- err = sqlCommonAddUserFolderMapping(ctx, user, vfolder, dbHandle)
+ err = sqlCommonAddUserFolderMapping(ctx, user, vfolder, idx, dbHandle)
if err != nil {
return err
}
@@ -2567,8 +2597,8 @@ func generateUserGroupMapping(ctx context.Context, user *User, dbHandle sqlQueri
if err != nil {
return err
}
- for _, group := range user.Groups {
- err = sqlCommonAddUserGroupMapping(ctx, user.Username, group.Name, group.Type, dbHandle)
+ for idx, group := range user.Groups {
+ err = sqlCommonAddUserGroupMapping(ctx, user.Username, group.Name, group.Type, idx, dbHandle)
if err != nil {
return err
}
@@ -2581,8 +2611,8 @@ func generateAdminGroupMapping(ctx context.Context, admin *Admin, dbHandle sqlQu
if err != nil {
return err
}
- for _, group := range admin.Groups {
- err = sqlCommonAddAdminGroupMapping(ctx, admin.Username, group.Name, group.Options, dbHandle)
+ for idx, group := range admin.Groups {
+ err = sqlCommonAddAdminGroupMapping(ctx, admin.Username, group.Name, group.Options, idx, dbHandle)
if err != nil {
return err
}
@@ -3238,14 +3268,14 @@ func sqlCommonAddSession(session Session, dbHandle *sql.DB) error {
return err
}
-func sqlCommonGetSession(key string, dbHandle sqlQuerier) (Session, error) {
+func sqlCommonGetSession(key string, sessionType SessionType, dbHandle sqlQuerier) (Session, error) {
var session Session
ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
defer cancel()
q := getSessionQuery()
var data []byte // type hint, some driver will use string instead of []byte if the type is any
- err := dbHandle.QueryRowContext(ctx, q, key).Scan(&session.Key, &data, &session.Type, &session.Timestamp)
+ err := dbHandle.QueryRowContext(ctx, q, key, sessionType).Scan(&session.Key, &data, &session.Type, &session.Timestamp)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return session, util.NewRecordNotFoundError(err.Error())
@@ -3256,12 +3286,12 @@ func sqlCommonGetSession(key string, dbHandle sqlQuerier) (Session, error) {
return session, nil
}
-func sqlCommonDeleteSession(key string, dbHandle *sql.DB) error {
+func sqlCommonDeleteSession(key string, sessionType SessionType, dbHandle *sql.DB) error {
ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
defer cancel()
q := getDeleteSessionQuery()
- res, err := dbHandle.ExecContext(ctx, q, key)
+ res, err := dbHandle.ExecContext(ctx, q, key, sessionType)
if err != nil {
return err
}
@@ -3936,16 +3966,22 @@ func sqlCommonUpdateDatabaseVersion(ctx context.Context, dbHandle sqlQuerier, ve
}
func sqlCommonExecSQLAndUpdateDBVersion(dbHandle *sql.DB, sqlQueries []string, newVersion int, isUp bool) error {
- if err := sqlAcquireLock(dbHandle); err != nil {
- return err
- }
- defer sqlReleaseLock(dbHandle)
-
ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout)
defer cancel()
+ conn, err := dbHandle.Conn(ctx)
+ if err != nil {
+ return fmt.Errorf("unable to get connection from pool: %w", err)
+ }
+ defer conn.Close()
+
+ if err := sqlAcquireLock(conn); err != nil {
+ return err
+ }
+ defer sqlReleaseLock(conn)
+
if newVersion > 0 {
- currentVersion, err := sqlCommonGetDatabaseVersion(dbHandle, false)
+ currentVersion, err := sqlCommonGetDatabaseVersion(conn, false)
if err == nil {
if (isUp && currentVersion.Version >= newVersion) || (!isUp && currentVersion.Version <= newVersion) {
providerLog(logger.LevelInfo, "current schema version: %v, requested: %v, did you execute simultaneous migrations?",
@@ -3955,7 +3991,7 @@ func sqlCommonExecSQLAndUpdateDBVersion(dbHandle *sql.DB, sqlQueries []string, n
}
}
- return sqlCommonExecuteTx(ctx, dbHandle, func(tx *sql.Tx) error {
+ return sqlCommonExecuteTxOnConn(ctx, conn, func(tx *sql.Tx) error {
for _, q := range sqlQueries {
if strings.TrimSpace(q) == "" {
continue
@@ -3972,7 +4008,7 @@ func sqlCommonExecSQLAndUpdateDBVersion(dbHandle *sql.DB, sqlQueries []string, n
})
}
-func sqlAcquireLock(dbHandle *sql.DB) error {
+func sqlAcquireLock(dbHandle *sql.Conn) error {
ctx, cancel := context.WithTimeout(context.Background(), longSQLQueryTimeout)
defer cancel()
@@ -4001,7 +4037,7 @@ func sqlAcquireLock(dbHandle *sql.DB) error {
return nil
}
-func sqlReleaseLock(dbHandle *sql.DB) {
+func sqlReleaseLock(dbHandle *sql.Conn) {
ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
defer cancel()
@@ -4023,6 +4059,20 @@ func sqlReleaseLock(dbHandle *sql.DB) {
}
}
+func sqlCommonExecuteTxOnConn(ctx context.Context, conn *sql.Conn, txFn func(*sql.Tx) error) error {
+ tx, err := conn.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+
+ err = txFn(tx)
+ if err != nil {
+ tx.Rollback() //nolint:errcheck
+ return err
+ }
+ return tx.Commit()
+}
+
func sqlCommonExecuteTx(ctx context.Context, dbHandle *sql.DB, txFn func(*sql.Tx) error) error {
if config.Driver == CockroachDataProviderName {
return crdb.ExecuteTx(ctx, dbHandle, nil, txFn)
diff --git a/internal/dataprovider/sqlite.go b/internal/dataprovider/sqlite.go
index 4a21c21c..98d2d2b9 100644
--- a/internal/dataprovider/sqlite.go
+++ b/internal/dataprovider/sqlite.go
@@ -12,8 +12,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-//go:build !nosqlite
-// +build !nosqlite
+//go:build !nosqlite && cgo
package dataprovider
@@ -24,6 +23,7 @@ import (
"errors"
"fmt"
"path/filepath"
+ "strings"
"time"
"github.com/mattn/go-sqlite3"
@@ -36,11 +36,11 @@ import (
const (
sqliteResetSQL = `DROP TABLE IF EXISTS "{{api_keys}}";
-DROP TABLE IF EXISTS "{{folders_mapping}}";
DROP TABLE IF EXISTS "{{users_folders_mapping}}";
DROP TABLE IF EXISTS "{{users_groups_mapping}}";
DROP TABLE IF EXISTS "{{admins_groups_mapping}}";
DROP TABLE IF EXISTS "{{groups_folders_mapping}}";
+DROP TABLE IF EXISTS "{{shares_groups_mapping}}";
DROP TABLE IF EXISTS "{{admins}}";
DROP TABLE IF EXISTS "{{folders}}";
DROP TABLE IF EXISTS "{{shares}}";
@@ -82,8 +82,8 @@ CREATE TABLE "{{folders}}" ("id" integer NOT NULL PRIMARY KEY, "name" varchar(25
"last_quota_update" bigint NOT NULL, "filesystem" text NULL);
CREATE TABLE "{{groups}}" ("id" integer NOT NULL PRIMARY KEY, "name" varchar(255) NOT NULL UNIQUE,
"description" varchar(512) NULL, "created_at" bigint NOT NULL, "updated_at" bigint NOT NULL, "user_settings" text NULL);
-CREATE TABLE "{{shared_sessions}}" ("key" varchar(128) NOT NULL PRIMARY KEY, "data" text NOT NULL,
-"type" integer NOT NULL, "timestamp" bigint NOT NULL);
+CREATE TABLE "{{shared_sessions}}" ("key" varchar(128) NOT NULL, "type" integer NOT NULL,
+"data" text NOT NULL, "timestamp" bigint NOT NULL, PRIMARY KEY ("key", "type"));
CREATE TABLE "{{users}}" ("id" integer NOT NULL PRIMARY KEY, "username" varchar(255) NOT NULL UNIQUE,
"status" integer NOT NULL, "expiration_date" bigint NOT NULL, "description" varchar(512) NULL, "password" text NULL,
"public_keys" text NULL, "home_dir" text NOT NULL, "uid" bigint NOT NULL, "gid" bigint NOT NULL,
@@ -98,21 +98,21 @@ CREATE TABLE "{{users}}" ("id" integer NOT NULL PRIMARY KEY, "username" varchar(
CREATE TABLE "{{groups_folders_mapping}}" ("id" integer NOT NULL PRIMARY KEY,
"folder_id" integer NOT NULL REFERENCES "{{folders}}" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
"group_id" integer NOT NULL REFERENCES "{{groups}}" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
-"virtual_path" text NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL,
+"virtual_path" text NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "sort_order" integer NOT NULL,
CONSTRAINT "{{prefix}}unique_group_folder_mapping" UNIQUE ("group_id", "folder_id"));
CREATE TABLE "{{users_groups_mapping}}" ("id" integer NOT NULL PRIMARY KEY,
"user_id" integer NOT NULL REFERENCES "{{users}}" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
"group_id" integer NOT NULL REFERENCES "{{groups}}" ("id") ON DELETE NO ACTION,
-"group_type" integer NOT NULL, CONSTRAINT "{{prefix}}unique_user_group_mapping" UNIQUE ("user_id", "group_id"));
+"group_type" integer NOT NULL, "sort_order" integer NOT NULL, CONSTRAINT "{{prefix}}unique_user_group_mapping" UNIQUE ("user_id", "group_id"));
CREATE TABLE "{{users_folders_mapping}}" ("id" integer NOT NULL PRIMARY KEY,
"user_id" integer NOT NULL REFERENCES "{{users}}" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
"folder_id" integer NOT NULL REFERENCES "{{folders}}" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
-"virtual_path" text NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL,
+"virtual_path" text NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "sort_order" integer NOT NULL,
CONSTRAINT "{{prefix}}unique_user_folder_mapping" UNIQUE ("user_id", "folder_id"));
CREATE TABLE "{{shares}}" ("id" integer NOT NULL PRIMARY KEY, "share_id" varchar(60) NOT NULL UNIQUE,
"name" varchar(255) NOT NULL, "description" varchar(512) NULL, "scope" integer NOT NULL, "paths" text NOT NULL,
"created_at" bigint NOT NULL, "updated_at" bigint NOT NULL, "last_use_at" bigint NOT NULL, "expires_at" bigint NOT NULL,
-"password" text NULL, "max_tokens" integer NOT NULL, "used_tokens" integer NOT NULL, "allow_from" text NULL,
+"password" text NULL, "max_tokens" integer NOT NULL, "used_tokens" integer NOT NULL, "allow_from" text NULL, "options" text NULL,
"user_id" integer NOT NULL REFERENCES "{{users}}" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED);
CREATE TABLE "{{api_keys}}" ("id" integer NOT NULL PRIMARY KEY, "name" varchar(255) NOT NULL,
"key_id" varchar(50) NOT NULL UNIQUE, "api_key" varchar(255) NOT NULL UNIQUE, "scope" integer NOT NULL,
@@ -134,7 +134,7 @@ CREATE TABLE "{{tasks}}" ("id" integer NOT NULL PRIMARY KEY, "name" varchar(255)
CREATE TABLE "{{admins_groups_mapping}}" ("id" integer NOT NULL PRIMARY KEY,
"admin_id" integer NOT NULL REFERENCES "{{admins}}" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
"group_id" integer NOT NULL REFERENCES "{{groups}}" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
-"options" text NOT NULL, CONSTRAINT "{{prefix}}unique_admin_group_mapping" UNIQUE ("admin_id", "group_id"));
+"options" text NOT NULL, "sort_order" integer NOT NULL, CONSTRAINT "{{prefix}}unique_admin_group_mapping" UNIQUE ("admin_id", "group_id"));
CREATE TABLE "{{ip_lists}}" ("id" integer NOT NULL PRIMARY KEY,
"type" integer NOT NULL, "ipornet" varchar(50) NOT NULL, "mode" integer NOT NULL, "description" varchar(512) NULL,
"first" BLOB NOT NULL, "last" BLOB NOT NULL, "ip_type" integer NOT NULL, "protocols" integer NOT NULL,
@@ -144,10 +144,13 @@ CREATE TABLE "{{configs}}" ("id" integer NOT NULL PRIMARY KEY, "configs" text NO
INSERT INTO {{configs}} (configs) VALUES ('{}');
CREATE INDEX "{{prefix}}users_folders_mapping_folder_id_idx" ON "{{users_folders_mapping}}" ("folder_id");
CREATE INDEX "{{prefix}}users_folders_mapping_user_id_idx" ON "{{users_folders_mapping}}" ("user_id");
+CREATE INDEX "{{prefix}}users_folders_mapping_sort_order_idx" ON "{{users_folders_mapping}}" ("sort_order");
CREATE INDEX "{{prefix}}users_groups_mapping_group_id_idx" ON "{{users_groups_mapping}}" ("group_id");
CREATE INDEX "{{prefix}}users_groups_mapping_user_id_idx" ON "{{users_groups_mapping}}" ("user_id");
+CREATE INDEX "{{prefix}}users_groups_mapping_sort_order_idx" ON "{{users_groups_mapping}}" ("sort_order");
CREATE INDEX "{{prefix}}groups_folders_mapping_folder_id_idx" ON "{{groups_folders_mapping}}" ("folder_id");
CREATE INDEX "{{prefix}}groups_folders_mapping_group_id_idx" ON "{{groups_folders_mapping}}" ("group_id");
+CREATE INDEX "{{prefix}}groups_folders_mapping_sort_order_idx" ON "{{groups_folders_mapping}}" ("sort_order");
CREATE INDEX "{{prefix}}api_keys_admin_id_idx" ON "{{api_keys}}" ("admin_id");
CREATE INDEX "{{prefix}}api_keys_user_id_idx" ON "{{api_keys}}" ("user_id");
CREATE INDEX "{{prefix}}users_updated_at_idx" ON "{{users}}" ("updated_at");
@@ -170,6 +173,7 @@ CREATE INDEX "{{prefix}}rules_actions_mapping_action_id_idx" ON "{{rules_actions
CREATE INDEX "{{prefix}}rules_actions_mapping_order_idx" ON "{{rules_actions_mapping}}" ("order");
CREATE INDEX "{{prefix}}admins_groups_mapping_admin_id_idx" ON "{{admins_groups_mapping}}" ("admin_id");
CREATE INDEX "{{prefix}}admins_groups_mapping_group_id_idx" ON "{{admins_groups_mapping}}" ("group_id");
+CREATE INDEX "{{prefix}}admins_groups_mapping_sort_order_idx" ON "{{admins_groups_mapping}}" ("sort_order");
CREATE INDEX "{{prefix}}users_role_id_idx" ON "{{users}}" ("role_id");
CREATE INDEX "{{prefix}}admins_role_id_idx" ON "{{admins}}" ("role_id");
CREATE INDEX "{{prefix}}ip_lists_type_idx" ON "{{ip_lists}}" ("type");
@@ -178,8 +182,22 @@ CREATE INDEX "{{prefix}}ip_lists_ip_type_idx" ON "{{ip_lists}}" ("ip_type");
CREATE INDEX "{{prefix}}ip_lists_ip_updated_at_idx" ON "{{ip_lists}}" ("updated_at");
CREATE INDEX "{{prefix}}ip_lists_ip_deleted_at_idx" ON "{{ip_lists}}" ("deleted_at");
CREATE INDEX "{{prefix}}ip_lists_first_last_idx" ON "{{ip_lists}}" ("first", "last");
-INSERT INTO {{schema_version}} (version) VALUES (28);
+INSERT INTO {{schema_version}} (version) VALUES (33);
`
+ sqliteV34SQL = `
+CREATE TABLE "{{shares_groups_mapping}}" (
+ "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "share_id" integer NOT NULL REFERENCES "{{shares}}" ("id") ON DELETE CASCADE,
+ "group_id" integer NOT NULL REFERENCES "{{groups}}" ("id") ON DELETE CASCADE,
+ "permissions" integer NOT NULL,
+ "sort_order" integer NOT NULL,
+ CONSTRAINT "{{prefix}}unique_share_group_mapping" UNIQUE ("share_id", "group_id")
+);
+CREATE INDEX "{{prefix}}shares_groups_mapping_sort_order_idx" ON "{{shares_groups_mapping}}" ("sort_order");
+CREATE INDEX "{{prefix}}shares_groups_mapping_group_id_idx" ON "{{shares_groups_mapping}}" ("group_id");
+CREATE INDEX "{{prefix}}shares_groups_mapping_share_id_idx" ON "{{shares_groups_mapping}}" ("share_id");
+`
+ sqliteV34DownSQL = `DROP TABLE IF EXISTS "{{shares_groups_mapping}}";`
)
// SQLiteProvider defines the auth provider for SQLite database
@@ -215,7 +233,7 @@ func initializeSQLiteProvider(basePath string) error {
providerLog(logger.LevelDebug, "sqlite database handle created, connection string: %q", connectionString)
dbHandle.SetMaxOpenConns(1)
provider = &SQLiteProvider{dbHandle: dbHandle}
- return nil
+ return executePragmaOptimize(dbHandle)
}
func (p *SQLiteProvider) checkAvailability() error {
@@ -246,6 +264,14 @@ func (p *SQLiteProvider) getUsedQuota(username string) (int, int64, int64, int64
return sqlCommonGetUsedQuota(username, p.dbHandle)
}
+func (p *SQLiteProvider) getAdminSignature(username string) (string, error) {
+ return sqlCommonGetAdminSignature(username, p.dbHandle)
+}
+
+func (p *SQLiteProvider) getUserSignature(username string) (string, error) {
+ return sqlCommonGetUserSignature(username, p.dbHandle)
+}
+
func (p *SQLiteProvider) setUpdatedAt(username string) {
sqlCommonSetUpdatedAt(username, p.dbHandle)
}
@@ -500,12 +526,12 @@ func (p *SQLiteProvider) addSharedSession(session Session) error {
return sqlCommonAddSession(session, p.dbHandle)
}
-func (p *SQLiteProvider) deleteSharedSession(key string) error {
- return sqlCommonDeleteSession(key, p.dbHandle)
+func (p *SQLiteProvider) deleteSharedSession(key string, sessionType SessionType) error {
+ return sqlCommonDeleteSession(key, sessionType, p.dbHandle)
}
-func (p *SQLiteProvider) getSharedSession(key string) (Session, error) {
- return sqlCommonGetSession(key, p.dbHandle)
+func (p *SQLiteProvider) getSharedSession(key string, sessionType SessionType) (Session, error) {
+ return sqlCommonGetSession(key, sessionType, p.dbHandle)
}
func (p *SQLiteProvider) cleanupSharedSessions(sessionType SessionType, before int64) error {
@@ -693,13 +719,13 @@ func (p *SQLiteProvider) initializeDatabase() error {
if errors.Is(err, sql.ErrNoRows) {
return errSchemaVersionEmpty
}
- logger.InfoToConsole("creating initial database schema, version 28")
- providerLog(logger.LevelInfo, "creating initial database schema, version 28")
+ logger.InfoToConsole("creating initial database schema, version 33")
+ providerLog(logger.LevelInfo, "creating initial database schema, version 33")
sql := sqlReplaceAll(sqliteInitialSQL)
- return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{sql}, 28, true)
+ return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{sql}, 33, true)
}
-func (p *SQLiteProvider) migrateDatabase() error { //nolint:dupl
+func (p *SQLiteProvider) migrateDatabase() error {
dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle, true)
if err != nil {
return err
@@ -709,13 +735,13 @@ func (p *SQLiteProvider) migrateDatabase() error { //nolint:dupl
case version == sqlDatabaseVersion:
providerLog(logger.LevelDebug, "sql database is up to date, current version: %d", version)
return ErrNoInitRequired
- case version < 28:
- err = fmt.Errorf("database schema version %d is too old, please see the upgrading docs", version)
+ case version < 33:
+ err = errSchemaVersionTooOld(version)
providerLog(logger.LevelError, "%v", err)
logger.ErrorToConsole("%v", err)
return err
- case version == 28:
- return updateSQLiteDatabaseFrom28To29(p.dbHandle)
+ case version == 33:
+ return updateSQLiteDatabaseFromV33(p.dbHandle)
default:
if version > sqlDatabaseVersion {
providerLog(logger.LevelError, "database schema version %d is newer than the supported one: %d", version,
@@ -738,8 +764,8 @@ func (p *SQLiteProvider) revertDatabase(targetVersion int) error {
}
switch dbVersion.Version {
- case 29:
- return downgradeSQLiteDatabaseFrom29To28(p.dbHandle)
+ case 34:
+ return downgradeSQLiteDatabaseFromV34(p.dbHandle)
default:
return fmt.Errorf("database schema version not handled: %d", dbVersion.Version)
}
@@ -777,24 +803,39 @@ func (p *SQLiteProvider) normalizeError(err error, fieldType int) error {
return err
}
-func updateSQLiteDatabaseFrom28To29(dbHandle *sql.DB) error {
- logger.InfoToConsole("updating database schema version: 28 -> 29")
- providerLog(logger.LevelInfo, "updating database schema version: 28 -> 29")
-
+func executePragmaOptimize(dbHandle *sql.DB) error {
ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
defer cancel()
- return sqlCommonUpdateDatabaseVersion(ctx, dbHandle, 29)
+ _, err := dbHandle.ExecContext(ctx, "PRAGMA optimize;")
+ return err
}
-func downgradeSQLiteDatabaseFrom29To28(dbHandle *sql.DB) error {
- logger.InfoToConsole("downgrading database schema version: 29 -> 28")
- providerLog(logger.LevelInfo, "downgrading database schema version: 29 -> 28")
+func updateSQLiteDatabaseFromV33(dbHandle *sql.DB) error {
+ return updateSQLiteDatabaseFrom33To34(dbHandle)
+}
- ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
- defer cancel()
+func downgradeSQLiteDatabaseFromV34(dbHandle *sql.DB) error {
+ return downgradeSQLiteDatabaseFrom34To33(dbHandle)
+}
- return sqlCommonUpdateDatabaseVersion(ctx, dbHandle, 28)
+func updateSQLiteDatabaseFrom33To34(dbHandle *sql.DB) error {
+ logger.InfoToConsole("updating database schema version: 33 -> 34")
+ providerLog(logger.LevelInfo, "updating database schema version: 33 -> 34")
+
+ sql := strings.ReplaceAll(sqliteV34SQL, "{{prefix}}", config.SQLTablesPrefix)
+ sql = strings.ReplaceAll(sql, "{{shares}}", sqlTableShares)
+ sql = strings.ReplaceAll(sql, "{{shares_groups_mapping}}", sqlTableSharesGroupsMapping)
+ sql = strings.ReplaceAll(sql, "{{groups}}", sqlTableGroups)
+ return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 34, true)
+}
+
+func downgradeSQLiteDatabaseFrom34To33(dbHandle *sql.DB) error {
+ logger.InfoToConsole("downgrading database schema version: 34 -> 33")
+ providerLog(logger.LevelInfo, "downgrading database schema version: 34 -> 33")
+
+ sql := strings.ReplaceAll(sqliteV34DownSQL, "{{shares_groups_mapping}}", sqlTableSharesGroupsMapping)
+ return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 33, false)
}
/*func setPragmaFK(dbHandle *sql.DB, value string) error {
diff --git a/internal/dataprovider/sqlite_disabled.go b/internal/dataprovider/sqlite_disabled.go
index 6c7ea18d..22138967 100644
--- a/internal/dataprovider/sqlite_disabled.go
+++ b/internal/dataprovider/sqlite_disabled.go
@@ -12,8 +12,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-//go:build nosqlite
-// +build nosqlite
+//go:build nosqlite || !cgo
package dataprovider
diff --git a/internal/dataprovider/sqlqueries.go b/internal/dataprovider/sqlqueries.go
index 2ce95b5c..2d5aaf6b 100644
--- a/internal/dataprovider/sqlqueries.go
+++ b/internal/dataprovider/sqlqueries.go
@@ -81,25 +81,27 @@ func getAddSessionQuery() string {
"ON DUPLICATE KEY UPDATE `data`=VALUES(`data`), `timestamp`=VALUES(`timestamp`)",
sqlTableSharedSessions, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3])
}
- return fmt.Sprintf(`INSERT INTO %s (key,data,type,timestamp) VALUES (%s,%s,%s,%s) ON CONFLICT(key) DO UPDATE SET data=
+ return fmt.Sprintf(`INSERT INTO %s (key,data,type,timestamp) VALUES (%s,%s,%s,%s) ON CONFLICT(key,type) DO UPDATE SET data=
EXCLUDED.data, timestamp=EXCLUDED.timestamp`,
sqlTableSharedSessions, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3])
}
func getDeleteSessionQuery() string {
if config.Driver == MySQLDataProviderName {
- return fmt.Sprintf("DELETE FROM %s WHERE `key` = %s", sqlTableSharedSessions, sqlPlaceholders[0])
+ return fmt.Sprintf("DELETE FROM %s WHERE `key` = %s AND `type` = %s",
+ sqlTableSharedSessions, sqlPlaceholders[0], sqlPlaceholders[1])
}
- return fmt.Sprintf(`DELETE FROM %s WHERE key = %s`, sqlTableSharedSessions, sqlPlaceholders[0])
+ return fmt.Sprintf(`DELETE FROM %s WHERE key = %s AND type = %s`,
+ sqlTableSharedSessions, sqlPlaceholders[0], sqlPlaceholders[1])
}
func getSessionQuery() string {
if config.Driver == MySQLDataProviderName {
- return fmt.Sprintf("SELECT `key`,`data`,`type`,`timestamp` FROM %s WHERE `key` = %s", sqlTableSharedSessions,
- sqlPlaceholders[0])
+ return fmt.Sprintf("SELECT `key`,`data`,`type`,`timestamp` FROM %s WHERE `key` = %s AND `type` = %s",
+ sqlTableSharedSessions, sqlPlaceholders[0], sqlPlaceholders[1])
}
- return fmt.Sprintf(`SELECT key,data,type,timestamp FROM %s WHERE key = %s`, sqlTableSharedSessions,
- sqlPlaceholders[0])
+ return fmt.Sprintf(`SELECT key,data,type,timestamp FROM %s WHERE key = %s AND type = %s`,
+ sqlTableSharedSessions, sqlPlaceholders[0], sqlPlaceholders[1])
}
func getCleanupSessionsQuery() string {
@@ -650,6 +652,14 @@ func getUpdateQuotaQuery(reset bool) string {
WHERE username = %s`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3])
}
+func getAdminSignatureQuery() string {
+ return fmt.Sprintf(`SELECT updated_at FROM %s WHERE username = %s`, sqlTableAdmins, sqlPlaceholders[0])
+}
+
+func getUserSignatureQuery() string {
+ return fmt.Sprintf(`SELECT updated_at FROM %s WHERE username = %s`, sqlTableUsers, sqlPlaceholders[0])
+}
+
func getSetUpdateAtQuery() string {
return fmt.Sprintf(`UPDATE %s SET updated_at = %s WHERE username = %s`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1])
}
@@ -757,10 +767,10 @@ func getClearUserGroupMappingQuery() string {
}
func getAddUserGroupMappingQuery() string {
- return fmt.Sprintf(`INSERT INTO %s (user_id,group_id,group_type) VALUES ((SELECT id FROM %s WHERE username = %s),
- (SELECT id FROM %s WHERE name = %s),%s)`,
+ return fmt.Sprintf(`INSERT INTO %s (user_id,group_id,group_type,sort_order) VALUES ((SELECT id FROM %s WHERE username = %s),
+ (SELECT id FROM %s WHERE name = %s),%s,%s)`,
sqlTableUsersGroupsMapping, sqlTableUsers, sqlPlaceholders[0], getSQLQuotedName(sqlTableGroups),
- sqlPlaceholders[1], sqlPlaceholders[2])
+ sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3])
}
func getClearAdminGroupMappingQuery() string {
@@ -769,10 +779,10 @@ func getClearAdminGroupMappingQuery() string {
}
func getAddAdminGroupMappingQuery() string {
- return fmt.Sprintf(`INSERT INTO %s (admin_id,group_id,options) VALUES ((SELECT id FROM %s WHERE username = %s),
- (SELECT id FROM %s WHERE name = %s),%s)`,
+ return fmt.Sprintf(`INSERT INTO %s (admin_id,group_id,options,sort_order) VALUES ((SELECT id FROM %s WHERE username = %s),
+ (SELECT id FROM %s WHERE name = %s),%s,%s)`,
sqlTableAdminsGroupsMapping, sqlTableAdmins, sqlPlaceholders[0], getSQLQuotedName(sqlTableGroups),
- sqlPlaceholders[1], sqlPlaceholders[2])
+ sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3])
}
func getClearGroupFolderMappingQuery() string {
@@ -781,10 +791,10 @@ func getClearGroupFolderMappingQuery() string {
}
func getAddGroupFolderMappingQuery() string {
- return fmt.Sprintf(`INSERT INTO %s (virtual_path,quota_size,quota_files,folder_id,group_id)
- VALUES (%s,%s,%s,(SELECT id FROM %s WHERE name = %s),(SELECT id FROM %s WHERE name = %s))`,
+ return fmt.Sprintf(`INSERT INTO %s (virtual_path,quota_size,quota_files,folder_id,group_id,sort_order)
+ VALUES (%s,%s,%s,(SELECT id FROM %s WHERE name = %s),(SELECT id FROM %s WHERE name = %s),%s)`,
sqlTableGroupsFoldersMapping, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlTableFolders,
- sqlPlaceholders[3], getSQLQuotedName(sqlTableGroups), sqlPlaceholders[4])
+ sqlPlaceholders[3], getSQLQuotedName(sqlTableGroups), sqlPlaceholders[4], sqlPlaceholders[5])
}
func getClearUserFolderMappingQuery() string {
@@ -793,10 +803,10 @@ func getClearUserFolderMappingQuery() string {
}
func getAddUserFolderMappingQuery() string {
- return fmt.Sprintf(`INSERT INTO %s (virtual_path,quota_size,quota_files,folder_id,user_id)
- VALUES (%s,%s,%s,(SELECT id FROM %s WHERE name = %s),(SELECT id FROM %s WHERE username = %s))`,
+ return fmt.Sprintf(`INSERT INTO %s (virtual_path,quota_size,quota_files,folder_id,user_id,sort_order)
+ VALUES (%s,%s,%s,(SELECT id FROM %s WHERE name = %s),(SELECT id FROM %s WHERE username = %s),%s)`,
sqlTableUsersFoldersMapping, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlTableFolders,
- sqlPlaceholders[3], sqlTableUsers, sqlPlaceholders[4])
+ sqlPlaceholders[3], sqlTableUsers, sqlPlaceholders[4], sqlPlaceholders[5])
}
func getFoldersQuery(order string, minimal bool) string {
@@ -838,7 +848,7 @@ func getRelatedGroupsForUsersQuery(users []User) string {
sb.WriteString(")")
}
return fmt.Sprintf(`SELECT g.name,ug.group_type,ug.user_id FROM %s g INNER JOIN %s ug ON g.id = ug.group_id WHERE
- ug.user_id IN %s ORDER BY g.name`, getSQLQuotedName(sqlTableGroups), sqlTableUsersGroupsMapping, sb.String())
+ ug.user_id IN %s ORDER BY ug.sort_order`, getSQLQuotedName(sqlTableGroups), sqlTableUsersGroupsMapping, sb.String())
}
func getRelatedGroupsForAdminsQuery(admins []Admin) string {
@@ -855,7 +865,7 @@ func getRelatedGroupsForAdminsQuery(admins []Admin) string {
sb.WriteString(")")
}
return fmt.Sprintf(`SELECT g.name,ag.options,ag.admin_id FROM %s g INNER JOIN %s ag ON g.id = ag.group_id WHERE
- ag.admin_id IN %s ORDER BY g.name`, getSQLQuotedName(sqlTableGroups), sqlTableAdminsGroupsMapping, sb.String())
+ ag.admin_id IN %s ORDER BY ag.sort_order`, getSQLQuotedName(sqlTableGroups), sqlTableAdminsGroupsMapping, sb.String())
}
func getRelatedFoldersForUsersQuery(users []User) string {
@@ -873,7 +883,7 @@ func getRelatedFoldersForUsersQuery(users []User) string {
}
return fmt.Sprintf(`SELECT f.id,f.name,f.path,f.used_quota_size,f.used_quota_files,f.last_quota_update,fm.virtual_path,
fm.quota_size,fm.quota_files,fm.user_id,f.filesystem,f.description FROM %s f INNER JOIN %s fm ON f.id = fm.folder_id WHERE
- fm.user_id IN %s ORDER BY f.name`, sqlTableFolders, sqlTableUsersFoldersMapping, sb.String())
+ fm.user_id IN %s ORDER BY fm.sort_order`, sqlTableFolders, sqlTableUsersFoldersMapping, sb.String())
}
func getRelatedUsersForFoldersQuery(folders []vfs.BaseVirtualFolder) string {
@@ -960,7 +970,7 @@ func getRelatedFoldersForGroupsQuery(groups []Group) string {
}
return fmt.Sprintf(`SELECT f.id,f.name,f.path,f.used_quota_size,f.used_quota_files,f.last_quota_update,fm.virtual_path,
fm.quota_size,fm.quota_files,fm.group_id,f.filesystem,f.description FROM %s f INNER JOIN %s fm ON f.id = fm.folder_id WHERE
- fm.group_id IN %s ORDER BY f.name`, sqlTableFolders, sqlTableGroupsFoldersMapping, sb.String())
+ fm.group_id IN %s ORDER BY fm.sort_order`, sqlTableFolders, sqlTableGroupsFoldersMapping, sb.String())
}
func getActiveTransfersQuery() string {
diff --git a/internal/dataprovider/unixcrypt.go b/internal/dataprovider/unixcrypt.go
index 9799e8a4..26c8f646 100644
--- a/internal/dataprovider/unixcrypt.go
+++ b/internal/dataprovider/unixcrypt.go
@@ -12,8 +12,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-//go:build unixcrypt
-// +build unixcrypt
+//go:build unixcrypt && cgo
package dataprovider
diff --git a/internal/dataprovider/unixcrypt_disabled.go b/internal/dataprovider/unixcrypt_disabled.go
index ecf76997..3e865110 100644
--- a/internal/dataprovider/unixcrypt_disabled.go
+++ b/internal/dataprovider/unixcrypt_disabled.go
@@ -12,8 +12,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-//go:build !unixcrypt
-// +build !unixcrypt
+//go:build !unixcrypt || !cgo
package dataprovider
diff --git a/internal/dataprovider/user.go b/internal/dataprovider/user.go
index 80970c26..8cea928d 100644
--- a/internal/dataprovider/user.go
+++ b/internal/dataprovider/user.go
@@ -22,6 +22,8 @@ import (
"net"
"os"
"path"
+ "path/filepath"
+ "slices"
"strconv"
"strings"
"time"
@@ -123,6 +125,8 @@ type UserFilters struct {
sdk.BaseUserFilters
// User must change password from WebClient/REST API at next login.
RequirePasswordChange bool `json:"require_password_change,omitempty"`
+ // AdditionalEmails defines additional email addresses
+ AdditionalEmails []string `json:"additional_emails,omitempty"`
// Time-based one time passwords configuration
TOTPConfig UserTOTPConfig `json:"totp_config,omitempty"`
// Recovery codes to use if the user loses access to their second factor auth device.
@@ -342,7 +346,11 @@ func (u *User) isTimeBasedAccessAllowed(when time.Time) bool {
if when.IsZero() {
when = time.Now()
}
- when = when.UTC()
+ if UseLocalTime() {
+ when = when.Local()
+ } else {
+ when = when.UTC()
+ }
weekDay := when.Weekday()
hhMM := when.Format("15:04")
for _, p := range u.Filters.AccessTime {
@@ -399,6 +407,15 @@ func (u *User) CheckMaxShareExpiration(expiresAt time.Time) error {
return nil
}
+// GetEmailAddresses returns all the email addresses.
+func (u *User) GetEmailAddresses() []string {
+ var res []string
+ if u.Email != "" {
+ res = append(res, u.Email)
+ }
+ return slices.Concat(res, u.Filters.AdditionalEmails)
+}
+
// GetSubDirPermissions returns permissions for sub directories
func (u *User) GetSubDirPermissions() []sdk.DirectoryPermissions {
var result []sdk.DirectoryPermissions
@@ -840,20 +857,20 @@ func (u *User) HasPermissionsInside(virtualPath string) bool {
// HasPerm returns true if the user has the given permission or any permission
func (u *User) HasPerm(permission, path string) bool {
perms := u.GetPermissionsForPath(path)
- if util.Contains(perms, PermAny) {
+ if slices.Contains(perms, PermAny) {
return true
}
- return util.Contains(perms, permission)
+ return slices.Contains(perms, permission)
}
// HasAnyPerm returns true if the user has at least one of the given permissions
func (u *User) HasAnyPerm(permissions []string, path string) bool {
perms := u.GetPermissionsForPath(path)
- if util.Contains(perms, PermAny) {
+ if slices.Contains(perms, PermAny) {
return true
}
for _, permission := range permissions {
- if util.Contains(perms, permission) {
+ if slices.Contains(perms, permission) {
return true
}
}
@@ -863,11 +880,11 @@ func (u *User) HasAnyPerm(permissions []string, path string) bool {
// HasPerms returns true if the user has all the given permissions
func (u *User) HasPerms(permissions []string, path string) bool {
perms := u.GetPermissionsForPath(path)
- if util.Contains(perms, PermAny) {
+ if slices.Contains(perms, PermAny) {
return true
}
for _, permission := range permissions {
- if !util.Contains(perms, permission) {
+ if !slices.Contains(perms, permission) {
return false
}
}
@@ -927,11 +944,11 @@ func (u *User) IsLoginMethodAllowed(loginMethod, protocol string) bool {
if len(u.Filters.DeniedLoginMethods) == 0 {
return true
}
- if util.Contains(u.Filters.DeniedLoginMethods, loginMethod) {
+ if slices.Contains(u.Filters.DeniedLoginMethods, loginMethod) {
return false
}
if protocol == protocolSSH && loginMethod == LoginMethodPassword {
- if util.Contains(u.Filters.DeniedLoginMethods, SSHLoginMethodPassword) {
+ if slices.Contains(u.Filters.DeniedLoginMethods, SSHLoginMethodPassword) {
return false
}
}
@@ -965,10 +982,10 @@ func (u *User) IsPartialAuth() bool {
method == SSHLoginMethodPassword {
continue
}
- if method == LoginMethodPassword && util.Contains(u.Filters.DeniedLoginMethods, SSHLoginMethodPassword) {
+ if method == LoginMethodPassword && slices.Contains(u.Filters.DeniedLoginMethods, SSHLoginMethodPassword) {
continue
}
- if !util.Contains(SSHMultiStepsLoginMethods, method) {
+ if !slices.Contains(SSHMultiStepsLoginMethods, method) {
return false
}
}
@@ -982,7 +999,7 @@ func (u *User) GetAllowedLoginMethods() []string {
if method == SSHLoginMethodPassword {
continue
}
- if !util.Contains(u.Filters.DeniedLoginMethods, method) {
+ if !slices.Contains(u.Filters.DeniedLoginMethods, method) {
allowedMethods = append(allowedMethods, method)
}
}
@@ -1052,7 +1069,7 @@ func (u *User) IsFileAllowed(virtualPath string) (bool, int) {
// CanManageMFA returns true if the user can add a multi-factor authentication configuration
func (u *User) CanManageMFA() bool {
- if util.Contains(u.Filters.WebClient, sdk.WebClientMFADisabled) {
+ if slices.Contains(u.Filters.WebClient, sdk.WebClientMFADisabled) {
return false
}
return len(mfa.GetAvailableTOTPConfigs()) > 0
@@ -1073,39 +1090,39 @@ func (u *User) skipExternalAuth() bool {
// CanManageShares returns true if the user can add, update and list shares
func (u *User) CanManageShares() bool {
- return !util.Contains(u.Filters.WebClient, sdk.WebClientSharesDisabled)
+ return !slices.Contains(u.Filters.WebClient, sdk.WebClientSharesDisabled)
}
// CanResetPassword returns true if this user is allowed to reset its password
func (u *User) CanResetPassword() bool {
- return !util.Contains(u.Filters.WebClient, sdk.WebClientPasswordResetDisabled)
+ return !slices.Contains(u.Filters.WebClient, sdk.WebClientPasswordResetDisabled)
}
// CanChangePassword returns true if this user is allowed to change its password
func (u *User) CanChangePassword() bool {
- return !util.Contains(u.Filters.WebClient, sdk.WebClientPasswordChangeDisabled)
+ return !slices.Contains(u.Filters.WebClient, sdk.WebClientPasswordChangeDisabled)
}
// CanChangeAPIKeyAuth returns true if this user is allowed to enable/disable API key authentication
func (u *User) CanChangeAPIKeyAuth() bool {
- return !util.Contains(u.Filters.WebClient, sdk.WebClientAPIKeyAuthChangeDisabled)
+ return !slices.Contains(u.Filters.WebClient, sdk.WebClientAPIKeyAuthChangeDisabled)
}
// CanChangeInfo returns true if this user is allowed to change its info such as email and description
func (u *User) CanChangeInfo() bool {
- return !util.Contains(u.Filters.WebClient, sdk.WebClientInfoChangeDisabled)
+ return !slices.Contains(u.Filters.WebClient, sdk.WebClientInfoChangeDisabled)
}
// CanManagePublicKeys returns true if this user is allowed to manage public keys
// from the WebClient. Used in WebClient UI
func (u *User) CanManagePublicKeys() bool {
- return !util.Contains(u.Filters.WebClient, sdk.WebClientPubKeyChangeDisabled)
+ return !slices.Contains(u.Filters.WebClient, sdk.WebClientPubKeyChangeDisabled)
}
// CanManageTLSCerts returns true if this user is allowed to manage TLS certificates
// from the WebClient. Used in WebClient UI
func (u *User) CanManageTLSCerts() bool {
- return !util.Contains(u.Filters.WebClient, sdk.WebClientTLSCertChangeDisabled)
+ return !slices.Contains(u.Filters.WebClient, sdk.WebClientTLSCertChangeDisabled)
}
// CanUpdateProfile returns true if the user is allowed to update the profile.
@@ -1117,7 +1134,7 @@ func (u *User) CanUpdateProfile() bool {
// CanAddFilesFromWeb returns true if the client can add files from the web UI.
// The specified target is the directory where the files must be uploaded
func (u *User) CanAddFilesFromWeb(target string) bool {
- if util.Contains(u.Filters.WebClient, sdk.WebClientWriteDisabled) {
+ if slices.Contains(u.Filters.WebClient, sdk.WebClientWriteDisabled) {
return false
}
return u.HasPerm(PermUpload, target) || u.HasPerm(PermOverwrite, target)
@@ -1126,7 +1143,7 @@ func (u *User) CanAddFilesFromWeb(target string) bool {
// CanAddDirsFromWeb returns true if the client can add directories from the web UI.
// The specified target is the directory where the new directory must be created
func (u *User) CanAddDirsFromWeb(target string) bool {
- if util.Contains(u.Filters.WebClient, sdk.WebClientWriteDisabled) {
+ if slices.Contains(u.Filters.WebClient, sdk.WebClientWriteDisabled) {
return false
}
return u.HasPerm(PermCreateDirs, target)
@@ -1135,7 +1152,7 @@ func (u *User) CanAddDirsFromWeb(target string) bool {
// CanRenameFromWeb returns true if the client can rename objects from the web UI.
// The specified src and dest are the source and target directories for the rename.
func (u *User) CanRenameFromWeb(src, dest string) bool {
- if util.Contains(u.Filters.WebClient, sdk.WebClientWriteDisabled) {
+ if slices.Contains(u.Filters.WebClient, sdk.WebClientWriteDisabled) {
return false
}
return u.HasAnyPerm(permsRenameAny, src) && u.HasAnyPerm(permsRenameAny, dest)
@@ -1144,7 +1161,7 @@ func (u *User) CanRenameFromWeb(src, dest string) bool {
// CanDeleteFromWeb returns true if the client can delete objects from the web UI.
// The specified target is the parent directory for the object to delete
func (u *User) CanDeleteFromWeb(target string) bool {
- if util.Contains(u.Filters.WebClient, sdk.WebClientWriteDisabled) {
+ if slices.Contains(u.Filters.WebClient, sdk.WebClientWriteDisabled) {
return false
}
return u.HasAnyPerm(permsDeleteAny, target)
@@ -1153,7 +1170,7 @@ func (u *User) CanDeleteFromWeb(target string) bool {
// CanCopyFromWeb returns true if the client can copy objects from the web UI.
// The specified src and dest are the source and target directories for the copy.
func (u *User) CanCopyFromWeb(src, dest string) bool {
- if util.Contains(u.Filters.WebClient, sdk.WebClientWriteDisabled) {
+ if slices.Contains(u.Filters.WebClient, sdk.WebClientWriteDisabled) {
return false
}
if !u.HasPerm(PermListItems, src) {
@@ -1213,7 +1230,7 @@ func (u *User) MustSetSecondFactor() bool {
return true
}
for _, p := range u.Filters.TwoFactorAuthProtocols {
- if !util.Contains(u.Filters.TOTPConfig.Protocols, p) {
+ if !slices.Contains(u.Filters.TOTPConfig.Protocols, p) {
return true
}
}
@@ -1224,11 +1241,11 @@ func (u *User) MustSetSecondFactor() bool {
// MustSetSecondFactorForProtocol returns true if the user must set a second factor authentication
// for the specified protocol
func (u *User) MustSetSecondFactorForProtocol(protocol string) bool {
- if util.Contains(u.Filters.TwoFactorAuthProtocols, protocol) {
+ if slices.Contains(u.Filters.TwoFactorAuthProtocols, protocol) {
if !u.Filters.TOTPConfig.Enabled {
return true
}
- if !util.Contains(u.Filters.TOTPConfig.Protocols, protocol) {
+ if !slices.Contains(u.Filters.TOTPConfig.Protocols, protocol) {
return true
}
}
@@ -1571,7 +1588,7 @@ func (u *User) mergeCryptFsConfig(group *Group) {
func (u *User) mergeWithPrimaryGroup(group *Group, replacer *strings.Replacer) {
if group.UserSettings.HomeDir != "" {
- u.HomeDir = u.replacePlaceholder(group.UserSettings.HomeDir, replacer)
+ u.HomeDir = filepath.Clean(u.replacePlaceholder(group.UserSettings.HomeDir, replacer))
}
if group.UserSettings.FsConfig.Provider != 0 {
u.FsConfig = u.replaceFsConfigPlaceholders(group.UserSettings.FsConfig, replacer)
@@ -1748,6 +1765,17 @@ func (u *User) hasRole(role string) bool {
return role == u.Role
}
+func (u *User) applyNamingRules() {
+ u.Username = config.convertName(u.Username)
+ u.Role = config.convertName(u.Role)
+ for idx := range u.Groups {
+ u.Groups[idx].Name = config.convertName(u.Groups[idx].Name)
+ }
+ for idx := range u.VirtualFolders {
+ u.VirtualFolders[idx].Name = config.convertName(u.VirtualFolders[idx].Name)
+ }
+}
+
func (u *User) getACopy() User {
u.SetEmptySecretsIfNil()
pubKeys := make([]string, len(u.PublicKeys))
@@ -1779,6 +1807,8 @@ func (u *User) getACopy() User {
filters.TOTPConfig.Secret = u.Filters.TOTPConfig.Secret.Clone()
filters.TOTPConfig.Protocols = make([]string, len(u.Filters.TOTPConfig.Protocols))
copy(filters.TOTPConfig.Protocols, u.Filters.TOTPConfig.Protocols)
+ filters.AdditionalEmails = make([]string, len(u.Filters.AdditionalEmails))
+ copy(filters.AdditionalEmails, u.Filters.AdditionalEmails)
filters.RecoveryCodes = make([]RecoveryCode, 0, len(u.Filters.RecoveryCodes))
for _, code := range u.Filters.RecoveryCodes {
if code.Secret == nil {
diff --git a/internal/ftpd/cryptfs_test.go b/internal/ftpd/cryptfs_test.go
index c036b59a..23567fff 100644
--- a/internal/ftpd/cryptfs_test.go
+++ b/internal/ftpd/cryptfs_test.go
@@ -134,6 +134,7 @@ func TestBasicFTPHandlingCryptFs(t *testing.T) {
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 }, 1*time.Second, 50*time.Millisecond)
assert.Eventually(t, func() bool { return common.Connections.GetClientConnections() == 0 }, 1000*time.Millisecond,
50*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func TestBufferedCryptFs(t *testing.T) {
@@ -179,6 +180,7 @@ func TestBufferedCryptFs(t *testing.T) {
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 }, 1*time.Second, 50*time.Millisecond)
assert.Eventually(t, func() bool { return common.Connections.GetClientConnections() == 0 }, 1000*time.Millisecond,
50*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func TestZeroBytesTransfersCryptFs(t *testing.T) {
diff --git a/internal/ftpd/ftpd.go b/internal/ftpd/ftpd.go
index 80dd1531..9fdfacf0 100644
--- a/internal/ftpd/ftpd.go
+++ b/internal/ftpd/ftpd.go
@@ -19,6 +19,7 @@ import (
"context"
"errors"
"fmt"
+ "log/slog"
"net"
"os"
"path/filepath"
@@ -66,8 +67,6 @@ type Binding struct {
// Set to 1 to require TLS for both data and control connection.
// Set to 2 to enable implicit TLS
TLSMode int `json:"tls_mode" mapstructure:"tls_mode"`
- // 0 disabled, 1 required
- TLSSessionReuse int `json:"tls_session_reuse" mapstructure:"tls_session_reuse"`
// Certificate and matching private key for this specific binding, if empty the global
// ones will be used, if any
CertificateFile string `json:"certificate_file" mapstructure:"certificate_file"`
@@ -109,11 +108,6 @@ type Binding struct {
// Please note that disabling the security checks you will make the FTP service vulnerable to bounce attacks
// on active data connections, so change the default value only if you are on a trusted/internal network
ActiveConnectionsSecurity int `json:"active_connections_security" mapstructure:"active_connections_security"`
- // Set to 1 to silently ignore any client requests to perform ASCII translations via the TYPE command.
- // That is, FTP clients can request ASCII translations, and SFTPGo will respond as the client expects,
- // but will not actually perform the translation for either uploads or downloads. This behavior can be
- // useful in circumstances involving older/mainframe clients and EBCDIC files.
- IgnoreASCIITransferType int `json:"ignore_ascii_transfer_type" mapstructure:"ignore_ascii_transfer_type"`
// Debug enables the FTP debug mode. In debug mode, every FTP command will be logged
Debug bool `json:"debug" mapstructure:"debug"`
ciphers []uint16
@@ -141,10 +135,6 @@ func (b *Binding) isTLSModeValid() bool {
return b.TLSMode >= 0 && b.TLSMode <= 2
}
-func (b *Binding) isTLSSessionReuseValid() bool {
- return b.TLSSessionReuse >= 0 && b.TLSSessionReuse <= 1
-}
-
func (b *Binding) checkSecuritySettings() error {
if b.PassiveConnectionsSecurity < 0 || b.PassiveConnectionsSecurity > 1 {
return fmt.Errorf("invalid passive_connections_security: %v", b.PassiveConnectionsSecurity)
@@ -412,9 +402,14 @@ func (c *Configuration) Initialize(configDir string) error {
server := NewServer(c, configDir, binding, idx)
go func(s *Server) {
- ftpLogger := logger.LeveledLogger{Sender: "ftpserverlib"}
+ ftpLogger := logger.NewSlogAdapter("ftpserverlib", []slog.Attr{
+ {
+ Key: "server_id",
+ Value: slog.StringValue(fmt.Sprintf("FTP_%d", s.ID)),
+ },
+ })
ftpServer := ftpserver.NewFtpServer(s)
- ftpServer.Logger = ftpLogger.With("server_id", fmt.Sprintf("FTP_%v", s.ID))
+ ftpServer.Logger = slog.New(ftpLogger)
logger.Info(logSender, "", "starting FTP serving, binding: %v", s.binding.GetAddress())
util.CheckTCP4Port(s.binding.Port)
exitChannel <- ftpServer.ListenAndServe()
diff --git a/internal/ftpd/ftpd_test.go b/internal/ftpd/ftpd_test.go
index d59d0c3d..aebf5f37 100644
--- a/internal/ftpd/ftpd_test.go
+++ b/internal/ftpd/ftpd_test.go
@@ -37,6 +37,7 @@ import (
ftpserver "github.com/fclairamb/ftpserverlib"
"github.com/jlaffaye/ftp"
+ "github.com/pkg/sftp"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog"
@@ -44,6 +45,7 @@ import (
sdkkms "github.com/sftpgo/sdk/kms"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "golang.org/x/crypto/ssh"
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/config"
@@ -441,7 +443,6 @@ func TestMain(m *testing.M) { //nolint:gocyclo
CertificateFile: certPath,
CertificateKeyFile: keyPath,
TLSMode: 1,
- TLSSessionReuse: 1,
ClientAuthType: 2,
},
}
@@ -528,16 +529,6 @@ func TestInitializationFailure(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "the provided passive IP \"127001\" is not valid")
ftpdConf.Bindings[1].ForcePassiveIP = ""
- ftpdConf.Bindings[1].TLSMode = 2
- ftpdConf.Bindings[1].TLSSessionReuse = 1
- err = ftpdConf.Initialize(configDir)
- require.Error(t, err, "TLS session resumption should not be supported with implicit FTPS")
- ftpdConf.Bindings[1].TLSMode = 0
- ftpdConf.Bindings[1].TLSSessionReuse = 100
- err = ftpdConf.Initialize(configDir)
- require.Error(t, err)
- assert.Contains(t, err.Error(), "unsupported TLS reuse mode")
- ftpdConf.Bindings[1].TLSSessionReuse = 0
err = ftpdConf.Initialize(configDir)
require.Error(t, err)
@@ -638,6 +629,8 @@ func TestBasicFTPHandling(t *testing.T) {
}
err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
assert.NoError(t, err)
+ _, err = client.FileSize(path.Join("/", testDir))
+ assert.Error(t, err)
size, err := client.FileSize(path.Join("/", testDir, testFileName))
assert.NoError(t, err)
assert.Equal(t, testFileSize, size)
@@ -671,6 +664,7 @@ func TestBasicFTPHandling(t *testing.T) {
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 }, 1*time.Second, 50*time.Millisecond)
assert.Eventually(t, func() bool { return common.Connections.GetClientConnections() == 0 }, 1000*time.Millisecond,
50*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func TestHTTPFs(t *testing.T) {
@@ -715,6 +709,7 @@ func TestHTTPFs(t *testing.T) {
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 }, 1*time.Second, 50*time.Millisecond)
assert.Eventually(t, func() bool { return common.Connections.GetClientConnections() == 0 }, 1000*time.Millisecond,
50*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func TestListDirWithWildcards(t *testing.T) {
@@ -1735,6 +1730,66 @@ func TestMaxPerHostConnections(t *testing.T) {
common.Config.MaxPerHostConnections = oldValue
}
+func TestMaxTransfers(t *testing.T) {
+ oldValue := common.Config.MaxPerHostConnections
+ common.Config.MaxPerHostConnections = 2
+
+ assert.Eventually(t, func() bool {
+ return common.Connections.GetClientConnections() == 0
+ }, 1000*time.Millisecond, 50*time.Millisecond)
+
+ user := getTestUser()
+ err := dataprovider.AddUser(&user, "", "", "")
+ assert.NoError(t, err)
+ user.Password = ""
+
+ testFilePath := filepath.Join(homeBasePath, testFileName)
+ testFileSize := int64(65535)
+ err = createTestFile(testFilePath, testFileSize)
+ assert.NoError(t, err)
+
+ conn, sftpClient, err := getSftpClient(user)
+ assert.NoError(t, err)
+ defer conn.Close()
+ defer sftpClient.Close()
+
+ f1, err := sftpClient.Create("file1")
+ assert.NoError(t, err)
+ f2, err := sftpClient.Create("file2")
+ assert.NoError(t, err)
+ _, err = f1.Write([]byte(" "))
+ assert.NoError(t, err)
+ _, err = f2.Write([]byte(" "))
+ assert.NoError(t, err)
+
+ client, err := getFTPClient(user, true, nil)
+ if assert.NoError(t, err) {
+ err = checkBasicFTP(client)
+ assert.NoError(t, err)
+ err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
+ assert.Error(t, err)
+ localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
+ err = ftpDownloadFile(testFileName, localDownloadPath, testFileSize, client, 0)
+ assert.Error(t, err)
+ err := client.Quit()
+ assert.NoError(t, err)
+ err = os.Remove(localDownloadPath)
+ assert.NoError(t, err)
+ }
+
+ err = f1.Close()
+ assert.NoError(t, err)
+ err = f2.Close()
+ assert.NoError(t, err)
+
+ err = dataprovider.DeleteUser(user.Username, "", "", "")
+ assert.NoError(t, err)
+ err = os.RemoveAll(user.GetHomeDir())
+ assert.NoError(t, err)
+
+ common.Config.MaxPerHostConnections = oldValue
+}
+
func TestRateLimiter(t *testing.T) {
oldConfig := config.GetCommonConfig()
@@ -2451,7 +2506,7 @@ func TestLoginWithDatabaseCredentials(t *testing.T) {
u := getTestUser()
u.FsConfig.Provider = sdk.GCSFilesystemProvider
u.FsConfig.GCSConfig.Bucket = "test"
- u.FsConfig.GCSConfig.Credentials = kms.NewPlainSecret(`{ "type": "service_account" }`)
+ u.FsConfig.GCSConfig.Credentials = kms.NewPlainSecret(`{ "type": "service_account", "private_key": " ", "client_email": "example@iam.gserviceaccount.com" }`)
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
assert.Equal(t, sdkkms.SecretStatusSecretBox, user.FsConfig.GCSConfig.Credentials.GetStatus())
@@ -2544,14 +2599,14 @@ func TestRename(t *testing.T) {
assert.NoError(t, err)
err = client.MakeDir(path.Join(otherDir, testDir))
assert.NoError(t, err)
- code, response, err := client.SendCommand(fmt.Sprintf("SITE CHMOD 0001 %v", otherDir))
+ code, response, err := client.SendCommand("SITE CHMOD 0001 %v", otherDir)
assert.NoError(t, err)
assert.Equal(t, ftp.StatusCommandOK, code)
assert.Equal(t, "SITE CHMOD command successful", response)
err = client.Rename(testDir, path.Join(otherDir, testDir))
assert.Error(t, err)
- code, response, err = client.SendCommand(fmt.Sprintf("SITE CHMOD 755 %v", otherDir))
+ code, response, err = client.SendCommand("SITE CHMOD 755 %v", otherDir)
assert.NoError(t, err)
assert.Equal(t, ftp.StatusCommandOK, code)
assert.Equal(t, "SITE CHMOD command successful", response)
@@ -2611,7 +2666,7 @@ func TestSymlink(t *testing.T) {
assert.NoError(t, err)
err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
assert.NoError(t, err)
- code, _, err := client.SendCommand(fmt.Sprintf("SITE SYMLINK %v %v", testFileName, testFileName+".link"))
+ code, _, err := client.SendCommand("SITE SYMLINK %v %v", testFileName, testFileName+".link")
assert.NoError(t, err)
assert.Equal(t, ftp.StatusCommandOK, code)
@@ -2622,15 +2677,15 @@ func TestSymlink(t *testing.T) {
assert.NoError(t, err)
err = client.MakeDir(path.Join(otherDir, testDir))
assert.NoError(t, err)
- code, response, err := client.SendCommand(fmt.Sprintf("SITE CHMOD 0001 %v", otherDir))
+ code, response, err := client.SendCommand("SITE CHMOD 0001 %v", otherDir)
assert.NoError(t, err)
assert.Equal(t, ftp.StatusCommandOK, code)
assert.Equal(t, "SITE CHMOD command successful", response)
- code, _, err = client.SendCommand(fmt.Sprintf("SITE SYMLINK %v %v", testDir, path.Join(otherDir, testDir)))
+ code, _, err = client.SendCommand("SITE SYMLINK %v %v", testDir, path.Join(otherDir, testDir))
assert.NoError(t, err)
assert.Equal(t, ftp.StatusFileUnavailable, code)
- code, response, err = client.SendCommand(fmt.Sprintf("SITE CHMOD 755 %v", otherDir))
+ code, response, err = client.SendCommand("SITE CHMOD 755 %v", otherDir)
assert.NoError(t, err)
assert.Equal(t, ftp.StatusCommandOK, code)
assert.Equal(t, "SITE CHMOD command successful", response)
@@ -2718,6 +2773,7 @@ func TestStat(t *testing.T) {
func TestUploadOverwriteVfolder(t *testing.T) {
u := getTestUser()
+ u.QuotaFiles = 1000
vdir := "/vdir"
mappedPath := filepath.Join(os.TempDir(), "vdir")
folderName := filepath.Base(mappedPath)
@@ -2749,14 +2805,24 @@ func TestUploadOverwriteVfolder(t *testing.T) {
assert.NoError(t, err)
folder, _, err := httpdtest.GetFolderByName(folderName, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, folder.UsedQuotaSize)
- assert.Equal(t, 1, folder.UsedQuotaFiles)
+ assert.Equal(t, int64(0), folder.UsedQuotaSize)
+ assert.Equal(t, 0, folder.UsedQuotaFiles)
+ user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+ assert.NoError(t, err)
+ assert.Equal(t, testFileSize, user.UsedQuotaSize)
+ assert.Equal(t, 1, user.UsedQuotaFiles)
+
err = ftpUploadFile(testFilePath, path.Join(vdir, testFileName), testFileSize, client, 0)
assert.NoError(t, err)
folder, _, err = httpdtest.GetFolderByName(folderName, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, folder.UsedQuotaSize)
- assert.Equal(t, 1, folder.UsedQuotaFiles)
+ assert.Equal(t, int64(0), folder.UsedQuotaSize)
+ assert.Equal(t, 0, folder.UsedQuotaFiles)
+ user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+ assert.NoError(t, err)
+ assert.Equal(t, testFileSize, user.UsedQuotaSize)
+ assert.Equal(t, 1, user.UsedQuotaFiles)
+
err = client.Quit()
assert.NoError(t, err)
err = os.Remove(testFilePath)
@@ -3051,7 +3117,7 @@ func TestChtimes(t *testing.T) {
assert.NoError(t, err)
mtime := time.Now().Format("20060102150405")
- code, response, err := client.SendCommand(fmt.Sprintf("MFMT %v %v", mtime, testFileName))
+ code, response, err := client.SendCommand("MFMT %v %v", mtime, testFileName)
assert.NoError(t, err)
assert.Equal(t, ftp.StatusFile, code)
assert.Equal(t, fmt.Sprintf("Modify=%v; %v", mtime, testFileName), response)
@@ -3088,12 +3154,12 @@ func TestMODEType(t *testing.T) {
if assert.NoError(t, err) {
code, response, err := client.SendCommand("MODE s")
assert.NoError(t, err)
- assert.Equal(t, ftp.StatusCommandOK, code)
- assert.Equal(t, "Transfer mode set to 'S'", response)
+ assert.Equal(t, ftp.StatusNotImplementedParameter, code)
+ assert.Equal(t, "Unsupported mode", response)
code, response, err = client.SendCommand("MODE S")
assert.NoError(t, err)
assert.Equal(t, ftp.StatusCommandOK, code)
- assert.Equal(t, "Transfer mode set to 'S'", response)
+ assert.Equal(t, "Using stream mode", response)
code, _, err = client.SendCommand("MODE Z")
assert.NoError(t, err)
@@ -3101,7 +3167,7 @@ func TestMODEType(t *testing.T) {
code, _, err = client.SendCommand("MODE SS")
assert.NoError(t, err)
- assert.Equal(t, ftp.StatusBadArguments, code)
+ assert.Equal(t, ftp.StatusNotImplementedParameter, code)
err = client.Quit()
assert.NoError(t, err)
@@ -3128,7 +3194,7 @@ func TestSTAT(t *testing.T) {
assert.NoError(t, err)
err = ftpUploadFile(testFilePath, path.Join(testDir, testFileName+"_1"), testFileSize, client, 0)
assert.NoError(t, err)
- code, response, err := client.SendCommand(fmt.Sprintf("STAT %s", testDir))
+ code, response, err := client.SendCommand("STAT %s", testDir)
assert.NoError(t, err)
assert.Equal(t, ftp.StatusDirectory, code)
assert.Contains(t, response, fmt.Sprintf("STAT %s", testDir))
@@ -3162,7 +3228,7 @@ func TestChown(t *testing.T) {
assert.NoError(t, err)
err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
assert.NoError(t, err)
- code, response, err := client.SendCommand(fmt.Sprintf("SITE CHOWN 1000:1000 %v", testFileName))
+ code, response, err := client.SendCommand("SITE CHOWN 1000:1000 %v", testFileName)
assert.NoError(t, err)
assert.Equal(t, ftp.StatusFileUnavailable, code)
assert.Equal(t, "Couldn't chown: operation unsupported", response)
@@ -3200,7 +3266,7 @@ func TestChmod(t *testing.T) {
err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
assert.NoError(t, err)
- code, response, err := client.SendCommand(fmt.Sprintf("SITE CHMOD 600 %v", testFileName))
+ code, response, err := client.SendCommand("SITE CHMOD 600 %v", testFileName)
assert.NoError(t, err)
assert.Equal(t, ftp.StatusCommandOK, code)
assert.Equal(t, "SITE CHMOD command successful", response)
@@ -3363,12 +3429,12 @@ func TestHASH(t *testing.T) {
err = f.Close()
assert.NoError(t, err)
- code, response, err := client.SendCommand(fmt.Sprintf("XSHA256 %v", testFileName))
+ code, response, err := client.SendCommand("XSHA256 %v", testFileName)
assert.NoError(t, err)
assert.Equal(t, ftp.StatusRequestedFileActionOK, code)
assert.Contains(t, response, hash)
- code, response, err = client.SendCommand(fmt.Sprintf("HASH %v", testFileName))
+ code, response, err = client.SendCommand("HASH %v", testFileName)
assert.NoError(t, err)
assert.Equal(t, ftp.StatusFile, code)
assert.Contains(t, response, hash)
@@ -3424,7 +3490,7 @@ func TestCombine(t *testing.T) {
err = ftpUploadFile(testFilePath, testFileName+".2", testFileSize, client, 0)
assert.NoError(t, err)
- code, response, err := client.SendCommand(fmt.Sprintf("COMB %v %v %v", testFileName, testFileName+".1", testFileName+".2"))
+ code, response, err := client.SendCommand("COMB %v %v %v", testFileName, testFileName+".1", testFileName+".2")
assert.NoError(t, err)
if user.Username == defaultUsername {
assert.Equal(t, ftp.StatusRequestedFileActionOK, code)
@@ -3461,61 +3527,6 @@ func TestCombine(t *testing.T) {
assert.NoError(t, err)
}
-func TestTLSSessionReuse(t *testing.T) {
- u := getTestUser()
- user, _, err := httpdtest.AddUser(u, http.StatusCreated)
- assert.NoError(t, err)
-
- client, err := getFTPClientWithSessionReuse(user, nil)
- if assert.NoError(t, err) {
- err = checkBasicFTP(client)
- assert.NoError(t, err)
-
- testFilePath := filepath.Join(homeBasePath, testFileName)
- testFileSize := int64(65535)
- err = createTestFile(testFilePath, testFileSize)
- assert.NoError(t, err)
-
- err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
- assert.NoError(t, err)
-
- localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
- err = ftpDownloadFile(testFileName, localDownloadPath, testFileSize, client, 0)
- assert.NoError(t, err)
-
- entries, err := client.List("/")
- assert.NoError(t, err)
- assert.Len(t, entries, 1)
-
- err = client.Quit()
- assert.NoError(t, err)
- err = os.Remove(testFilePath)
- assert.NoError(t, err)
- err = os.Remove(localDownloadPath)
- assert.NoError(t, err)
- }
-
- // this TLS config does not support session resumption
- tlsConfig := &tls.Config{
- ServerName: "localhost",
- InsecureSkipVerify: true, // use this for tests only
- MinVersion: tls.VersionTLS12,
- }
- client, err = getFTPClientWithSessionReuse(user, tlsConfig)
- if assert.NoError(t, err) {
- err = checkBasicFTP(client)
- assert.Error(t, err)
-
- err = client.Quit()
- assert.NoError(t, err)
- }
-
- _, err = httpdtest.RemoveUser(user, http.StatusOK)
- assert.NoError(t, err)
- err = os.RemoveAll(user.GetHomeDir())
- assert.NoError(t, err)
-}
-
func TestClientCertificateAuthRevokedCert(t *testing.T) {
u := getTestUser()
u.Username = tlsClient2Username
@@ -3951,6 +3962,7 @@ func TestNestedVirtualFolders(t *testing.T) {
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 }, 1*time.Second, 50*time.Millisecond)
assert.Eventually(t, func() bool { return common.Connections.GetClientConnections() == 0 }, 1000*time.Millisecond,
50*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func checkBasicFTP(client *ftp.ServerConn) error {
@@ -4202,6 +4214,30 @@ func getPreLoginScriptContent(user dataprovider.User, nonJSONResponse bool) []by
return content
}
+func getSftpClient(user dataprovider.User) (*ssh.Client, *sftp.Client, error) {
+ var sftpClient *sftp.Client
+ config := &ssh.ClientConfig{
+ User: user.Username,
+ HostKeyCallback: ssh.InsecureIgnoreHostKey(),
+ Timeout: 5 * time.Second,
+ }
+ if user.Password != "" {
+ config.Auth = []ssh.AuthMethod{ssh.Password(user.Password)}
+ } else {
+ config.Auth = []ssh.AuthMethod{ssh.Password(defaultPassword)}
+ }
+
+ conn, err := ssh.Dial("tcp", sftpServerAddr, config)
+ if err != nil {
+ return conn, sftpClient, err
+ }
+ sftpClient, err = sftp.NewClient(conn)
+ if err != nil {
+ conn.Close()
+ }
+ return conn, sftpClient, err
+}
+
func getExitCodeScriptContent(exitCode int) []byte {
content := []byte("#!/bin/sh\n\n")
content = append(content, []byte(fmt.Sprintf("exit %v", exitCode))...)
diff --git a/internal/ftpd/handler.go b/internal/ftpd/handler.go
index 036c3977..613eaa07 100644
--- a/internal/ftpd/handler.go
+++ b/internal/ftpd/handler.go
@@ -29,6 +29,7 @@ import (
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
"github.com/drakkan/sftpgo/v2/internal/logger"
+ "github.com/drakkan/sftpgo/v2/internal/util"
"github.com/drakkan/sftpgo/v2/internal/vfs"
)
@@ -97,6 +98,7 @@ func (c *Connection) Create(_ string) (afero.File, error) {
// Mkdir creates a directory using the connection filesystem
func (c *Connection) Mkdir(name string, _ os.FileMode) error {
c.UpdateLastActivity()
+ name = util.CleanPath(name)
return c.CreateDir(name, true)
}
@@ -120,6 +122,7 @@ func (c *Connection) OpenFile(_ string, _ int, _ os.FileMode) (afero.File, error
// We implements ClientDriverExtensionRemoveDir for directories
func (c *Connection) Remove(name string) error {
c.UpdateLastActivity()
+ name = util.CleanPath(name)
fs, p, err := c.GetFsAndResolvedPath(name)
if err != nil {
@@ -147,6 +150,8 @@ func (c *Connection) RemoveAll(_ string) error {
// Rename renames a file or a directory
func (c *Connection) Rename(oldname, newname string) error {
c.UpdateLastActivity()
+ oldname = util.CleanPath(oldname)
+ newname = util.CleanPath(newname)
return c.BaseConnection.Rename(oldname, newname)
}
@@ -155,6 +160,7 @@ func (c *Connection) Rename(oldname, newname string) error {
// if any happens
func (c *Connection) Stat(name string) (os.FileInfo, error) {
c.UpdateLastActivity()
+ name = util.CleanPath(name)
c.doWildcardListDir = false
if !c.User.HasPerm(dataprovider.PermListItems, path.Dir(name)) {
@@ -198,6 +204,7 @@ func (c *Connection) Chown(_ string, _, _ int) error {
// Chmod changes the mode of the named file/directory
func (c *Connection) Chmod(name string, mode os.FileMode) error {
c.UpdateLastActivity()
+ name = util.CleanPath(name)
attrs := common.StatAttributes{
Flags: common.StatAttrPerms,
@@ -209,6 +216,7 @@ func (c *Connection) Chmod(name string, mode os.FileMode) error {
// Chtimes changes the access and modification times of the named file
func (c *Connection) Chtimes(name string, atime time.Time, mtime time.Time) error {
c.UpdateLastActivity()
+ name = util.CleanPath(name)
attrs := common.StatAttributes{
Flags: common.StatAttrTimes,
@@ -221,6 +229,7 @@ func (c *Connection) Chtimes(name string, atime time.Time, mtime time.Time) erro
// GetAvailableSpace implements ClientDriverExtensionAvailableSpace interface
func (c *Connection) GetAvailableSpace(dirName string) (int64, error) {
c.UpdateLastActivity()
+ dirName = util.CleanPath(dirName)
diskQuota, transferQuota := c.HasSpace(false, false, path.Join(dirName, "fakefile.txt"))
if !diskQuota.HasSpace || !transferQuota.HasUploadSpace() {
@@ -279,6 +288,7 @@ func (c *Connection) AllocateSpace(_ int) error {
// RemoveDir implements ClientDriverExtensionRemoveDir
func (c *Connection) RemoveDir(name string) error {
c.UpdateLastActivity()
+ name = util.CleanPath(name)
return c.BaseConnection.RemoveDir(name)
}
@@ -286,13 +296,16 @@ func (c *Connection) RemoveDir(name string) error {
// Symlink implements ClientDriverExtensionSymlink
func (c *Connection) Symlink(oldname, newname string) error {
c.UpdateLastActivity()
+ oldname = util.CleanPath(oldname)
+ newname = util.CleanPath(newname)
- return c.BaseConnection.CreateSymlink(oldname, newname)
+ return c.CreateSymlink(oldname, newname)
}
// ReadDir implements ClientDriverExtensionFilelist
-func (c *Connection) ReadDir(name string) (ftpserver.DirLister, error) {
+func (c *Connection) ReadDir(name string) ([]os.FileInfo, error) {
c.UpdateLastActivity()
+ name = util.CleanPath(name)
if c.doWildcardListDir {
c.doWildcardListDir = false
@@ -306,21 +319,27 @@ func (c *Connection) ReadDir(name string) (ftpserver.DirLister, error) {
if err != nil {
return nil, err
}
- return &patternDirLister{
+ patternLister := &patternDirLister{
DirLister: lister,
pattern: baseName,
lastCommand: c.clientContext.GetLastCommand(),
dirName: name,
- connectionPath: c.clientContext.Path(),
- }, nil
+ connectionPath: util.CleanPath(c.clientContext.Path()),
+ }
+ return consumeDirLister(patternLister)
}
- return c.ListDir(name)
+ lister, err := c.ListDir(name)
+ if err != nil {
+ return nil, err
+ }
+ return consumeDirLister(lister)
}
// GetHandle implements ClientDriverExtentionFileTransfer
func (c *Connection) GetHandle(name string, flags int, offset int64) (ftpserver.FileTransfer, error) {
c.UpdateLastActivity()
+ name = util.CleanPath(name)
fs, p, err := c.GetFsAndResolvedPath(name)
if err != nil {
@@ -331,6 +350,11 @@ func (c *Connection) GetHandle(name string, flags int, offset int64) (ftpserver.
return nil, errCOMBNotSupported
}
+ if err := common.Connections.IsNewTransferAllowed(c.User.Username); err != nil {
+ c.Log(logger.LevelInfo, "denying transfer due to count limits")
+ return nil, c.GetPermissionDeniedError()
+ }
+
if flags&os.O_WRONLY != 0 {
return c.uploadFile(fs, p, name, flags)
}
@@ -465,7 +489,7 @@ func (c *Connection) handleFTPUploadToExistingFile(fs vfs.Fs, flags int, resolve
}
if common.Config.IsAtomicUploadEnabled() && fs.IsAtomicUploadSupported() {
- _, _, err = fs.Rename(resolvedPath, filePath)
+ _, _, err = fs.Rename(resolvedPath, filePath, 0)
if err != nil {
c.Log(logger.LevelError, "error renaming existing file for atomic upload, source: %q, dest: %q, err: %+v",
resolvedPath, filePath, err)
@@ -493,10 +517,7 @@ func (c *Connection) handleFTPUploadToExistingFile(fs vfs.Fs, flags int, resolve
if vfs.HasTruncateSupport(fs) {
vfolder, err := c.User.GetVirtualFolderForPath(path.Dir(requestPath))
if err == nil {
- dataprovider.UpdateVirtualFolderQuota(&vfolder.BaseVirtualFolder, 0, -fileSize, false) //nolint:errcheck
- if vfolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&c.User, 0, -fileSize, false) //nolint:errcheck
- }
+ dataprovider.UpdateUserFolderQuota(&vfolder, &c.User, 0, -fileSize, false)
} else {
dataprovider.UpdateUserQuota(&c.User, 0, -fileSize, false) //nolint:errcheck
}
@@ -581,3 +602,24 @@ func (l *patternDirLister) Next(limit int) ([]os.FileInfo, error) {
}
}
}
+
+func consumeDirLister(lister vfs.DirLister) ([]os.FileInfo, error) {
+ defer lister.Close()
+
+ var results []os.FileInfo
+
+ for {
+ files, err := lister.Next(vfs.ListerBatchSize)
+ finished := errors.Is(err, io.EOF)
+ results = append(results, files...)
+ if err != nil && !finished {
+ return results, err
+ }
+ if finished {
+ lister.Close()
+ break
+ }
+ }
+
+ return results, nil
+}
diff --git a/internal/ftpd/internal_test.go b/internal/ftpd/internal_test.go
index 646968cc..4e167524 100644
--- a/internal/ftpd/internal_test.go
+++ b/internal/ftpd/internal_test.go
@@ -404,7 +404,7 @@ func (fs MockOsFs) Remove(name string, _ bool) error {
}
// Rename renames (moves) source to target
-func (fs MockOsFs) Rename(source, target string) (int, int64, error) {
+func (fs MockOsFs) Rename(source, target string, _ int) (int, int64, error) {
if fs.err != nil {
return -1, -1, fs.err
}
@@ -527,15 +527,24 @@ func TestServerGetSettings(t *testing.T) {
Bindings: []Binding{binding},
PassivePortRange: PortRange{
Start: 10000,
- End: 11000,
+ End: 10000,
},
}
assert.False(t, binding.HasProxy())
server := NewServer(c, configDir, binding, 0)
settings, err := server.GetSettings()
assert.NoError(t, err)
- assert.Equal(t, 10000, settings.PassiveTransferPortRange.Start)
- assert.Equal(t, 11000, settings.PassiveTransferPortRange.End)
+ if ranger, ok := settings.PassiveTransferPortRange.(*ftpserver.PortRange); ok {
+ assert.Equal(t, 10000, ranger.Start)
+ assert.Equal(t, 10000, ranger.End)
+ }
+ c.PassivePortRange.End = 11000
+ settings, err = server.GetSettings()
+ assert.NoError(t, err)
+ if ranger, ok := settings.PassiveTransferPortRange.(*ftpserver.PortRange); ok {
+ assert.Equal(t, 10000, ranger.Start)
+ assert.Equal(t, 11000, ranger.End)
+ }
common.Config.ProxyProtocol = 1
_, err = server.GetSettings()
@@ -664,6 +673,7 @@ func TestClientVersion(t *testing.T) {
common.Connections.Remove(connection.GetID())
}
assert.Len(t, common.Connections.GetStats(""), 0)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func TestDriverMethodsNotImplemented(t *testing.T) {
@@ -689,16 +699,26 @@ func TestDriverMethodsNotImplemented(t *testing.T) {
func TestExtraData(t *testing.T) {
mockCC := mockFTPClientContext{}
- _, ok := mockCC.Extra().(bool)
+ _, ok := mockCC.Extra().(*tlsState)
require.False(t, ok)
- mockCC.SetExtra(false)
- val, ok := mockCC.Extra().(bool)
+ mockCC.SetExtra(&tlsState{
+ LoginWithMutualTLS: false,
+ Version: tls.VersionName(tls.VersionTLS13),
+ Cipher: tls.CipherSuiteName(tls.TLS_AES_128_GCM_SHA256),
+ KEX: tls.X25519MLKEM768.String(),
+ })
+ state, ok := mockCC.Extra().(*tlsState)
require.True(t, ok)
- require.False(t, val)
- mockCC.SetExtra(true)
- val, ok = mockCC.Extra().(bool)
+ require.False(t, state.LoginWithMutualTLS)
+ require.Equal(t, tls.VersionName(tls.VersionTLS13), state.Version)
+ require.Equal(t, tls.CipherSuiteName(tls.TLS_AES_128_GCM_SHA256), state.Cipher)
+ require.Equal(t, tls.X25519MLKEM768.String(), state.KEX)
+ mockCC.SetExtra(&tlsState{
+ LoginWithMutualTLS: true,
+ })
+ state, ok = mockCC.Extra().(*tlsState)
require.True(t, ok)
- require.True(t, val)
+ require.True(t, state.LoginWithMutualTLS)
}
func TestResolvePathErrors(t *testing.T) {
@@ -885,7 +905,6 @@ func TestTransferErrors(t *testing.T) {
fs := newMockOsFs(nil, nil, false, connID, user.GetHomeDir())
connection := &Connection{
BaseConnection: common.NewBaseConnection(connID, common.ProtocolFTP, "", "", user),
- clientContext: mockCC,
}
baseTransfer := common.NewBaseTransfer(file, connection.BaseConnection, nil, file.Name(), file.Name(), testfile,
common.TransferDownload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{})
@@ -919,6 +938,7 @@ func TestTransferErrors(t *testing.T) {
pipeWriter := vfs.NewPipeWriter(w)
baseTransfer = common.NewBaseTransfer(nil, connection.BaseConnection, nil, testfile, testfile, testfile,
common.TransferUpload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{})
+ tr.Connection.RemoveTransfer(tr)
tr = newTransfer(baseTransfer, pipeWriter, nil, 0)
err = r.Close()
@@ -934,6 +954,7 @@ func TestTransferErrors(t *testing.T) {
if assert.Error(t, err) {
assert.EqualError(t, err, common.ErrOpUnsupported.Error())
}
+ tr.Connection.RemoveTransfer(tr)
err = os.Remove(testfile)
assert.NoError(t, err)
}
diff --git a/internal/ftpd/server.go b/internal/ftpd/server.go
index ecd1edb3..2e5790ae 100644
--- a/internal/ftpd/server.go
+++ b/internal/ftpd/server.go
@@ -22,6 +22,7 @@ import (
"net"
"os"
"path/filepath"
+ "slices"
ftpserver "github.com/fclairamb/ftpserverlib"
"github.com/sftpgo/sdk/plugin/notifier"
@@ -35,6 +36,15 @@ import (
"github.com/drakkan/sftpgo/v2/internal/version"
)
+// tlsState tracks TLS connection state for a client
+type tlsState struct {
+ // LoginWithMutualTLS indicates whether the user logged in using TLS certificate authentication
+ LoginWithMutualTLS bool
+ Version string
+ Cipher string
+ KEX string
+}
+
// Server implements the ftpserverlib MainDriver interface
type Server struct {
ID int
@@ -82,7 +92,7 @@ func (s *Server) GetSettings() (*ftpserver.Settings, error) {
return nil, err
}
var portRange *ftpserver.PortRange
- if s.config.PassivePortRange.Start > 0 && s.config.PassivePortRange.End > s.config.PassivePortRange.Start {
+ if s.config.PassivePortRange.Start > 0 && s.config.PassivePortRange.End >= s.config.PassivePortRange.Start {
portRange = &ftpserver.PortRange{
Start: s.config.PassivePortRange.Start,
End: s.config.PassivePortRange.End,
@@ -109,34 +119,31 @@ func (s *Server) GetSettings() (*ftpserver.Settings, error) {
return nil, fmt.Errorf("unsupported TLS mode: %d", s.binding.TLSMode)
}
- if !s.binding.isTLSSessionReuseValid() {
- return nil, fmt.Errorf("unsupported TLS reuse mode %d", s.binding.TLSSessionReuse)
- }
-
- if (s.binding.TLSMode > 0 || s.binding.TLSSessionReuse > 0) && certMgr == nil {
+ if s.binding.TLSMode > 0 && certMgr == nil {
return nil, errors.New("to enable TLS you need to provide a certificate")
}
- return &ftpserver.Settings{
- Listener: ftpListener,
- ListenAddr: s.binding.GetAddress(),
- PublicIPResolver: s.binding.passiveIPResolver,
- PassiveTransferPortRange: portRange,
- ActiveTransferPortNon20: s.config.ActiveTransfersPortNon20,
- IdleTimeout: -1,
- ConnectionTimeout: 20,
- Banner: s.statusBanner,
- TLSRequired: ftpserver.TLSRequirement(s.binding.TLSMode),
- TLSSessionReuse: ftpserver.TLSSessionReuse(s.binding.TLSSessionReuse),
- DisableSite: !s.config.EnableSite,
- DisableActiveMode: s.config.DisableActiveMode,
- EnableHASH: s.config.HASHSupport > 0,
- EnableCOMB: s.config.CombineSupport > 0,
- DefaultTransferType: ftpserver.TransferTypeBinary,
- IgnoreASCIITranferType: s.binding.IgnoreASCIITransferType == 1,
- ActiveConnectionsCheck: ftpserver.DataConnectionRequirement(s.binding.ActiveConnectionsSecurity),
- PasvConnectionsCheck: ftpserver.DataConnectionRequirement(s.binding.PassiveConnectionsSecurity),
- }, nil
+ settings := &ftpserver.Settings{
+ Listener: ftpListener,
+ ListenAddr: s.binding.GetAddress(),
+ PublicIPResolver: s.binding.passiveIPResolver,
+ ActiveTransferPortNon20: s.config.ActiveTransfersPortNon20,
+ IdleTimeout: -1,
+ ConnectionTimeout: 20,
+ Banner: s.statusBanner,
+ TLSRequired: ftpserver.TLSRequirement(s.binding.TLSMode),
+ DisableSite: !s.config.EnableSite,
+ DisableActiveMode: s.config.DisableActiveMode,
+ EnableHASH: s.config.HASHSupport > 0,
+ EnableCOMB: s.config.CombineSupport > 0,
+ DefaultTransferType: ftpserver.TransferTypeBinary,
+ ActiveConnectionsCheck: ftpserver.DataConnectionRequirement(s.binding.ActiveConnectionsSecurity),
+ PasvConnectionsCheck: ftpserver.DataConnectionRequirement(s.binding.PassiveConnectionsSecurity),
+ }
+ if portRange != nil {
+ settings.PassiveTransferPortRange = portRange
+ }
+ return settings, nil
}
// ClientConnected is called to send the very first welcome message
@@ -180,27 +187,26 @@ func (s *Server) ClientDisconnected(cc ftpserver.ClientContext) {
// AuthUser authenticates the user and selects an handling driver
func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string) (ftpserver.ClientDriver, error) {
loginMethod := dataprovider.LoginMethodPassword
- if verified, ok := cc.Extra().(bool); ok && verified {
+ tlsState, ok := cc.Extra().(*tlsState)
+ if ok && tlsState != nil && tlsState.LoginWithMutualTLS {
loginMethod = dataprovider.LoginMethodTLSCertificateAndPwd
}
ipAddr := util.GetIPFromRemoteAddress(cc.RemoteAddr().String())
user, err := dataprovider.CheckUserAndPass(username, password, ipAddr, common.ProtocolFTP)
if err != nil {
user.Username = username
- updateLoginMetrics(&user, ipAddr, loginMethod, err)
+ updateLoginMetrics(&user, ipAddr, loginMethod, err, nil)
return nil, dataprovider.ErrInvalidCredentials
}
connection, err := s.validateUser(user, cc, loginMethod)
- defer updateLoginMetrics(&user, ipAddr, loginMethod, err)
+ defer updateLoginMetrics(&user, ipAddr, loginMethod, err, connection)
if err != nil {
return nil, err
}
setStartDirectory(user.Filters.StartDirectory, cc)
- connection.Log(logger.LevelInfo, "User %q logged in with %q from ip %q, TLS enabled? %t",
- user.Username, loginMethod, ipAddr, cc.HasTLSForControl())
dataprovider.UpdateLastLogin(&user)
return connection, nil
}
@@ -234,42 +240,52 @@ func (s *Server) WrapPassiveListener(listener net.Listener) (net.Listener, error
// VerifyConnection checks whether a user should be authenticated using a client certificate without prompting for a password
func (s *Server) VerifyConnection(cc ftpserver.ClientContext, user string, tlsConn *tls.Conn) (ftpserver.ClientDriver, error) {
+ if tlsConn == nil {
+ return nil, nil
+ }
+ state := tlsConn.ConnectionState()
+ cc.SetExtra(&tlsState{
+ LoginWithMutualTLS: false,
+ Cipher: tls.CipherSuiteName(state.CipherSuite),
+ Version: tls.VersionName(state.Version),
+ KEX: state.CurveID.String(),
+ })
if !s.binding.isMutualTLSEnabled() {
return nil, nil
}
- cc.SetExtra(false)
- if tlsConn != nil {
- state := tlsConn.ConnectionState()
- if len(state.PeerCertificates) > 0 {
- ipAddr := util.GetIPFromRemoteAddress(cc.RemoteAddr().String())
- dbUser, err := dataprovider.CheckUserBeforeTLSAuth(user, ipAddr, common.ProtocolFTP, state.PeerCertificates[0])
+
+ if len(state.PeerCertificates) > 0 {
+ ipAddr := util.GetIPFromRemoteAddress(cc.RemoteAddr().String())
+ dbUser, err := dataprovider.CheckUserBeforeTLSAuth(user, ipAddr, common.ProtocolFTP, state.PeerCertificates[0])
+ if err != nil {
+ dbUser.Username = user
+ updateLoginMetrics(&dbUser, ipAddr, dataprovider.LoginMethodTLSCertificate, err, nil)
+ return nil, dataprovider.ErrInvalidCredentials
+ }
+ if dbUser.IsTLSVerificationEnabled() {
+ dbUser, err = dataprovider.CheckUserAndTLSCert(user, ipAddr, common.ProtocolFTP, state.PeerCertificates[0])
if err != nil {
- dbUser.Username = user
- updateLoginMetrics(&dbUser, ipAddr, dataprovider.LoginMethodTLSCertificate, err)
- return nil, dataprovider.ErrInvalidCredentials
+ return nil, err
}
- if dbUser.IsTLSVerificationEnabled() {
- dbUser, err = dataprovider.CheckUserAndTLSCert(user, ipAddr, common.ProtocolFTP, state.PeerCertificates[0])
+
+ cc.SetExtra(&tlsState{
+ LoginWithMutualTLS: true,
+ Cipher: tls.CipherSuiteName(state.CipherSuite),
+ Version: tls.VersionName(state.Version),
+ KEX: state.CurveID.String(),
+ })
+
+ if dbUser.IsLoginMethodAllowed(dataprovider.LoginMethodTLSCertificate, common.ProtocolFTP) {
+ connection, err := s.validateUser(dbUser, cc, dataprovider.LoginMethodTLSCertificate)
+
+ defer updateLoginMetrics(&dbUser, ipAddr, dataprovider.LoginMethodTLSCertificate, err, connection)
+
if err != nil {
return nil, err
}
-
- cc.SetExtra(true)
-
- if dbUser.IsLoginMethodAllowed(dataprovider.LoginMethodTLSCertificate, common.ProtocolFTP) {
- connection, err := s.validateUser(dbUser, cc, dataprovider.LoginMethodTLSCertificate)
-
- defer updateLoginMetrics(&dbUser, ipAddr, dataprovider.LoginMethodTLSCertificate, err)
-
- if err != nil {
- return nil, err
- }
- setStartDirectory(dbUser.Filters.StartDirectory, cc)
- connection.Log(logger.LevelInfo, "User id: %d, logged in with FTP using a TLS certificate, username: %q, home_dir: %q remote addr: %q",
- dbUser.ID, dbUser.Username, dbUser.HomeDir, ipAddr)
- dataprovider.UpdateLastLogin(&dbUser)
- return connection, nil
- }
+ setStartDirectory(dbUser.Filters.StartDirectory, cc)
+ dataprovider.UpdateLastLogin(&dbUser)
+ return connection, nil
}
}
}
@@ -295,9 +311,7 @@ func (s *Server) buildTLSConfig() {
s.binding.GetAddress(), s.binding.ciphers, certID)
if s.binding.isMutualTLSEnabled() {
s.tlsConfig.ClientCAs = certMgr.GetRootCAs()
- if s.binding.TLSSessionReuse != int(ftpserver.TLSSessionReuseRequired) {
- s.tlsConfig.VerifyConnection = s.verifyTLSConnection
- }
+ s.tlsConfig.VerifyConnection = s.verifyTLSConnection
switch s.binding.ClientAuthType {
case 1:
s.tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
@@ -361,7 +375,7 @@ func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext
user.Username, user.HomeDir)
return nil, fmt.Errorf("cannot login user with invalid home dir: %q", user.HomeDir)
}
- if util.Contains(user.Filters.DeniedProtocols, common.ProtocolFTP) {
+ if slices.Contains(user.Filters.DeniedProtocols, common.ProtocolFTP) {
logger.Info(logSender, connectionID, "cannot login user %q, protocol FTP is not allowed", user.Username)
return nil, fmt.Errorf("protocol FTP is not allowed for user %q", user.Username)
}
@@ -416,13 +430,19 @@ func setStartDirectory(startDirectory string, cc ftpserver.ClientContext) {
cc.SetPath(startDirectory)
}
-func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err error) {
+func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err error, c *Connection) {
metric.AddLoginAttempt(loginMethod)
if err == nil {
+ info := ""
+ if tlsState, ok := c.clientContext.Extra().(*tlsState); ok && tlsState != nil {
+ info = fmt.Sprintf("%s - %s - %s", tlsState.Version, tlsState.Cipher, tlsState.KEX)
+ }
+ logger.LoginLog(user.Username, ip, loginMethod, common.ProtocolFTP, c.ID, c.GetClientVersion(),
+ c.clientContext.HasTLSForControl(), info)
plugin.Handler.NotifyLogEvent(notifier.LogEventTypeLoginOK, common.ProtocolFTP, user.Username, ip, "", nil)
+ common.DelayLogin(nil)
} else if err != common.ErrInternalFailure {
- logger.ConnectionFailedLog(user.Username, ip, loginMethod,
- common.ProtocolFTP, err.Error())
+ logger.ConnectionFailedLog(user.Username, ip, loginMethod, common.ProtocolFTP, err.Error())
event := common.HostEventLoginFailed
logEv := notifier.LogEventTypeLoginFailed
if errors.Is(err, util.ErrNotFound) {
@@ -431,6 +451,9 @@ func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err err
}
common.AddDefenderEvent(ip, common.ProtocolFTP, event)
plugin.Handler.NotifyLogEvent(logEv, common.ProtocolFTP, user.Username, ip, "", err)
+ if loginMethod != dataprovider.LoginMethodTLSCertificate {
+ common.DelayLogin(err)
+ }
}
metric.AddLoginResult(loginMethod, err)
dataprovider.ExecutePostLoginHook(user, loginMethod, ip, common.ProtocolFTP, err)
diff --git a/internal/ftpd/transfer.go b/internal/ftpd/transfer.go
index ed0ce660..071f8b1e 100644
--- a/internal/ftpd/transfer.go
+++ b/internal/ftpd/transfer.go
@@ -136,7 +136,7 @@ func (t *transfer) closeIO() error {
} else if t.reader != nil {
err = t.reader.Close()
if metadater, ok := t.reader.(vfs.Metadater); ok {
- t.BaseTransfer.SetMetadata(metadater.Metadata())
+ t.SetMetadata(metadater.Metadata())
}
}
return err
diff --git a/internal/httpd/api_admin.go b/internal/httpd/api_admin.go
index b232362c..2fd093a0 100644
--- a/internal/httpd/api_admin.go
+++ b/internal/httpd/api_admin.go
@@ -21,10 +21,10 @@ import (
"net/http"
"net/url"
- "github.com/go-chi/jwtauth/v5"
"github.com/go-chi/render"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/smtp"
"github.com/drakkan/sftpgo/v2/internal/util"
)
@@ -68,7 +68,7 @@ func renderAdmin(w http.ResponseWriter, r *http.Request, username string, status
func addAdmin(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -90,7 +90,7 @@ func addAdmin(w http.ResponseWriter, r *http.Request) {
func disableAdmin2FA(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -138,7 +138,7 @@ func updateAdmin(w http.ResponseWriter, r *http.Request) {
return
}
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -149,8 +149,8 @@ func updateAdmin(w http.ResponseWriter, r *http.Request) {
http.StatusBadRequest)
return
}
- if claims.isCriticalPermRemoved(updatedAdmin.Permissions) {
- sendAPIResponse(w, r, errors.New("you cannot remove these permissions to yourself"), "", http.StatusBadRequest)
+ if !util.SlicesEqual(admin.Permissions, updatedAdmin.Permissions) {
+ sendAPIResponse(w, r, errors.New("you cannot change your permissions"), "", http.StatusBadRequest)
return
}
if updatedAdmin.Status == 0 {
@@ -182,7 +182,7 @@ func updateAdmin(w http.ResponseWriter, r *http.Request) {
func deleteAdmin(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
username := getURLParam(r, "username")
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -202,7 +202,7 @@ func deleteAdmin(w http.ResponseWriter, r *http.Request) {
func getAdminProfile(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -224,7 +224,7 @@ func getAdminProfile(w http.ResponseWriter, r *http.Request) {
func updateAdminProfile(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -297,6 +297,7 @@ func changeAdminPassword(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
}
+ invalidateToken(r)
sendAPIResponse(w, r, err, "Password updated", http.StatusOK)
}
@@ -316,7 +317,7 @@ func doChangeAdminPassword(r *http.Request, currentPassword, newPassword, confir
util.I18nErrorChangePwdNoDifferent,
)
}
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil {
return util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken)
}
@@ -334,14 +335,3 @@ func doChangeAdminPassword(r *http.Request, currentPassword, newPassword, confir
return dataprovider.UpdateAdmin(&admin, dataprovider.ActionExecutorSelf, util.GetIPFromRemoteAddress(r.RemoteAddr), claims.Role)
}
-
-func getTokenClaims(r *http.Request) (jwtTokenClaims, error) {
- tokenClaims := jwtTokenClaims{}
- _, claims, err := jwtauth.FromContext(r.Context())
- if err != nil {
- return tokenClaims, err
- }
- tokenClaims.Decode(claims)
-
- return tokenClaims, nil
-}
diff --git a/internal/httpd/api_configs.go b/internal/httpd/api_configs.go
index 399c7ad2..520f81a9 100644
--- a/internal/httpd/api_configs.go
+++ b/internal/httpd/api_configs.go
@@ -66,7 +66,7 @@ func testSMTPConfig(w http.ResponseWriter, r *http.Request) {
}
}
if req.AuthType == 3 {
- if err := req.Config.OAuth2.Validate(); err != nil {
+ if err := req.OAuth2.Validate(); err != nil {
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
return
}
@@ -85,7 +85,7 @@ type oauth2TokenRequest struct {
BaseRedirectURL string `json:"base_redirect_url"`
}
-func handleSMTPOAuth2TokenRequestPost(w http.ResponseWriter, r *http.Request) {
+func (s *httpdServer) handleSMTPOAuth2TokenRequestPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
var req oauth2TokenRequest
@@ -106,20 +106,20 @@ func handleSMTPOAuth2TokenRequestPost(w http.ResponseWriter, r *http.Request) {
}
configs.SetNilsToEmpty()
if err := configs.SMTP.TryDecrypt(); err == nil {
- req.OAuth2Config.ClientSecret = configs.SMTP.OAuth2.ClientSecret.GetPayload()
+ req.ClientSecret = configs.SMTP.OAuth2.ClientSecret.GetPayload()
}
}
- cfg := req.OAuth2Config.GetOAuth2()
+ cfg := req.GetOAuth2()
cfg.RedirectURL = req.BaseRedirectURL + webOAuth2RedirectPath
clientSecret := kms.NewPlainSecret(cfg.ClientSecret)
clientSecret.SetAdditionalData(xid.New().String())
pendingAuth := newOAuth2PendingAuth(req.Provider, cfg.RedirectURL, cfg.ClientID, clientSecret)
oauth2Mgr.addPendingAuth(pendingAuth)
- stateToken := createOAuth2Token(pendingAuth.State, util.GetIPFromRemoteAddress(r.RemoteAddr))
+ stateToken := createOAuth2Token(s.csrfTokenAuth, pendingAuth.State, util.GetIPFromRemoteAddress(r.RemoteAddr))
if stateToken == "" {
sendAPIResponse(w, r, nil, "unable to create state token", http.StatusInternalServerError)
return
}
- u := cfg.AuthCodeURL(stateToken, oauth2.AccessTypeOffline)
+ u := cfg.AuthCodeURL(stateToken, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(pendingAuth.Verifier))
sendAPIResponse(w, r, nil, u, http.StatusOK)
}
diff --git a/internal/httpd/api_eventrule.go b/internal/httpd/api_eventrule.go
index a4daaaa6..b8474af4 100644
--- a/internal/httpd/api_eventrule.go
+++ b/internal/httpd/api_eventrule.go
@@ -24,6 +24,7 @@ import (
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/util"
)
@@ -42,7 +43,7 @@ func getEventActions(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, actions)
}
-func renderEventAction(w http.ResponseWriter, r *http.Request, name string, claims *jwtTokenClaims, status int) {
+func renderEventAction(w http.ResponseWriter, r *http.Request, name string, claims *jwt.Claims, status int) {
action, err := dataprovider.EventActionExists(name)
if err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
@@ -61,19 +62,19 @@ func renderEventAction(w http.ResponseWriter, r *http.Request, name string, clai
func getEventActionByName(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
}
name := getURLParam(r, "name")
- renderEventAction(w, r, name, &claims, http.StatusOK)
+ renderEventAction(w, r, name, claims, http.StatusOK)
}
func addEventAction(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -91,12 +92,12 @@ func addEventAction(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Add("Location", fmt.Sprintf("%s/%s", eventActionsPath, url.PathEscape(action.Name)))
- renderEventAction(w, r, action.Name, &claims, http.StatusCreated)
+ renderEventAction(w, r, action.Name, claims, http.StatusCreated)
}
func updateEventAction(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -136,7 +137,7 @@ func updateEventAction(w http.ResponseWriter, r *http.Request) {
func deleteEventAction(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -165,7 +166,7 @@ func getEventRules(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, rules)
}
-func renderEventRule(w http.ResponseWriter, r *http.Request, name string, claims *jwtTokenClaims, status int) {
+func renderEventRule(w http.ResponseWriter, r *http.Request, name string, claims *jwt.Claims, status int) {
rule, err := dataprovider.EventRuleExists(name)
if err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
@@ -184,19 +185,19 @@ func renderEventRule(w http.ResponseWriter, r *http.Request, name string, claims
func getEventRuleByName(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
}
name := getURLParam(r, "name")
- renderEventRule(w, r, name, &claims, http.StatusOK)
+ renderEventRule(w, r, name, claims, http.StatusOK)
}
func addEventRule(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -213,12 +214,12 @@ func addEventRule(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Add("Location", fmt.Sprintf("%s/%s", eventRulesPath, url.PathEscape(rule.Name)))
- renderEventRule(w, r, rule.Name, &claims, http.StatusCreated)
+ renderEventRule(w, r, rule.Name, claims, http.StatusCreated)
}
func updateEventRule(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -249,7 +250,7 @@ func updateEventRule(w http.ResponseWriter, r *http.Request) {
func deleteEventRule(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
diff --git a/internal/httpd/api_events.go b/internal/httpd/api_events.go
index 5c218271..a5ad1a5c 100644
--- a/internal/httpd/api_events.go
+++ b/internal/httpd/api_events.go
@@ -27,6 +27,7 @@ import (
"github.com/sftpgo/sdk/plugin/notifier"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/plugin"
"github.com/drakkan/sftpgo/v2/internal/util"
)
@@ -68,8 +69,8 @@ func getCommonSearchParamsFromRequest(r *http.Request) (eventsearcher.CommonSear
}
c.EndTimestamp = ts
}
- c.Username = r.URL.Query().Get("username")
- c.IP = r.URL.Query().Get("ip")
+ c.Username = strings.TrimSpace(r.URL.Query().Get("username"))
+ c.IP = strings.TrimSpace(r.URL.Query().Get("ip"))
c.InstanceIDs = getCommaSeparatedQueryParam(r, "instance_ids")
c.FromID = r.URL.Query().Get("from_id")
@@ -93,9 +94,9 @@ func getFsSearchParamsFromRequest(r *http.Request) (eventsearcher.FsEventSearch,
s.FsProvider = val
}
s.Actions = getCommaSeparatedQueryParam(r, "actions")
- s.SSHCmd = r.URL.Query().Get("ssh_cmd")
- s.Bucket = r.URL.Query().Get("bucket")
- s.Endpoint = r.URL.Query().Get("endpoint")
+ s.SSHCmd = strings.TrimSpace(r.URL.Query().Get("ssh_cmd"))
+ s.Bucket = strings.TrimSpace(r.URL.Query().Get("bucket"))
+ s.Endpoint = strings.TrimSpace(r.URL.Query().Get("endpoint"))
s.Protocols = getCommaSeparatedQueryParam(r, "protocols")
statuses := getCommaSeparatedQueryParam(r, "statuses")
for _, status := range statuses {
@@ -117,7 +118,7 @@ func getProviderSearchParamsFromRequest(r *http.Request) (eventsearcher.Provider
return s, err
}
s.Actions = getCommaSeparatedQueryParam(r, "actions")
- s.ObjectName = r.URL.Query().Get("object_name")
+ s.ObjectName = strings.TrimSpace(r.URL.Query().Get("object_name"))
s.ObjectTypes = getCommaSeparatedQueryParam(r, "object_types")
return s, nil
}
@@ -143,7 +144,7 @@ func getLogSearchParamsFromRequest(r *http.Request) (eventsearcher.LogEventSearc
func searchFsEvents(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -176,7 +177,7 @@ func searchFsEvents(w http.ResponseWriter, r *http.Request) {
func searchProviderEvents(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -211,7 +212,7 @@ func searchProviderEvents(w http.ResponseWriter, r *http.Request) {
func searchLogEvents(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
diff --git a/internal/httpd/api_folder.go b/internal/httpd/api_folder.go
index debc8483..c46d4ab3 100644
--- a/internal/httpd/api_folder.go
+++ b/internal/httpd/api_folder.go
@@ -23,6 +23,7 @@ import (
"github.com/go-chi/render"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/util"
"github.com/drakkan/sftpgo/v2/internal/vfs"
)
@@ -45,7 +46,7 @@ func getFolders(w http.ResponseWriter, r *http.Request) {
func addFolder(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -62,12 +63,12 @@ func addFolder(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Add("Location", fmt.Sprintf("%s/%s", folderPath, url.PathEscape(folder.Name)))
- renderFolder(w, r, folder.Name, &claims, http.StatusCreated)
+ renderFolder(w, r, folder.Name, claims, http.StatusCreated)
}
func updateFolder(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -100,7 +101,7 @@ func updateFolder(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, nil, "Folder updated", http.StatusOK)
}
-func renderFolder(w http.ResponseWriter, r *http.Request, name string, claims *jwtTokenClaims, status int) {
+func renderFolder(w http.ResponseWriter, r *http.Request, name string, claims *jwt.Claims, status int) {
folder, err := dataprovider.GetFolderByName(name)
if err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
@@ -119,18 +120,18 @@ func renderFolder(w http.ResponseWriter, r *http.Request, name string, claims *j
func getFolderByName(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
}
name := getURLParam(r, "name")
- renderFolder(w, r, name, &claims, http.StatusOK)
+ renderFolder(w, r, name, claims, http.StatusOK)
}
func deleteFolder(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
diff --git a/internal/httpd/api_group.go b/internal/httpd/api_group.go
index 125319d5..beae9ad2 100644
--- a/internal/httpd/api_group.go
+++ b/internal/httpd/api_group.go
@@ -23,6 +23,7 @@ import (
"github.com/go-chi/render"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/util"
)
@@ -44,7 +45,7 @@ func getGroups(w http.ResponseWriter, r *http.Request) {
func addGroup(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -61,12 +62,12 @@ func addGroup(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Add("Location", fmt.Sprintf("%s/%s", groupPath, url.PathEscape(group.Name)))
- renderGroup(w, r, group.Name, &claims, http.StatusCreated)
+ renderGroup(w, r, group.Name, claims, http.StatusCreated)
}
func updateGroup(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -98,7 +99,7 @@ func updateGroup(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, nil, "Group updated", http.StatusOK)
}
-func renderGroup(w http.ResponseWriter, r *http.Request, name string, claims *jwtTokenClaims, status int) {
+func renderGroup(w http.ResponseWriter, r *http.Request, name string, claims *jwt.Claims, status int) {
group, err := dataprovider.GroupExists(name)
if err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
@@ -117,18 +118,18 @@ func renderGroup(w http.ResponseWriter, r *http.Request, name string, claims *jw
func getGroupByName(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
}
name := getURLParam(r, "name")
- renderGroup(w, r, name, &claims, http.StatusOK)
+ renderGroup(w, r, name, claims, http.StatusOK)
}
func deleteGroup(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
diff --git a/internal/httpd/api_http_user.go b/internal/httpd/api_http_user.go
index a65ddda5..45bbd52f 100644
--- a/internal/httpd/api_http_user.go
+++ b/internal/httpd/api_http_user.go
@@ -31,12 +31,13 @@ import (
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/util"
)
func getUserConnection(w http.ResponseWriter, r *http.Request) (*Connection, error) {
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return nil, fmt.Errorf("invalid token claims %w", err)
@@ -49,15 +50,12 @@ func getUserConnection(w http.ResponseWriter, r *http.Request) (*Connection, err
connID := xid.New().String()
protocol := getProtocolFromRequest(r)
connectionID := fmt.Sprintf("%v_%v", protocol, connID)
- if err := checkHTTPClientUser(&user, r, connectionID, false); err != nil {
+ if err := checkHTTPClientUser(&user, r, connectionID, false, false); err != nil {
sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return nil, err
}
- connection := &Connection{
- BaseConnection: common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r),
- r.RemoteAddr, user),
- request: r,
- }
+ baseConn := common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r), r.RemoteAddr, user)
+ connection := newConnection(baseConn, w, r)
if err = common.Connections.Add(connection); err != nil {
sendAPIResponse(w, r, err, "Unable to add connection", http.StatusTooManyRequests)
return connection, err
@@ -317,6 +315,13 @@ func uploadUserFiles(w http.ResponseWriter, r *http.Request) {
}
defer common.Connections.Remove(connection.GetID())
+ if err := common.Connections.IsNewTransferAllowed(connection.User.Username); err != nil {
+ connection.Log(logger.LevelInfo, "denying file write due to number of transfer limits")
+ sendAPIResponse(w, r, err, "Denying file write due to transfer count limits",
+ http.StatusConflict)
+ return
+ }
+
transferQuota := connection.GetTransferQuota()
if !transferQuota.HasUploadSpace() {
connection.Log(logger.LevelInfo, "denying file write due to transfer quota limits")
@@ -453,7 +458,7 @@ func getUserFilesAsZipStream(w http.ResponseWriter, r *http.Request) {
func getUserProfile(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -469,15 +474,16 @@ func getUserProfile(w http.ResponseWriter, r *http.Request) {
Description: user.Description,
AllowAPIKeyAuth: user.Filters.AllowAPIKeyAuth,
},
- PublicKeys: user.PublicKeys,
- TLSCerts: user.Filters.TLSCerts,
+ AdditionalEmails: user.Filters.AdditionalEmails,
+ PublicKeys: user.PublicKeys,
+ TLSCerts: user.Filters.TLSCerts,
}
render.JSON(w, r, resp)
}
func updateUserProfile(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -508,6 +514,7 @@ func updateUserProfile(w http.ResponseWriter, r *http.Request) {
}
if userMerged.CanChangeInfo() {
user.Email = req.Email
+ user.Filters.AdditionalEmails = req.AdditionalEmails
user.Description = req.Description
}
if err := dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, util.GetIPFromRemoteAddress(r.RemoteAddr), user.Role); err != nil {
@@ -531,6 +538,7 @@ func changeUserPassword(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
}
+ invalidateToken(r)
sendAPIResponse(w, r, err, "Password updated", http.StatusOK)
}
@@ -550,7 +558,7 @@ func doChangeUserPassword(r *http.Request, currentPassword, newPassword, confirm
util.I18nErrorChangePwdNoDifferent,
)
}
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
return util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken)
}
diff --git a/internal/httpd/api_iplist.go b/internal/httpd/api_iplist.go
index 5c1de975..d74b68d8 100644
--- a/internal/httpd/api_iplist.go
+++ b/internal/httpd/api_iplist.go
@@ -25,6 +25,7 @@ import (
"github.com/go-chi/render"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/util"
)
@@ -68,7 +69,7 @@ func getIPListEntry(w http.ResponseWriter, r *http.Request) {
func addIPListEntry(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -91,7 +92,7 @@ func addIPListEntry(w http.ResponseWriter, r *http.Request) {
func updateIPListEntry(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -125,7 +126,7 @@ func updateIPListEntry(w http.ResponseWriter, r *http.Request) {
func deleteIPListEntry(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
diff --git a/internal/httpd/api_keys.go b/internal/httpd/api_keys.go
index d95d88de..a9a3bcb2 100644
--- a/internal/httpd/api_keys.go
+++ b/internal/httpd/api_keys.go
@@ -23,6 +23,7 @@ import (
"github.com/go-chi/render"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/util"
)
@@ -56,7 +57,7 @@ func getAPIKeyByID(w http.ResponseWriter, r *http.Request) {
func addAPIKey(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -87,7 +88,7 @@ func addAPIKey(w http.ResponseWriter, r *http.Request) {
func updateAPIKey(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -119,7 +120,7 @@ func updateAPIKey(w http.ResponseWriter, r *http.Request) {
func deleteAPIKey(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
keyID := getURLParam(r, "id")
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
diff --git a/internal/httpd/api_maintenance.go b/internal/httpd/api_maintenance.go
index 10347de8..560c702e 100644
--- a/internal/httpd/api_maintenance.go
+++ b/internal/httpd/api_maintenance.go
@@ -29,6 +29,7 @@ import (
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/util"
"github.com/drakkan/sftpgo/v2/internal/vfs"
@@ -115,7 +116,7 @@ func dumpData(w http.ResponseWriter, r *http.Request) {
func loadDataFromRequest(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, MaxRestoreSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -143,7 +144,7 @@ func loadDataFromRequest(w http.ResponseWriter, r *http.Request) {
func loadData(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -551,9 +552,10 @@ func RestoreUsers(users []dataprovider.User, inputFile string, mode, scanQuota i
return fmt.Errorf("unable to restore user %q: %w", user.Username, err)
}
if scanQuota == 1 || (scanQuota == 2 && user.HasQuotaRestrictions()) {
- if common.QuotaScans.AddUserQuotaScan(user.Username, user.Role) {
+ user, err = dataprovider.GetUserWithGroupSettings(user.Username, "")
+ if err == nil && common.QuotaScans.AddUserQuotaScan(user.Username, user.Role) {
logger.Debug(logSender, "", "starting quota scan for restored user: %q", user.Username)
- go doUserQuotaScan(user) //nolint:errcheck
+ go doUserQuotaScan(&user) //nolint:errcheck
}
}
}
diff --git a/internal/httpd/api_mfa.go b/internal/httpd/api_mfa.go
index 0b7e282e..73df7593 100644
--- a/internal/httpd/api_mfa.go
+++ b/internal/httpd/api_mfa.go
@@ -20,12 +20,14 @@ import (
"fmt"
"io"
"net/http"
+ "slices"
"strconv"
"strings"
"github.com/go-chi/render"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/kms"
"github.com/drakkan/sftpgo/v2/internal/mfa"
"github.com/drakkan/sftpgo/v2/internal/util"
@@ -65,13 +67,13 @@ func getTOTPConfigs(w http.ResponseWriter, r *http.Request) {
func generateTOTPSecret(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
}
var accountName string
- if claims.hasUserAudience() {
+ if hasUserAudience(claims) {
accountName = fmt.Sprintf("User %q", claims.Username)
} else {
accountName = fmt.Sprintf("Admin %q", claims.Username)
@@ -112,7 +114,7 @@ func getQRCode(w http.ResponseWriter, r *http.Request) {
func saveTOTPConfig(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -123,7 +125,7 @@ func saveTOTPConfig(w http.ResponseWriter, r *http.Request) {
recoveryCodes = append(recoveryCodes, dataprovider.RecoveryCode{Secret: kms.NewPlainSecret(code)})
}
baseURL := webBaseClientPath
- if claims.hasUserAudience() {
+ if hasUserAudience(claims) {
if err := saveUserTOTPConfig(claims.Username, r, recoveryCodes); err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
@@ -138,8 +140,7 @@ func saveTOTPConfig(w http.ResponseWriter, r *http.Request) {
if claims.MustSetTwoFactorAuth {
// force logout
defer func() {
- c := jwtTokenClaims{}
- c.removeCookie(w, r, baseURL)
+ removeCookie(w, r, baseURL)
}()
}
@@ -164,14 +165,14 @@ func validateTOTPPasscode(w http.ResponseWriter, r *http.Request) {
func getRecoveryCodes(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
}
recoveryCodes := make([]recoveryCode, 0, 12)
var accountRecoveryCodes []dataprovider.RecoveryCode
- if claims.hasUserAudience() {
+ if hasUserAudience(claims) {
user, err := dataprovider.UserExists(claims.Username, "")
if err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
@@ -210,7 +211,7 @@ func getRecoveryCodes(w http.ResponseWriter, r *http.Request) {
func generateRecoveryCodes(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -222,7 +223,7 @@ func generateRecoveryCodes(w http.ResponseWriter, r *http.Request) {
recoveryCodes = append(recoveryCodes, code)
accountRecoveryCodes = append(accountRecoveryCodes, dataprovider.RecoveryCode{Secret: kms.NewPlainSecret(code)})
}
- if claims.hasUserAudience() {
+ if hasUserAudience(claims) {
user, err := dataprovider.UserExists(claims.Username, "")
if err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
@@ -276,7 +277,7 @@ func saveUserTOTPConfig(username string, r *http.Request, recoveryCodes []datapr
return util.NewValidationError("two-factor authentication must be enabled")
}
for _, p := range userMerged.Filters.TwoFactorAuthProtocols {
- if !util.Contains(user.Filters.TOTPConfig.Protocols, p) {
+ if !slices.Contains(user.Filters.TOTPConfig.Protocols, p) {
return util.NewValidationError(fmt.Sprintf("totp: the following protocols are required: %q",
strings.Join(userMerged.Filters.TwoFactorAuthProtocols, ", ")))
}
diff --git a/internal/httpd/api_quota.go b/internal/httpd/api_quota.go
index de6ff8bb..db339e39 100644
--- a/internal/httpd/api_quota.go
+++ b/internal/httpd/api_quota.go
@@ -23,6 +23,7 @@ import (
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/vfs"
)
@@ -44,7 +45,7 @@ type transferQuotaUsage struct {
func getUsersQuotaScans(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -91,7 +92,7 @@ func startFolderQuotaScan(w http.ResponseWriter, r *http.Request) {
func updateUserTransferQuotaUsage(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -132,7 +133,7 @@ func updateUserTransferQuotaUsage(w http.ResponseWriter, r *http.Request) {
}
func doUpdateUserQuotaUsage(w http.ResponseWriter, r *http.Request, username string, usage quotaUsage) {
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -204,7 +205,7 @@ func doStartUserQuotaScan(w http.ResponseWriter, r *http.Request, username strin
sendAPIResponse(w, r, nil, "Quota tracking is disabled!", http.StatusForbidden)
return
}
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -219,7 +220,7 @@ func doStartUserQuotaScan(w http.ResponseWriter, r *http.Request, username strin
http.StatusConflict)
return
}
- go doUserQuotaScan(user) //nolint:errcheck
+ go doUserQuotaScan(&user) //nolint:errcheck
sendAPIResponse(w, r, err, "Scan started", http.StatusAccepted)
}
@@ -242,14 +243,14 @@ func doStartFolderQuotaScan(w http.ResponseWriter, r *http.Request, name string)
sendAPIResponse(w, r, err, "Scan started", http.StatusAccepted)
}
-func doUserQuotaScan(user dataprovider.User) error {
+func doUserQuotaScan(user *dataprovider.User) error {
defer common.QuotaScans.RemoveUserQuotaScan(user.Username)
numFiles, size, err := user.ScanQuota()
if err != nil {
logger.Warn(logSender, "", "error scanning user quota %q: %v", user.Username, err)
return err
}
- err = dataprovider.UpdateUserQuota(&user, numFiles, size, true)
+ err = dataprovider.UpdateUserQuota(user, numFiles, size, true)
logger.Debug(logSender, "", "user quota scanned, user: %q, error: %v", user.Username, err)
return err
}
diff --git a/internal/httpd/api_retention.go b/internal/httpd/api_retention.go
index a47a1119..1502a327 100644
--- a/internal/httpd/api_retention.go
+++ b/internal/httpd/api_retention.go
@@ -15,67 +15,20 @@
package httpd
import (
- "fmt"
"net/http"
"github.com/go-chi/render"
"github.com/drakkan/sftpgo/v2/internal/common"
- "github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
)
func getRetentionChecks(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
}
render.JSON(w, r, common.RetentionChecks.Get(claims.Role))
}
-
-func startRetentionCheck(w http.ResponseWriter, r *http.Request) {
- r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
- if err != nil || claims.Username == "" {
- sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
- return
- }
- username := getURLParam(r, "username")
- user, err := dataprovider.GetUserWithGroupSettings(username, claims.Role)
- if err != nil {
- sendAPIResponse(w, r, err, "", getRespStatus(err))
- return
- }
- var check common.RetentionCheck
-
- err = render.DecodeJSON(r.Body, &check.Folders)
- if err != nil {
- sendAPIResponse(w, r, err, "", http.StatusBadRequest)
- return
- }
-
- check.Notifications = getCommaSeparatedQueryParam(r, "notifications")
- for _, notification := range check.Notifications {
- if notification == common.RetentionCheckNotificationEmail {
- admin, err := dataprovider.AdminExists(claims.Username)
- if err != nil {
- sendAPIResponse(w, r, err, "", getRespStatus(err))
- return
- }
- check.Email = admin.Email
- }
- }
- if err := check.Validate(); err != nil {
- sendAPIResponse(w, r, err, "Invalid retention check", http.StatusBadRequest)
- return
- }
- c := common.RetentionChecks.Add(check, &user)
- if c == nil {
- sendAPIResponse(w, r, err, fmt.Sprintf("Another check is already in progress for user %q", username),
- http.StatusConflict)
- return
- }
- go c.Start() //nolint:errcheck
- sendAPIResponse(w, r, err, "Check started", http.StatusAccepted)
-}
diff --git a/internal/httpd/api_role.go b/internal/httpd/api_role.go
index 6e9a23ee..d8840155 100644
--- a/internal/httpd/api_role.go
+++ b/internal/httpd/api_role.go
@@ -23,6 +23,7 @@ import (
"github.com/go-chi/render"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/util"
)
@@ -44,7 +45,7 @@ func getRoles(w http.ResponseWriter, r *http.Request) {
func addRole(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -67,7 +68,7 @@ func addRole(w http.ResponseWriter, r *http.Request) {
func updateRole(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -119,7 +120,7 @@ func getRoleByName(w http.ResponseWriter, r *http.Request) {
func deleteRole(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
diff --git a/internal/httpd/api_shares.go b/internal/httpd/api_shares.go
index c45f6f1f..1ea1542e 100644
--- a/internal/httpd/api_shares.go
+++ b/internal/httpd/api_shares.go
@@ -22,23 +22,24 @@ import (
"net/url"
"os"
"path"
+ "slices"
"strings"
"time"
- "github.com/go-chi/jwtauth/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
"github.com/sftpgo/sdk"
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/util"
)
func getShares(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -58,7 +59,7 @@ func getShares(w http.ResponseWriter, r *http.Request) {
func getShareByID(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -76,7 +77,7 @@ func getShareByID(w http.ResponseWriter, r *http.Request) {
func addShare(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -107,7 +108,7 @@ func addShare(w http.ResponseWriter, r *http.Request) {
share.Name = share.ShareID
}
if share.Password == "" {
- if util.Contains(claims.Permissions, sdk.WebClientShareNoPasswordDisabled) {
+ if slices.Contains(claims.Permissions, sdk.WebClientShareNoPasswordDisabled) {
sendAPIResponse(w, r, nil, "You are not authorized to share files/folders without a password",
http.StatusForbidden)
return
@@ -125,7 +126,7 @@ func addShare(w http.ResponseWriter, r *http.Request) {
func updateShare(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -155,7 +156,7 @@ func updateShare(w http.ResponseWriter, r *http.Request) {
updatedShare.Password = share.Password
}
if updatedShare.Password == "" {
- if util.Contains(claims.Permissions, sdk.WebClientShareNoPasswordDisabled) {
+ if slices.Contains(claims.Permissions, sdk.WebClientShareNoPasswordDisabled) {
sendAPIResponse(w, r, nil, "You are not authorized to share files/folders without a password",
http.StatusForbidden)
return
@@ -176,7 +177,7 @@ func updateShare(w http.ResponseWriter, r *http.Request) {
func deleteShare(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
shareID := getURLParam(r, "id")
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -379,6 +380,12 @@ func (s *httpdServer) uploadFilesToShare(w http.ResponseWriter, r *http.Request)
if err != nil {
return
}
+ if err := common.Connections.IsNewTransferAllowed(connection.User.Username); err != nil {
+ connection.Log(logger.LevelInfo, "denying file write due to number of transfer limits")
+ sendAPIResponse(w, r, err, "Denying file write due to transfer count limits",
+ http.StatusConflict)
+ return
+ }
transferQuota := connection.GetTransferQuota()
if !transferQuota.HasUploadSpace() {
@@ -425,36 +432,41 @@ func (s *httpdServer) uploadFilesToShare(w http.ResponseWriter, r *http.Request)
}
}
+func (s *httpdServer) getShareClaims(r *http.Request, shareID string) (context.Context, *jwt.Claims, error) {
+ token, err := jwt.VerifyRequest(s.tokenAuth, r, jwt.TokenFromCookie)
+ if err != nil || token == nil {
+ return nil, nil, errInvalidToken
+ }
+ tokenString := jwt.TokenFromCookie(r)
+ if tokenString == "" || invalidatedJWTTokens.Get(tokenString) {
+ return nil, nil, errInvalidToken
+ }
+ if !token.Audience.Contains(tokenAudienceWebShare) {
+ logger.Debug(logSender, "", "invalid token audience for share %q", shareID)
+ return nil, nil, errInvalidToken
+ }
+ ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
+ if err := validateIPForToken(token, ipAddr); err != nil {
+ logger.Debug(logSender, "", "token for share %q is not valid for the ip address %q", shareID, ipAddr)
+ return nil, nil, err
+ }
+ if token.Username != shareID {
+ logger.Debug(logSender, "", "token not valid for share %q", shareID)
+ return nil, nil, errInvalidToken
+ }
+ ctx := jwt.NewContext(r.Context(), token, nil)
+ return ctx, token, nil
+}
+
func (s *httpdServer) checkWebClientShareCredentials(w http.ResponseWriter, r *http.Request, share *dataprovider.Share) error {
doRedirect := func() {
redirectURL := path.Join(webClientPubSharesPath, share.ShareID, fmt.Sprintf("login?next=%s", url.QueryEscape(r.RequestURI)))
http.Redirect(w, r, redirectURL, http.StatusFound)
}
- token, err := jwtauth.VerifyRequest(s.tokenAuth, r, jwtauth.TokenFromCookie)
- if err != nil || token == nil {
+ if _, _, err := s.getShareClaims(r, share.ShareID); err != nil {
doRedirect()
- return errInvalidToken
- }
- if !util.Contains(token.Audience(), tokenAudienceWebShare) {
- logger.Debug(logSender, "", "invalid token audience for share %q", share.ShareID)
- doRedirect()
- return errInvalidToken
- }
- if tokenValidationMode != tokenValidationNoIPMatch {
- ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if !util.Contains(token.Audience(), ipAddr) {
- logger.Debug(logSender, "", "token for share %q is not valid for the ip address %q", share.ShareID, ipAddr)
- doRedirect()
- return errInvalidToken
- }
- }
- ctx := jwtauth.NewContext(r.Context(), token, nil)
- claims, err := getTokenClaims(r.WithContext(ctx))
- if err != nil || claims.Username != share.ShareID {
- logger.Debug(logSender, "", "token not valid for share %q", share.ShareID)
- doRedirect()
- return errInvalidToken
+ return err
}
return nil
}
@@ -480,7 +492,7 @@ func (s *httpdServer) checkPublicShare(w http.ResponseWriter, r *http.Request, v
renderError(err, "", statusCode)
return share, nil, err
}
- if !util.Contains(validScopes, share.Scope) {
+ if !slices.Contains(validScopes, share.Scope) {
err := errors.New("invalid share scope")
renderError(util.NewI18nError(err, util.I18nErrorShareScope), "", http.StatusForbidden)
return share, nil, err
@@ -494,7 +506,6 @@ func (s *httpdServer) checkPublicShare(w http.ResponseWriter, r *http.Request, v
if share.Password != "" {
if isWebClient {
if err := s.checkWebClientShareCredentials(w, r, &share); err != nil {
- handleDefenderEventLoginFailed(ipAddr, err) //nolint:errcheck
return share, nil, dataprovider.ErrInvalidCredentials
}
} else {
@@ -512,6 +523,7 @@ func (s *httpdServer) checkPublicShare(w http.ResponseWriter, r *http.Request, v
return share, nil, dataprovider.ErrInvalidCredentials
}
}
+ common.DelayLogin(nil)
}
user, err := getUserForShare(share)
if err != nil {
@@ -519,11 +531,8 @@ func (s *httpdServer) checkPublicShare(w http.ResponseWriter, r *http.Request, v
return share, nil, err
}
connID := xid.New().String()
- connection := &Connection{
- BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTPShare, util.GetHTTPLocalAddress(r),
- r.RemoteAddr, user),
- request: r,
- }
+ baseConn := common.NewBaseConnection(connID, common.ProtocolHTTPShare, util.GetHTTPLocalAddress(r), r.RemoteAddr, user)
+ connection := newConnection(baseConn, w, r)
return share, connection, nil
}
@@ -536,7 +545,7 @@ func getUserForShare(share dataprovider.Share) (dataprovider.User, error) {
if !user.CanManageShares() {
return user, util.NewI18nError(util.NewRecordNotFoundError("this share does not exist"), util.I18nError404Message)
}
- if share.Password == "" && util.Contains(user.Filters.WebClient, sdk.WebClientShareNoPasswordDisabled) {
+ if share.Password == "" && slices.Contains(user.Filters.WebClient, sdk.WebClientShareNoPasswordDisabled) {
return user, util.NewI18nError(
fmt.Errorf("sharing without a password was disabled: %w", os.ErrPermission),
util.I18nError403Message,
@@ -561,6 +570,7 @@ func validateBrowsableShare(share dataprovider.Share, connection *Connection) er
basePath := share.Paths[0]
info, err := connection.Stat(basePath, 0)
if err != nil {
+ connection.CloseFS() //nolint:errcheck
return util.NewI18nError(
fmt.Errorf("unable to check the share directory: %w", err),
util.I18nErrorShareInvalidPath,
diff --git a/internal/httpd/api_user.go b/internal/httpd/api_user.go
index 7b55082a..4af45f30 100644
--- a/internal/httpd/api_user.go
+++ b/internal/httpd/api_user.go
@@ -27,6 +27,7 @@ import (
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/smtp"
"github.com/drakkan/sftpgo/v2/internal/util"
@@ -39,7 +40,7 @@ func getUsers(w http.ResponseWriter, r *http.Request) {
if err != nil {
return
}
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -55,16 +56,16 @@ func getUsers(w http.ResponseWriter, r *http.Request) {
func getUserByUsername(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
}
username := getURLParam(r, "username")
- renderUser(w, r, username, &claims, http.StatusOK)
+ renderUser(w, r, username, claims, http.StatusOK)
}
-func renderUser(w http.ResponseWriter, r *http.Request, username string, claims *jwtTokenClaims, status int) {
+func renderUser(w http.ResponseWriter, r *http.Request, username string, claims *jwt.Claims, status int) {
user, err := dataprovider.UserExists(username, claims.Role)
if err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
@@ -84,7 +85,7 @@ func renderUser(w http.ResponseWriter, r *http.Request, username string, claims
func addUser(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -117,12 +118,12 @@ func addUser(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Add("Location", fmt.Sprintf("%s/%s", userPath, url.PathEscape(user.Username)))
- renderUser(w, r, user.Username, &claims, http.StatusCreated)
+ renderUser(w, r, user.Username, claims, http.StatusCreated)
}
func disableUser2FA(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -150,7 +151,7 @@ func disableUser2FA(w http.ResponseWriter, r *http.Request) {
func updateUser(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -202,7 +203,7 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
func deleteUser(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -264,7 +265,9 @@ func disconnectUser(username, admin, role string) {
logger.Warn(logSender, "", "unable to disconnect user %q, error getting node %q: %v", username, stat.Node, err)
continue
}
- if err := n.SendDeleteRequest(admin, role, fmt.Sprintf("%s/%s", activeConnectionsPath, stat.ConnectionID)); err != nil {
+ perms := []string{dataprovider.PermAdminCloseConnections}
+ uri := fmt.Sprintf("%s/%s", activeConnectionsPath, stat.ConnectionID)
+ if err := n.SendDeleteRequest(admin, role, uri, perms); err != nil {
logger.Warn(logSender, "", "unable to disconnect user %q from node %q, error: %v", username, n.Name, err)
}
}
@@ -278,6 +281,9 @@ func updateEncryptedSecrets(fsConfig *vfs.Filesystem, currentFsConfig *vfs.Files
if fsConfig.S3Config.AccessSecret.IsNotPlainAndNotEmpty() {
fsConfig.S3Config.AccessSecret = currentFsConfig.S3Config.AccessSecret
}
+ if fsConfig.S3Config.SSECustomerKey.IsNotPlainAndNotEmpty() {
+ fsConfig.S3Config.SSECustomerKey = currentFsConfig.S3Config.SSECustomerKey
+ }
case sdk.AzureBlobFilesystemProvider:
if fsConfig.AzBlobConfig.AccountKey.IsNotPlainAndNotEmpty() {
fsConfig.AzBlobConfig.AccountKey = currentFsConfig.AzBlobConfig.AccountKey
diff --git a/internal/httpd/api_utils.go b/internal/httpd/api_utils.go
index 78d7219f..cef57b53 100644
--- a/internal/httpd/api_utils.go
+++ b/internal/httpd/api_utils.go
@@ -27,6 +27,7 @@ import (
"net/url"
"os"
"path"
+ "slices"
"strconv"
"strings"
"sync"
@@ -36,10 +37,12 @@ import (
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
"github.com/klauspost/compress/zip"
+ "github.com/rs/xid"
"github.com/sftpgo/sdk/plugin/notifier"
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/metric"
"github.com/drakkan/sftpgo/v2/internal/plugin"
@@ -70,8 +73,9 @@ type adminProfile struct {
type userProfile struct {
baseProfile
- PublicKeys []string `json:"public_keys,omitempty"`
- TLSCerts []string `json:"tls_certs,omitempty"`
+ AdditionalEmails []string `json:"additional_emails,omitempty"`
+ PublicKeys []string `json:"public_keys,omitempty"`
+ TLSCerts []string `json:"tls_certs,omitempty"`
}
func sendAPIResponse(w http.ResponseWriter, r *http.Request, err error, message string, code int) {
@@ -158,7 +162,7 @@ func getURLPath(r *http.Request) string {
func getCommaSeparatedQueryParam(r *http.Request, key string) []string {
var result []string
- for _, val := range strings.Split(r.URL.Query().Get(key), ",") {
+ for val := range strings.SplitSeq(r.URL.Query().Get(key), ",") {
val = strings.TrimSpace(val)
if val != "" {
result = append(result, val)
@@ -174,7 +178,7 @@ func getBoolQueryParam(r *http.Request, param string) bool {
func getActiveConnections(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -188,7 +192,7 @@ func getActiveConnections(w http.ResponseWriter, r *http.Request) {
func handleCloseConnection(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -214,7 +218,9 @@ func handleCloseConnection(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, nil, http.StatusText(status), status)
return
}
- if err := n.SendDeleteRequest(claims.Username, claims.Role, fmt.Sprintf("%s/%s", activeConnectionsPath, connectionID)); err != nil {
+ perms := []string{dataprovider.PermAdminCloseConnections}
+ uri := fmt.Sprintf("%s/%s", activeConnectionsPath, connectionID)
+ if err := n.SendDeleteRequest(claims.Username, claims.Role, uri, perms); err != nil {
logger.Warn(logSender, "", "unable to delete connection id %q from node %q: %v", connectionID, n.Name, err)
sendAPIResponse(w, r, nil, "Not Found", http.StatusNotFound)
return
@@ -240,7 +246,8 @@ func getNodesConnections(admin, role string) []common.ConnectionStatus {
defer wg.Done()
var stats []common.ConnectionStatus
- if err := node.SendGetRequest(admin, role, activeConnectionsPath, &stats); err != nil {
+ perms := []string{dataprovider.PermAdminViewConnections}
+ if err := node.SendGetRequest(admin, role, activeConnectionsPath, perms, &stats); err != nil {
logger.Warn(logSender, "", "unable to get connections from node %s: %v", node.Name, err)
return
}
@@ -362,6 +369,16 @@ func streamJSONArray(w http.ResponseWriter, chunkSize int, dataGetter func(limit
streamData(w, []byte("]"))
}
+func renderPNGImage(w http.ResponseWriter, r *http.Request, b []byte) {
+ if len(b) == 0 {
+ ctx := context.WithValue(r.Context(), render.StatusCtxKey, http.StatusNotFound)
+ render.PlainText(w, r.WithContext(ctx), http.StatusText(http.StatusNotFound))
+ return
+ }
+ w.Header().Set("Content-Type", "image/png")
+ streamData(w, b)
+}
+
func getCompressedFileName(username string, files []string) string {
if len(files) == 1 {
name := path.Base(files[0])
@@ -383,7 +400,7 @@ func renderCompressedFiles(w http.ResponseWriter, conn *Connection, baseDir stri
for _, file := range files {
fullPath := util.CleanPath(path.Join(baseDir, file))
- if err := addZipEntry(wr, conn, fullPath, baseDir, 0); err != nil {
+ if err := addZipEntry(wr, conn, fullPath, baseDir, nil, 0); err != nil {
if share != nil {
dataprovider.UpdateShareLastUse(share, -1) //nolint:errcheck
}
@@ -399,16 +416,19 @@ func renderCompressedFiles(w http.ResponseWriter, conn *Connection, baseDir stri
}
}
-func addZipEntry(wr *zip.Writer, conn *Connection, entryPath, baseDir string, recursion int) error {
+func addZipEntry(wr *zip.Writer, conn *Connection, entryPath, baseDir string, info os.FileInfo, recursion int) error {
if recursion >= util.MaxRecursion {
conn.Log(logger.LevelDebug, "unable to add zip entry %q, recursion too depth: %d", entryPath, recursion)
return util.ErrRecursionTooDeep
}
recursion++
- info, err := conn.Stat(entryPath, 1)
- if err != nil {
- conn.Log(logger.LevelDebug, "unable to add zip entry %q, stat error: %v", entryPath, err)
- return err
+ var err error
+ if info == nil {
+ info, err = conn.Stat(entryPath, 1)
+ if err != nil {
+ conn.Log(logger.LevelDebug, "unable to add zip entry %q, stat error: %v", entryPath, err)
+ return err
+ }
}
entryName, err := getZipEntryName(entryPath, baseDir)
if err != nil {
@@ -440,7 +460,7 @@ func addZipEntry(wr *zip.Writer, conn *Connection, entryPath, baseDir string, re
}
for _, info := range contents {
fullPath := util.CleanPath(path.Join(entryPath, info.Name()))
- if err := addZipEntry(wr, conn, fullPath, baseDir, recursion); err != nil {
+ if err := addZipEntry(wr, conn, fullPath, baseDir, info, recursion); err != nil {
return err
}
}
@@ -686,10 +706,11 @@ func handleDefenderEventLoginFailed(ipAddr string, err error) error {
err = dataprovider.ErrInvalidCredentials
}
common.AddDefenderEvent(ipAddr, common.ProtocolHTTP, event)
+ common.DelayLogin(err)
return err
}
-func updateLoginMetrics(user *dataprovider.User, loginMethod, ip string, err error) {
+func updateLoginMetrics(user *dataprovider.User, loginMethod, ip string, err error, r *http.Request) {
metric.AddLoginAttempt(loginMethod)
var protocol string
switch loginMethod {
@@ -699,7 +720,9 @@ func updateLoginMetrics(user *dataprovider.User, loginMethod, ip string, err err
protocol = common.ProtocolHTTP
}
if err == nil {
+ logger.LoginLog(user.Username, ip, loginMethod, protocol, "", r.UserAgent(), r.TLS != nil, "")
plugin.Handler.NotifyLogEvent(notifier.LogEventTypeLoginOK, protocol, user.Username, ip, "", nil)
+ common.DelayLogin(nil)
} else if err != common.ErrInternalFailure && err != common.ErrNoCredentials {
logger.ConnectionFailedLog(user.Username, ip, loginMethod, protocol, err.Error())
err = handleDefenderEventLoginFailed(ip, err)
@@ -713,15 +736,15 @@ func updateLoginMetrics(user *dataprovider.User, loginMethod, ip string, err err
dataprovider.ExecutePostLoginHook(user, loginMethod, ip, protocol, err)
}
-func checkHTTPClientUser(user *dataprovider.User, r *http.Request, connectionID string, checkSessions bool) error {
- if util.Contains(user.Filters.DeniedProtocols, common.ProtocolHTTP) {
+func checkHTTPClientUser(user *dataprovider.User, r *http.Request, connectionID string, checkSessions, isOIDCLogin bool) error {
+ if slices.Contains(user.Filters.DeniedProtocols, common.ProtocolHTTP) {
logger.Info(logSender, connectionID, "cannot login user %q, protocol HTTP is not allowed", user.Username)
return util.NewI18nError(
fmt.Errorf("protocol HTTP is not allowed for user %q", user.Username),
util.I18nErrorProtocolForbidden,
)
}
- if !isLoggedInWithOIDC(r) && !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, common.ProtocolHTTP) {
+ if !isLoggedInWithOIDC(r) && !isOIDCLogin && !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, common.ProtocolHTTP) {
logger.Info(logSender, connectionID, "cannot login user %q, password login method is not allowed", user.Username)
return util.NewI18nError(
fmt.Errorf("login method password is not allowed for user %q", user.Username),
@@ -746,8 +769,34 @@ func checkHTTPClientUser(user *dataprovider.User, r *http.Request, connectionID
return nil
}
+func getActiveAdmin(username, ipAddr string) (dataprovider.Admin, error) {
+ admin, err := dataprovider.AdminExists(username)
+ if err != nil {
+ return admin, err
+ }
+ if err := admin.CanLogin(ipAddr); err != nil {
+ return admin, util.NewRecordNotFoundError(fmt.Sprintf("admin %q cannot login: %v", username, err))
+ }
+ return admin, nil
+}
+
+func getActiveUser(username string, r *http.Request) (dataprovider.User, error) {
+ user, err := dataprovider.GetUserWithGroupSettings(username, "")
+ if err != nil {
+ return user, err
+ }
+ if err := user.CheckLoginConditions(); err != nil {
+ return user, util.NewRecordNotFoundError(fmt.Sprintf("user %q cannot login: %v", username, err))
+ }
+ if err := checkHTTPClientUser(&user, r, xid.New().String(), false, false); err != nil {
+ return user, util.NewRecordNotFoundError(fmt.Sprintf("user %q cannot login: %v", username, err))
+ }
+ return user, nil
+}
+
func handleForgotPassword(r *http.Request, username string, isAdmin bool) error {
- var email, subject string
+ var emails []string
+ var subject string
var err error
var admin dataprovider.Admin
var user dataprovider.User
@@ -756,12 +805,14 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
return util.NewI18nError(util.NewValidationError("username is mandatory"), util.I18nErrorUsernameRequired)
}
if isAdmin {
- admin, err = dataprovider.AdminExists(username)
- email = admin.Email
+ admin, err = getActiveAdmin(username, util.GetIPFromRemoteAddress(r.RemoteAddr))
+ if admin.Email != "" {
+ emails = []string{admin.Email}
+ }
subject = fmt.Sprintf("Email Verification Code for admin %q", username)
} else {
- user, err = dataprovider.GetUserWithGroupSettings(username, "")
- email = user.Email
+ user, err = getActiveUser(username, r)
+ emails = user.GetEmailAddresses()
subject = fmt.Sprintf("Email Verification Code for user %q", username)
if err == nil {
if !isUserAllowedToResetPassword(r, &user) {
@@ -775,13 +826,14 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
if err != nil {
if errors.Is(err, util.ErrNotFound) {
handleDefenderEventLoginFailed(util.GetIPFromRemoteAddress(r.RemoteAddr), err) //nolint:errcheck
- logger.Debug(logSender, middleware.GetReqID(r.Context()), "username %q does not exists, reset password request silently ignored, is admin? %v",
- username, isAdmin)
+ logger.Debug(logSender, middleware.GetReqID(r.Context()),
+ "username %q does not exists or cannot login, reset password request silently ignored, is admin? %t, err: %v",
+ username, isAdmin, err)
return nil
}
return util.NewI18nError(util.NewGenericError("Error retrieving your account, please try again later"), util.I18nErrorGetUser)
}
- if email == "" {
+ if len(emails) == 0 {
return util.NewI18nError(
util.NewValidationError("Your account does not have an email address, it is not possible to reset your password by sending an email verification code"),
util.I18nErrorPwdResetNoEmail,
@@ -796,7 +848,7 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
return util.NewGenericError("Unable to render password reset template")
}
startTime := time.Now()
- if err := smtp.SendEmail([]string{email}, nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
+ if err := smtp.SendEmail(emails, nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
logger.Warn(logSender, middleware.GetReqID(r.Context()), "unable to send password reset code via email: %v, elapsed: %v",
err, time.Since(startTime))
return util.NewI18nError(
@@ -804,8 +856,8 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
util.I18nErrorPwdResetSendEmail,
)
}
- logger.Debug(logSender, middleware.GetReqID(r.Context()), "reset code sent via email to %q, email: %q, is admin? %v, elapsed: %v",
- username, email, isAdmin, time.Since(startTime))
+ logger.Debug(logSender, middleware.GetReqID(r.Context()), "reset code sent via email to %q, emails: %+v, is admin? %v, elapsed: %v",
+ username, emails, isAdmin, time.Since(startTime))
return resetCodesMgr.Add(c)
}
@@ -836,7 +888,7 @@ func handleResetPassword(r *http.Request, code, newPassword, confirmPassword str
return &admin, &user, util.NewValidationError("invalid confirmation code")
}
if isAdmin {
- admin, err = dataprovider.AdminExists(resetCode.Username)
+ admin, err = getActiveAdmin(resetCode.Username, ipAddr)
if err != nil {
return &admin, &user, util.NewValidationError("unable to associate the confirmation code with an existing admin")
}
@@ -849,7 +901,7 @@ func handleResetPassword(r *http.Request, code, newPassword, confirmPassword str
err = resetCodesMgr.Delete(code)
return &admin, &user, err
}
- user, err = dataprovider.GetUserWithGroupSettings(resetCode.Username, "")
+ user, err = getActiveUser(resetCode.Username, r)
if err != nil {
return &admin, &user, util.NewValidationError("Unable to associate the confirmation code with an existing user")
}
@@ -873,7 +925,7 @@ func isUserAllowedToResetPassword(r *http.Request, user *dataprovider.User) bool
if !user.CanResetPassword() {
return false
}
- if util.Contains(user.Filters.DeniedProtocols, common.ProtocolHTTP) {
+ if slices.Contains(user.Filters.DeniedProtocols, common.ProtocolHTTP) {
return false
}
if !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, common.ProtocolHTTP) {
@@ -892,9 +944,18 @@ func getProtocolFromRequest(r *http.Request) string {
return common.ProtocolHTTP
}
-func hideConfidentialData(claims *jwtTokenClaims, r *http.Request) bool {
- if !claims.hasPerm(dataprovider.PermAdminManageSystem) {
+func hideConfidentialData(claims *jwt.Claims, r *http.Request) bool {
+ if !claims.HasPerm(dataprovider.PermAdminAny) {
return true
}
return r.URL.Query().Get("confidential_data") != "1"
}
+
+func responseControllerDeadlines(rc *http.ResponseController, read, write time.Time) {
+ if err := rc.SetReadDeadline(read); err != nil {
+ logger.Error(logSender, "", "unable to set read timeout to %s: %v", read, err)
+ }
+ if err := rc.SetWriteDeadline(write); err != nil {
+ logger.Error(logSender, "", "unable to set write timeout to %s: %v", write, err)
+ }
+}
diff --git a/internal/httpd/auth_utils.go b/internal/httpd/auth_utils.go
index 4650e2f6..ecd9ce86 100644
--- a/internal/httpd/auth_utils.go
+++ b/internal/httpd/auth_utils.go
@@ -15,16 +15,14 @@
package httpd
import (
+ "crypto/rand"
"errors"
"fmt"
"net/http"
"time"
- "github.com/go-chi/jwtauth/v5"
- "github.com/lestrrat-go/jwx/v2/jwt"
- "github.com/rs/xid"
-
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/util"
)
@@ -41,256 +39,123 @@ const (
tokenAudienceAPIUser tokenAudience = "APIUser"
tokenAudienceCSRF tokenAudience = "CSRF"
tokenAudienceOAuth2 tokenAudience = "OAuth2"
-)
-
-type tokenValidation = int
-
-const (
- tokenValidationFull = iota
- tokenValidationNoIPMatch tokenValidation = iota
+ tokenAudienceWebLogin tokenAudience = "WebLogin"
)
const (
- claimUsernameKey = "username"
- claimPermissionsKey = "permissions"
- claimRole = "role"
- claimAPIKey = "api_key"
- claimNodeID = "node_id"
- claimMustChangePasswordKey = "chpwd"
- claimMustSetSecondFactorKey = "2fa_required"
- claimRequiredTwoFactorProtocols = "2fa_protos"
- claimHideUserPageSection = "hus"
- basicRealm = "Basic realm=\"SFTPGo\""
- jwtCookieKey = "jwt"
+ tokenValidationModeDefault = 0
+ tokenValidationModeNoIPMatch = 1
+ tokenValidationModeUserSignature = 2
+)
+
+const (
+ basicRealm = "Basic realm=\"SFTPGo\""
)
var (
- tokenDuration = 20 * time.Minute
- shareTokenDuration = 12 * time.Hour
+ apiTokenDuration = 20 * time.Minute
+ cookieTokenDuration = 20 * time.Minute
+ shareTokenDuration = 2 * time.Hour
// csrf token duration is greater than normal token duration to reduce issues
// with the login form
- csrfTokenDuration = 6 * time.Hour
- tokenRefreshThreshold = 10 * time.Minute
- tokenValidationMode = tokenValidationFull
+ csrfTokenDuration = 4 * time.Hour
+ cookieRefreshThreshold = 10 * time.Minute
+ maxTokenDuration = 12 * time.Hour
+ tokenValidationMode = tokenValidationModeDefault
)
-type jwtTokenClaims struct {
- Username string
- Permissions []string
- Role string
- Signature string
- Audience []string
- APIKeyID string
- NodeID string
- MustSetTwoFactorAuth bool
- MustChangePassword bool
- RequiredTwoFactorProtocols []string
- HideUserPageSections int
+func isTokenDurationValid(minutes int) bool {
+ return minutes >= 1 && minutes <= 720
}
-func (c *jwtTokenClaims) hasUserAudience() bool {
- for _, audience := range c.Audience {
- if audience == tokenAudienceWebClient || audience == tokenAudienceAPIUser {
- return true
+func updateTokensDuration(api, cookie, share int) {
+ if isTokenDurationValid(api) {
+ apiTokenDuration = time.Duration(api) * time.Minute
+ }
+ if isTokenDurationValid(cookie) {
+ cookieTokenDuration = time.Duration(cookie) * time.Minute
+ cookieRefreshThreshold = cookieTokenDuration / 2
+ if cookieTokenDuration > csrfTokenDuration {
+ csrfTokenDuration = cookieTokenDuration
}
}
-
- return false
+ if isTokenDurationValid(share) {
+ shareTokenDuration = time.Duration(share) * time.Minute
+ }
+ logger.Debug(logSender, "", "API token duration %s, cookie token duration %s, cookie refresh threshold %s, share token duration %s, csrf token duration %s",
+ apiTokenDuration, cookieTokenDuration, cookieRefreshThreshold, shareTokenDuration, csrfTokenDuration)
}
-func (c *jwtTokenClaims) asMap() map[string]any {
- claims := make(map[string]any)
-
- claims[claimUsernameKey] = c.Username
- claims[claimPermissionsKey] = c.Permissions
- if c.Role != "" {
- claims[claimRole] = c.Role
- }
- if c.APIKeyID != "" {
- claims[claimAPIKey] = c.APIKeyID
- }
- if c.NodeID != "" {
- claims[claimNodeID] = c.NodeID
- }
- claims[jwt.SubjectKey] = c.Signature
- if c.MustChangePassword {
- claims[claimMustChangePasswordKey] = c.MustChangePassword
- }
- if c.MustSetTwoFactorAuth {
- claims[claimMustSetSecondFactorKey] = c.MustSetTwoFactorAuth
- }
- if len(c.RequiredTwoFactorProtocols) > 0 {
- claims[claimRequiredTwoFactorProtocols] = c.RequiredTwoFactorProtocols
- }
- if c.HideUserPageSections > 0 {
- claims[claimHideUserPageSection] = c.HideUserPageSections
- }
-
- return claims
-}
-
-func (c *jwtTokenClaims) decodeSliceString(val any) []string {
- switch v := val.(type) {
- case []any:
- result := make([]string, 0, len(v))
- for _, elem := range v {
- switch elemValue := elem.(type) {
- case string:
- result = append(result, elemValue)
- }
- }
- return result
- case []string:
- return v
+func getTokenDuration(audience tokenAudience) time.Duration {
+ switch audience {
+ case tokenAudienceWebShare:
+ return shareTokenDuration
+ case tokenAudienceWebLogin, tokenAudienceCSRF:
+ return csrfTokenDuration
+ case tokenAudienceAPI, tokenAudienceAPIUser:
+ return apiTokenDuration
+ case tokenAudienceWebAdmin, tokenAudienceWebClient:
+ return cookieTokenDuration
+ case tokenAudienceWebAdminPartial, tokenAudienceWebClientPartial, tokenAudienceOAuth2:
+ return 5 * time.Minute
default:
- return nil
+ logger.Error(logSender, "", "token duration not handled for audience: %q", audience)
+ return 20 * time.Minute
}
}
-func (c *jwtTokenClaims) decodeBoolean(val any) bool {
- switch v := val.(type) {
- case bool:
- return v
- default:
- return false
+func getMaxCookieDuration() time.Duration {
+ result := csrfTokenDuration
+ if shareTokenDuration > result {
+ result = shareTokenDuration
}
+ if cookieTokenDuration > result {
+ result = cookieTokenDuration
+ }
+ return result
}
-func (c *jwtTokenClaims) decodeString(val any) string {
- switch v := val.(type) {
- case string:
- return v
- default:
- return ""
- }
+func hasUserAudience(claims *jwt.Claims) bool {
+ return claims.HasAnyAudience([]string{tokenAudienceWebClient, tokenAudienceAPIUser})
}
-func (c *jwtTokenClaims) Decode(token map[string]any) {
- c.Permissions = nil
- c.Username = c.decodeString(token[claimUsernameKey])
- c.Signature = c.decodeString(token[jwt.SubjectKey])
-
- audience := token[jwt.AudienceKey]
- switch v := audience.(type) {
- case []string:
- c.Audience = v
- }
-
- if val, ok := token[claimAPIKey]; ok {
- c.APIKeyID = c.decodeString(val)
- }
-
- if val, ok := token[claimNodeID]; ok {
- c.NodeID = c.decodeString(val)
- }
-
- if val, ok := token[claimRole]; ok {
- c.Role = c.decodeString(val)
- }
-
- permissions := token[claimPermissionsKey]
- c.Permissions = c.decodeSliceString(permissions)
-
- if val, ok := token[claimMustChangePasswordKey]; ok {
- c.MustChangePassword = c.decodeBoolean(val)
- }
-
- if val, ok := token[claimMustSetSecondFactorKey]; ok {
- c.MustSetTwoFactorAuth = c.decodeBoolean(val)
- }
-
- if val, ok := token[claimRequiredTwoFactorProtocols]; ok {
- c.RequiredTwoFactorProtocols = c.decodeSliceString(val)
- }
-
- if val, ok := token[claimHideUserPageSection]; ok {
- switch v := val.(type) {
- case float64:
- c.HideUserPageSections = int(v)
- }
- }
-}
-
-func (c *jwtTokenClaims) isCriticalPermRemoved(permissions []string) bool {
- if util.Contains(permissions, dataprovider.PermAdminAny) {
- return false
- }
- if (util.Contains(c.Permissions, dataprovider.PermAdminManageAdmins) ||
- util.Contains(c.Permissions, dataprovider.PermAdminAny)) &&
- !util.Contains(permissions, dataprovider.PermAdminManageAdmins) &&
- !util.Contains(permissions, dataprovider.PermAdminAny) {
- return true
- }
- return false
-}
-
-func (c *jwtTokenClaims) hasPerm(perm string) bool {
- if util.Contains(c.Permissions, dataprovider.PermAdminAny) {
- return true
- }
-
- return util.Contains(c.Permissions, perm)
-}
-
-func (c *jwtTokenClaims) createToken(tokenAuth *jwtauth.JWTAuth, audience tokenAudience, ip string) (jwt.Token, string, error) {
- claims := c.asMap()
- now := time.Now().UTC()
-
- claims[jwt.JwtIDKey] = xid.New().String()
- claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second)
- claims[jwt.ExpirationKey] = now.Add(tokenDuration)
- claims[jwt.AudienceKey] = []string{audience, ip}
-
- return tokenAuth.Encode(claims)
-}
-
-func (c *jwtTokenClaims) createTokenResponse(tokenAuth *jwtauth.JWTAuth, audience tokenAudience, ip string) (map[string]any, error) {
- token, tokenString, err := c.createToken(tokenAuth, audience, ip)
- if err != nil {
- return nil, err
- }
-
- response := make(map[string]any)
- response["access_token"] = tokenString
- response["expires_at"] = token.Expiration().Format(time.RFC3339)
-
- return response, nil
-}
-
-func (c *jwtTokenClaims) createAndSetCookie(w http.ResponseWriter, r *http.Request, tokenAuth *jwtauth.JWTAuth,
+func createAndSetCookie(w http.ResponseWriter, r *http.Request, claims *jwt.Claims, tokenAuth *jwt.Signer,
audience tokenAudience, ip string,
) error {
- resp, err := c.createTokenResponse(tokenAuth, audience, ip)
+ duration := getTokenDuration(audience)
+ token, err := tokenAuth.SignWithParams(claims, audience, ip, duration)
if err != nil {
return err
}
+ resp := claims.BuildTokenResponse(token)
var basePath string
if audience == tokenAudienceWebAdmin || audience == tokenAudienceWebAdminPartial {
basePath = webBaseAdminPath
} else {
basePath = webBaseClientPath
}
- duration := tokenDuration
- if audience == tokenAudienceWebShare {
- duration = shareTokenDuration
- }
+ setCookie(w, r, basePath, resp.Token, duration)
+
+ return nil
+}
+
+func setCookie(w http.ResponseWriter, r *http.Request, cookiePath, cookieValue string, duration time.Duration) {
http.SetCookie(w, &http.Cookie{
- Name: jwtCookieKey,
- Value: resp["access_token"].(string),
- Path: basePath,
+ Name: jwt.CookieKey,
+ Value: cookieValue,
+ Path: cookiePath,
Expires: time.Now().Add(duration),
MaxAge: int(duration / time.Second),
HttpOnly: true,
Secure: isTLS(r),
SameSite: http.SameSiteStrictMode,
})
-
- return nil
}
-func (c *jwtTokenClaims) removeCookie(w http.ResponseWriter, r *http.Request, cookiePath string) {
+func removeCookie(w http.ResponseWriter, r *http.Request, cookiePath string) {
+ invalidateToken(r)
http.SetCookie(w, &http.Cookie{
- Name: jwtCookieKey,
+ Name: jwt.CookieKey,
Value: "",
Path: cookiePath,
Expires: time.Unix(0, 0),
@@ -300,10 +165,9 @@ func (c *jwtTokenClaims) removeCookie(w http.ResponseWriter, r *http.Request, co
SameSite: http.SameSiteStrictMode,
})
w.Header().Add("Cache-Control", `no-cache="Set-Cookie"`)
- invalidateToken(r)
}
-func tokenFromContext(r *http.Request) string {
+func oidcTokenFromContext(r *http.Request) string {
if token, ok := r.Context().Value(oidcGeneratedToken).(string); ok {
return token
}
@@ -315,16 +179,16 @@ func isTLS(r *http.Request) bool {
return true
}
if proto, ok := r.Context().Value(forwardedProtoKey).(string); ok {
- return proto == "https"
+ return proto == "https" //nolint:goconst
}
return false
}
func isTokenInvalidated(r *http.Request) bool {
var findTokenFns []func(r *http.Request) string
- findTokenFns = append(findTokenFns, jwtauth.TokenFromHeader)
- findTokenFns = append(findTokenFns, jwtauth.TokenFromCookie)
- findTokenFns = append(findTokenFns, tokenFromContext)
+ findTokenFns = append(findTokenFns, jwt.TokenFromHeader)
+ findTokenFns = append(findTokenFns, jwt.TokenFromCookie)
+ findTokenFns = append(findTokenFns, oidcTokenFromContext)
isTokenFound := false
for _, fn := range findTokenFns {
@@ -341,55 +205,78 @@ func isTokenInvalidated(r *http.Request) bool {
}
func invalidateToken(r *http.Request) {
- tokenString := jwtauth.TokenFromHeader(r)
+ tokenString := jwt.TokenFromHeader(r)
if tokenString != "" {
- invalidatedJWTTokens.Add(tokenString, time.Now().Add(tokenDuration).UTC())
+ invalidateTokenString(r, tokenString, apiTokenDuration)
}
- tokenString = jwtauth.TokenFromCookie(r)
+ tokenString = jwt.TokenFromCookie(r)
if tokenString != "" {
- invalidatedJWTTokens.Add(tokenString, time.Now().Add(tokenDuration).UTC())
+ invalidateTokenString(r, tokenString, getMaxCookieDuration())
}
}
+func invalidateTokenString(r *http.Request, tokenString string, fallbackDuration time.Duration) {
+ token, err := jwt.FromContext(r.Context())
+ if err != nil {
+ invalidatedJWTTokens.Add(tokenString, time.Now().Add(fallbackDuration).UTC())
+ return
+ }
+ invalidatedJWTTokens.Add(tokenString, token.Expiry.Time().Add(1*time.Minute).UTC())
+}
+
func getUserFromToken(r *http.Request) *dataprovider.User {
user := &dataprovider.User{}
- _, claims, err := jwtauth.FromContext(r.Context())
+ claims, err := jwt.FromContext(r.Context())
if err != nil {
return user
}
- tokenClaims := jwtTokenClaims{}
- tokenClaims.Decode(claims)
- user.Username = tokenClaims.Username
- user.Filters.WebClient = tokenClaims.Permissions
- user.Role = tokenClaims.Role
+ user.Username = claims.Username
+ user.Filters.WebClient = claims.Permissions
+ user.Role = claims.Role
return user
}
func getAdminFromToken(r *http.Request) *dataprovider.Admin {
admin := &dataprovider.Admin{}
- _, claims, err := jwtauth.FromContext(r.Context())
+ claims, err := jwt.FromContext(r.Context())
if err != nil {
return admin
}
- tokenClaims := jwtTokenClaims{}
- tokenClaims.Decode(claims)
- admin.Username = tokenClaims.Username
- admin.Permissions = tokenClaims.Permissions
- admin.Filters.Preferences.HideUserPageSections = tokenClaims.HideUserPageSections
- admin.Role = tokenClaims.Role
+ admin.Username = claims.Username
+ admin.Permissions = claims.Permissions
+ admin.Filters.Preferences.HideUserPageSections = claims.HideUserPageSections
+ admin.Role = claims.Role
return admin
}
-func createCSRFToken(ip string) string {
- claims := make(map[string]any)
- now := time.Now().UTC()
+func createLoginCookie(w http.ResponseWriter, r *http.Request, csrfTokenAuth *jwt.Signer, tokenID, basePath, ip string,
+) {
+ c := jwt.NewClaims(tokenAudienceWebLogin, ip, getTokenDuration(tokenAudienceWebLogin))
+ c.ID = tokenID
+ resp, err := c.GenerateTokenResponse(csrfTokenAuth)
+ if err != nil {
+ return
+ }
+ setCookie(w, r, basePath, resp.Token, csrfTokenDuration)
+}
- claims[jwt.JwtIDKey] = xid.New().String()
- claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second)
- claims[jwt.ExpirationKey] = now.Add(csrfTokenDuration)
- claims[jwt.AudienceKey] = []string{tokenAudienceCSRF, ip}
-
- _, tokenString, err := csrfTokenAuth.Encode(claims)
+func createCSRFToken(w http.ResponseWriter, r *http.Request, csrfTokenAuth *jwt.Signer, tokenID,
+ basePath string,
+) string {
+ ip := util.GetIPFromRemoteAddress(r.RemoteAddr)
+ claims := jwt.NewClaims(tokenAudienceCSRF, ip, csrfTokenDuration)
+ claims.ID = rand.Text()
+ if tokenID != "" {
+ createLoginCookie(w, r, csrfTokenAuth, tokenID, basePath, ip)
+ claims.Ref = tokenID
+ } else {
+ if c, err := jwt.FromContext(r.Context()); err == nil {
+ claims.Ref = c.ID
+ } else {
+ logger.Error(logSender, "", "unable to add reference to CSRF token: %v", err)
+ }
+ }
+ tokenString, err := csrfTokenAuth.Sign(claims)
if err != nil {
logger.Debug(logSender, "", "unable to create CSRF token: %v", err)
return ""
@@ -397,38 +284,80 @@ func createCSRFToken(ip string) string {
return tokenString
}
-func verifyCSRFToken(tokenString, ip string) error {
- token, err := jwtauth.VerifyToken(csrfTokenAuth, tokenString)
+func verifyCSRFToken(r *http.Request, csrfTokenAuth *jwt.Signer) error {
+ tokenString := r.Form.Get(csrfFormToken)
+ token, err := jwt.VerifyToken(csrfTokenAuth, tokenString)
if err != nil || token == nil {
logger.Debug(logSender, "", "error validating CSRF token %q: %v", tokenString, err)
return fmt.Errorf("unable to verify form token: %v", err)
}
- if !util.Contains(token.Audience(), tokenAudienceCSRF) {
+ if !token.Audience.Contains(tokenAudienceCSRF) {
logger.Debug(logSender, "", "error validating CSRF token audience")
return errors.New("the form token is not valid")
}
- if tokenValidationMode != tokenValidationNoIPMatch {
- if !util.Contains(token.Audience(), ip) {
- logger.Debug(logSender, "", "error validating CSRF token IP audience")
- return errors.New("the form token is not valid")
- }
+ if err := validateIPForToken(token, util.GetIPFromRemoteAddress(r.RemoteAddr)); err != nil {
+ logger.Debug(logSender, "", "error validating CSRF token IP audience")
+ return errors.New("the form token is not valid")
+ }
+ return checkCSRFTokenRef(r, token)
+}
+
+func checkCSRFTokenRef(r *http.Request, token *jwt.Claims) error {
+ claims, err := jwt.FromContext(r.Context())
+ if err != nil {
+ logger.Debug(logSender, "", "error getting token claims for CSRF validation: %v", err)
+ return err
+ }
+ if token.ID == "" {
+ logger.Debug(logSender, "", "error validating CSRF token, missing reference")
+ return errors.New("the form token is not valid")
+ }
+ if claims.ID != token.Ref {
+ logger.Debug(logSender, "", "error validating CSRF reference, id %q, reference %q", claims.ID, token.ID)
+ return errors.New("unexpected form token")
}
return nil
}
-func createOAuth2Token(state, ip string) string {
- claims := make(map[string]any)
- now := time.Now().UTC()
+func verifyLoginCookie(r *http.Request) error {
+ token, err := jwt.FromContext(r.Context())
+ if err != nil {
+ logger.Debug(logSender, "", "error getting login token: %v", err)
+ return errInvalidToken
+ }
+ if isTokenInvalidated(r) {
+ logger.Debug(logSender, "", "the login token has been invalidated")
+ return errInvalidToken
+ }
+ if !token.Audience.Contains(tokenAudienceWebLogin) {
+ logger.Debug(logSender, "", "the token with id %q is not valid for audience %q", token.ID, tokenAudienceWebLogin)
+ return errInvalidToken
+ }
+ ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
+ if err := validateIPForToken(token, ipAddr); err != nil {
+ return err
+ }
+ return nil
+}
- claims[jwt.JwtIDKey] = state
- claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second)
- claims[jwt.ExpirationKey] = now.Add(3 * time.Minute)
- claims[jwt.AudienceKey] = []string{tokenAudienceOAuth2, ip}
+func verifyLoginCookieAndCSRFToken(r *http.Request, csrfTokenAuth *jwt.Signer) error {
+ if err := verifyLoginCookie(r); err != nil {
+ return err
+ }
+ if err := verifyCSRFToken(r, csrfTokenAuth); err != nil {
+ return err
+ }
+ return nil
+}
- _, tokenString, err := csrfTokenAuth.Encode(claims)
+func createOAuth2Token(csrfTokenAuth *jwt.Signer, state, ip string) string {
+ claims := jwt.NewClaims(tokenAudienceOAuth2, ip, getTokenDuration(tokenAudienceOAuth2))
+ claims.ID = state
+
+ tokenString, err := csrfTokenAuth.Sign(claims)
if err != nil {
logger.Debug(logSender, "", "unable to create OAuth2 token: %v", err)
return ""
@@ -436,8 +365,8 @@ func createOAuth2Token(state, ip string) string {
return tokenString
}
-func verifyOAuth2Token(tokenString, ip string) (string, error) {
- token, err := jwtauth.VerifyToken(csrfTokenAuth, tokenString)
+func verifyOAuth2Token(csrfTokenAuth *jwt.Signer, tokenString, ip string) (string, error) {
+ token, err := jwt.VerifyToken(csrfTokenAuth, tokenString)
if err != nil || token == nil {
logger.Debug(logSender, "", "error validating OAuth2 token %q: %v", tokenString, err)
return "", util.NewI18nError(
@@ -446,22 +375,62 @@ func verifyOAuth2Token(tokenString, ip string) (string, error) {
)
}
- if !util.Contains(token.Audience(), tokenAudienceOAuth2) {
+ if !token.Audience.Contains(tokenAudienceOAuth2) {
logger.Debug(logSender, "", "error validating OAuth2 token audience")
return "", util.NewI18nError(errors.New("invalid OAuth2 state"), util.I18nOAuth2InvalidState)
}
- if tokenValidationMode != tokenValidationNoIPMatch {
- if !util.Contains(token.Audience(), ip) {
- logger.Debug(logSender, "", "error validating OAuth2 token IP audience")
- return "", util.NewI18nError(errors.New("invalid OAuth2 state"), util.I18nOAuth2InvalidState)
- }
+ if err := validateIPForToken(token, ip); err != nil {
+ logger.Debug(logSender, "", "error validating OAuth2 token IP audience")
+ return "", util.NewI18nError(errors.New("invalid OAuth2 state"), util.I18nOAuth2InvalidState)
}
- if val, ok := token.Get(jwt.JwtIDKey); ok {
- if state, ok := val.(string); ok {
- return state, nil
- }
+ if token.ID != "" {
+ return token.ID, nil
}
logger.Debug(logSender, "", "jti not found in OAuth2 token")
return "", util.NewI18nError(errors.New("invalid OAuth2 state"), util.I18nOAuth2InvalidState)
}
+
+func validateIPForToken(token *jwt.Claims, ip string) error {
+ if tokenValidationMode&tokenValidationModeNoIPMatch == 0 {
+ if !token.Audience.Contains(ip) {
+ return errInvalidToken
+ }
+ }
+ return nil
+}
+
+func checkTokenSignature(r *http.Request, token *jwt.Claims) error {
+ if _, ok := r.Context().Value(oidcTokenKey).(string); ok {
+ return nil
+ }
+ var err error
+ if tokenValidationMode&tokenValidationModeUserSignature != 0 {
+ for _, audience := range token.Audience {
+ switch audience {
+ case tokenAudienceAPI, tokenAudienceWebAdmin:
+ err = validateSignatureForToken(token, dataprovider.GetAdminSignature)
+ case tokenAudienceAPIUser, tokenAudienceWebClient:
+ err = validateSignatureForToken(token, dataprovider.GetUserSignature)
+ }
+ }
+ }
+ if err != nil {
+ invalidateToken(r)
+ }
+ return err
+}
+
+func validateSignatureForToken(token *jwt.Claims, getter func(string) (string, error)) error {
+ signature, err := getter(token.Username)
+ if err != nil {
+ logger.Debug(logSender, "", "unable to get signature for username %q: %v", token.Username, err)
+ return errInvalidToken
+ }
+ if signature != "" && signature == token.Subject {
+ return nil
+ }
+ logger.Debug(logSender, "", "signature mismatch for username %q, signature %q, token signature %q",
+ token.Username, signature, token.Subject)
+ return errInvalidToken
+}
diff --git a/internal/httpd/file.go b/internal/httpd/file.go
index 5bc4627b..daa70b49 100644
--- a/internal/httpd/file.go
+++ b/internal/httpd/file.go
@@ -126,7 +126,7 @@ func (f *httpdFile) closeIO() error {
} else if f.reader != nil {
err = f.reader.Close()
if metadater, ok := f.reader.(vfs.Metadater); ok {
- f.BaseTransfer.SetMetadata(metadater.Metadata())
+ f.SetMetadata(metadater.Metadata())
}
}
return err
diff --git a/internal/httpd/handler.go b/internal/httpd/handler.go
index d417cc4a..15b085fe 100644
--- a/internal/httpd/handler.go
+++ b/internal/httpd/handler.go
@@ -35,6 +35,17 @@ import (
type Connection struct {
*common.BaseConnection
request *http.Request
+ rc *http.ResponseController
+}
+
+func newConnection(conn *common.BaseConnection, w http.ResponseWriter, r *http.Request) *Connection {
+ rc := http.NewResponseController(w)
+ responseControllerDeadlines(rc, time.Time{}, time.Time{})
+ return &Connection{
+ BaseConnection: conn,
+ request: r,
+ rc: rc,
+ }
}
// GetClientVersion returns the connected client's version.
@@ -60,6 +71,9 @@ func (c *Connection) GetRemoteAddress() string {
// Disconnect closes the active transfer
func (c *Connection) Disconnect() (err error) {
+ if c.rc != nil {
+ responseControllerDeadlines(c.rc, time.Now().Add(5*time.Second), time.Now().Add(5*time.Second))
+ }
return c.SignalTransfersAbort()
}
@@ -97,6 +111,11 @@ func (c *Connection) ReadDir(name string) (vfs.DirLister, error) {
func (c *Connection) getFileReader(name string, offset int64, method string) (io.ReadCloser, error) {
c.UpdateLastActivity()
+ if err := common.Connections.IsNewTransferAllowed(c.User.Username); err != nil {
+ c.Log(logger.LevelInfo, "denying file read due to transfer count limits")
+ return nil, util.NewI18nError(c.GetPermissionDeniedError(), util.I18nError403Message)
+ }
+
transferQuota := c.GetTransferQuota()
if !transferQuota.HasDownloadSpace() {
c.Log(logger.LevelInfo, "denying file read due to quota limits")
@@ -176,7 +195,7 @@ func (c *Connection) getFileWriter(name string) (io.WriteCloser, error) {
}
if common.Config.IsAtomicUploadEnabled() && fs.IsAtomicUploadSupported() {
- _, _, err = fs.Rename(p, filePath)
+ _, _, err = fs.Rename(p, filePath, 0)
if err != nil {
c.Log(logger.LevelError, "error renaming existing file for atomic upload, source: %q, dest: %q, err: %+v",
p, filePath, err)
@@ -188,6 +207,10 @@ func (c *Connection) getFileWriter(name string) (io.WriteCloser, error) {
}
func (c *Connection) handleUploadFile(fs vfs.Fs, resolvedPath, filePath, requestPath string, isNewFile bool, fileSize int64) (io.WriteCloser, error) {
+ if err := common.Connections.IsNewTransferAllowed(c.User.Username); err != nil {
+ c.Log(logger.LevelInfo, "denying file write due to transfer count limits")
+ return nil, util.NewI18nError(c.GetPermissionDeniedError(), util.I18nError403Message)
+ }
diskQuota, transferQuota := c.HasSpace(isNewFile, false, requestPath)
if !diskQuota.HasSpace || !transferQuota.HasUploadSpace() {
c.Log(logger.LevelInfo, "denying file write due to quota limits")
@@ -213,10 +236,7 @@ func (c *Connection) handleUploadFile(fs vfs.Fs, resolvedPath, filePath, request
if vfs.HasTruncateSupport(fs) {
vfolder, err := c.User.GetVirtualFolderForPath(path.Dir(requestPath))
if err == nil {
- dataprovider.UpdateVirtualFolderQuota(&vfolder.BaseVirtualFolder, 0, -fileSize, false) //nolint:errcheck
- if vfolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&c.User, 0, -fileSize, false) //nolint:errcheck
- }
+ dataprovider.UpdateUserFolderQuota(&vfolder, &c.User, 0, -fileSize, false)
} else {
dataprovider.UpdateUserQuota(&c.User, 0, -fileSize, false) //nolint:errcheck
}
@@ -323,6 +343,10 @@ func (t *throttledReader) GetRealFsPath(_ string) string {
return ""
}
+func (t *throttledReader) GetFsPath() string {
+ return ""
+}
+
func (t *throttledReader) SetTimes(_ string, _ time.Time, _ time.Time) bool {
return false
}
diff --git a/internal/httpd/httpd.go b/internal/httpd/httpd.go
index 62a4cdfb..13e39206 100644
--- a/internal/httpd/httpd.go
+++ b/internal/httpd/httpd.go
@@ -23,16 +23,16 @@ import (
"fmt"
"net"
"net/http"
+ "net/url"
"os"
"path"
"path/filepath"
"runtime"
"strings"
+ "sync"
"time"
"github.com/go-chi/chi/v5"
- "github.com/go-chi/jwtauth/v5"
- "github.com/lestrrat-go/jwx/v2/jwa"
"github.com/drakkan/sftpgo/v2/internal/acme"
"github.com/drakkan/sftpgo/v2/internal/common"
@@ -84,7 +84,6 @@ const (
user2FARecoveryCodesPath = "/api/v2/user/2fa/recoverycodes"
userProfilePath = "/api/v2/user/profile"
userSharesPath = "/api/v2/user/shares"
- retentionBasePath = "/api/v2/retention/users"
retentionChecksPath = "/api/v2/retention/users/checks"
fsEventsPath = "/api/v2/events/fs"
providerEventsPath = "/api/v2/events/provider"
@@ -196,7 +195,6 @@ var (
cleanupTicker *time.Ticker
cleanupDone chan bool
invalidatedJWTTokens tokenManager
- csrfTokenAuth *jwtauth.JWTAuth
webRootPath string
webBasePath string
webBaseAdminPath string
@@ -288,12 +286,88 @@ var (
installationCodeHint string
fnInstallationCodeResolver FnInstallationCodeResolver
configurationDir string
+ dbBrandingConfig brandingCache
)
func init() {
updateWebAdminURLs("")
updateWebClientURLs("")
acme.SetReloadHTTPDCertsFn(ReloadCertificateMgr)
+ common.SetUpdateBrandingFn(dbBrandingConfig.Set)
+}
+
+type brandingCache struct {
+ mu sync.RWMutex
+ configs *dataprovider.BrandingConfigs
+}
+
+func (b *brandingCache) Set(configs *dataprovider.BrandingConfigs) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ b.configs = configs
+}
+
+func (b *brandingCache) getWebAdminLogo() []byte {
+ b.mu.RLock()
+ defer b.mu.RUnlock()
+
+ return b.configs.WebAdmin.Logo
+}
+
+func (b *brandingCache) getWebAdminFavicon() []byte {
+ b.mu.RLock()
+ defer b.mu.RUnlock()
+
+ return b.configs.WebAdmin.Favicon
+}
+
+func (b *brandingCache) getWebClientLogo() []byte {
+ b.mu.RLock()
+ defer b.mu.RUnlock()
+
+ return b.configs.WebClient.Logo
+}
+
+func (b *brandingCache) getWebClientFavicon() []byte {
+ b.mu.RLock()
+ defer b.mu.RUnlock()
+
+ return b.configs.WebClient.Favicon
+}
+
+func (b *brandingCache) mergeBrandingConfig(branding UIBranding, isWebClient bool) UIBranding {
+ b.mu.RLock()
+ defer b.mu.RUnlock()
+
+ var urlPrefix string
+ var cfg dataprovider.BrandingConfig
+ if isWebClient {
+ cfg = b.configs.WebClient
+ urlPrefix = "webclient"
+ } else {
+ cfg = b.configs.WebAdmin
+ urlPrefix = "webadmin"
+ }
+ if cfg.Name != "" {
+ branding.Name = cfg.Name
+ }
+ if cfg.ShortName != "" {
+ branding.ShortName = cfg.ShortName
+ }
+ if cfg.DisclaimerName != "" {
+ branding.DisclaimerName = cfg.DisclaimerName
+ }
+ if cfg.DisclaimerURL != "" {
+ branding.DisclaimerPath = cfg.DisclaimerURL
+ }
+ if len(cfg.Logo) > 0 {
+ branding.LogoPath = path.Join("/", "branding", urlPrefix, "logo.png")
+ }
+ if len(cfg.Favicon) > 0 {
+ branding.FaviconPath = path.Join("/", "branding", urlPrefix, "favicon.png")
+ }
+ return branding
}
// FnInstallationCodeResolver defines a method to get the installation code.
@@ -340,9 +414,17 @@ type SecurityConf struct {
ContentSecurityPolicy string `json:"content_security_policy" mapstructure:"content_security_policy"`
// PermissionsPolicy allows to set the Permissions-Policy header value. Default is "".
PermissionsPolicy string `json:"permissions_policy" mapstructure:"permissions_policy"`
- // CrossOriginOpenerPolicy allows to set the `Cross-Origin-Opener-Policy` header value. Default is "".
+ // CrossOriginOpenerPolicy allows to set the Cross-Origin-Opener-Policy header value. Default is "".
CrossOriginOpenerPolicy string `json:"cross_origin_opener_policy" mapstructure:"cross_origin_opener_policy"`
- proxyHeaders []string
+ // CrossOriginResourcePolicy allows to set the Cross-Origin-Resource-Policy header value. Default is "".
+ CrossOriginResourcePolicy string `json:"cross_origin_resource_policy" mapstructure:"cross_origin_resource_policy"`
+ // CrossOriginEmbedderPolicy allows to set the Cross-Origin-Embedder-Policy header value. Default is "".
+ CrossOriginEmbedderPolicy string `json:"cross_origin_embedder_policy" mapstructure:"cross_origin_embedder_policy"`
+ // CacheControl allows to set the Cache-Control header value.
+ CacheControl string `json:"cache_control" mapstructure:"cache_control"`
+ // ReferrerPolicy allows to set the Referrer-Policy header values.
+ ReferrerPolicy string `json:"referrer_policy" mapstructure:"referrer_policy"`
+ proxyHeaders []string
}
func (s *SecurityConf) updateProxyHeaders() {
@@ -398,8 +480,6 @@ type UIBranding struct {
// For example, if you create a directory named "branding" inside the static dir and
// put the "mylogo.png" file in it, you must set "/branding/mylogo.png" as logo path.
LogoPath string `json:"logo_path" mapstructure:"logo_path"`
- // Path to the image to show on the login screen relative to "static_files_path"
- LoginImagePath string `json:"login_image_path" mapstructure:"login_image_path"`
// Path to your favicon relative to "static_files_path"
FaviconPath string `json:"favicon_path" mapstructure:"favicon_path"`
// DisclaimerName defines the name for the link to your optional disclaimer
@@ -411,24 +491,23 @@ type UIBranding struct {
// the default CSS files
DefaultCSS []string `json:"default_css" mapstructure:"default_css"`
// Additional CSS file paths, relative to "static_files_path", to include
- ExtraCSS []string `json:"extra_css" mapstructure:"extra_css"`
+ ExtraCSS []string `json:"extra_css" mapstructure:"extra_css"`
+ DefaultLogoPath string `json:"-" mapstructure:"-"`
+ DefaultFaviconPath string `json:"-" mapstructure:"-"`
}
func (b *UIBranding) check() {
+ b.DefaultLogoPath = "/img/logo.png"
+ b.DefaultFaviconPath = "/favicon.png"
if b.LogoPath != "" {
b.LogoPath = util.CleanPath(b.LogoPath)
} else {
- b.LogoPath = "/img/logo.png"
- }
- if b.LoginImagePath != "" {
- b.LoginImagePath = util.CleanPath(b.LoginImagePath)
- } else {
- b.LoginImagePath = "/img/login_image.png"
+ b.LogoPath = b.DefaultLogoPath
}
if b.FaviconPath != "" {
b.FaviconPath = util.CleanPath(b.FaviconPath)
} else {
- b.FaviconPath = "/favicon.ico"
+ b.FaviconPath = b.DefaultFaviconPath
}
if b.DisclaimerPath != "" {
if !strings.HasPrefix(b.DisclaimerPath, "https://") && !strings.HasPrefix(b.DisclaimerPath, "http://") {
@@ -488,7 +567,21 @@ type Binding struct {
//
// You can combine the values. For example 3 means that you can only login using OIDC on
// both WebClient and WebAdmin UI.
+ // Deprecated because it is not extensible, use DisabledLoginMethods
EnabledLoginMethods int `json:"enabled_login_methods" mapstructure:"enabled_login_methods"`
+ // Defines the login methods disabled for the WebAdmin and WebClient UIs:
+ //
+ // - 1 means OIDC for the WebAdmin UI
+ // - 2 means OIDC for the WebClient UI
+ // - 4 means login form for the WebAdmin UI
+ // - 8 means login form for the WebClient UI
+ // - 16 means basic auth for admin REST API
+ // - 32 means basic auth for user REST API
+ // - 64 means API key auth for admins
+ // - 128 means API key auth for users
+ // You can combine the values. For example 12 means that you can only login using OIDC on
+ // both WebClient and WebAdmin UI.
+ DisabledLoginMethods int `json:"disabled_login_methods" mapstructure:"disabled_login_methods"`
// you also need to provide a certificate for enabling HTTPS
EnableHTTPS bool `json:"enable_https" mapstructure:"enable_https"`
// Certificate and matching private key for this specific binding, if empty the global
@@ -513,6 +606,9 @@ type Binding struct {
TLSCipherSuites []string `json:"tls_cipher_suites" mapstructure:"tls_cipher_suites"`
// HTTP protocols in preference order. Supported values: http/1.1, h2
Protocols []string `json:"tls_protocols" mapstructure:"tls_protocols"`
+ // Defines whether to use the common proxy protocol configuration or the
+ // binding-specific proxy header configuration.
+ ProxyMode int `json:"proxy_mode" mapstructure:"proxy_mode"`
// List of IP addresses and IP ranges allowed to set client IP proxy headers and
// X-Forwarded-Proto header.
ProxyAllowed []string `json:"proxy_allowed" mapstructure:"proxy_allowed"`
@@ -532,6 +628,12 @@ type Binding struct {
HideLoginURL int `json:"hide_login_url" mapstructure:"hide_login_url"`
// Enable the built-in OpenAPI renderer
RenderOpenAPI bool `json:"render_openapi" mapstructure:"render_openapi"`
+ // BaseURL defines the external base URL for generating public links
+ // (currently share access link), bypassing the default browser-based
+ // detection.
+ BaseURL string `json:"base_url" mapstructure:"base_url"`
+ // Languages defines the list of enabled translations for the WebAdmin and WebClient UI.
+ Languages []string `json:"languages" mapstructure:"languages"`
// Defining an OIDC configuration the web admin and web client UI will use OpenID to authenticate users.
OIDC OIDC `json:"oidc" mapstructure:"oidc"`
// Security defines security headers to add to HTTP responses and allows to restrict allowed hosts
@@ -558,6 +660,36 @@ func (b *Binding) checkBranding() {
}
}
+func (b *Binding) webAdminBranding() UIBranding {
+ return dbBrandingConfig.mergeBrandingConfig(b.Branding.WebAdmin, false)
+}
+
+func (b *Binding) webClientBranding() UIBranding {
+ return dbBrandingConfig.mergeBrandingConfig(b.Branding.WebClient, true)
+}
+
+func (b *Binding) languages() []string {
+ return b.Languages
+}
+
+func (b *Binding) validateBaseURL() error {
+ if b.BaseURL == "" {
+ return nil
+ }
+ u, err := url.ParseRequestURI(b.BaseURL)
+ if err != nil {
+ return err
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return fmt.Errorf("invalid base URL schema %s", b.BaseURL)
+ }
+ if u.Host == "" {
+ return fmt.Errorf("invalid base URL host %s", b.BaseURL)
+ }
+ b.BaseURL = strings.TrimRight(u.String(), "/")
+ return nil
+}
+
func (b *Binding) parseAllowedProxy() error {
if filepath.IsAbs(b.Address) && len(b.ProxyAllowed) > 0 {
// unix domain socket
@@ -591,47 +723,90 @@ func (b *Binding) IsValid() bool {
return false
}
+func (b *Binding) check() error {
+ if err := b.parseAllowedProxy(); err != nil {
+ return err
+ }
+ if err := b.validateBaseURL(); err != nil {
+ return err
+ }
+ b.checkBranding()
+ b.Security.updateProxyHeaders()
+ return nil
+}
+
func (b *Binding) isWebAdminOIDCLoginDisabled() bool {
if b.EnableWebAdmin {
- if b.EnabledLoginMethods == 0 {
- return false
- }
- return b.EnabledLoginMethods&1 == 0
+ return b.DisabledLoginMethods&1 != 0
}
return false
}
func (b *Binding) isWebClientOIDCLoginDisabled() bool {
if b.EnableWebClient {
- if b.EnabledLoginMethods == 0 {
- return false
- }
- return b.EnabledLoginMethods&2 == 0
+ return b.DisabledLoginMethods&2 != 0
}
return false
}
func (b *Binding) isWebAdminLoginFormDisabled() bool {
if b.EnableWebAdmin {
- if b.EnabledLoginMethods == 0 {
- return false
- }
- return b.EnabledLoginMethods&4 == 0
+ return b.DisabledLoginMethods&4 != 0
}
return false
}
func (b *Binding) isWebClientLoginFormDisabled() bool {
if b.EnableWebClient {
- if b.EnabledLoginMethods == 0 {
- return false
- }
- return b.EnabledLoginMethods&8 == 0
+ return b.DisabledLoginMethods&8 != 0
}
return false
}
+func (b *Binding) isAdminTokenEndpointDisabled() bool {
+ return b.DisabledLoginMethods&16 != 0
+}
+
+func (b *Binding) isUserTokenEndpointDisabled() bool {
+ return b.DisabledLoginMethods&32 != 0
+}
+
+func (b *Binding) isAdminAPIKeyAuthDisabled() bool {
+ return b.DisabledLoginMethods&64 != 0
+}
+
+func (b *Binding) isUserAPIKeyAuthDisabled() bool {
+ return b.DisabledLoginMethods&128 != 0
+}
+
+func (b *Binding) hasLoginForAPI() bool {
+ return !b.isAdminTokenEndpointDisabled() || !b.isUserTokenEndpointDisabled() ||
+ !b.isAdminAPIKeyAuthDisabled() || !b.isUserAPIKeyAuthDisabled()
+}
+
+// convertLoginMethods checks if the deprecated EnabledLoginMethods is set and
+// convert the value to DisabledLoginMethods.
+func (b *Binding) convertLoginMethods() {
+ if b.DisabledLoginMethods > 0 || b.EnabledLoginMethods == 0 {
+ // DisabledLoginMethods already in use or EnabledLoginMethods not set.
+ return
+ }
+ if b.EnabledLoginMethods&1 == 0 {
+ b.DisabledLoginMethods++
+ }
+ if b.EnabledLoginMethods&2 == 0 {
+ b.DisabledLoginMethods += 2
+ }
+ if b.EnabledLoginMethods&4 == 0 {
+ b.DisabledLoginMethods += 4
+ }
+ if b.EnabledLoginMethods&8 == 0 {
+ b.DisabledLoginMethods += 8
+ }
+}
+
func (b *Binding) checkLoginMethods() error {
+ b.convertLoginMethods()
if b.isWebAdminLoginFormDisabled() && b.isWebAdminOIDCLoginDisabled() {
return errors.New("no login method available for WebAdmin UI")
}
@@ -648,6 +823,9 @@ func (b *Binding) checkLoginMethods() error {
return errors.New("no login method available for WebClient UI")
}
}
+ if b.EnableRESTAPI && !b.hasLoginForAPI() {
+ return errors.New("no login method available for REST API")
+ }
return nil
}
@@ -671,6 +849,17 @@ func (b *Binding) showClientLoginURL() bool {
return true
}
+func (b *Binding) isMutualTLSEnabled() bool {
+ return b.ClientAuthType == 1
+}
+
+func (b *Binding) listenerWrapper() func(net.Listener) (net.Listener, error) {
+ if b.ProxyMode == 1 {
+ return common.Config.GetProxyListener
+ }
+ return nil
+}
+
type defenderStatus struct {
IsActive bool `json:"is_active"`
}
@@ -764,6 +953,12 @@ type Conf struct {
// By default all the available security checks are enabled. Set to 1 to disable the requirement
// that a token must be used by the same IP for which it was issued.
TokenValidation int `json:"token_validation" mapstructure:"token_validation"`
+ // CookieLifetime defines the duration of cookies for WebAdmin and WebClient
+ CookieLifetime int `json:"cookie_lifetime" mapstructure:"cookie_lifetime"`
+ // ShareCookieLifetime defines the duration of cookies for public shares
+ ShareCookieLifetime int `json:"share_cookie_lifetime" mapstructure:"share_cookie_lifetime"`
+ // JWTLifetime defines the duration of JWT tokens used in REST API
+ JWTLifetime int `json:"jwt_lifetime" mapstructure:"jwt_lifetime"`
// MaxUploadFileSize Defines the maximum request body size, in bytes, for Web Client/API HTTP upload requests.
// 0 means no limit
MaxUploadFileSize int64 `json:"max_upload_file_size" mapstructure:"max_upload_file_size"`
@@ -872,11 +1067,7 @@ func (c *Conf) getKeyPairs(configDir string) []common.TLSKeyPair {
}
func (c *Conf) setTokenValidationMode() {
- if c.TokenValidation == 1 {
- tokenValidationMode = tokenValidationNoIPMatch
- } else {
- tokenValidationMode = tokenValidationFull
- }
+ tokenValidationMode = c.TokenValidation
}
func (c *Conf) loadFromProvider() error {
@@ -885,6 +1076,7 @@ func (c *Conf) loadFromProvider() error {
return fmt.Errorf("unable to load config from provider: %w", err)
}
configs.SetNilsToEmpty()
+ dbBrandingConfig.Set(configs.Branding)
if configs.ACME.Domain == "" || !configs.ACME.HasProtocol(common.ProtocolHTTP) {
return nil
}
@@ -970,7 +1162,6 @@ func (c *Conf) Initialize(configDir string, isShared int) error {
c.SigningPassphrase = passphrase
}
- csrfTokenAuth = jwtauth.New(jwa.HS256.String(), getSigningKey(c.SigningPassphrase), nil)
hideSupportLink = c.HideSupportLink
exitChannel := make(chan error, 1)
@@ -979,11 +1170,9 @@ func (c *Conf) Initialize(configDir string, isShared int) error {
if !binding.IsValid() {
continue
}
- if err := binding.parseAllowedProxy(); err != nil {
+ if err := binding.check(); err != nil {
return err
}
- binding.checkBranding()
- binding.Security.updateProxyHeaders()
go func(b Binding) {
if err := b.OIDC.initialize(); err != nil {
@@ -1004,7 +1193,8 @@ func (c *Conf) Initialize(configDir string, isShared int) error {
maxUploadFileSize = c.MaxUploadFileSize
installationCode = c.Setup.InstallationCode
installationCodeHint = c.Setup.InstallationCodeHint
- startCleanupTicker(tokenDuration / 2)
+ updateTokensDuration(c.JWTLifetime, c.CookieLifetime, c.ShareCookieLifetime)
+ startCleanupTicker(10 * time.Minute)
c.setTokenValidationMode()
return <-exitChannel
}
@@ -1177,10 +1367,12 @@ func updateWebAdminURLs(baseURL string) {
}
// GetHTTPRouter returns an HTTP handler suitable to use for test cases
-func GetHTTPRouter(b Binding) http.Handler {
+func GetHTTPRouter(b Binding) (http.Handler, error) {
server := newHttpdServer(b, filepath.Join("..", "..", "static"), "", CorsConfig{}, filepath.Join("..", "..", "openapi"))
- server.initializeRouter()
- return server.router
+ if err := server.initializeRouter(); err != nil {
+ return nil, err
+ }
+ return server.router, nil
}
// the ticker cannot be started/stopped from multiple goroutines
@@ -1218,11 +1410,14 @@ func stopCleanupTicker() {
}
func getSigningKey(signingPassphrase string) []byte {
+ var key []byte
if signingPassphrase != "" {
- sk := sha256.Sum256([]byte(signingPassphrase))
- return sk[:]
+ key = []byte(signingPassphrase)
+ } else {
+ key = util.GenerateRandomBytes(32)
}
- return util.GenerateRandomBytes(32)
+ sk := sha256.Sum256(key)
+ return sk[:]
}
// SetInstallationCodeResolver sets a function to call to resolve the installation code
diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go
index c22d6075..be4f8d88 100644
--- a/internal/httpd/httpd_test.go
+++ b/internal/httpd/httpd_test.go
@@ -20,6 +20,9 @@ import (
"encoding/json"
"errors"
"fmt"
+ "image"
+ "image/color"
+ "image/png"
"io"
"io/fs"
"math"
@@ -33,6 +36,7 @@ import (
"path/filepath"
"regexp"
"runtime"
+ "slices"
"strconv"
"strings"
"sync"
@@ -42,9 +46,10 @@ import (
"github.com/go-chi/render"
_ "github.com/go-sql-driver/mysql"
_ "github.com/jackc/pgx/v5/stdlib"
- "github.com/lithammer/shortuuid/v3"
+ "github.com/lithammer/shortuuid/v4"
_ "github.com/mattn/go-sqlite3"
"github.com/mhale/smtpd"
+ "github.com/pkg/sftp"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"github.com/rs/xid"
@@ -121,7 +126,6 @@ const (
user2FARecoveryCodesPath = "/api/v2/user/2fa/recoverycodes"
userProfilePath = "/api/v2/user/profile"
userSharesPath = "/api/v2/user/shares"
- retentionBasePath = "/api/v2/retention/users"
fsEventsPath = "/api/v2/events/fs"
providerEventsPath = "/api/v2/events/provider"
logEventsPath = "/api/v2/events/logs"
@@ -324,7 +328,7 @@ type recoveryCode struct {
Used bool `json:"used"`
}
-func TestMain(m *testing.M) {
+func TestMain(m *testing.M) { //nolint:gocyclo
homeBasePath = os.TempDir()
logfilePath := filepath.Join(configDir, "sftpgo_api_test.log")
logger.InitLogger(logfilePath, 5, 1, 28, false, false, zerolog.DebugLevel)
@@ -366,6 +370,24 @@ func TestMain(m *testing.M) {
os.Exit(1)
}
+ kmsConfig := config.GetKMSConfig()
+ err = kmsConfig.Initialize()
+ if err != nil {
+ logger.ErrorToConsole("error initializing kms: %v", err)
+ os.Exit(1)
+ }
+ err = plugin.Initialize(pluginsConfig, "debug")
+ if err != nil {
+ logger.ErrorToConsole("error initializing plugin: %v", err)
+ os.Exit(1)
+ }
+ mfaConfig := config.GetMFAConfig()
+ err = mfaConfig.Initialize()
+ if err != nil {
+ logger.ErrorToConsole("error initializing MFA: %v", err)
+ os.Exit(1)
+ }
+
err = dataprovider.Initialize(providerConf, configDir, true)
if err != nil {
logger.WarnToConsole("error initializing data provider: %v", err)
@@ -385,23 +407,6 @@ func TestMain(m *testing.M) {
httpConfig.RetryMax = 1
httpConfig.Timeout = 5
httpConfig.Initialize(configDir) //nolint:errcheck
- kmsConfig := config.GetKMSConfig()
- err = kmsConfig.Initialize()
- if err != nil {
- logger.ErrorToConsole("error initializing kms: %v", err)
- os.Exit(1)
- }
- mfaConfig := config.GetMFAConfig()
- err = mfaConfig.Initialize()
- if err != nil {
- logger.ErrorToConsole("error initializing MFA: %v", err)
- os.Exit(1)
- }
- err = plugin.Initialize(pluginsConfig, "debug")
- if err != nil {
- logger.ErrorToConsole("error initializing plugin: %v", err)
- os.Exit(1)
- }
httpdConf := config.GetHTTPDConfig()
@@ -414,6 +419,7 @@ func TestMain(m *testing.M) {
Value: "https",
},
},
+ CacheControl: "private",
}
httpdtest.SetBaseURL(httpBaseURL)
// required to test sftpfs
@@ -474,7 +480,12 @@ func TestMain(m *testing.M) {
waitTCPListening(httpdConf.Bindings[0].GetAddress())
httpd.ReloadCertificateMgr() //nolint:errcheck
- testServer = httptest.NewServer(httpd.GetHTTPRouter(httpdConf.Bindings[0]))
+ handler, err := httpd.GetHTTPRouter(httpdConf.Bindings[0])
+ if err != nil {
+ logger.ErrorToConsole("unable to get http test handler: %v", err)
+ os.Exit(1)
+ }
+ testServer = httptest.NewServer(handler)
defer testServer.Close()
exitCode := m.Run()
@@ -569,29 +580,36 @@ func TestInitialization(t *testing.T) {
assert.Contains(t, err.Error(), "oidc")
}
httpdConf.Bindings[0].OIDC = httpd.OIDC{}
+ httpdConf.Bindings[0].BaseURL = "ftp://127.0.0.1"
+ err = httpdConf.Initialize(configDir, isShared)
+ if assert.Error(t, err) {
+ assert.Contains(t, err.Error(), "URL schema")
+ }
+ httpdConf.Bindings[0].BaseURL = ""
httpdConf.Bindings[0].EnableWebClient = true
httpdConf.Bindings[0].EnableWebAdmin = true
- httpdConf.Bindings[0].EnabledLoginMethods = 1
+ httpdConf.Bindings[0].EnableRESTAPI = true
+ httpdConf.Bindings[0].DisabledLoginMethods = 14
err = httpdConf.Initialize(configDir, isShared)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "no login method available for WebAdmin UI")
}
- httpdConf.Bindings[0].EnabledLoginMethods = 2
+ httpdConf.Bindings[0].DisabledLoginMethods = 13
err = httpdConf.Initialize(configDir, isShared)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "no login method available for WebAdmin UI")
}
- httpdConf.Bindings[0].EnabledLoginMethods = 6
+ httpdConf.Bindings[0].DisabledLoginMethods = 9
err = httpdConf.Initialize(configDir, isShared)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "no login method available for WebClient UI")
}
- httpdConf.Bindings[0].EnabledLoginMethods = 4
+ httpdConf.Bindings[0].DisabledLoginMethods = 11
err = httpdConf.Initialize(configDir, isShared)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "no login method available for WebClient UI")
}
- httpdConf.Bindings[0].EnabledLoginMethods = 3
+ httpdConf.Bindings[0].DisabledLoginMethods = 12
err = httpdConf.Initialize(configDir, isShared)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "no login method available for WebAdmin UI")
@@ -601,6 +619,12 @@ func TestInitialization(t *testing.T) {
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "no login method available for WebClient UI")
}
+ httpdConf.Bindings[0].EnableWebClient = false
+ httpdConf.Bindings[0].DisabledLoginMethods = 240
+ err = httpdConf.Initialize(configDir, isShared)
+ if assert.Error(t, err) {
+ assert.Contains(t, err.Error(), "no login method available for REST API")
+ }
err = dataprovider.Close()
assert.NoError(t, err)
err = httpdConf.Initialize(configDir, isShared)
@@ -617,6 +641,7 @@ func TestInitialization(t *testing.T) {
func TestBasicUserHandling(t *testing.T) {
u := getTestUser()
u.Email = "user@user.com"
+ u.Filters.AdditionalEmails = []string{"email1@user.com", "email2@user.com"}
user, resp, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err, string(resp))
_, resp, err = httpdtest.AddUser(u, http.StatusConflict)
@@ -657,6 +682,12 @@ func TestBasicUserHandling(t *testing.T) {
assert.NoError(t, err)
assert.Contains(t, string(body), "Validation error: email")
+ user.Email = ""
+ user.Filters.AdditionalEmails = []string{"invalid@email"}
+ _, body, err = httpdtest.UpdateUser(user, http.StatusBadRequest, "")
+ assert.NoError(t, err)
+ assert.Contains(t, string(body), "Validation error: email")
+
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
}
@@ -714,7 +745,7 @@ func TestRoleRelations(t *testing.T) {
a.Role = role.Name
_, resp, err = httpdtest.AddAdmin(a, http.StatusBadRequest)
assert.NoError(t, err)
- assert.Contains(t, string(resp), "a role admin cannot have the following permissions")
+ assert.Contains(t, string(resp), "a role admin cannot be a super admin")
a.Permissions = []string{dataprovider.PermAdminAddUsers, dataprovider.PermAdminChangeUsers,
dataprovider.PermAdminDeleteUsers, dataprovider.PermAdminViewUsers}
@@ -874,6 +905,289 @@ func TestTLSCert(t *testing.T) {
assert.NoError(t, err)
}
+func TestSortRelatedFolders(t *testing.T) {
+ folder1 := util.GenerateUniqueID()
+ folder2 := util.GenerateUniqueID()
+ folder3 := util.GenerateUniqueID()
+
+ f1 := vfs.BaseVirtualFolder{
+ Name: folder1,
+ MappedPath: filepath.Clean(os.TempDir()),
+ }
+ f2 := vfs.BaseVirtualFolder{
+ Name: folder2,
+ MappedPath: filepath.Clean(os.TempDir()),
+ }
+ f3 := vfs.BaseVirtualFolder{
+ Name: folder3,
+ MappedPath: filepath.Clean(os.TempDir()),
+ }
+ _, _, err := httpdtest.AddFolder(f1, http.StatusCreated)
+ assert.NoError(t, err)
+ _, _, err = httpdtest.AddFolder(f2, http.StatusCreated)
+ assert.NoError(t, err)
+ _, _, err = httpdtest.AddFolder(f3, http.StatusCreated)
+ assert.NoError(t, err)
+
+ u := getTestUser()
+ u.VirtualFolders = []vfs.VirtualFolder{
+ {
+ BaseVirtualFolder: f1,
+ VirtualPath: "/" + folder1,
+ },
+ {
+ BaseVirtualFolder: f2,
+ VirtualPath: "/" + folder2,
+ },
+ {
+ BaseVirtualFolder: f3,
+ VirtualPath: "/" + folder3,
+ },
+ }
+ user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+ assert.NoError(t, err)
+ user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+ assert.NoError(t, err)
+ if assert.Len(t, user.VirtualFolders, 3) {
+ assert.Equal(t, folder1, user.VirtualFolders[0].Name)
+ assert.Equal(t, folder2, user.VirtualFolders[1].Name)
+ assert.Equal(t, folder3, user.VirtualFolders[2].Name)
+ }
+ // Update
+ user.VirtualFolders = []vfs.VirtualFolder{
+ {
+ BaseVirtualFolder: f2,
+ VirtualPath: "/" + folder2,
+ },
+ {
+ BaseVirtualFolder: f1,
+ VirtualPath: "/" + folder1,
+ },
+ {
+ BaseVirtualFolder: f3,
+ VirtualPath: "/" + folder3,
+ },
+ }
+ user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+ assert.NoError(t, err)
+ user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+ assert.NoError(t, err)
+ if assert.Len(t, user.VirtualFolders, 3) {
+ assert.Equal(t, folder2, user.VirtualFolders[0].Name)
+ assert.Equal(t, folder1, user.VirtualFolders[1].Name)
+ assert.Equal(t, folder3, user.VirtualFolders[2].Name)
+ }
+
+ g := getTestGroup()
+ g.VirtualFolders = []vfs.VirtualFolder{
+ {
+ BaseVirtualFolder: f1,
+ VirtualPath: "/" + folder1,
+ },
+ {
+ BaseVirtualFolder: f2,
+ VirtualPath: "/" + folder2,
+ },
+ {
+ BaseVirtualFolder: f3,
+ VirtualPath: "/" + folder3,
+ },
+ }
+ group, _, err := httpdtest.AddGroup(g, http.StatusCreated)
+ assert.NoError(t, err)
+ group, _, err = httpdtest.GetGroupByName(group.Name, http.StatusOK)
+ assert.NoError(t, err)
+ if assert.Len(t, group.VirtualFolders, 3) {
+ assert.Equal(t, folder1, group.VirtualFolders[0].Name)
+ assert.Equal(t, folder2, group.VirtualFolders[1].Name)
+ assert.Equal(t, folder3, group.VirtualFolders[2].Name)
+ }
+ group, _, err = httpdtest.GetGroupByName(group.Name, http.StatusOK)
+ assert.NoError(t, err)
+ group.VirtualFolders = []vfs.VirtualFolder{
+ {
+ BaseVirtualFolder: f3,
+ VirtualPath: "/" + folder3,
+ },
+ {
+ BaseVirtualFolder: f1,
+ VirtualPath: "/" + folder1,
+ },
+ {
+ BaseVirtualFolder: f2,
+ VirtualPath: "/" + folder2,
+ },
+ }
+ group, _, err = httpdtest.UpdateGroup(group, http.StatusOK)
+ assert.NoError(t, err)
+ group, _, err = httpdtest.GetGroupByName(group.Name, http.StatusOK)
+ assert.NoError(t, err)
+ if assert.Len(t, group.VirtualFolders, 3) {
+ assert.Equal(t, folder3, group.VirtualFolders[0].Name)
+ assert.Equal(t, folder1, group.VirtualFolders[1].Name)
+ assert.Equal(t, folder2, group.VirtualFolders[2].Name)
+ }
+
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveGroup(group, http.StatusOK)
+ assert.NoError(t, err)
+
+ _, err = httpdtest.RemoveFolder(f1, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveFolder(f2, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveFolder(f3, http.StatusOK)
+ assert.NoError(t, err)
+}
+
+func TestSortRelatedGroups(t *testing.T) {
+ name1 := util.GenerateUniqueID()
+ name2 := util.GenerateUniqueID()
+ name3 := util.GenerateUniqueID()
+
+ g1 := getTestGroup()
+ g1.Name = name1
+ g2 := getTestGroup()
+ g2.Name = name2
+ g3 := getTestGroup()
+ g3.Name = name3
+
+ group1, _, err := httpdtest.AddGroup(g1, http.StatusCreated)
+ assert.NoError(t, err)
+ group2, _, err := httpdtest.AddGroup(g2, http.StatusCreated)
+ assert.NoError(t, err)
+ group3, _, err := httpdtest.AddGroup(g3, http.StatusCreated)
+ assert.NoError(t, err)
+
+ u := getTestUser()
+ u.Groups = []sdk.GroupMapping{
+ {
+ Name: name1,
+ },
+ }
+ _, _, err = httpdtest.AddUser(u, http.StatusBadRequest)
+ assert.NoError(t, err)
+ u.Groups = []sdk.GroupMapping{
+ {
+ Name: name1,
+ Type: sdk.GroupTypePrimary,
+ },
+ {
+ Name: name2,
+ Type: sdk.GroupTypeSecondary,
+ },
+ {
+ Name: name3,
+ Type: sdk.GroupTypeMembership,
+ },
+ }
+ user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+ assert.NoError(t, err)
+ user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+ assert.NoError(t, err)
+ if assert.Len(t, user.Groups, 3) {
+ assert.Equal(t, name1, user.Groups[0].Name)
+ assert.Equal(t, name2, user.Groups[1].Name)
+ assert.Equal(t, name3, user.Groups[2].Name)
+ }
+ user.Groups = []sdk.GroupMapping{
+ {
+ Name: name2,
+ Type: sdk.GroupTypeSecondary,
+ },
+ {
+ Name: name3,
+ Type: sdk.GroupTypeMembership,
+ },
+ {
+ Name: name1,
+ Type: sdk.GroupTypePrimary,
+ },
+ }
+ user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+ assert.NoError(t, err)
+ user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+ assert.NoError(t, err)
+ if assert.Len(t, user.Groups, 3) {
+ assert.Equal(t, name2, user.Groups[0].Name)
+ assert.Equal(t, name3, user.Groups[1].Name)
+ assert.Equal(t, name1, user.Groups[2].Name)
+ }
+
+ a := getTestAdmin()
+ a.Username = altAdminUsername
+ a.Groups = []dataprovider.AdminGroupMapping{
+ {
+ Name: name3,
+ Options: dataprovider.AdminGroupMappingOptions{
+ AddToUsersAs: dataprovider.GroupAddToUsersAsSecondary,
+ },
+ },
+ {
+ Name: name2,
+ Options: dataprovider.AdminGroupMappingOptions{
+ AddToUsersAs: dataprovider.GroupAddToUsersAsPrimary,
+ },
+ },
+ {
+ Name: name1,
+ Options: dataprovider.AdminGroupMappingOptions{
+ AddToUsersAs: dataprovider.GroupAddToUsersAsMembership,
+ },
+ },
+ }
+ admin, _, err := httpdtest.AddAdmin(a, http.StatusCreated)
+ assert.NoError(t, err)
+ admin, _, err = httpdtest.GetAdminByUsername(admin.Username, http.StatusOK)
+ assert.NoError(t, err)
+ if assert.Len(t, admin.Groups, 3) {
+ assert.Equal(t, name3, admin.Groups[0].Name)
+ assert.Equal(t, name2, admin.Groups[1].Name)
+ assert.Equal(t, name1, admin.Groups[2].Name)
+ }
+ admin.Groups = []dataprovider.AdminGroupMapping{
+ {
+ Name: name1,
+ Options: dataprovider.AdminGroupMappingOptions{
+ AddToUsersAs: dataprovider.GroupAddToUsersAsPrimary,
+ },
+ },
+ {
+ Name: name3,
+ Options: dataprovider.AdminGroupMappingOptions{
+ AddToUsersAs: dataprovider.GroupAddToUsersAsMembership,
+ },
+ },
+ {
+ Name: name2,
+ Options: dataprovider.AdminGroupMappingOptions{
+ AddToUsersAs: dataprovider.GroupAddToUsersAsSecondary,
+ },
+ },
+ }
+ admin, _, err = httpdtest.UpdateAdmin(admin, http.StatusOK)
+ assert.NoError(t, err)
+ admin, _, err = httpdtest.GetAdminByUsername(admin.Username, http.StatusOK)
+ assert.NoError(t, err)
+ if assert.Len(t, admin.Groups, 3) {
+ assert.Equal(t, name1, admin.Groups[0].Name)
+ assert.Equal(t, name3, admin.Groups[1].Name)
+ assert.Equal(t, name2, admin.Groups[2].Name)
+ }
+
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveGroup(group1, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveGroup(group2, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveGroup(group3, http.StatusOK)
+ assert.NoError(t, err)
+}
+
func TestBasicGroupHandling(t *testing.T) {
g := getTestGroup()
g.UserSettings.Filters.TLSCerts = []string{"invalid cert"} // ignored for groups
@@ -1337,25 +1651,25 @@ func TestGroupSettingsOverride(t *testing.T) {
var folderNames []string
if assert.Len(t, user.VirtualFolders, 4) {
for _, f := range user.VirtualFolders {
- if !util.Contains(folderNames, f.Name) {
+ if !slices.Contains(folderNames, f.Name) {
folderNames = append(folderNames, f.Name)
}
switch f.Name {
case folderName1:
assert.Equal(t, mappedPath1, f.MappedPath)
- assert.Equal(t, 3, f.BaseVirtualFolder.FsConfig.OSConfig.ReadBufferSize)
- assert.Equal(t, 5, f.BaseVirtualFolder.FsConfig.OSConfig.WriteBufferSize)
- assert.True(t, util.Contains([]string{"/vdir1", "/vdir2"}, f.VirtualPath))
+ assert.Equal(t, 3, f.FsConfig.OSConfig.ReadBufferSize)
+ assert.Equal(t, 5, f.FsConfig.OSConfig.WriteBufferSize)
+ assert.True(t, slices.Contains([]string{"/vdir1", "/vdir2"}, f.VirtualPath))
case folderName2:
assert.Equal(t, mappedPath2, f.MappedPath)
assert.Equal(t, "/vdir3", f.VirtualPath)
- assert.Equal(t, 0, f.BaseVirtualFolder.FsConfig.OSConfig.ReadBufferSize)
- assert.Equal(t, 0, f.BaseVirtualFolder.FsConfig.OSConfig.WriteBufferSize)
+ assert.Equal(t, 0, f.FsConfig.OSConfig.ReadBufferSize)
+ assert.Equal(t, 0, f.FsConfig.OSConfig.WriteBufferSize)
case folderName3:
assert.Equal(t, mappedPath3, f.MappedPath)
assert.Equal(t, "/vdir4", f.VirtualPath)
- assert.Equal(t, 1, f.BaseVirtualFolder.FsConfig.OSConfig.ReadBufferSize)
- assert.Equal(t, 2, f.BaseVirtualFolder.FsConfig.OSConfig.WriteBufferSize)
+ assert.Equal(t, 1, f.FsConfig.OSConfig.ReadBufferSize)
+ assert.Equal(t, 2, f.FsConfig.OSConfig.WriteBufferSize)
}
}
}
@@ -1475,6 +1789,19 @@ func TestGroupSettingsOverride(t *testing.T) {
assert.Contains(t, user.Filters.WebClient, sdk.WebClientInfoChangeDisabled)
assert.Contains(t, user.Filters.WebClient, sdk.WebClientMFADisabled)
}
+ // Attempt to create a user with a weak password and group1 as the primary group: this should fail
+ u = getTestUser()
+ u.Username = rand.Text()
+ u.Password = defaultPassword
+ u.Groups = []sdk.GroupMapping{
+ {
+ Name: group1.Name,
+ Type: sdk.GroupTypePrimary,
+ },
+ }
+ _, resp, err = httpdtest.AddUser(u, http.StatusBadRequest)
+ assert.NoError(t, err)
+ assert.Contains(t, string(resp), "insecure password")
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
@@ -1766,7 +2093,7 @@ func TestIPListEntriesValidation(t *testing.T) {
}
func TestBasicActionRulesHandling(t *testing.T) {
- actionName := "test action"
+ actionName := "test_action"
a := dataprovider.BaseEventAction{
Name: actionName,
Description: "test description",
@@ -1826,6 +2153,10 @@ func TestBasicActionRulesHandling(t *testing.T) {
},
},
}
+ dataprovider.EnabledActionCommands = []string{a.Options.CmdConfig.Cmd}
+ defer func() {
+ dataprovider.EnabledActionCommands = nil
+ }()
_, _, err = httpdtest.UpdateEventAction(a, http.StatusOK)
assert.NoError(t, err)
// invalid type
@@ -1838,9 +2169,9 @@ func TestBasicActionRulesHandling(t *testing.T) {
EmailConfig: dataprovider.EventActionEmailConfig{
Recipients: []string{"email@example.com"},
Bcc: []string{"bcc@example.com"},
- Subject: "Event: {{Event}}",
+ Subject: "Event: {{.Event}}",
Body: "test mail body",
- Attachments: []string{"/{{VirtualPath}}"},
+ Attachments: []string{"/{{.VirtualPath}}"},
},
}
@@ -1878,7 +2209,7 @@ func TestBasicActionRulesHandling(t *testing.T) {
Value: "b",
},
},
- Body: `{"event":"{{Event}}","name":"{{Name}}"}`,
+ Body: `{"event":"{{.Event}}","name":"{{.Name}}"}`,
},
}
action, _, err = httpdtest.UpdateEventAction(a, http.StatusOK)
@@ -1902,14 +2233,15 @@ func TestBasicActionRulesHandling(t *testing.T) {
assert.Equal(t, defaultPassword, dbAction.Options.HTTPConfig.Password.GetPayload())
r := dataprovider.EventRule{
- Name: "test rule name",
+ Name: "test_rule_name",
Status: 1,
Description: "",
Trigger: dataprovider.EventTriggerFsEvent,
Conditions: dataprovider.EventConditions{
FsEvents: []string{"upload"},
Options: dataprovider.ConditionOptions{
- MinFileSize: 1024 * 1024,
+ EventStatuses: []int{2, 3},
+ MinFileSize: 1024 * 1024,
},
},
Actions: []dataprovider.EventAction{
@@ -2100,16 +2432,16 @@ func TestActionRuleRelations(t *testing.T) {
action1, _, err = httpdtest.GetEventActionByName(action1.Name, http.StatusOK)
assert.NoError(t, err)
assert.Len(t, action1.Rules, 1)
- assert.True(t, util.Contains(action1.Rules, rule1.Name))
+ assert.True(t, slices.Contains(action1.Rules, rule1.Name))
action2, _, err = httpdtest.GetEventActionByName(action2.Name, http.StatusOK)
assert.NoError(t, err)
assert.Len(t, action2.Rules, 1)
- assert.True(t, util.Contains(action2.Rules, rule2.Name))
+ assert.True(t, slices.Contains(action2.Rules, rule2.Name))
action3, _, err = httpdtest.GetEventActionByName(action3.Name, http.StatusOK)
assert.NoError(t, err)
assert.Len(t, action3.Rules, 2)
- assert.True(t, util.Contains(action3.Rules, rule1.Name))
- assert.True(t, util.Contains(action3.Rules, rule2.Name))
+ assert.True(t, slices.Contains(action3.Rules, rule1.Name))
+ assert.True(t, slices.Contains(action3.Rules, rule2.Name))
// referenced actions cannot be removed
_, err = httpdtest.RemoveEventAction(action1, http.StatusBadRequest)
assert.NoError(t, err)
@@ -2137,7 +2469,7 @@ func TestActionRuleRelations(t *testing.T) {
action3, _, err = httpdtest.GetEventActionByName(action3.Name, http.StatusOK)
assert.NoError(t, err)
assert.Len(t, action3.Rules, 1)
- assert.True(t, util.Contains(action3.Rules, rule1.Name))
+ assert.True(t, slices.Contains(action3.Rules, rule1.Name))
_, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
assert.NoError(t, err)
@@ -2163,7 +2495,7 @@ func TestActionRuleRelations(t *testing.T) {
}
func TestOnDemandEventRules(t *testing.T) {
- ruleName := "test on demand rule"
+ ruleName := "test_on_demand_rule"
a := dataprovider.BaseEventAction{
Name: "a",
Type: dataprovider.ActionTypeBackup,
@@ -2204,7 +2536,7 @@ func TestOnDemandEventRules(t *testing.T) {
}
func TestIDPLoginEventRule(t *testing.T) {
- ruleName := "test IDP login rule"
+ ruleName := "test_IDP_login_rule"
a := dataprovider.BaseEventAction{
Name: "a",
Type: dataprovider.ActionTypeIDPAccountCheck,
@@ -2359,13 +2691,24 @@ func TestEventActionValidation(t *testing.T) {
assert.NoError(t, err)
assert.Contains(t, string(resp), "command is required")
action.Options.CmdConfig.Cmd = "relative"
+ dataprovider.EnabledActionCommands = []string{action.Options.CmdConfig.Cmd}
+ defer func() {
+ dataprovider.EnabledActionCommands = nil
+ }()
+
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
assert.NoError(t, err)
assert.Contains(t, string(resp), "invalid command, it must be an absolute path")
action.Options.CmdConfig.Cmd = filepath.Join(os.TempDir(), "cmd")
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
assert.NoError(t, err)
+ assert.Contains(t, string(resp), "is not allowed")
+
+ dataprovider.EnabledActionCommands = []string{action.Options.CmdConfig.Cmd}
+ _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
+ assert.NoError(t, err)
assert.Contains(t, string(resp), "invalid command action timeout")
+
action.Options.CmdConfig.Timeout = 30
action.Options.CmdConfig.EnvVars = []dataprovider.KeyValue{
{
@@ -2380,6 +2723,17 @@ func TestEventActionValidation(t *testing.T) {
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
assert.NoError(t, err)
assert.Contains(t, string(resp), "invalid command args")
+ action.Options.CmdConfig.Args = nil
+ // restrict commands
+ if runtime.GOOS == osWindows {
+ dataprovider.EnabledActionCommands = []string{"C:\\cmd.exe"}
+ } else {
+ dataprovider.EnabledActionCommands = []string{"/bin/sh"}
+ }
+ _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
+ assert.NoError(t, err)
+ assert.Contains(t, string(resp), "is not allowed")
+ dataprovider.EnabledActionCommands = nil
action.Type = dataprovider.ActionTypeEmail
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
@@ -2454,28 +2808,34 @@ func TestEventActionValidation(t *testing.T) {
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
assert.NoError(t, err)
assert.Contains(t, string(resp), "no path to rename specified")
- action.Options.FsConfig.Renames = []dataprovider.KeyValue{
+ action.Options.FsConfig.Renames = []dataprovider.RenameConfig{
{
- Key: "",
- Value: "/adir",
+ KeyValue: dataprovider.KeyValue{
+ Key: "",
+ Value: "/adir",
+ },
},
}
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
assert.NoError(t, err)
assert.Contains(t, string(resp), "invalid paths to rename")
- action.Options.FsConfig.Renames = []dataprovider.KeyValue{
+ action.Options.FsConfig.Renames = []dataprovider.RenameConfig{
{
- Key: "adir",
- Value: "/adir",
+ KeyValue: dataprovider.KeyValue{
+ Key: "adir",
+ Value: "/adir",
+ },
},
}
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
assert.NoError(t, err)
assert.Contains(t, string(resp), "rename source and target cannot be equal")
- action.Options.FsConfig.Renames = []dataprovider.KeyValue{
+ action.Options.FsConfig.Renames = []dataprovider.RenameConfig{
{
- Key: "/",
- Value: "/dir",
+ KeyValue: dataprovider.KeyValue{
+ Key: "/",
+ Value: "/dir",
+ },
},
}
_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
@@ -2697,6 +3057,21 @@ func TestEventRuleValidation(t *testing.T) {
_, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest)
assert.NoError(t, err)
assert.Contains(t, string(resp), "sync execution is only supported for upload and pre-* events")
+
+ rule.Conditions.FsEvents = []string{"download"}
+ rule.Conditions.Options.EventStatuses = []int{3, 2, 8}
+ rule.Actions = []dataprovider.EventAction{
+ {
+ BaseEventAction: dataprovider.BaseEventAction{
+ Name: "action",
+ },
+ Order: 1,
+ },
+ }
+ _, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest)
+ assert.NoError(t, err)
+ assert.Contains(t, string(resp), "invalid event_status")
+
rule.Trigger = dataprovider.EventTriggerProviderEvent
rule.Actions = []dataprovider.EventAction{
{
@@ -3289,32 +3664,44 @@ func TestLoginRedirectNext(t *testing.T) {
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), fmt.Sprintf("action=%q", redirectURI))
// now login the user and check the redirect
- csrfToken, err := getCSRFTokenMock(webClientLoginPath, defaultRemoteAddr)
+ loginCookie, csrfToken, err := getCSRFTokenMock(webClientLoginPath, defaultRemoteAddr)
assert.NoError(t, err)
+ assert.NotEmpty(t, loginCookie)
form := getLoginForm(defaultUsername, defaultPassword, csrfToken)
req, err = http.NewRequest(http.MethodPost, redirectURI, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
req.RequestURI = redirectURI
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr)
assert.Equal(t, uri, rr.Header().Get("Location"))
// unsafe URI
+ loginCookie, csrfToken, err = getCSRFTokenMock(webClientLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, loginCookie)
+ form = getLoginForm(defaultUsername, defaultPassword, csrfToken)
unsafeURI := webClientLoginPath + "?next=" + url.QueryEscape("http://example.net")
req, err = http.NewRequest(http.MethodPost, unsafeURI, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
req.RequestURI = unsafeURI
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr)
assert.Equal(t, webClientFilesPath, rr.Header().Get("Location"))
+ loginCookie, csrfToken, err = getCSRFTokenMock(webClientLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, loginCookie)
+ form = getLoginForm(defaultUsername, defaultPassword, csrfToken)
unsupportedURI := webClientLoginPath + "?next=" + url.QueryEscape(webClientProfilePath)
req, err = http.NewRequest(http.MethodPost, unsupportedURI, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
req.RequestURI = unsupportedURI
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr)
@@ -3397,7 +3784,7 @@ func TestMustChangePasswordRequirement(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusForbidden, rr)
- csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webChangeClientPwdPath, webToken)
assert.NoError(t, err)
form := make(url.Values)
form.Set(csrfFormToken, csrfToken)
@@ -3682,9 +4069,10 @@ func TestAdminMustChangePasswordRequirement(t *testing.T) {
setJWTCookieForReq(req, webToken)
rr := executeRequest(req)
checkResponseCode(t, http.StatusForbidden, rr)
-
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ // The change password page should be accessible, we get the CSRF from it.
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webChangeAdminPwdPath, webToken)
assert.NoError(t, err)
+
form := make(url.Values)
form.Set(csrfFormToken, csrfToken)
form.Set("current_password", defaultTokenAuthPass)
@@ -4804,9 +5192,6 @@ func TestAddUserInvalidFsConfig(t *testing.T) {
u.FsConfig.AzBlobConfig.AccountName = "name"
_, _, err = httpdtest.AddUser(u, http.StatusBadRequest)
assert.NoError(t, err)
- u.FsConfig.AzBlobConfig.Container = "container"
- _, _, err = httpdtest.AddUser(u, http.StatusBadRequest)
- assert.NoError(t, err)
u.FsConfig.AzBlobConfig.AccountKey = kms.NewSecret(sdkkms.SecretStatusRedacted, "key", "", "")
u.FsConfig.AzBlobConfig.KeyPrefix = "/amedir/subdir/"
_, _, err = httpdtest.AddUser(u, http.StatusBadRequest)
@@ -4910,6 +5295,12 @@ func TestUserRedactedPassword(t *testing.T) {
assert.Contains(t, err.Error(), "cannot save a user with a redacted secret")
}
u.FsConfig.S3Config.AccessSecret = kms.NewPlainSecret("secret")
+ u.FsConfig.S3Config.SSECustomerKey = kms.NewSecret(sdkkms.SecretStatusRedacted, "mysecretkey", "", "")
+ _, resp, err = httpdtest.AddUser(u, http.StatusBadRequest)
+ assert.NoError(t, err, string(resp))
+ assert.Contains(t, string(resp), "cannot save a user with a redacted secret")
+
+ u.FsConfig.S3Config.SSECustomerKey = kms.NewPlainSecret("key")
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
@@ -4958,6 +5349,13 @@ func TestRetentionAPI(t *testing.T) {
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
assert.NoError(t, err)
+ t.Cleanup(func() {
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+ err = os.RemoveAll(user.GetHomeDir())
+ assert.NoError(t, err)
+ })
+
checks, _, err := httpdtest.GetRetentionChecks(http.StatusOK)
assert.NoError(t, err)
assert.Len(t, checks, 0)
@@ -4971,21 +5369,19 @@ func TestRetentionAPI(t *testing.T) {
folderRetention := []dataprovider.FolderRetention{
{
Path: "/",
- Retention: 0,
+ Retention: 24,
DeleteEmptyDirs: true,
},
}
- _, err = httpdtest.StartRetentionCheck(altAdminUsername, folderRetention, http.StatusNotFound)
- assert.NoError(t, err)
+ check := common.RetentionCheck{
+ Folders: folderRetention,
+ }
+ c := common.RetentionChecks.Add(check, &user)
+ require.NotNil(t, c)
- resp, err := httpdtest.StartRetentionCheck(user.Username, folderRetention, http.StatusBadRequest)
- assert.NoError(t, err)
- assert.Contains(t, string(resp), "Invalid retention check")
-
- folderRetention[0].Retention = 24
- _, err = httpdtest.StartRetentionCheck(user.Username, folderRetention, http.StatusAccepted)
- assert.NoError(t, err)
+ err = c.Start()
+ require.NoError(t, err)
assert.Eventually(t, func() bool {
return len(common.RetentionChecks.Get("")) == 0
@@ -4996,8 +5392,8 @@ func TestRetentionAPI(t *testing.T) {
err = os.Chtimes(localFilePath, time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour))
assert.NoError(t, err)
- _, err = httpdtest.StartRetentionCheck(user.Username, folderRetention, http.StatusAccepted)
- assert.NoError(t, err)
+ err = c.Start()
+ require.NoError(t, err)
assert.Eventually(t, func() bool {
return len(common.RetentionChecks.Get("")) == 0
@@ -5006,54 +5402,25 @@ func TestRetentionAPI(t *testing.T) {
assert.NoFileExists(t, localFilePath)
assert.NoDirExists(t, filepath.Dir(localFilePath))
- check := common.RetentionCheck{
- Folders: folderRetention,
- }
- c := common.RetentionChecks.Add(check, &user)
+ c = common.RetentionChecks.Add(check, &user)
assert.NotNil(t, c)
- _, err = httpdtest.StartRetentionCheck(user.Username, folderRetention, http.StatusConflict)
+ assert.Nil(t, common.RetentionChecks.Add(check, &user)) // a check for this user is already in progress
+
+ checks, _, err = httpdtest.GetRetentionChecks(http.StatusOK)
assert.NoError(t, err)
+ assert.Len(t, checks, 1)
err = c.Start()
assert.NoError(t, err)
- assert.Len(t, common.RetentionChecks.Get(""), 0)
- admin := getTestAdmin()
- admin.Username = altAdminUsername
- admin.Password = altAdminPassword
- admin, _, err = httpdtest.AddAdmin(admin, http.StatusCreated)
- assert.NoError(t, err)
+ assert.Eventually(t, func() bool {
+ return len(common.RetentionChecks.Get("")) == 0
+ }, 1000*time.Millisecond, 50*time.Millisecond)
- token, err := getJWTAPITokenFromTestServer(altAdminUsername, altAdminPassword)
- assert.NoError(t, err)
- req, _ := http.NewRequest(http.MethodPost, retentionBasePath+"/"+user.Username+"/check",
- bytes.NewBuffer([]byte("invalid json")))
- setBearerForReq(req, token)
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusBadRequest, rr)
-
- asJSON, err := json.Marshal(folderRetention)
- assert.NoError(t, err)
- req, _ = http.NewRequest(http.MethodPost, retentionBasePath+"/"+user.Username+"/check?notifications=Email,",
- bytes.NewBuffer(asJSON))
- setBearerForReq(req, token)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusBadRequest, rr)
- assert.Contains(t, rr.Body.String(), "to notify results via email")
-
- _, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
- assert.NoError(t, err)
- req, _ = http.NewRequest(http.MethodPost, retentionBasePath+"/"+user.Username+"/check?notifications=Email",
- bytes.NewBuffer(asJSON))
- setBearerForReq(req, token)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusNotFound, rr)
-
- _, err = httpdtest.RemoveUser(user, http.StatusOK)
- assert.NoError(t, err)
- err = os.RemoveAll(user.GetHomeDir())
+ checks, _, err = httpdtest.GetRetentionChecks(http.StatusOK)
assert.NoError(t, err)
+ assert.Len(t, checks, 0)
}
func TestAddUserInvalidVirtualFolders(t *testing.T) {
@@ -5183,6 +5550,13 @@ func TestUserPublicKey(t *testing.T) {
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
+ // DSA keys are not accepted
+ u = getTestUser()
+ u.Password = ""
+ u.PublicKeys = []string{"ssh-dss AAAAB3NzaC1kc3MAAACBAK+BKLZs1Vd0cWYOquKfp++0ml9hkzB7UDRozT3nhRcyHcuwASsXiVTqsg96oGjBcUUy076CXlsfJEXE2P0dF6tt1wvABPMwKpOn+kIrfJ0j93X2c2KIZNlD4YuNUJjLHu1DvgQHw8NMps6l5D0M5NFCRdD3NYhI5zFVJJ4CzikrAAAAFQCRBagw7gEbs0gd8So7OLMcSVzs/wAAAIBjuo7U9q8npchQ3otgCvj0xIwsQ+Fi9bH0SBceqbCcVzFYY6JXSQ0XmwHs+0AuvRCPIGaBdfcm+w+9YOxREtdEVjcmkYlfJpTaVljjWcWFWTQddbiamZhQ/xLU9CNLK4oYLwIGLZjCcG7nRDdLtLQdBFuzP/faEi3TD2BK114QmAAAAIEAj1n34pH2WKwbSZhzmz/OG0VzqJICFWboiM44LZl2AqcRBvEEycdHlGe2IKaj5lEtLgBKJt9NSFhBIzWh7gcEzSMlkiDecdYSFlDc4snmTiXaoiIehV59nTY6gc8GLWCzuem+WdHxvJ4yOSWF9k+a+Y+/v/35shNLkfokViOlN7k="}
+ _, resp, err := httpdtest.AddUser(u, http.StatusBadRequest)
+ assert.NoError(t, err)
+ assert.Contains(t, string(resp), "DSA key format is insecure and it is not allowed")
}
func TestUpdateUserEmptyPassword(t *testing.T) {
@@ -5629,6 +6003,7 @@ func TestUserS3Config(t *testing.T) {
user.FsConfig.S3Config.Bucket = "test" //nolint:goconst
user.FsConfig.S3Config.AccessKey = "Server-Access-Key"
user.FsConfig.S3Config.AccessSecret = kms.NewPlainSecret("Server-Access-Secret")
+ user.FsConfig.S3Config.SSECustomerKey = kms.NewPlainSecret("SSE-encryption-key")
user.FsConfig.S3Config.RoleARN = "myRoleARN"
user.FsConfig.S3Config.Endpoint = "http://127.0.0.1:9000"
user.FsConfig.S3Config.UploadPartSize = 8
@@ -5662,6 +6037,10 @@ func TestUserS3Config(t *testing.T) {
assert.NotEmpty(t, user.FsConfig.S3Config.AccessSecret.GetPayload())
assert.Empty(t, user.FsConfig.S3Config.AccessSecret.GetAdditionalData())
assert.Empty(t, user.FsConfig.S3Config.AccessSecret.GetKey())
+ assert.Equal(t, sdkkms.SecretStatusSecretBox, user.FsConfig.S3Config.SSECustomerKey.GetStatus())
+ assert.NotEmpty(t, user.FsConfig.S3Config.SSECustomerKey.GetPayload())
+ assert.Empty(t, user.FsConfig.S3Config.SSECustomerKey.GetAdditionalData())
+ assert.Empty(t, user.FsConfig.S3Config.SSECustomerKey.GetKey())
assert.Equal(t, 60, user.FsConfig.S3Config.DownloadPartMaxTime)
assert.Equal(t, 40, user.FsConfig.S3Config.UploadPartMaxTime)
assert.True(t, user.FsConfig.S3Config.SkipTLSVerify)
@@ -5686,13 +6065,14 @@ func TestUserS3Config(t *testing.T) {
user.ID = 0
user.CreatedAt = 0
user.VirtualFolders = nil
+ user.FsConfig.S3Config.SSECustomerKey = kms.NewEmptySecret()
secret := kms.NewSecret(sdkkms.SecretStatusSecretBox, "Server-Access-Secret", "", "")
user.FsConfig.S3Config.AccessSecret = secret
_, _, err = httpdtest.AddUser(user, http.StatusCreated)
assert.Error(t, err)
user.FsConfig.S3Config.AccessSecret.SetStatus(sdkkms.SecretStatusPlain)
- user, _, err = httpdtest.AddUser(user, http.StatusCreated)
- assert.NoError(t, err)
+ user, resp, err := httpdtest.AddUser(user, http.StatusCreated)
+ assert.NoError(t, err, string(resp))
initialSecretPayload := user.FsConfig.S3Config.AccessSecret.GetPayload()
assert.Equal(t, sdkkms.SecretStatusSecretBox, user.FsConfig.S3Config.AccessSecret.GetStatus())
assert.NotEmpty(t, initialSecretPayload)
@@ -6069,6 +6449,7 @@ func TestUserHiddenFields(t *testing.T) {
u1.FsConfig.S3Config.Region = "us-east-1"
u1.FsConfig.S3Config.AccessKey = "S3-Access-Key"
u1.FsConfig.S3Config.AccessSecret = kms.NewPlainSecret("S3-Access-Secret")
+ u1.FsConfig.S3Config.SSECustomerKey = kms.NewPlainSecret("SSE-secret-key")
user1, _, err := httpdtest.AddUser(u1, http.StatusCreated)
assert.NoError(t, err)
@@ -6141,6 +6522,10 @@ func TestUserHiddenFields(t *testing.T) {
assert.Empty(t, user1.FsConfig.S3Config.AccessSecret.GetAdditionalData())
assert.NotEmpty(t, user1.FsConfig.S3Config.AccessSecret.GetStatus())
assert.NotEmpty(t, user1.FsConfig.S3Config.AccessSecret.GetPayload())
+ assert.Empty(t, user1.FsConfig.S3Config.SSECustomerKey.GetKey())
+ assert.Empty(t, user1.FsConfig.S3Config.SSECustomerKey.GetAdditionalData())
+ assert.NotEmpty(t, user1.FsConfig.S3Config.SSECustomerKey.GetStatus())
+ assert.NotEmpty(t, user1.FsConfig.S3Config.SSECustomerKey.GetPayload())
user2, _, err = httpdtest.GetUserByUsername(user2.Username, http.StatusOK)
assert.NoError(t, err)
@@ -6195,12 +6580,22 @@ func TestUserHiddenFields(t *testing.T) {
assert.NotEmpty(t, user1.FsConfig.S3Config.AccessSecret.GetAdditionalData())
assert.NotEmpty(t, user1.FsConfig.S3Config.AccessSecret.GetStatus())
assert.NotEmpty(t, user1.FsConfig.S3Config.AccessSecret.GetPayload())
+ assert.NotEmpty(t, user1.FsConfig.S3Config.SSECustomerKey.GetKey())
+ assert.NotEmpty(t, user1.FsConfig.S3Config.SSECustomerKey.GetAdditionalData())
+ assert.NotEmpty(t, user1.FsConfig.S3Config.SSECustomerKey.GetStatus())
+ assert.NotEmpty(t, user1.FsConfig.S3Config.SSECustomerKey.GetPayload())
err = user1.FsConfig.S3Config.AccessSecret.Decrypt()
assert.NoError(t, err)
+ err = user1.FsConfig.S3Config.SSECustomerKey.Decrypt()
+ assert.NoError(t, err)
assert.Equal(t, sdkkms.SecretStatusPlain, user1.FsConfig.S3Config.AccessSecret.GetStatus())
assert.Equal(t, u1.FsConfig.S3Config.AccessSecret.GetPayload(), user1.FsConfig.S3Config.AccessSecret.GetPayload())
assert.Empty(t, user1.FsConfig.S3Config.AccessSecret.GetKey())
assert.Empty(t, user1.FsConfig.S3Config.AccessSecret.GetAdditionalData())
+ assert.Equal(t, sdkkms.SecretStatusPlain, user1.FsConfig.S3Config.SSECustomerKey.GetStatus())
+ assert.Equal(t, u1.FsConfig.S3Config.SSECustomerKey.GetPayload(), user1.FsConfig.S3Config.SSECustomerKey.GetPayload())
+ assert.Empty(t, user1.FsConfig.S3Config.SSECustomerKey.GetKey())
+ assert.Empty(t, user1.FsConfig.S3Config.SSECustomerKey.GetAdditionalData())
user2, err = dataprovider.UserExists(user2.Username, "")
assert.NoError(t, err)
@@ -6637,6 +7032,7 @@ func TestCloseActiveConnection(t *testing.T) {
_, err = httpdtest.CloseConnection(c.GetID(), http.StatusOK)
assert.NoError(t, err)
assert.Len(t, common.Connections.GetStats(""), 0)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func TestCloseConnectionAfterUserUpdateDelete(t *testing.T) {
@@ -6669,6 +7065,7 @@ func TestCloseConnectionAfterUserUpdateDelete(t *testing.T) {
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
assert.Len(t, common.Connections.GetStats(""), 0)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func TestAdminGenerateRecoveryCodesSaveError(t *testing.T) {
@@ -6727,6 +7124,57 @@ func TestAdminGenerateRecoveryCodesSaveError(t *testing.T) {
assert.NoError(t, err)
}
+func TestAdminCredentialsWithSpaces(t *testing.T) {
+ a := getTestAdmin()
+ a.Username = xid.New().String()
+ a.Password = " " + xid.New().String() + " "
+ admin, _, err := httpdtest.AddAdmin(a, http.StatusCreated)
+ assert.NoError(t, err)
+ // For admins the password is always trimmed.
+ _, err = getJWTAPITokenFromTestServer(a.Username, a.Password)
+ assert.Error(t, err)
+ _, err = getJWTAPITokenFromTestServer(a.Username, strings.TrimSpace(a.Password))
+ assert.NoError(t, err)
+ // The password sent from the WebAdmin UI is automatically trimmed
+ _, err = getJWTWebToken(a.Username, a.Password)
+ assert.NoError(t, err)
+ _, err = getJWTWebToken(a.Username, strings.TrimSpace(a.Password))
+ assert.NoError(t, err)
+
+ _, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
+ assert.NoError(t, err)
+}
+
+func TestUserCredentialsWithSpaces(t *testing.T) {
+ u := getTestUser()
+ u.Password = " " + xid.New().String() + " "
+ user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+ assert.NoError(t, err)
+ // For users the password is not trimmed
+ _, err = getJWTAPIUserTokenFromTestServer(u.Username, u.Password)
+ assert.NoError(t, err)
+ _, err = getJWTAPIUserTokenFromTestServer(u.Username, strings.TrimSpace(u.Password))
+ assert.Error(t, err)
+
+ _, err = getJWTWebClientTokenFromTestServer(u.Username, u.Password)
+ assert.NoError(t, err)
+ _, err = getJWTWebClientTokenFromTestServer(u.Username, strings.TrimSpace(u.Password))
+ assert.Error(t, err)
+
+ user.Password = u.Password
+ conn, sftpClient, err := getSftpClient(user)
+ if assert.NoError(t, err) {
+ conn.Close()
+ sftpClient.Close()
+ }
+ user.Password = strings.TrimSpace(u.Password)
+ _, _, err = getSftpClient(user)
+ assert.Error(t, err)
+
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+}
+
func TestNamingRules(t *testing.T) {
smtpCfg := smtp.Config{
Host: "127.0.0.1",
@@ -6822,10 +7270,10 @@ func TestNamingRules(t *testing.T) {
return
}
- csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
- assert.NoError(t, err)
token, err = getJWTWebClientTokenFromTestServer(user.Username, defaultPassword)
assert.NoError(t, err)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webClientProfilePath, token)
+ assert.NoError(t, err)
form := make(url.Values)
form.Set(csrfFormToken, csrfToken)
req, err := http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
@@ -6836,6 +7284,8 @@ func TestNamingRules(t *testing.T) {
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidUser)
// test user reset password. Setting the new password will fail because the username is not valid
+ loginCookie, csrfToken, err := getCSRFTokenMock(webClientLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set("username", user.Username)
form.Set(csrfFormToken, csrfToken)
@@ -6843,6 +7293,7 @@ func TestNamingRules(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
@@ -6855,6 +7306,7 @@ func TestNamingRules(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusOK, rr.Code)
@@ -6896,7 +7348,7 @@ func TestNamingRules(t *testing.T) {
token, err = getJWTWebTokenFromTestServer(admin.Username, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err = getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webAdminProfilePath, token)
assert.NoError(t, err)
form = make(url.Values)
form.Set(csrfFormToken, csrfToken)
@@ -6927,6 +7379,8 @@ func TestNamingRules(t *testing.T) {
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "the following characters are allowed")
// test admin reset password
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set("username", admin.Username)
form.Set(csrfFormToken, csrfToken)
@@ -6934,10 +7388,13 @@ func TestNamingRules(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webAdminForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
assert.GreaterOrEqual(t, len(lastResetCode), 20)
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set(csrfFormToken, csrfToken)
form.Set("code", lastResetCode)
@@ -6946,6 +7403,7 @@ func TestNamingRules(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webAdminResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusOK, rr.Code)
@@ -7097,12 +7555,13 @@ func TestSaveErrors(t *testing.T) {
assert.NoError(t, err)
assert.Contains(t, string(resp), "the following characters are allowed")
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ loginCookie, csrfToken, err := getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
assert.NoError(t, err)
form := getLoginForm(a.Username, a.Password, csrfToken)
req, err := http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
@@ -7110,6 +7569,8 @@ func TestSaveErrors(t *testing.T) {
cookie, err := getCookieFromResponse(rr)
assert.NoError(t, err)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webAdminTwoFactorRecoveryPath, cookie)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set("recovery_code", recCode)
form.Set(csrfFormToken, csrfToken)
@@ -7122,12 +7583,13 @@ func TestSaveErrors(t *testing.T) {
assert.Equal(t, http.StatusInternalServerError, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nError500Message)
- csrfToken, err = getCSRFToken(httpBaseURL + webClientLoginPath)
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
assert.NoError(t, err)
form = getLoginForm(u.Username, u.Password, csrfToken)
req, err = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
@@ -7135,6 +7597,8 @@ func TestSaveErrors(t *testing.T) {
cookie, err = getCookieFromResponse(rr)
assert.NoError(t, err)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webClientTwoFactorRecoveryPath, cookie)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set("recovery_code", recCode)
form.Set(csrfFormToken, csrfToken)
@@ -7277,7 +7741,7 @@ func TestProviderErrors(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusInternalServerError, rr)
// password reset errors
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ loginCookie, csrfToken, err := getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
assert.NoError(t, err)
form := make(url.Values)
form.Set("username", "username")
@@ -7285,6 +7749,7 @@ func TestProviderErrors(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusOK, rr.Code)
@@ -7411,7 +7876,7 @@ func TestProviderErrors(t *testing.T) {
backupData = dataprovider.BackupData{
EventActions: []dataprovider.BaseEventAction{
{
- Name: "quota reset",
+ Name: "quota_reset",
Type: dataprovider.ActionTypeFolderQuotaReset,
},
},
@@ -7426,7 +7891,7 @@ func TestProviderErrors(t *testing.T) {
backupData = dataprovider.BackupData{
EventRules: []dataprovider.EventRule{
{
- Name: "quota reset",
+ Name: "quota_reset",
Trigger: dataprovider.EventTriggerSchedule,
Conditions: dataprovider.EventConditions{
Schedules: []dataprovider.Schedule{
@@ -8220,7 +8685,7 @@ func TestLoaddata(t *testing.T) {
}
action := dataprovider.BaseEventAction{
ID: 81,
- Name: "test restore action",
+ Name: "test_restore_action",
Type: dataprovider.ActionTypeHTTP,
Options: dataprovider.BaseEventActionOptions{
HTTPConfig: dataprovider.EventActionHTTPConfig{
@@ -8230,13 +8695,13 @@ func TestLoaddata(t *testing.T) {
Timeout: 10,
SkipTLSVerify: true,
Method: http.MethodPost,
- Body: `{"event":"{{Event}}","name":"{{Name}}"}`,
+ Body: `{"event":"{{.Event}}","name":"{{.Name}}"}`,
},
},
}
rule := dataprovider.EventRule{
ID: 100,
- Name: "test rule restore",
+ Name: "test_rule_restore",
Description: "",
Trigger: dataprovider.EventTriggerFsEvent,
Conditions: dataprovider.EventConditions{
@@ -8254,7 +8719,7 @@ func TestLoaddata(t *testing.T) {
configs := dataprovider.Configs{
SFTPD: &dataprovider.SFTPDConfigs{
HostKeyAlgos: []string{ssh.KeyAlgoRSA, ssh.CertAlgoRSAv01},
- PublicKeyAlgos: []string{ssh.InsecureKeyAlgoDSA},
+ PublicKeyAlgos: []string{ssh.InsecureKeyAlgoDSA}, //nolint:staticcheck
},
SMTP: &dataprovider.SMTPConfigs{
Host: "mail.example.com",
@@ -8321,7 +8786,7 @@ func TestLoaddata(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, configs.SMTP, configsGet.SMTP)
assert.Equal(t, []string{ssh.KeyAlgoRSA}, configsGet.SFTPD.HostKeyAlgos)
- assert.Equal(t, []string{ssh.InsecureKeyAlgoDSA}, configsGet.SFTPD.PublicKeyAlgos)
+ assert.Equal(t, []string{ssh.InsecureKeyAlgoDSA}, configsGet.SFTPD.PublicKeyAlgos) //nolint:staticcheck
assert.Len(t, configsGet.SFTPD.KexAlgorithms, 0)
assert.Len(t, configsGet.SFTPD.Ciphers, 0)
assert.Len(t, configsGet.SFTPD.MACs, 0)
@@ -8476,6 +8941,85 @@ func TestLoaddata(t *testing.T) {
assert.NoError(t, err)
}
+func TestLoaddataConvertActions(t *testing.T) {
+ a1 := dataprovider.BaseEventAction{
+ Name: xid.New().String(),
+ Type: dataprovider.ActionTypeEmail,
+ Options: dataprovider.BaseEventActionOptions{
+ EmailConfig: dataprovider.EventActionEmailConfig{
+ Recipients: []string{"failure@example.com"},
+ Subject: `Failed "{{Event}}" from "{{Name}}"`,
+ Body: "Object name: {{ObjectName}} object type: {{ObjectType}}, IP: {{IP}}",
+ },
+ },
+ }
+ a2 := dataprovider.BaseEventAction{
+ Name: xid.New().String(),
+ Type: dataprovider.ActionTypeFilesystem,
+ Options: dataprovider.BaseEventActionOptions{
+ FsConfig: dataprovider.EventActionFilesystemConfig{
+ Type: dataprovider.FilesystemActionRename,
+ Renames: []dataprovider.RenameConfig{
+ {
+ KeyValue: dataprovider.KeyValue{
+ Key: "/{{VirtualDirPath}}/{{ObjectName}}",
+ Value: "/{{ObjectName}}_renamed",
+ },
+ },
+ },
+ },
+ },
+ }
+ backupData := dataprovider.BackupData{
+ EventActions: []dataprovider.BaseEventAction{a1, a2},
+ Version: 16,
+ }
+ backupContent, err := json.Marshal(backupData)
+ assert.NoError(t, err)
+ backupFilePath := filepath.Join(backupsPath, "backup.json")
+ err = os.WriteFile(backupFilePath, backupContent, os.ModePerm)
+ assert.NoError(t, err)
+ _, resp, err := httpdtest.Loaddata(backupFilePath, "1", "2", http.StatusOK)
+ assert.NoError(t, err, string(resp))
+ // Check that actions are migrated.
+ action1, _, err := httpdtest.GetEventActionByName(a1.Name, http.StatusOK)
+ assert.NoError(t, err)
+ action2, _, err := httpdtest.GetEventActionByName(a2.Name, http.StatusOK)
+ assert.NoError(t, err)
+ assert.Equal(t, `Failed "{{.Event}}" from "{{.Name}}"`, action1.Options.EmailConfig.Subject)
+ assert.Equal(t, `Object name: {{.ObjectName}} object type: {{.ObjectType}}, IP: {{.IP}}`, action1.Options.EmailConfig.Body)
+ assert.Equal(t, `/{{.VirtualDirPath}}/{{.ObjectName}}`, action2.Options.FsConfig.Renames[0].Key)
+ assert.Equal(t, `/{{.ObjectName}}_renamed`, action2.Options.FsConfig.Renames[0].Value)
+ // If we restore a backup from the current version actions are not migrated.
+ backupData = dataprovider.BackupData{
+ EventActions: []dataprovider.BaseEventAction{a1, a2},
+ Version: dataprovider.DumpVersion,
+ }
+ backupContent, err = json.Marshal(backupData)
+ assert.NoError(t, err)
+ backupFilePath = filepath.Join(backupsPath, "backup.json")
+ err = os.WriteFile(backupFilePath, backupContent, os.ModePerm)
+ assert.NoError(t, err)
+ _, resp, err = httpdtest.Loaddata(backupFilePath, "1", "2", http.StatusOK)
+ assert.NoError(t, err, string(resp))
+ action1, _, err = httpdtest.GetEventActionByName(a1.Name, http.StatusOK)
+ assert.NoError(t, err)
+ action2, _, err = httpdtest.GetEventActionByName(a2.Name, http.StatusOK)
+ assert.NoError(t, err)
+ assert.Equal(t, `Failed "{{Event}}" from "{{Name}}"`, action1.Options.EmailConfig.Subject)
+ assert.Equal(t, `Object name: {{ObjectName}} object type: {{ObjectType}}, IP: {{IP}}`, action1.Options.EmailConfig.Body)
+ assert.Equal(t, `/{{VirtualDirPath}}/{{ObjectName}}`, action2.Options.FsConfig.Renames[0].Key)
+ assert.Equal(t, `/{{ObjectName}}_renamed`, action2.Options.FsConfig.Renames[0].Value)
+ // Cleanup.
+ _, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveEventAction(action2, http.StatusOK)
+ assert.NoError(t, err)
+ actions, _, err := httpdtest.GetEventActions(0, 0, http.StatusOK)
+ assert.NoError(t, err)
+ assert.Len(t, actions, 0)
+}
+
func TestLoaddataMode(t *testing.T) {
err := dataprovider.UpdateConfigs(nil, "", "", "")
assert.NoError(t, err)
@@ -8522,7 +9066,7 @@ func TestLoaddataMode(t *testing.T) {
}
action := dataprovider.BaseEventAction{
ID: 81,
- Name: "test restore action data mode",
+ Name: "test_restore_action_data_mode",
Description: "action desc",
Type: dataprovider.ActionTypeHTTP,
Options: dataprovider.BaseEventActionOptions{
@@ -8533,13 +9077,13 @@ func TestLoaddataMode(t *testing.T) {
Timeout: 10,
SkipTLSVerify: true,
Method: http.MethodPost,
- Body: `{"event":"{{Event}}","name":"{{Name}}"}`,
+ Body: `{"event":"{{.Event}}","name":"{{.Name}}"}`,
},
},
}
rule := dataprovider.EventRule{
ID: 100,
- Name: "test rule restore data mode",
+ Name: "test_rule_restore_data_mode",
Description: "rule desc",
Trigger: dataprovider.EventTriggerFsEvent,
Conditions: dataprovider.EventConditions{
@@ -8668,7 +9212,7 @@ func TestLoaddataMode(t *testing.T) {
entry, _, err = httpdtest.UpdateIPListEntry(entry, http.StatusOK)
assert.NoError(t, err)
- configs.SFTPD.PublicKeyAlgos = append(configs.SFTPD.PublicKeyAlgos, ssh.InsecureKeyAlgoDSA)
+ configs.SFTPD.PublicKeyAlgos = append(configs.SFTPD.PublicKeyAlgos, ssh.InsecureKeyAlgoDSA) //nolint:staticcheck
err = dataprovider.UpdateConfigs(&configs, "", "", "")
assert.NoError(t, err)
backupData.Configs = &configs
@@ -8737,6 +9281,7 @@ func TestLoaddataMode(t *testing.T) {
assert.NoError(t, err)
// mode 2 will update the user and close the previous connection
assert.Len(t, common.Connections.GetStats(""), 0)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, oldUploadBandwidth, user.UploadBandwidth)
@@ -8879,7 +9424,7 @@ func TestBasicUserHandlingMock(t *testing.T) {
assert.Equal(t, user.MaxSessions, updatedUser.MaxSessions)
assert.Equal(t, user.UploadBandwidth, updatedUser.UploadBandwidth)
assert.Equal(t, 1, len(updatedUser.Permissions["/"]))
- assert.True(t, util.Contains(updatedUser.Permissions["/"], dataprovider.PermAny))
+ assert.True(t, slices.Contains(updatedUser.Permissions["/"], dataprovider.PermAny))
req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+user.Username, nil)
setBearerForReq(req, token)
rr = executeRequest(req)
@@ -9045,7 +9590,7 @@ func TestEventRuleErrorsMock(t *testing.T) {
checkResponseCode(t, http.StatusBadRequest, rr)
a := dataprovider.BaseEventAction{
- Name: "action name",
+ Name: "action_name",
Description: "test description",
Type: dataprovider.ActionTypeBackup,
Options: dataprovider.BaseEventActionOptions{},
@@ -9060,7 +9605,7 @@ func TestEventRuleErrorsMock(t *testing.T) {
checkResponseCode(t, http.StatusBadRequest, rr)
r := dataprovider.EventRule{
- Name: "test event rule",
+ Name: "test_event_rule",
Trigger: dataprovider.EventTriggerSchedule,
Conditions: dataprovider.EventConditions{
Schedules: []dataprovider.Schedule{
@@ -9271,12 +9816,13 @@ func TestAdminTwoFactorLogin(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ loginCookie, csrfToken, err := getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
assert.NoError(t, err)
form := getLoginForm(altAdminUsername, altAdminPassword, csrfToken)
req, err = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
@@ -9331,6 +9877,8 @@ func TestAdminTwoFactorLogin(t *testing.T) {
assert.Equal(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCSRF)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webAdminTwoFactorRecoveryPath, cookie)
+ assert.NoError(t, err)
form.Set(csrfFormToken, csrfToken)
form.Set("passcode", "invalid_passcode")
req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorPath, bytes.NewBuffer([]byte(form.Encode())))
@@ -9369,10 +9917,13 @@ func TestAdminTwoFactorLogin(t *testing.T) {
rr = executeRequest(req)
assert.Equal(t, http.StatusNotFound, rr.Code)
// get a new cookie and login using a recovery code
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
form = getLoginForm(altAdminUsername, altAdminPassword, csrfToken)
req, err = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
@@ -9393,6 +9944,8 @@ func TestAdminTwoFactorLogin(t *testing.T) {
assert.Equal(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCSRF)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webAdminTwoFactorRecoveryPath, cookie)
+ assert.NoError(t, err)
form.Set(csrfFormToken, csrfToken)
form.Set("recovery_code", "")
req, err = http.NewRequest(http.MethodPost, webAdminTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode())))
@@ -9443,16 +9996,21 @@ func TestAdminTwoFactorLogin(t *testing.T) {
}
assert.True(t, found)
// the same recovery code cannot be reused
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
form = getLoginForm(altAdminUsername, altAdminPassword, csrfToken)
req, err = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webAdminTwoFactorPath, rr.Header().Get("Location"))
cookie, err = getCookieFromResponse(rr)
assert.NoError(t, err)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webAdminTwoFactorRecoveryPath, cookie)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set("recovery_code", recoveryCode)
form.Set(csrfFormToken, csrfToken)
@@ -9475,12 +10033,23 @@ func TestAdminTwoFactorLogin(t *testing.T) {
assert.Equal(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCredentials)
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
form = getLoginForm(altAdminUsername, altAdminPassword, csrfToken)
req, err = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
+ assert.Equal(t, http.StatusOK, rr.Code)
+ assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCSRF)
+
+ req, err = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
+ assert.NoError(t, err)
+ req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webAdminTwoFactorPath, rr.Header().Get("Location"))
cookie, err = getCookieFromResponse(rr)
@@ -9508,6 +10077,8 @@ func TestAdminTwoFactorLogin(t *testing.T) {
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "two-factor authentication is not enabled")
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webAdminTwoFactorRecoveryPath, cookie)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set("recovery_code", recoveryCode)
form.Set(csrfFormToken, csrfToken)
@@ -9781,7 +10352,7 @@ func TestSMTPConfig(t *testing.T) {
tokenHeader := "X-CSRF-TOKEN"
webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webConfigsPath, webToken)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, smtpTestURL, bytes.NewBuffer([]byte("{")))
assert.NoError(t, err)
@@ -9865,7 +10436,7 @@ func TestOAuth2TokenRequest(t *testing.T) {
tokenHeader := "X-CSRF-TOKEN"
webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webConfigsPath, webToken)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, webOAuth2TokenPath, bytes.NewBuffer([]byte("{")))
assert.NoError(t, err)
@@ -10006,14 +10577,24 @@ func TestWebUserTwoFactorLogin(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
- csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
+ loginCookie, csrfToken, err := getCSRFTokenMock(webClientLoginPath, defaultRemoteAddr)
assert.NoError(t, err)
form := getLoginForm(defaultUsername, defaultPassword, csrfToken)
+ // CSRF verification fails if there is no cookie
req, err = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
+ assert.Equal(t, http.StatusOK, rr.Code)
+ assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCSRF)
+
+ req, err = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode())))
+ assert.NoError(t, err)
+ req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientTwoFactorPath, rr.Header().Get("Location"))
cookie, err := getCookieFromResponse(rr)
@@ -10030,6 +10611,13 @@ func TestWebUserTwoFactorLogin(t *testing.T) {
assert.NoError(t, err)
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
+ // invalid IP address
+ req, err = http.NewRequest(http.MethodGet, webClientTwoFactorPath, nil)
+ assert.NoError(t, err)
+ setJWTCookieForReq(req, cookie)
+ req.RemoteAddr = "6.7.8.9:4567"
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusNotFound, rr)
req, err = http.NewRequest(http.MethodGet, webClientTwoFactorRecoveryPath, nil)
assert.NoError(t, err)
@@ -10065,6 +10653,8 @@ func TestWebUserTwoFactorLogin(t *testing.T) {
assert.Equal(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCSRF)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webClientTwoFactorPath, cookie)
+ assert.NoError(t, err)
form.Set(csrfFormToken, csrfToken)
form.Set("passcode", "invalid_user_passcode")
req, err = http.NewRequest(http.MethodPost, webClientTwoFactorPath, bytes.NewBuffer([]byte(form.Encode())))
@@ -10104,10 +10694,13 @@ func TestWebUserTwoFactorLogin(t *testing.T) {
rr = executeRequest(req)
assert.Equal(t, http.StatusNotFound, rr.Code)
// get a new cookie and login using a recovery code
+ loginCookie, csrfToken, err = getCSRFTokenMock(webClientLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
form = getLoginForm(defaultUsername, defaultPassword, csrfToken)
req, err = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
@@ -10127,6 +10720,8 @@ func TestWebUserTwoFactorLogin(t *testing.T) {
assert.Equal(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCSRF)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webClientTwoFactorRecoveryPath, cookie)
+ assert.NoError(t, err)
form.Set(csrfFormToken, csrfToken)
form.Set("recovery_code", "")
req, err = http.NewRequest(http.MethodPost, webClientTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode())))
@@ -10192,16 +10787,22 @@ func TestWebUserTwoFactorLogin(t *testing.T) {
}
assert.True(t, found)
// the same recovery code cannot be reused
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
form = getLoginForm(defaultUsername, defaultPassword, csrfToken)
req, err = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientTwoFactorPath, rr.Header().Get("Location"))
cookie, err = getCookieFromResponse(rr)
assert.NoError(t, err)
+
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webClientTwoFactorRecoveryPath, cookie)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set("recovery_code", recoveryCode)
form.Set(csrfFormToken, csrfToken)
@@ -10224,10 +10825,13 @@ func TestWebUserTwoFactorLogin(t *testing.T) {
assert.Equal(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCredentials)
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
form = getLoginForm(defaultUsername, defaultPassword, csrfToken)
req, err = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
@@ -10249,11 +10853,12 @@ func TestWebUserTwoFactorLogin(t *testing.T) {
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "two-factor authentication is not enabled")
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webClientTwoFactorRecoveryPath, cookie)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set("recovery_code", recoveryCode)
form.Set("passcode", passcode)
form.Set(csrfFormToken, csrfToken)
-
req, err = http.NewRequest(http.MethodPost, webClientTwoFactorRecoveryPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
setJWTCookieForReq(req, cookie)
@@ -10325,7 +10930,7 @@ func TestWebUserTwoFactoryLoginRedirect(t *testing.T) {
rr := executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
- csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
+ loginCookie, csrfToken, err := getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
assert.NoError(t, err)
form := getLoginForm(defaultUsername, defaultPassword, csrfToken)
uri := webClientFilesPath + "?path=%2F"
@@ -10335,6 +10940,7 @@ func TestWebUserTwoFactoryLoginRedirect(t *testing.T) {
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
req.RequestURI = loginURI
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
@@ -10342,20 +10948,29 @@ func TestWebUserTwoFactoryLoginRedirect(t *testing.T) {
cookie, err := getCookieFromResponse(rr)
assert.NoError(t, err)
// test unsafe redirects
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
+ form = getLoginForm(defaultUsername, defaultPassword, csrfToken)
externalURI := webClientLoginPath + "?next=" + url.QueryEscape("https://example.com")
req, err = http.NewRequest(http.MethodPost, externalURI, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
req.RequestURI = externalURI
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientTwoFactorPath, rr.Header().Get("Location"))
+
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
+ form = getLoginForm(defaultUsername, defaultPassword, csrfToken)
internalURI := webClientLoginPath + "?next=" + url.QueryEscape(webClientMFAPath)
req, err = http.NewRequest(http.MethodPost, internalURI, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
req.RequestURI = internalURI
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
@@ -10369,6 +10984,8 @@ func TestWebUserTwoFactoryLoginRedirect(t *testing.T) {
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), fmt.Sprintf("action=%q", expectedURI))
// login with the passcode
+ csrfToken, err = getCSRFTokenFromInternalPageMock(expectedURI, cookie)
+ assert.NoError(t, err)
passcode, err := generateTOTPPasscode(key.Secret())
assert.NoError(t, err)
form = make(url.Values)
@@ -10800,18 +11417,22 @@ func TestMFAInvalidSecret(t *testing.T) {
checkResponseCode(t, http.StatusInternalServerError, rr)
assert.Contains(t, rr.Body.String(), "Unable to decrypt recovery codes")
- csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
+ loginCookie, csrfToken, err := getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
assert.NoError(t, err)
form := getLoginForm(defaultUsername, defaultPassword, csrfToken)
req, err = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientTwoFactorPath, rr.Header().Get("Location"))
cookie, err := getCookieFromResponse(rr)
assert.NoError(t, err)
+
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webClientTwoFactorPath, cookie)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set(csrfFormToken, csrfToken)
form.Set("passcode", "123456")
@@ -10823,6 +11444,8 @@ func TestMFAInvalidSecret(t *testing.T) {
rr = executeRequest(req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webClientTwoFactorRecoveryPath, cookie)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set(csrfFormToken, csrfToken)
form.Set("recovery_code", "RC-123456")
@@ -10868,18 +11491,22 @@ func TestMFAInvalidSecret(t *testing.T) {
err = dataprovider.UpdateAdmin(&admin, "", "", "")
assert.NoError(t, err)
- csrfToken, err = getCSRFToken(httpBaseURL + webLoginPath)
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
assert.NoError(t, err)
form = getLoginForm(altAdminUsername, altAdminPassword, csrfToken)
req, err = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webAdminTwoFactorPath, rr.Header().Get("Location"))
cookie, err = getCookieFromResponse(rr)
assert.NoError(t, err)
+
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webAdminTwoFactorRecoveryPath, cookie)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set(csrfFormToken, csrfToken)
form.Set("passcode", "123456")
@@ -10891,6 +11518,8 @@ func TestMFAInvalidSecret(t *testing.T) {
rr = executeRequest(req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webAdminTwoFactorRecoveryPath, cookie)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set(csrfFormToken, csrfToken)
form.Set("recovery_code", "RC-123456")
@@ -11129,6 +11758,7 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
checkResponseCode(t, http.StatusBadRequest, rr)
email := "userapi@example.com"
+ additionalEmails := []string{"userapi1@example.com"}
description := "user API description"
profileReq := make(map[string]any)
profileReq["allow_api_key_auth"] = true
@@ -11136,6 +11766,7 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
profileReq["description"] = description
profileReq["public_keys"] = []string{testPubKey, testPubKey1}
profileReq["tls_certs"] = []string{httpsCert}
+ profileReq["additional_emails"] = additionalEmails
asJSON, err := json.Marshal(profileReq)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
@@ -11153,6 +11784,7 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
err = json.Unmarshal(rr.Body.Bytes(), &profileReq)
assert.NoError(t, err)
assert.Equal(t, email, profileReq["email"].(string))
+ assert.Len(t, profileReq["additional_emails"].([]interface{}), 1)
assert.Equal(t, description, profileReq["description"].(string))
assert.True(t, profileReq["allow_api_key_auth"].(bool))
val, ok := profileReq["public_keys"].([]any)
@@ -11174,6 +11806,17 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "Validation error: email")
+ // set an invalid additional email
+ profileReq = make(map[string]any)
+ profileReq["additional_emails"] = []string{"not an email"}
+ asJSON, err = json.Marshal(profileReq)
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
+ assert.NoError(t, err)
+ setBearerForReq(req, token)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusBadRequest, rr)
+ assert.Contains(t, rr.Body.String(), "Validation error: email")
// set an invalid public key
profileReq = make(map[string]any)
profileReq["public_keys"] = []string{"not a public key"}
@@ -11372,11 +12015,17 @@ func TestWebAPIChangeUserPwdMock(t *testing.T) {
assert.NoError(t, err)
token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
- // invalid json
- req, err := http.NewRequest(http.MethodPut, userPwdPath, bytes.NewBuffer([]byte("{")))
+
+ req, err := http.NewRequest(http.MethodGet, userProfilePath, nil)
assert.NoError(t, err)
setBearerForReq(req, token)
rr := executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
+ // invalid json
+ req, err = http.NewRequest(http.MethodPut, userPwdPath, bytes.NewBuffer([]byte("{")))
+ assert.NoError(t, err)
+ setBearerForReq(req, token)
+ rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
pwd := make(map[string]string)
@@ -11399,6 +12048,13 @@ func TestWebAPIChangeUserPwdMock(t *testing.T) {
setBearerForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
+
+ req, err = http.NewRequest(http.MethodGet, userProfilePath, nil)
+ assert.NoError(t, err)
+ setBearerForReq(req, token)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusUnauthorized, rr)
+
_, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
assert.Error(t, err)
token, err = getJWTAPIUserTokenFromTestServer(defaultUsername, altAdminPassword)
@@ -11548,6 +12204,12 @@ func TestChangeAdminPwdMock(t *testing.T) {
setBearerForReq(req, altToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
+ // try using the old token
+ req, err = http.NewRequest(http.MethodGet, versionPath, nil)
+ assert.NoError(t, err)
+ setBearerForReq(req, altToken)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusUnauthorized, rr)
_, err = getJWTAPITokenFromTestServer(altAdminUsername, altAdminPassword)
assert.Error(t, err)
@@ -11577,7 +12239,7 @@ func TestUpdateAdminMock(t *testing.T) {
assert.Error(t, err)
admin := getTestAdmin()
admin.Username = altAdminUsername
- admin.Permissions = []string{dataprovider.PermAdminManageAdmins}
+ admin.Permissions = []string{dataprovider.PermAdminAny}
asJSON, err := json.Marshal(admin)
assert.NoError(t, err)
req, _ := http.NewRequest(http.MethodPost, adminPath, bytes.NewBuffer(asJSON))
@@ -11619,7 +12281,7 @@ func TestUpdateAdminMock(t *testing.T) {
setBearerForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
- assert.Contains(t, rr.Body.String(), "you cannot remove these permissions to yourself")
+ assert.Contains(t, rr.Body.String(), "you cannot change your permissions")
admin.Permissions = []string{dataprovider.PermAdminAny}
admin.Role = "missing role"
asJSON, err = json.Marshal(admin)
@@ -11634,7 +12296,7 @@ func TestUpdateAdminMock(t *testing.T) {
altToken, err := getJWTAPITokenFromTestServer(altAdminUsername, defaultTokenAuthPass)
assert.NoError(t, err)
admin.Password = "" // it must remain unchanged
- admin.Permissions = []string{dataprovider.PermAdminManageAdmins, dataprovider.PermAdminCloseConnections}
+ admin.Permissions = []string{dataprovider.PermAdminAny}
asJSON, err = json.Marshal(admin)
assert.NoError(t, err)
req, _ = http.NewRequest(http.MethodPut, path.Join(adminPath, altAdminUsername), bytes.NewBuffer(asJSON))
@@ -12004,7 +12666,7 @@ func TestUserPermissionsMock(t *testing.T) {
err = render.DecodeJSON(rr.Body, &updatedUser)
assert.NoError(t, err)
if val, ok := updatedUser.Permissions["/otherdir"]; ok {
- assert.True(t, util.Contains(val, dataprovider.PermListItems))
+ assert.True(t, slices.Contains(val, dataprovider.PermListItems))
assert.Equal(t, 1, len(val))
} else {
assert.Fail(t, "expected dir not found in permissions")
@@ -12403,8 +13065,7 @@ func TestStartQuotaScanNonExistentFolderMock(t *testing.T) {
token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
folder := vfs.BaseVirtualFolder{
- MappedPath: os.TempDir(),
- Name: "afolder",
+ Name: "afolder",
}
req, _ := http.NewRequest(http.MethodPost, path.Join(quotasBasePath, "folders", folder.Name, "scan"), nil)
setBearerForReq(req, token)
@@ -12725,8 +13386,6 @@ func TestWebClientLoginMock(t *testing.T) {
assert.NoError(t, err)
webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
- assert.NoError(t, err)
// a web token is not valid for API or WebAdmin usage
req, _ := http.NewRequest(http.MethodGet, serverStatusPath, nil)
setBearerForReq(req, webToken)
@@ -12788,6 +13447,8 @@ func TestWebClientLoginMock(t *testing.T) {
assert.NoError(t, err)
apiUserToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webClientProfilePath, webToken)
+ assert.NoError(t, err)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
@@ -12907,6 +13568,7 @@ func TestWebClientMaxConnections(t *testing.T) {
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
assert.Len(t, common.Connections.GetStats(""), 0)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
common.Config.MaxTotalConnections = oldValue
}
@@ -13002,17 +13664,19 @@ func TestDefender(t *testing.T) {
checkResponseCode(t, http.StatusForbidden, rr)
assert.Contains(t, rr.Body.String(), "your IP address is blocked")
// requests for static files should be always allowed
- req, err = http.NewRequest(http.MethodGet, "/static/favicon.ico", nil)
+ req, err = http.NewRequest(http.MethodGet, "/static/favicon.png", nil)
assert.NoError(t, err)
req.RemoteAddr = remoteAddr
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
+ assert.Empty(t, rr.Header().Get("Cache-Control"))
req, err = http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/foo", nil)
assert.NoError(t, err)
req.RemoteAddr = remoteAddr
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
+ assert.Equal(t, "no-cache, no-store, max-age=0, must-revalidate, private", rr.Header().Get("Cache-Control"))
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
@@ -13106,7 +13770,7 @@ func TestMaxSessions(t *testing.T) {
checkResponseCode(t, http.StatusTooManyRequests, rr)
assert.Contains(t, rr.Body.String(), "too many open sessions")
// web client requests
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webClientProfilePath, webToken)
assert.NoError(t, err)
form := make(url.Values)
form.Set(csrfFormToken, csrfToken)
@@ -13158,6 +13822,8 @@ func TestMaxSessions(t *testing.T) {
err = smtpCfg.Initialize(configDir, true)
assert.NoError(t, err)
+ loginCookie, csrfToken, err := getCSRFTokenMock(webClientLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set(csrfFormToken, csrfToken)
form.Set("username", user.Username)
@@ -13165,10 +13831,14 @@ func TestMaxSessions(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
assert.GreaterOrEqual(t, len(lastResetCode), 20)
+
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set(csrfFormToken, csrfToken)
form.Set("password", defaultPassword)
@@ -13177,6 +13847,7 @@ func TestMaxSessions(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusOK, rr.Code)
@@ -13192,6 +13863,129 @@ func TestMaxSessions(t *testing.T) {
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
assert.Len(t, common.Connections.GetStats(""), 0)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
+}
+
+func TestMaxTransfers(t *testing.T) {
+ oldValue := common.Config.MaxPerHostConnections
+ common.Config.MaxPerHostConnections = 2
+
+ assert.Eventually(t, func() bool {
+ return common.Connections.GetClientConnections() == 0
+ }, 1000*time.Millisecond, 50*time.Millisecond)
+ user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+ assert.NoError(t, err)
+
+ webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
+ assert.NoError(t, err)
+ webAPIToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+ assert.NoError(t, err)
+
+ share := dataprovider.Share{
+ Name: "test share",
+ Scope: dataprovider.ShareScopeReadWrite,
+ Paths: []string{"/"},
+ Password: defaultPassword,
+ }
+ asJSON, err := json.Marshal(share)
+ assert.NoError(t, err)
+ req, err := http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON))
+ assert.NoError(t, err)
+ setBearerForReq(req, webAPIToken)
+ rr := executeRequest(req)
+ checkResponseCode(t, http.StatusCreated, rr)
+ objectID := rr.Header().Get("X-Object-ID")
+ assert.NotEmpty(t, objectID)
+
+ fileName := "testfile.txt"
+ req, err = http.NewRequest(http.MethodPost, userUploadFilePath+"?path="+fileName, bytes.NewBuffer([]byte(" ")))
+ assert.NoError(t, err)
+ setBearerForReq(req, webAPIToken)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusCreated, rr)
+
+ conn, sftpClient, err := getSftpClient(user)
+ assert.NoError(t, err)
+ defer conn.Close()
+ defer sftpClient.Close()
+
+ f1, err := sftpClient.Create("file1")
+ assert.NoError(t, err)
+ f2, err := sftpClient.Create("file2")
+ assert.NoError(t, err)
+ _, err = f1.Write([]byte(" "))
+ assert.NoError(t, err)
+ _, err = f2.Write([]byte(" "))
+ assert.NoError(t, err)
+
+ body := new(bytes.Buffer)
+ writer := multipart.NewWriter(body)
+ part, err := writer.CreateFormFile("filenames", "filepre")
+ assert.NoError(t, err)
+ _, err = part.Write([]byte("file content"))
+ assert.NoError(t, err)
+ err = writer.Close()
+ assert.NoError(t, err)
+ reader := bytes.NewReader(body.Bytes())
+ _, err = reader.Seek(0, io.SeekStart)
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, userFilesPath, reader)
+ assert.NoError(t, err)
+ req.Header.Add("Content-Type", writer.FormDataContentType())
+ setBearerForReq(req, webAPIToken)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusConflict, rr)
+
+ req, err = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+fileName, nil)
+ assert.NoError(t, err)
+ setJWTCookieForReq(req, webToken)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
+ assert.Contains(t, rr.Body.String(), util.I18nError403Message)
+
+ req, err = http.NewRequest(http.MethodPost, userUploadFilePath+"?path="+fileName, bytes.NewBuffer([]byte(" ")))
+ assert.NoError(t, err)
+ setBearerForReq(req, webAPIToken)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusForbidden, rr)
+
+ body = new(bytes.Buffer)
+ writer = multipart.NewWriter(body)
+ part1, err := writer.CreateFormFile("filenames", "file11.txt")
+ assert.NoError(t, err)
+ _, err = part1.Write([]byte("file11 content"))
+ assert.NoError(t, err)
+ part2, err := writer.CreateFormFile("filenames", "file22.txt")
+ assert.NoError(t, err)
+ _, err = part2.Write([]byte("file22 content"))
+ assert.NoError(t, err)
+ err = writer.Close()
+ assert.NoError(t, err)
+ reader = bytes.NewReader(body.Bytes())
+ req, err = http.NewRequest(http.MethodPost, sharesPath+"/"+objectID, reader)
+ assert.NoError(t, err)
+ req.Header.Add("Content-Type", writer.FormDataContentType())
+ req.SetBasicAuth(defaultUsername, defaultPassword)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusConflict, rr)
+
+ err = f1.Close()
+ assert.NoError(t, err)
+ err = f2.Close()
+ assert.NoError(t, err)
+
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+ err = os.RemoveAll(user.GetHomeDir())
+ assert.NoError(t, err)
+ assert.Eventually(t, func() bool {
+ return len(common.Connections.GetStats("")) == 0
+ }, 1000*time.Millisecond, 50*time.Millisecond)
+ assert.Eventually(t, func() bool {
+ return common.Connections.GetTotalTransfers() == 0
+ }, 1000*time.Millisecond, 50*time.Millisecond)
+
+ common.Config.MaxPerHostConnections = oldValue
}
func TestWebConfigsMock(t *testing.T) {
@@ -13203,8 +13997,6 @@ func TestWebConfigsMock(t *testing.T) {
assert.NoError(t, err)
webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
- assert.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, webConfigsPath, nil)
assert.NoError(t, err)
@@ -13213,13 +14005,17 @@ func TestWebConfigsMock(t *testing.T) {
checkResponseCode(t, http.StatusOK, rr)
form := make(url.Values)
- req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+ b, contentType, err := getMultipartFormData(form, "", "")
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
assert.NoError(t, err)
setJWTCookieForReq(req, webToken)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusForbidden, rr)
// parse form error
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webConfigsPath, webToken)
+ assert.NoError(t, err)
form.Set(csrfFormToken, csrfToken)
req, err = http.NewRequest(http.MethodPost, webConfigsPath+"?p=p%C3%AO%GH", bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
@@ -13229,25 +14025,29 @@ func TestWebConfigsMock(t *testing.T) {
checkResponseCode(t, http.StatusBadRequest, rr)
// save SFTP configs
form.Set("sftp_host_key_algos", ssh.KeyAlgoRSA)
- form.Add("sftp_host_key_algos", ssh.InsecureCertAlgoDSAv01)
- form.Set("sftp_pub_key_algos", ssh.InsecureKeyAlgoDSA)
+ form.Add("sftp_host_key_algos", ssh.InsecureCertAlgoDSAv01) //nolint:staticcheck
+ form.Set("sftp_pub_key_algos", ssh.InsecureKeyAlgoDSA) //nolint:staticcheck
form.Set("form_action", "sftp_submit")
- req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+ b, contentType, err = getMultipartFormData(form, "", "")
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
assert.NoError(t, err)
setJWTCookieForReq(req, webToken)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), util.I18nError500Message) // invalid algo
form.Set("sftp_host_key_algos", ssh.KeyAlgoRSA)
form.Add("sftp_host_key_algos", ssh.CertAlgoRSAv01)
- form.Set("sftp_pub_key_algos", ssh.InsecureKeyAlgoDSA)
+ form.Set("sftp_pub_key_algos", ssh.InsecureKeyAlgoDSA) //nolint:staticcheck
form.Set("sftp_kex_algos", "diffie-hellman-group18-sha512")
form.Add("sftp_kex_algos", ssh.KeyExchangeDH16SHA512)
- req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+ b, contentType, err = getMultipartFormData(form, "", "")
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
assert.NoError(t, err)
setJWTCookieForReq(req, webToken)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), util.I18nConfigsOK)
@@ -13257,15 +14057,17 @@ func TestWebConfigsMock(t *testing.T) {
assert.Len(t, configs.SFTPD.HostKeyAlgos, 1)
assert.Contains(t, configs.SFTPD.HostKeyAlgos, ssh.KeyAlgoRSA)
assert.Len(t, configs.SFTPD.PublicKeyAlgos, 1)
- assert.Contains(t, configs.SFTPD.PublicKeyAlgos, ssh.InsecureKeyAlgoDSA)
+ assert.Contains(t, configs.SFTPD.PublicKeyAlgos, ssh.InsecureKeyAlgoDSA) //nolint:staticcheck
assert.Len(t, configs.SFTPD.KexAlgorithms, 1)
assert.Contains(t, configs.SFTPD.KexAlgorithms, ssh.KeyExchangeDH16SHA512)
// invalid form action
form.Set("form_action", "")
- req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+ b, contentType, err = getMultipartFormData(form, "", "")
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
assert.NoError(t, err)
setJWTCookieForReq(req, webToken)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), util.I18nError400Message)
@@ -13277,10 +14079,12 @@ func TestWebConfigsMock(t *testing.T) {
form.Set("smtp_password", defaultPassword)
form.Set("smtp_domain", "localdomain")
form.Set("smtp_auth", "100")
- req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+ b, contentType, err = getMultipartFormData(form, "", "")
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
assert.NoError(t, err)
setJWTCookieForReq(req, webToken)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), util.I18nError500Message) // invalid smtp_auth
@@ -13291,10 +14095,12 @@ func TestWebConfigsMock(t *testing.T) {
form.Set("smtp_debug", "checked")
form.Set("smtp_oauth2_provider", "1")
form.Set("smtp_oauth2_client_id", "123")
- req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+ b, contentType, err = getMultipartFormData(form, "", "")
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
assert.NoError(t, err)
setJWTCookieForReq(req, webToken)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), util.I18nConfigsOK)
@@ -13304,7 +14110,7 @@ func TestWebConfigsMock(t *testing.T) {
assert.Len(t, configs.SFTPD.HostKeyAlgos, 1)
assert.Contains(t, configs.SFTPD.HostKeyAlgos, ssh.KeyAlgoRSA)
assert.Len(t, configs.SFTPD.PublicKeyAlgos, 1)
- assert.Contains(t, configs.SFTPD.PublicKeyAlgos, ssh.InsecureKeyAlgoDSA)
+ assert.Contains(t, configs.SFTPD.PublicKeyAlgos, ssh.InsecureKeyAlgoDSA) //nolint:staticcheck
assert.Equal(t, "mail.example.net", configs.SMTP.Host)
assert.Equal(t, 587, configs.SMTP.Port)
assert.Equal(t, "Example ", configs.SMTP.From)
@@ -13321,10 +14127,12 @@ func TestWebConfigsMock(t *testing.T) {
form.Set("smtp_password", redactedSecret)
form.Set("smtp_auth", "")
configs.SMTP.AuthType = 0 // empty will be converted to 0
- req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+ b, contentType, err = getMultipartFormData(form, "", "")
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
assert.NoError(t, err)
setJWTCookieForReq(req, webToken)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), util.I18nConfigsOK)
@@ -13340,10 +14148,12 @@ func TestWebConfigsMock(t *testing.T) {
updatedConfigs.SMTP.Password = kms.NewSecret(sdkkms.SecretStatusSecretBox, encryptedPayload, secretKey, "")
err = dataprovider.UpdateConfigs(&updatedConfigs, "", "", "")
assert.NoError(t, err)
- req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+ b, contentType, err = getMultipartFormData(form, "", "")
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
assert.NoError(t, err)
setJWTCookieForReq(req, webToken)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), util.I18nConfigsOK)
@@ -13353,19 +14163,23 @@ func TestWebConfigsMock(t *testing.T) {
form.Add("acme_protocols", "2")
form.Add("acme_protocols", "3")
form.Set("acme_domain", "example.com")
+ b, contentType, err = getMultipartFormData(form, "", "")
+ assert.NoError(t, err)
// no email set, validation will fail
- req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
assert.NoError(t, err)
setJWTCookieForReq(req, webToken)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidEmail)
form.Set("acme_domain", "")
- req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+ b, contentType, err = getMultipartFormData(form, "", "")
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
assert.NoError(t, err)
setJWTCookieForReq(req, webToken)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), util.I18nConfigsOK)
@@ -13375,7 +14189,7 @@ func TestWebConfigsMock(t *testing.T) {
assert.Len(t, configs.SFTPD.HostKeyAlgos, 1)
assert.Contains(t, configs.SFTPD.HostKeyAlgos, ssh.KeyAlgoRSA)
assert.Len(t, configs.SFTPD.PublicKeyAlgos, 1)
- assert.Contains(t, configs.SFTPD.PublicKeyAlgos, ssh.InsecureKeyAlgoDSA)
+ assert.Contains(t, configs.SFTPD.PublicKeyAlgos, ssh.InsecureKeyAlgoDSA) //nolint:staticcheck
assert.Equal(t, 80, configs.ACME.HTTP01Challenge.Port)
assert.Equal(t, 7, configs.ACME.Protocols)
assert.Empty(t, configs.ACME.Domain)
@@ -13396,10 +14210,12 @@ func TestWebConfigsMock(t *testing.T) {
form.Add("acme_protocols", "1000")
form.Set("acme_domain", domain)
form.Set("acme_email", "email@example.com")
- req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
+ b, contentType, err = getMultipartFormData(form, "", "")
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
assert.NoError(t, err)
setJWTCookieForReq(req, webToken)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), util.I18nConfigsOK)
@@ -13423,6 +14239,201 @@ func TestWebConfigsMock(t *testing.T) {
assert.NoError(t, err)
}
+func TestBrandingConfigMock(t *testing.T) {
+ err := dataprovider.UpdateConfigs(nil, "", "", "")
+ assert.NoError(t, err)
+
+ webClientLogoPath := "/static/branding/webclient/logo.png"
+ webClientFaviconPath := "/static/branding/webclient/favicon.png"
+ webAdminLogoPath := "/static/branding/webadmin/logo.png"
+ webAdminFaviconPath := "/static/branding/webadmin/favicon.png"
+ // no custom log or favicon was set
+ for _, p := range []string{webClientLogoPath, webClientFaviconPath, webAdminLogoPath, webAdminFaviconPath} {
+ req, err := http.NewRequest(http.MethodGet, p, nil)
+ assert.NoError(t, err)
+ rr := executeRequest(req)
+ checkResponseCode(t, http.StatusNotFound, rr)
+ }
+
+ webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
+ assert.NoError(t, err)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webConfigsPath, webToken)
+ assert.NoError(t, err)
+ form := make(url.Values)
+ form.Set(csrfFormToken, csrfToken)
+ form.Set("form_action", "branding_submit")
+ form.Set("branding_webadmin_name", "Custom WebAdmin")
+ form.Set("branding_webadmin_short_name", "WebAdmin")
+ form.Set("branding_webadmin_disclaimer_name", "Admin disclaimer")
+ form.Set("branding_webadmin_disclaimer_url", "invalid, not a URL")
+ form.Set("branding_webclient_name", "Custom WebClient")
+ form.Set("branding_webclient_short_name", "WebClient")
+ form.Set("branding_webclient_disclaimer_name", "Client disclaimer")
+ form.Set("branding_webclient_disclaimer_url", "https://example.com")
+ b, contentType, err := getMultipartFormData(form, "", "")
+ assert.NoError(t, err)
+ req, err := http.NewRequest(http.MethodPost, webConfigsPath, &b)
+ assert.NoError(t, err)
+ setJWTCookieForReq(req, webToken)
+ req.Header.Set("Content-Type", contentType)
+ rr := executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
+ assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidDisclaimerURL)
+
+ form.Set("branding_webadmin_disclaimer_url", "https://example.net")
+ tmpFile := filepath.Join(os.TempDir(), util.GenerateUniqueID()+".png")
+ err = createTestPNG(tmpFile, 512, 512, color.RGBA{100, 200, 200, 0xff})
+ assert.NoError(t, err)
+
+ b, contentType, err = getMultipartFormData(form, "branding_webadmin_logo", tmpFile)
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
+ assert.NoError(t, err)
+ setJWTCookieForReq(req, webToken)
+ req.Header.Set("Content-Type", contentType)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
+ assert.Contains(t, rr.Body.String(), util.I18nConfigsOK)
+ // check
+ configs, err := dataprovider.GetConfigs()
+ assert.NoError(t, err)
+ assert.Equal(t, "Custom WebAdmin", configs.Branding.WebAdmin.Name)
+ assert.Equal(t, "WebAdmin", configs.Branding.WebAdmin.ShortName)
+ assert.Equal(t, "Admin disclaimer", configs.Branding.WebAdmin.DisclaimerName)
+ assert.Equal(t, "https://example.net", configs.Branding.WebAdmin.DisclaimerURL)
+ assert.Equal(t, "Custom WebClient", configs.Branding.WebClient.Name)
+ assert.Equal(t, "WebClient", configs.Branding.WebClient.ShortName)
+ assert.Equal(t, "Client disclaimer", configs.Branding.WebClient.DisclaimerName)
+ assert.Equal(t, "https://example.com", configs.Branding.WebClient.DisclaimerURL)
+ assert.Greater(t, len(configs.Branding.WebAdmin.Logo), 0)
+ assert.Len(t, configs.Branding.WebAdmin.Favicon, 0)
+ assert.Len(t, configs.Branding.WebClient.Logo, 0)
+ assert.Len(t, configs.Branding.WebClient.Favicon, 0)
+
+ err = createTestPNG(tmpFile, 256, 256, color.RGBA{120, 220, 220, 0xff})
+ assert.NoError(t, err)
+ form.Set("branding_webadmin_logo_remove", "0") // 0 preserves WebAdmin logo
+ b, contentType, err = getMultipartFormData(form, "branding_webadmin_favicon", tmpFile)
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
+ assert.NoError(t, err)
+ setJWTCookieForReq(req, webToken)
+ req.Header.Set("Content-Type", contentType)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
+ assert.Contains(t, rr.Body.String(), util.I18nConfigsOK)
+ configs, err = dataprovider.GetConfigs()
+ assert.NoError(t, err)
+ assert.Equal(t, "Custom WebAdmin", configs.Branding.WebAdmin.Name)
+ assert.Equal(t, "WebAdmin", configs.Branding.WebAdmin.ShortName)
+ assert.Equal(t, "Admin disclaimer", configs.Branding.WebAdmin.DisclaimerName)
+ assert.Equal(t, "https://example.net", configs.Branding.WebAdmin.DisclaimerURL)
+ assert.Equal(t, "Custom WebClient", configs.Branding.WebClient.Name)
+ assert.Equal(t, "WebClient", configs.Branding.WebClient.ShortName)
+ assert.Equal(t, "Client disclaimer", configs.Branding.WebClient.DisclaimerName)
+ assert.Equal(t, "https://example.com", configs.Branding.WebClient.DisclaimerURL)
+ assert.Greater(t, len(configs.Branding.WebAdmin.Logo), 0)
+ assert.Greater(t, len(configs.Branding.WebAdmin.Favicon), 0)
+ assert.Len(t, configs.Branding.WebClient.Logo, 0)
+ assert.Len(t, configs.Branding.WebClient.Favicon, 0)
+
+ err = createTestPNG(tmpFile, 256, 256, color.RGBA{80, 90, 110, 0xff})
+ assert.NoError(t, err)
+ b, contentType, err = getMultipartFormData(form, "branding_webclient_logo", tmpFile)
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
+ assert.NoError(t, err)
+ setJWTCookieForReq(req, webToken)
+ req.Header.Set("Content-Type", contentType)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
+ assert.Contains(t, rr.Body.String(), util.I18nConfigsOK)
+ configs, err = dataprovider.GetConfigs()
+ assert.NoError(t, err)
+ assert.Greater(t, len(configs.Branding.WebClient.Logo), 0)
+
+ err = createTestPNG(tmpFile, 256, 256, color.RGBA{120, 50, 120, 0xff})
+ assert.NoError(t, err)
+ b, contentType, err = getMultipartFormData(form, "branding_webclient_favicon", tmpFile)
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
+ assert.NoError(t, err)
+ setJWTCookieForReq(req, webToken)
+ req.Header.Set("Content-Type", contentType)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
+ assert.Contains(t, rr.Body.String(), util.I18nConfigsOK)
+ configs, err = dataprovider.GetConfigs()
+ assert.NoError(t, err)
+ assert.Greater(t, len(configs.Branding.WebClient.Favicon), 0)
+
+ for _, p := range []string{webClientLogoPath, webClientFaviconPath, webAdminLogoPath, webAdminFaviconPath} {
+ req, err := http.NewRequest(http.MethodGet, p, nil)
+ assert.NoError(t, err)
+ rr := executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
+ }
+ // remove images
+ form.Set("branding_webadmin_logo_remove", "1")
+ form.Set("branding_webclient_logo_remove", "1")
+ form.Set("branding_webadmin_favicon_remove", "1")
+ form.Set("branding_webclient_favicon_remove", "1")
+ b, contentType, err = getMultipartFormData(form, "", "")
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
+ assert.NoError(t, err)
+ setJWTCookieForReq(req, webToken)
+ req.Header.Set("Content-Type", contentType)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
+ assert.Contains(t, rr.Body.String(), util.I18nConfigsOK)
+ configs, err = dataprovider.GetConfigs()
+ assert.NoError(t, err)
+ assert.Len(t, configs.Branding.WebAdmin.Logo, 0)
+ assert.Len(t, configs.Branding.WebAdmin.Favicon, 0)
+ assert.Len(t, configs.Branding.WebClient.Logo, 0)
+ assert.Len(t, configs.Branding.WebClient.Favicon, 0)
+ for _, p := range []string{webClientLogoPath, webClientFaviconPath, webAdminLogoPath, webAdminFaviconPath} {
+ req, err := http.NewRequest(http.MethodGet, p, nil)
+ assert.NoError(t, err)
+ rr := executeRequest(req)
+ checkResponseCode(t, http.StatusNotFound, rr)
+ }
+ form.Del("branding_webadmin_logo_remove")
+ form.Del("branding_webclient_logo_remove")
+ form.Del("branding_webadmin_favicon_remove")
+ form.Del("branding_webclient_favicon_remove")
+ // image too large
+ err = createTestPNG(tmpFile, 768, 512, color.RGBA{120, 50, 120, 0xff})
+ assert.NoError(t, err)
+ b, contentType, err = getMultipartFormData(form, "branding_webclient_logo", tmpFile)
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
+ assert.NoError(t, err)
+ setJWTCookieForReq(req, webToken)
+ req.Header.Set("Content-Type", contentType)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
+ assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidPNGSize)
+ // not a png image
+ err = createTestFile(tmpFile, 128)
+ assert.NoError(t, err)
+ b, contentType, err = getMultipartFormData(form, "branding_webclient_logo", tmpFile)
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webConfigsPath, &b)
+ assert.NoError(t, err)
+ setJWTCookieForReq(req, webToken)
+ req.Header.Set("Content-Type", contentType)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
+ assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidPNG)
+
+ err = os.Remove(tmpFile)
+ assert.NoError(t, err)
+ err = dataprovider.UpdateConfigs(nil, "", "", "")
+ assert.NoError(t, err)
+}
+
func TestSFTPLoopError(t *testing.T) {
user1 := getTestUser()
user2 := getTestUser()
@@ -13464,7 +14475,7 @@ func TestSFTPLoopError(t *testing.T) {
err = smtpCfg.Initialize(configDir, true)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ loginCookie, csrfToken, err := getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
assert.NoError(t, err)
form := make(url.Values)
form.Set(csrfFormToken, csrfToken)
@@ -13473,10 +14484,14 @@ func TestSFTPLoopError(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
assert.GreaterOrEqual(t, len(lastResetCode), 20)
+
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set(csrfFormToken, csrfToken)
form.Set("password", defaultPassword)
@@ -13485,6 +14500,7 @@ func TestSFTPLoopError(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusOK, rr.Code)
@@ -13544,8 +14560,6 @@ func TestWebClientChangePwd(t *testing.T) {
assert.NoError(t, err)
webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
- assert.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, webChangeClientPwdPath, nil)
assert.NoError(t, err)
@@ -13568,6 +14582,8 @@ func TestWebClientChangePwd(t *testing.T) {
checkResponseCode(t, http.StatusForbidden, rr)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCSRF)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webChangeClientPwdPath, webToken)
+ assert.NoError(t, err)
form.Set(csrfFormToken, csrfToken)
req, _ = http.NewRequest(http.MethodPost, webChangeClientPwdPath, bytes.NewBuffer([]byte(form.Encode())))
req.RemoteAddr = defaultRemoteAddr
@@ -13599,6 +14615,13 @@ func TestWebClientChangePwd(t *testing.T) {
checkResponseCode(t, http.StatusFound, rr)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
+ req, err = http.NewRequest(http.MethodGet, webClientPingPath, nil)
+ assert.NoError(t, err)
+ req.RemoteAddr = defaultRemoteAddr
+ setJWTCookieForReq(req, webToken)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusFound, rr)
+
_, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.Error(t, err)
_, err = getJWTWebClientTokenFromTestServer(defaultUsername+"1", defaultPassword+"1")
@@ -13615,6 +14638,9 @@ func TestWebClientChangePwd(t *testing.T) {
webToken, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword+"1")
assert.NoError(t, err)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webClientProfilePath, webToken)
+ assert.NoError(t, err)
+ form.Set(csrfFormToken, csrfToken)
form.Set("current_password", defaultPassword+"1")
form.Set("new_password1", defaultPassword)
form.Set("new_password2", defaultPassword)
@@ -14043,6 +15069,47 @@ func TestShareUsage(t *testing.T) {
executeRequest(req)
}
+func TestSharePasswordPolicy(t *testing.T) {
+ g := getTestGroup()
+ g.UserSettings.Filters.PasswordStrength = 70
+ group, _, err := httpdtest.AddGroup(g, http.StatusCreated)
+ assert.NoError(t, err)
+
+ u := getTestUser()
+ u.Groups = []sdk.GroupMapping{
+ {
+ Name: g.Name,
+ Type: sdk.GroupTypePrimary,
+ },
+ }
+ u.Password = rand.Text()
+ user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+ assert.NoError(t, err)
+
+ webAPIToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, u.Password)
+ assert.NoError(t, err)
+
+ share := dataprovider.Share{
+ Name: util.GenerateUniqueID(),
+ Scope: dataprovider.ShareScopeRead,
+ Paths: []string{"/"},
+ Password: defaultPassword,
+ }
+ asJSON, err := json.Marshal(share)
+ assert.NoError(t, err)
+ req, err := http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON))
+ assert.NoError(t, err)
+ setBearerForReq(req, webAPIToken)
+ rr := executeRequest(req)
+ checkResponseCode(t, http.StatusBadRequest, rr)
+ assert.Contains(t, rr.Body.String(), "insecure password")
+
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveGroup(group, http.StatusOK)
+ assert.NoError(t, err)
+}
+
func TestShareMaxExpiration(t *testing.T) {
u := getTestUser()
u.Filters.MaxSharesExpiration = 5
@@ -14059,8 +15126,6 @@ func TestShareMaxExpiration(t *testing.T) {
assert.NoError(t, err)
webClientToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
- assert.NoError(t, err)
s := dataprovider.Share{
Name: "test share",
@@ -14114,6 +15179,9 @@ func TestShareMaxExpiration(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "share must expire before")
+
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webClientSharePath, webClientToken)
+ assert.NoError(t, err)
form := make(url.Values)
form.Set("name", s.Name)
form.Set("scope", strconv.Itoa(int(s.Scope)))
@@ -14226,12 +15294,13 @@ func TestWebClientShareCredentials(t *testing.T) {
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCSRF)
// set the CSRF token
- csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
+ loginCookie, csrfToken, err := getCSRFTokenMock(loginURI, defaultRemoteAddr)
assert.NoError(t, err)
form.Set(csrfFormToken, csrfToken)
req, err = http.NewRequest(http.MethodPost, loginURI, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
@@ -14274,31 +15343,67 @@ func TestWebClientShareCredentials(t *testing.T) {
req.RemoteAddr = "1.2.3.4:1234"
rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr)
+ // logout to a different share, the cookie is not valid.
+ req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, shareWriteID, "logout"), nil)
+ assert.NoError(t, err)
+ req.RequestURI = uri
+ setJWTCookieForReq(req, cookie)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusForbidden, rr)
+ assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidToken)
+ // logout
+ req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, shareReadID, "logout"), nil)
+ assert.NoError(t, err)
+ req.RequestURI = uri
+ setJWTCookieForReq(req, cookie)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusFound, rr)
+ // the cookie is no longer valid
+ req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, shareReadID, "download?b=c"), nil)
+ assert.NoError(t, err)
+ req.RequestURI = uri
+ setJWTCookieForReq(req, cookie)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusFound, rr)
+ assert.Contains(t, rr.Header().Get("Location"), "/login")
+
// try to login with invalid credentials
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
+ form.Set(csrfFormToken, csrfToken)
form.Set("share_password", "")
req, err = http.NewRequest(http.MethodPost, loginURI, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCredentials)
// login with the next param set
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
+ form.Set(csrfFormToken, csrfToken)
form.Set("share_password", defaultPassword)
nextURI := path.Join(webClientPubSharesPath, shareReadID, "browse")
loginURI = path.Join(webClientPubSharesPath, shareReadID, fmt.Sprintf("login?next=%s", url.QueryEscape(nextURI)))
req, err = http.NewRequest(http.MethodPost, loginURI, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr)
assert.Equal(t, nextURI, rr.Header().Get("Location"))
// try to login to a missing share
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
+ form.Set(csrfFormToken, csrfToken)
loginURI = path.Join(webClientPubSharesPath, "missing", "login")
req, err = http.NewRequest(http.MethodPost, loginURI, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
@@ -14441,7 +15546,7 @@ func TestShareMaxSessions(t *testing.T) {
assert.Contains(t, rr.Body.String(), "too many open sessions")
share = dataprovider.Share{
- Name: "test share max sessions read/write",
+ Name: "test share max sessions read&write",
Scope: dataprovider.ShareScopeReadWrite,
Paths: []string{"/"},
}
@@ -14467,6 +15572,7 @@ func TestShareMaxSessions(t *testing.T) {
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
assert.Len(t, common.Connections.GetStats(""), 0)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func TestShareUploadSingle(t *testing.T) {
@@ -15937,7 +17043,7 @@ func TestWebClientExistenceCheck(t *testing.T) {
webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webClientProfilePath, webToken)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, webClientExistPath, nil)
@@ -16296,7 +17402,7 @@ func TestWebGetFiles(t *testing.T) {
assert.NoError(t, err)
assert.Len(t, dirEntries, 1)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webClientProfilePath, webToken)
assert.NoError(t, err)
form := make(url.Values)
form.Set("files", fmt.Sprintf(`["%s","%s","%s"]`, testFileName, testDir, testFileName+extensions[2]))
@@ -16600,7 +17706,8 @@ func TestRenameDifferentResource(t *testing.T) {
assert.NoError(t, err)
webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webClientProfilePath, webToken)
+ assert.NoError(t, err)
getStatusResponse := func(taskID string) int {
req, _ := http.NewRequest(http.MethodGet, webClientTasksPath+"/"+url.PathEscape(taskID), nil)
@@ -17354,7 +18461,7 @@ func TestWebClientTasksAPI(t *testing.T) {
webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webClientProfilePath, webToken)
assert.NoError(t, err)
webToken1, err := getJWTWebClientTokenFromTestServer(user1.Username, defaultPassword)
assert.NoError(t, err)
@@ -18052,7 +19159,7 @@ func TestWebAPIVFolder(t *testing.T) {
folder, _, err := httpdtest.GetFolderByName(folderName, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, int64(len(fileContents)), folder.UsedQuotaSize)
+ assert.Equal(t, int64(0), folder.UsedQuotaSize)
_, err = reader.Seek(0, io.SeekStart)
assert.NoError(t, err)
@@ -18069,7 +19176,7 @@ func TestWebAPIVFolder(t *testing.T) {
folder, _, err = httpdtest.GetFolderByName(folderName, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, int64(len(fileContents)), folder.UsedQuotaSize)
+ assert.Equal(t, int64(0), folder.UsedQuotaSize)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
@@ -18411,7 +19518,7 @@ func TestCompressionErrorMock(t *testing.T) {
webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webClientProfilePath, webToken)
assert.NoError(t, err)
form := make(url.Values)
@@ -18600,6 +19707,7 @@ func TestClientUserClose(t *testing.T) {
wg.Wait()
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 },
1*time.Second, 100*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
@@ -18661,12 +19769,13 @@ func TestWebAdminSetupMock(t *testing.T) {
checkResponseCode(t, http.StatusFound, rr)
assert.Equal(t, webAdminSetupPath, rr.Header().Get("Location"))
- csrfToken, err := getCSRFToken(httpBaseURL + webAdminSetupPath)
+ loginCookie, csrfToken, err := getCSRFTokenMock(webAdminSetupPath, defaultRemoteAddr)
assert.NoError(t, err)
form := make(url.Values)
req, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusForbidden, rr)
@@ -18674,6 +19783,7 @@ func TestWebAdminSetupMock(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
@@ -18682,6 +19792,7 @@ func TestWebAdminSetupMock(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
@@ -18690,6 +19801,7 @@ func TestWebAdminSetupMock(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
@@ -18708,6 +19820,7 @@ func TestWebAdminSetupMock(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
@@ -18721,6 +19834,7 @@ func TestWebAdminSetupMock(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr)
@@ -18850,6 +19964,12 @@ func TestWebAdminLoginMock(t *testing.T) {
cookie := rr.Header().Get("Cookie")
assert.Empty(t, cookie)
+ req, _ = http.NewRequest(http.MethodGet, webStatusPath, nil)
+ req.RemoteAddr = defaultRemoteAddr
+ setJWTCookieForReq(req, webToken)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusFound, rr)
+
req, _ = http.NewRequest(http.MethodGet, logoutPath, nil)
setBearerForReq(req, apiToken)
rr = executeRequest(req)
@@ -18866,12 +19986,13 @@ func TestWebAdminLoginMock(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ loginCookie, csrfToken, err := getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
assert.NoError(t, err)
// now try using wrong password
form := getLoginForm(defaultTokenAuthUser, "wrong pwd", csrfToken)
req, _ = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
@@ -18880,6 +20001,7 @@ func TestWebAdminLoginMock(t *testing.T) {
form = getLoginForm("wrong username", defaultTokenAuthPass, csrfToken)
req, _ = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
@@ -18894,10 +20016,12 @@ func TestWebAdminLoginMock(t *testing.T) {
assert.NoError(t, err)
rAddr := "127.1.1.1:1234"
- csrfToken, err = getCSRFTokenMock(webLoginPath, rAddr)
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, rAddr)
assert.NoError(t, err)
+ assert.NotEmpty(t, loginCookie)
form = getLoginForm(altAdminUsername, altAdminPassword, csrfToken)
req, _ = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = rAddr
rr = executeRequest(req)
@@ -18905,20 +20029,24 @@ func TestWebAdminLoginMock(t *testing.T) {
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCredentials)
rAddr = "10.9.9.9:1234"
- csrfToken, err = getCSRFTokenMock(webLoginPath, rAddr)
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, rAddr)
assert.NoError(t, err)
+ assert.NotEmpty(t, loginCookie)
form = getLoginForm(altAdminUsername, altAdminPassword, csrfToken)
req, _ = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = rAddr
+ setLoginCookie(req, loginCookie)
rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr)
rAddr = "127.0.1.1:4567"
- csrfToken, err = getCSRFTokenMock(webLoginPath, rAddr)
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, rAddr)
assert.NoError(t, err)
+ assert.NotEmpty(t, loginCookie)
form = getLoginForm(altAdminUsername, altAdminPassword, csrfToken)
req, _ = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = rAddr
req.Header.Set("X-Forwarded-For", "10.9.9.9")
@@ -18967,10 +20095,10 @@ func TestWebUserShare(t *testing.T) {
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
- assert.NoError(t, err)
token, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webClientProfilePath, token)
+ assert.NoError(t, err)
userAPItoken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
@@ -19208,10 +20336,10 @@ func TestWebUserShareNoPasswordDisabled(t *testing.T) {
user.Filters.DefaultSharesExpiration = 30
user, _, err = httpdtest.UpdateUser(u, http.StatusOK, "")
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
- assert.NoError(t, err)
token, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webClientSharePath, token)
+ assert.NoError(t, err)
userAPItoken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
@@ -19292,14 +20420,75 @@ func TestWebUserShareNoPasswordDisabled(t *testing.T) {
assert.NoError(t, err)
}
+func TestInvalidCSRF(t *testing.T) {
+ user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+ assert.NoError(t, err)
+
+ for _, loginURL := range []string{webClientLoginPath, webLoginPath} {
+ // try using an invalid CSRF token
+ loginCookie1, csrfToken1, err := getCSRFTokenMock(loginURL, defaultRemoteAddr)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, loginCookie1)
+ assert.NotEmpty(t, csrfToken1)
+ loginCookie2, csrfToken2, err := getCSRFTokenMock(loginURL, defaultRemoteAddr)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, loginCookie2)
+ assert.NotEmpty(t, csrfToken2)
+ rAddr := "1.2.3.4"
+ loginCookie3, csrfToken3, err := getCSRFTokenMock(loginURL, rAddr)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, loginCookie3)
+ assert.NotEmpty(t, csrfToken3)
+
+ form := getLoginForm(defaultUsername, defaultPassword, csrfToken1)
+ req, err := http.NewRequest(http.MethodPost, loginURL, bytes.NewBuffer([]byte(form.Encode())))
+ assert.NoError(t, err)
+ req.RemoteAddr = defaultRemoteAddr
+ req.RequestURI = loginURL
+ setLoginCookie(req, loginCookie2)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr := executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
+ assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCSRF)
+
+ // use a CSRF token as login cookie (invalid audience)
+ form = getLoginForm(defaultUsername, defaultPassword, csrfToken1)
+ req, err = http.NewRequest(http.MethodPost, loginURL, bytes.NewBuffer([]byte(form.Encode())))
+ assert.NoError(t, err)
+ req.RemoteAddr = defaultRemoteAddr
+ req.RequestURI = loginURL
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", csrfToken1))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
+ assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCSRF)
+ // invalid IP
+ form = getLoginForm(defaultUsername, defaultPassword, csrfToken3)
+ req, err = http.NewRequest(http.MethodPost, loginURL, bytes.NewBuffer([]byte(form.Encode())))
+ assert.NoError(t, err)
+ req.RemoteAddr = defaultRemoteAddr
+ req.RequestURI = loginURL
+ setLoginCookie(req, loginCookie3)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
+ assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCSRF)
+ }
+
+ err = os.RemoveAll(user.GetHomeDir())
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+}
+
func TestWebUserProfile(t *testing.T) {
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
- assert.NoError(t, err)
token, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webClientProfilePath, token)
+ assert.NoError(t, err)
email := "user@user.com"
description := "User"
@@ -19311,6 +20500,7 @@ func TestWebUserProfile(t *testing.T) {
form.Set("public_keys[0][public_key]", testPubKey)
form.Set("public_keys[1][public_key]", testPubKey1)
form.Set("tls_certs[0][tls_cert]", httpsCert)
+ form.Set("additional_emails[0][additional_email]", "email1@user.com")
// no csrf token
req, err := http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
@@ -19337,6 +20527,9 @@ func TestWebUserProfile(t *testing.T) {
assert.Len(t, user.Filters.TLSCerts, 1)
assert.Equal(t, email, user.Email)
assert.Equal(t, description, user.Description)
+ if assert.Len(t, user.Filters.AdditionalEmails, 1) {
+ assert.Equal(t, "email1@user.com", user.Filters.AdditionalEmails[0])
+ }
// set an invalid email
form.Set("email", "not an email")
@@ -19375,6 +20568,8 @@ func TestWebUserProfile(t *testing.T) {
assert.NoError(t, err)
token, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webClientProfilePath, token)
+ assert.NoError(t, err)
form.Set("allow_api_key_auth", "0")
form.Set(csrfFormToken, csrfToken)
@@ -19399,9 +20594,12 @@ func TestWebUserProfile(t *testing.T) {
assert.NoError(t, err)
token, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webClientProfilePath, token)
+ assert.NoError(t, err)
form.Set("public_keys[0][public_key]", testPubKey)
form.Set("public_keys[1][public_key]", testPubKey1)
form.Set("tls_certs[0][tls_cert]", "")
+ form.Set(csrfFormToken, csrfToken)
req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
req.RemoteAddr = defaultRemoteAddr
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -19422,8 +20620,11 @@ func TestWebUserProfile(t *testing.T) {
assert.NoError(t, err)
token, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webClientProfilePath, token)
+ assert.NoError(t, err)
form.Set("email", "newemail@user.com")
form.Set("description", "new description")
+ form.Set(csrfFormToken, csrfToken)
req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
req.RemoteAddr = defaultRemoteAddr
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -19445,6 +20646,9 @@ func TestWebUserProfile(t *testing.T) {
assert.NoError(t, err)
token, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webChangeClientPwdPath, token)
+ assert.NoError(t, err)
+ form.Set(csrfFormToken, csrfToken)
req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
req.RemoteAddr = defaultRemoteAddr
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -19475,7 +20679,7 @@ func TestWebAdminProfile(t *testing.T) {
assert.NoError(t, err)
token, err := getJWTWebTokenFromTestServer(admin.Username, altAdminPassword)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webAdminProfilePath, token)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, webAdminProfilePath, nil)
assert.NoError(t, err)
@@ -19552,7 +20756,7 @@ func TestWebAdminPwdChange(t *testing.T) {
token, err := getJWTWebTokenFromTestServer(admin.Username, altAdminPassword)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webChangeAdminPwdPath, token)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, webChangeAdminPwdPath, nil)
assert.NoError(t, err)
@@ -20021,7 +21225,7 @@ func TestBasicWebUsersMock(t *testing.T) {
setJWTCookieForReq(req, webToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webUserPath, webToken)
assert.NoError(t, err)
form := make(url.Values)
form.Set("username", user.Username)
@@ -20083,7 +21287,7 @@ func TestWebAdminBasicMock(t *testing.T) {
admin := getTestAdmin()
admin.Username = altAdminUsername
admin.Password = altAdminPassword
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webAdminPath, token)
assert.NoError(t, err)
form := make(url.Values)
form.Set("username", admin.Username)
@@ -20127,13 +21331,6 @@ func TestWebAdminBasicMock(t *testing.T) {
assert.Contains(t, rr.Body.String(), util.I18nError500Message)
form.Set("default_users_expiration", "10")
- req, _ = http.NewRequest(http.MethodPost, webAdminPath, bytes.NewBuffer([]byte(form.Encode())))
- req.RemoteAddr = defaultRemoteAddr
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- setJWTCookieForReq(req, token)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr)
-
form.Set("password", admin.Password)
req, _ = http.NewRequest(http.MethodPost, webAdminPath, bytes.NewBuffer([]byte(form.Encode())))
req.RemoteAddr = defaultRemoteAddr
@@ -20163,6 +21360,17 @@ func TestWebAdminBasicMock(t *testing.T) {
checkResponseCode(t, http.StatusForbidden, rr)
assert.Contains(t, rr.Body.String(), "Invalid token")
+ req, err = http.NewRequest(http.MethodPost, webAdminTOTPSavePath, bytes.NewBuffer(asJSON))
+ assert.NoError(t, err)
+ setJWTCookieForReq(req, altToken)
+ setCSRFHeaderForReq(req, csrfToken) // invalid CSRF token
+ req.RemoteAddr = defaultRemoteAddr
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusForbidden, rr)
+ assert.Contains(t, rr.Body.String(), "the token is not valid")
+
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webAdminPath, altToken)
+ assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPost, webAdminTOTPSavePath, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setJWTCookieForReq(req, altToken)
@@ -20239,7 +21447,7 @@ func TestWebAdminBasicMock(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusSeeOther, rr)
- form.Set(csrfFormToken, "invalid csrf")
+ form.Set(csrfFormToken, csrfToken) // associated to altToken
req, _ = http.NewRequest(http.MethodPost, path.Join(webAdminPath, altAdminUsername), bytes.NewBuffer([]byte(form.Encode())))
req.RemoteAddr = defaultRemoteAddr
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -20248,6 +21456,8 @@ func TestWebAdminBasicMock(t *testing.T) {
checkResponseCode(t, http.StatusForbidden, rr)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCSRF)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webAdminPath, token)
+ assert.NoError(t, err)
form.Set(csrfFormToken, csrfToken)
form.Set("email", "not-an-email")
req, _ = http.NewRequest(http.MethodPost, path.Join(webAdminPath, altAdminUsername), bytes.NewBuffer([]byte(form.Encode())))
@@ -20357,7 +21567,7 @@ func TestWebAdminGroupsMock(t *testing.T) {
admin := getTestAdmin()
admin.Username = altAdminUsername
admin.Password = altAdminPassword
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webAdminPath, token)
assert.NoError(t, err)
form := make(url.Values)
form.Set(csrfFormToken, csrfToken)
@@ -20495,7 +21705,7 @@ func TestAdminUpdateSelfMock(t *testing.T) {
assert.NoError(t, err)
token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webAdminPath, token)
assert.NoError(t, err)
form := make(url.Values)
form.Set("username", admin.Username)
@@ -20553,9 +21763,8 @@ func TestWebMaintenanceMock(t *testing.T) {
setJWTCookieForReq(req, token)
rr := executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webMaintenancePath, token)
assert.NoError(t, err)
-
form := make(url.Values)
form.Set("mode", "a")
b, contentType, _ := getMultipartFormData(form, "", "")
@@ -20674,7 +21883,7 @@ func TestWebUserAddMock(t *testing.T) {
assert.NoError(t, err)
apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webUserPath, webToken)
assert.NoError(t, err)
group1 := getTestGroup()
group1.Name += "_1"
@@ -20697,6 +21906,7 @@ func TestWebUserAddMock(t *testing.T) {
user.AdditionalInfo = "info"
user.Description = "user dsc"
user.Email = "test@test.com"
+ user.Filters.AdditionalEmails = []string{"example1@test.com", "example2@test.com"}
mappedDir := filepath.Join(os.TempDir(), "mapped")
folderName := filepath.Base(mappedDir)
f := vfs.BaseVirtualFolder{
@@ -20714,6 +21924,8 @@ func TestWebUserAddMock(t *testing.T) {
form.Set(csrfFormToken, csrfToken)
form.Set("username", user.Username)
form.Set("email", user.Email)
+ form.Set("additional_emails[0][additional_email]", user.Filters.AdditionalEmails[0])
+ form.Set("additional_emails[1][additional_email]", user.Filters.AdditionalEmails[1])
form.Set("home_dir", user.HomeDir)
form.Set("osfs_read_buffer_size", "2")
form.Set("osfs_write_buffer_size", "3")
@@ -21040,6 +22252,7 @@ func TestWebUserAddMock(t *testing.T) {
assert.True(t, newUser.Filters.DisableFsChecks)
assert.False(t, newUser.Filters.AllowAPIKeyAuth)
assert.Equal(t, user.Email, newUser.Email)
+ assert.Equal(t, len(user.Filters.AdditionalEmails), len(newUser.Filters.AdditionalEmails))
assert.Equal(t, "/start/dir", newUser.Filters.StartDirectory)
assert.Equal(t, 0, newUser.Filters.FTPSecurity)
assert.Equal(t, 10, newUser.Filters.DefaultSharesExpiration)
@@ -21048,10 +22261,10 @@ func TestWebUserAddMock(t *testing.T) {
assert.Equal(t, 60, newUser.Filters.PasswordStrength)
assert.Greater(t, newUser.LastPasswordChange, int64(0))
assert.True(t, newUser.Filters.RequirePasswordChange)
- assert.True(t, util.Contains(newUser.PublicKeys, testPubKey))
+ assert.True(t, slices.Contains(newUser.PublicKeys, testPubKey))
if val, ok := newUser.Permissions["/subdir"]; ok {
- assert.True(t, util.Contains(val, dataprovider.PermListItems))
- assert.True(t, util.Contains(val, dataprovider.PermDownload))
+ assert.True(t, slices.Contains(val, dataprovider.PermListItems))
+ assert.True(t, slices.Contains(val, dataprovider.PermDownload))
} else {
assert.Fail(t, "user permissions must contain /somedir", "actual: %v", newUser.Permissions)
}
@@ -21070,20 +22283,20 @@ func TestWebUserAddMock(t *testing.T) {
case "/dir1":
assert.Len(t, filter.DeniedPatterns, 1)
assert.Len(t, filter.AllowedPatterns, 1)
- assert.True(t, util.Contains(filter.AllowedPatterns, "*.png"))
- assert.True(t, util.Contains(filter.DeniedPatterns, "*.zip"))
+ assert.True(t, slices.Contains(filter.AllowedPatterns, "*.png"))
+ assert.True(t, slices.Contains(filter.DeniedPatterns, "*.zip"))
assert.Equal(t, sdk.DenyPolicyDefault, filter.DenyPolicy)
case "/dir2":
assert.Len(t, filter.DeniedPatterns, 1)
assert.Len(t, filter.AllowedPatterns, 2)
- assert.True(t, util.Contains(filter.AllowedPatterns, "*.jpg"))
- assert.True(t, util.Contains(filter.AllowedPatterns, "*.png"))
- assert.True(t, util.Contains(filter.DeniedPatterns, "*.mkv"))
+ assert.True(t, slices.Contains(filter.AllowedPatterns, "*.jpg"))
+ assert.True(t, slices.Contains(filter.AllowedPatterns, "*.png"))
+ assert.True(t, slices.Contains(filter.DeniedPatterns, "*.mkv"))
assert.Equal(t, sdk.DenyPolicyHide, filter.DenyPolicy)
case "/dir3":
assert.Len(t, filter.DeniedPatterns, 1)
assert.Len(t, filter.AllowedPatterns, 0)
- assert.True(t, util.Contains(filter.DeniedPatterns, "*.rar"))
+ assert.True(t, slices.Contains(filter.DeniedPatterns, "*.rar"))
assert.Equal(t, sdk.DenyPolicyDefault, filter.DenyPolicy)
}
}
@@ -21129,8 +22342,6 @@ func TestWebUserUpdateMock(t *testing.T) {
assert.NoError(t, err)
apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
- assert.NoError(t, err)
user := getTestUser()
user.Filters.BandwidthLimits = []sdk.BandwidthLimit{
{
@@ -21169,6 +22380,8 @@ func TestWebUserUpdateMock(t *testing.T) {
checkResponseCode(t, http.StatusForbidden, rr)
assert.Contains(t, rr.Body.String(), "Invalid token")
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webClientProfilePath, userToken)
+ assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPost, webClientTOTPSavePath, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setJWTCookieForReq(req, userToken)
@@ -21253,6 +22466,8 @@ func TestWebUserUpdateMock(t *testing.T) {
checkResponseCode(t, http.StatusForbidden, rr)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCSRF)
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webUserPath, webToken)
+ assert.NoError(t, err)
form.Set(csrfFormToken, csrfToken)
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
@@ -21322,16 +22537,16 @@ func TestWebUserUpdateMock(t *testing.T) {
assert.Equal(t, 40, updateUser.Filters.PasswordStrength)
assert.True(t, updateUser.Filters.RequirePasswordChange)
if val, ok := updateUser.Permissions["/otherdir"]; ok {
- assert.True(t, util.Contains(val, dataprovider.PermListItems))
- assert.True(t, util.Contains(val, dataprovider.PermUpload))
+ assert.True(t, slices.Contains(val, dataprovider.PermListItems))
+ assert.True(t, slices.Contains(val, dataprovider.PermUpload))
} else {
assert.Fail(t, "user permissions must contains /otherdir", "actual: %v", updateUser.Permissions)
}
- assert.True(t, util.Contains(updateUser.Filters.AllowedIP, "192.168.1.3/32"))
- assert.True(t, util.Contains(updateUser.Filters.DeniedIP, "10.0.0.2/32"))
- assert.True(t, util.Contains(updateUser.Filters.DeniedLoginMethods, dataprovider.SSHLoginMethodKeyboardInteractive))
- assert.True(t, util.Contains(updateUser.Filters.DeniedProtocols, common.ProtocolFTP))
- assert.True(t, util.Contains(updateUser.Filters.FilePatterns[0].DeniedPatterns, "*.zip"))
+ assert.True(t, slices.Contains(updateUser.Filters.AllowedIP, "192.168.1.3/32"))
+ assert.True(t, slices.Contains(updateUser.Filters.DeniedIP, "10.0.0.2/32"))
+ assert.True(t, slices.Contains(updateUser.Filters.DeniedLoginMethods, dataprovider.SSHLoginMethodKeyboardInteractive))
+ assert.True(t, slices.Contains(updateUser.Filters.DeniedProtocols, common.ProtocolFTP))
+ assert.True(t, slices.Contains(updateUser.Filters.FilePatterns[0].DeniedPatterns, "*.zip"))
assert.Len(t, updateUser.Filters.BandwidthLimits, 0)
assert.Len(t, updateUser.Filters.TLSCerts, 1)
req, err = http.NewRequest(http.MethodDelete, path.Join(userPath, user.Username), nil)
@@ -21402,112 +22617,10 @@ func TestRenderUserTemplateMock(t *testing.T) {
assert.NoError(t, err)
}
-func TestUserTemplateWithFoldersMock(t *testing.T) {
- folder := vfs.BaseVirtualFolder{
- Name: "vfolder",
- MappedPath: filepath.Join(os.TempDir(), "mapped"),
- Description: "vfolder desc with spéciàl ch@rs",
- }
-
- token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
- assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
- assert.NoError(t, err)
- user := getTestUser()
- form := make(url.Values)
- form.Set("username", user.Username)
- form.Set("home_dir", filepath.Join(os.TempDir(), "%username%"))
- form.Set("uid", strconv.FormatInt(int64(user.UID), 10))
- form.Set("gid", strconv.FormatInt(int64(user.GID), 10))
- form.Set("max_sessions", strconv.FormatInt(int64(user.MaxSessions), 10))
- form.Set("quota_size", strconv.FormatInt(user.QuotaSize, 10))
- form.Set("quota_files", strconv.FormatInt(int64(user.QuotaFiles), 10))
- form.Set("upload_bandwidth", "0")
- form.Set("download_bandwidth", "0")
- form.Set("upload_data_transfer", "0")
- form.Set("download_data_transfer", "0")
- form.Set("total_data_transfer", "0")
- form.Set("permissions", "*")
- form.Set("status", strconv.Itoa(user.Status))
- form.Set("expiration_date", "2020-01-01 00:00:00")
- form.Set("fs_provider", "0")
- form.Set("max_upload_file_size", "0")
- form.Set("default_shares_expiration", "0")
- form.Set("max_shares_expiration", "0")
- form.Set("password_expiration", "0")
- form.Set("password_strength", "0")
- form.Set("ftp_security", "1")
- form.Set("external_auth_cache_time", "0")
- form.Set("description", "desc %username% %password%")
- form.Set("start_directory", "/base/%username%")
- form.Set("vfolder_path", "/vdir%username%")
- form.Set("vfolder_name", folder.Name)
- form.Set("vfolder_quota_size", "-1")
- form.Set("vfolder_quota_files", "-1")
- form.Add("tpl_username", "auser1")
- form.Add("tpl_password", "password1")
- form.Add("tpl_public_keys", " ")
- form.Add("tpl_username", "auser2")
- form.Add("tpl_password", "password2")
- form.Add("tpl_public_keys", testPubKey)
- form.Add("tpl_username", "auser1")
- form.Add("tpl_password", "password")
- form.Add("tpl_public_keys", "")
- form.Set("form_action", "export_from_template")
- b, contentType, _ := getMultipartFormData(form, "", "")
- req, _ := http.NewRequest(http.MethodPost, webTemplateUser, &b)
- setJWTCookieForReq(req, token)
- req.Header.Set("Content-Type", contentType)
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusForbidden, rr)
- require.Contains(t, rr.Body.String(), util.I18nErrorInvalidCSRF)
-
- folder, resp, err := httpdtest.AddFolder(folder, http.StatusCreated)
- assert.NoError(t, err, string(resp))
-
- form.Set(csrfFormToken, csrfToken)
- b, contentType, _ = getMultipartFormData(form, "", "")
- req, _ = http.NewRequest(http.MethodPost, webTemplateUser, &b)
- setJWTCookieForReq(req, token)
- req.Header.Set("Content-Type", contentType)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr)
-
- var dump dataprovider.BackupData
- err = json.Unmarshal(rr.Body.Bytes(), &dump)
- assert.NoError(t, err)
- assert.Len(t, dump.Users, 2)
- assert.Len(t, dump.Folders, 1)
- user1 := dump.Users[0]
- user2 := dump.Users[1]
- folder1 := dump.Folders[0]
- assert.Equal(t, "auser1", user1.Username)
- assert.Equal(t, "auser2", user2.Username)
- assert.Equal(t, "desc auser1 password1", user1.Description)
- assert.Equal(t, "desc auser2 password2", user2.Description)
- assert.Equal(t, filepath.Join(os.TempDir(), user1.Username), user1.HomeDir)
- assert.Equal(t, filepath.Join(os.TempDir(), user2.Username), user2.HomeDir)
- assert.Equal(t, path.Join("/base", user1.Username), user1.Filters.StartDirectory)
- assert.Equal(t, path.Join("/base", user2.Username), user2.Filters.StartDirectory)
- assert.Equal(t, 0, user2.Filters.DefaultSharesExpiration)
- assert.Equal(t, folder.Name, folder1.Name)
- assert.Len(t, user1.PublicKeys, 0)
- assert.Len(t, user2.PublicKeys, 1)
- assert.Len(t, user1.VirtualFolders, 1)
- assert.Len(t, user2.VirtualFolders, 1)
- assert.Equal(t, "/vdirauser1", user1.VirtualFolders[0].VirtualPath)
- assert.Equal(t, "/vdirauser2", user2.VirtualFolders[0].VirtualPath)
- assert.Equal(t, 1, user1.Filters.FTPSecurity)
- assert.Equal(t, 1, user2.Filters.FTPSecurity)
-
- _, err = httpdtest.RemoveFolder(folder, http.StatusOK)
- assert.NoError(t, err)
-}
-
func TestUserSaveFromTemplateMock(t *testing.T) {
token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webTemplateFolder, token)
assert.NoError(t, err)
user1 := "u1"
user2 := "u2"
@@ -21539,18 +22652,48 @@ func TestUserSaveFromTemplateMock(t *testing.T) {
form.Add("template_users[0][tpl_public_keys]", " ")
form.Add("template_users[1][tpl_username]", user2)
form.Add("template_users[1][tpl_public_keys]", testPubKey)
- form.Set(csrfFormToken, csrfToken)
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ := http.NewRequest(http.MethodPost, webTemplateUser, &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr := executeRequest(req)
+ checkResponseCode(t, http.StatusForbidden, rr)
+ assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCSRF)
+
+ form.Set(csrfFormToken, csrfToken)
+ b, contentType, _ = getMultipartFormData(form, "", "")
+ req, _ = http.NewRequest(http.MethodPost, webTemplateUser, &b)
+ setJWTCookieForReq(req, token)
+ req.Header.Set("Content-Type", contentType)
+ rr = executeRequest(req)
checkResponseCode(t, http.StatusSeeOther, rr)
u1, _, err := httpdtest.GetUserByUsername(user1, http.StatusOK)
assert.NoError(t, err)
+ assert.False(t, u1.Filters.RequirePasswordChange)
u2, _, err := httpdtest.GetUserByUsername(user2, http.StatusOK)
assert.NoError(t, err)
+ assert.False(t, u2.Filters.RequirePasswordChange)
+
+ _, err = httpdtest.RemoveUser(u1, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveUser(u2, http.StatusOK)
+ assert.NoError(t, err)
+
+ form.Add("tpl_require_password_change", "checked")
+ b, contentType, _ = getMultipartFormData(form, "", "")
+ req, _ = http.NewRequest(http.MethodPost, webTemplateUser, &b)
+ setJWTCookieForReq(req, token)
+ req.Header.Set("Content-Type", contentType)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusSeeOther, rr)
+
+ u1, _, err = httpdtest.GetUserByUsername(user1, http.StatusOK)
+ assert.NoError(t, err)
+ assert.True(t, u1.Filters.RequirePasswordChange)
+ u2, _, err = httpdtest.GetUserByUsername(user2, http.StatusOK)
+ assert.NoError(t, err)
+ assert.True(t, u2.Filters.RequirePasswordChange)
_, err = httpdtest.RemoveUser(u1, http.StatusOK)
assert.NoError(t, err)
@@ -21577,9 +22720,11 @@ func TestUserSaveFromTemplateMock(t *testing.T) {
assert.NoError(t, err)
}
-func TestUserTemplateMock(t *testing.T) {
+func TestUserTemplateErrors(t *testing.T) {
token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webTemplateFolder, token)
+ assert.NoError(t, err)
user := getTestUser()
user.FsConfig.Provider = sdk.S3FilesystemProvider
user.FsConfig.S3Config.Bucket = "test"
@@ -21590,8 +22735,6 @@ func TestUserTemplateMock(t *testing.T) {
user.FsConfig.S3Config.UploadConcurrency = 4
user.FsConfig.S3Config.DownloadPartSize = 6
user.FsConfig.S3Config.DownloadConcurrency = 3
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
- assert.NoError(t, err)
form := make(url.Values)
form.Set(csrfFormToken, csrfToken)
form.Set("username", user.Username)
@@ -21617,6 +22760,7 @@ func TestUserTemplateMock(t *testing.T) {
form.Set("s3_region", user.FsConfig.S3Config.Region)
form.Set("s3_access_key", "%username%")
form.Set("s3_access_secret", "%password%")
+ form.Set("s3_sse_customer_key", "%password%")
form.Set("s3_key_prefix", "base/%username%")
form.Set("max_upload_file_size", "0")
form.Set("default_shares_expiration", "0")
@@ -21641,13 +22785,14 @@ func TestUserTemplateMock(t *testing.T) {
form.Set("s3_upload_concurrency", strconv.Itoa(user.FsConfig.S3Config.UploadConcurrency))
form.Set("s3_download_part_size", strconv.FormatInt(user.FsConfig.S3Config.DownloadPartSize, 10))
form.Set("s3_download_concurrency", strconv.Itoa(user.FsConfig.S3Config.DownloadConcurrency))
-
+ // no user defined
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webTemplateUser, &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
+ assert.Contains(t, rr.Body.String(), util.I18nErrorUserTemplate)
form.Set("template_users[0][tpl_username]", "user1")
form.Set("template_users[0][tpl_password]", "password1")
@@ -21670,73 +22815,141 @@ func TestUserTemplateMock(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
require.Contains(t, rr.Body.String(), util.I18nErrorUserTemplate)
+}
+
+func TestUserTemplateRoleAndPermissions(t *testing.T) {
+ r1 := getTestRole()
+ r2 := getTestRole()
+ r2.Name += "_mod"
+ role1, resp, err := httpdtest.AddRole(r1, http.StatusCreated)
+ assert.NoError(t, err, string(resp))
+ role2, resp, err := httpdtest.AddRole(r2, http.StatusCreated)
+ assert.NoError(t, err, string(resp))
+ admin := getTestAdmin()
+ admin.Username = altAdminUsername
+ admin.Password = altAdminPassword
+ admin.Role = role1.Name
+ admin.Permissions = []string{dataprovider.PermAdminManageFolders, dataprovider.PermAdminChangeUsers,
+ dataprovider.PermAdminViewUsers}
+ admin, _, err = httpdtest.AddAdmin(admin, http.StatusCreated)
+ assert.NoError(t, err)
+
+ token, err := getJWTWebTokenFromTestServer(altAdminUsername, altAdminPassword)
+ assert.NoError(t, err)
+
+ req, _ := http.NewRequest(http.MethodGet, webTemplateUser, nil)
+ setJWTCookieForReq(req, token)
+ rr := executeRequest(req)
+ checkResponseCode(t, http.StatusForbidden, rr)
+
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webTemplateFolder, token)
+ assert.NoError(t, err)
+ user1 := "u1"
+ user2 := "u2"
+ form := make(url.Values)
+ form.Set("username", "")
+ form.Set("role", role2.Name)
+ form.Set("home_dir", filepath.Join(os.TempDir(), "%username%"))
+ form.Set("upload_bandwidth", "0")
+ form.Set("download_bandwidth", "0")
+ form.Set("upload_data_transfer", "0")
+ form.Set("download_data_transfer", "0")
+ form.Set("total_data_transfer", "0")
+ form.Set("uid", "0")
+ form.Set("gid", "0")
+ form.Set("max_sessions", "0")
+ form.Set("quota_size", "0")
+ form.Set("quota_files", "0")
+ form.Set("permissions", "*")
+ form.Set("status", "1")
+ form.Set("expiration_date", "")
+ form.Set("fs_provider", "0")
+ form.Set("max_upload_file_size", "0")
+ form.Set("default_shares_expiration", "0")
+ form.Set("max_shares_expiration", "0")
+ form.Set("password_expiration", "0")
+ form.Set("password_strength", "0")
+ form.Set("external_auth_cache_time", "0")
+ form.Add("template_users[0][tpl_username]", user1)
+ form.Add("template_users[0][tpl_password]", "password1")
+ form.Add("template_users[0][tpl_public_keys]", " ")
+ form.Add("template_users[1][tpl_username]", user2)
+ form.Add("template_users[1][tpl_public_keys]", testPubKey)
+ form.Set(csrfFormToken, csrfToken)
+ b, contentType, _ := getMultipartFormData(form, "", "")
+ req, _ = http.NewRequest(http.MethodPost, webTemplateUser, &b)
+ setJWTCookieForReq(req, token)
+ req.Header.Set("Content-Type", contentType)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusForbidden, rr)
+ // Add the required permissions
+ admin.Permissions = append(admin.Permissions, dataprovider.PermAdminAddUsers)
+ _, _, err = httpdtest.UpdateAdmin(admin, http.StatusOK)
+ assert.NoError(t, err)
+
+ token, err = getJWTWebTokenFromTestServer(altAdminUsername, altAdminPassword)
+ assert.NoError(t, err)
+
+ req, _ = http.NewRequest(http.MethodGet, webTemplateUser, nil)
+ setJWTCookieForReq(req, token)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
+
+ csrfToken, err = getCSRFTokenFromInternalPageMock(webTemplateUser, token)
+ assert.NoError(t, err)
+ form.Set(csrfFormToken, csrfToken)
- form.Set("template_users[0][tpl_username]", "user1")
- form.Set("template_users[0][tpl_password]", "password1")
- form.Set("template_users[0][tpl_public_keys]", " ")
- form.Set("template_users[1][tpl_username]", "user2")
- form.Set("template_users[1][tpl_password]", "password2")
- form.Set("template_users[1][tpl_public_keys]", testPubKey)
- form.Set("template_users[2][tpl_username]", "")
- form.Set("template_users[2][tpl_password]", "password3")
- form.Set("template_users[2][tpl_public_keys]", testPubKey)
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webTemplateUser, &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr)
+ checkResponseCode(t, http.StatusSeeOther, rr)
- var dump dataprovider.BackupData
- err = json.Unmarshal(rr.Body.Bytes(), &dump)
- require.NoError(t, err)
- require.Len(t, dump.Users, 2)
- require.Len(t, dump.Admins, 0)
- require.Len(t, dump.Folders, 0)
+ u1, _, err := httpdtest.GetUserByUsername(user1, http.StatusOK)
+ assert.NoError(t, err)
+ assert.Equal(t, admin.Role, u1.Role)
+ u2, _, err := httpdtest.GetUserByUsername(user2, http.StatusOK)
+ assert.NoError(t, err)
+ assert.Equal(t, admin.Role, u2.Role)
- var user1, user2 dataprovider.User
- for _, u := range dump.Users {
- switch u.Username {
- case "user1":
- user1 = u
- default:
- user2 = u
- }
- }
- require.Equal(t, "user1", user1.Username)
- require.Equal(t, sdk.S3FilesystemProvider, user1.FsConfig.Provider)
- require.Equal(t, "user2", user2.Username)
- require.Equal(t, sdk.S3FilesystemProvider, user2.FsConfig.Provider)
- require.Len(t, user1.PublicKeys, 0)
- require.Len(t, user2.PublicKeys, 1)
- require.Equal(t, filepath.Join(os.TempDir(), user1.Username), user1.HomeDir)
- require.Equal(t, filepath.Join(os.TempDir(), user2.Username), user2.HomeDir)
- require.Equal(t, user1.Username, user1.FsConfig.S3Config.AccessKey)
- require.Equal(t, user2.Username, user2.FsConfig.S3Config.AccessKey)
- require.Equal(t, path.Join("base", user1.Username)+"/", user1.FsConfig.S3Config.KeyPrefix)
- require.Equal(t, path.Join("base", user2.Username)+"/", user2.FsConfig.S3Config.KeyPrefix)
- require.True(t, user1.FsConfig.S3Config.AccessSecret.IsEncrypted())
- err = user1.FsConfig.S3Config.AccessSecret.Decrypt()
- require.NoError(t, err)
- require.Equal(t, "password1", user1.FsConfig.S3Config.AccessSecret.GetPayload())
- require.True(t, user2.FsConfig.S3Config.AccessSecret.IsEncrypted())
- err = user2.FsConfig.S3Config.AccessSecret.Decrypt()
- require.NoError(t, err)
- require.Equal(t, "password2", user2.FsConfig.S3Config.AccessSecret.GetPayload())
- require.True(t, user1.Filters.Hooks.ExternalAuthDisabled)
- require.True(t, user1.Filters.Hooks.CheckPasswordDisabled)
- require.False(t, user1.Filters.Hooks.PreLoginDisabled)
- require.True(t, user2.Filters.Hooks.ExternalAuthDisabled)
- require.True(t, user2.Filters.Hooks.CheckPasswordDisabled)
- require.False(t, user2.Filters.Hooks.PreLoginDisabled)
- require.True(t, user1.Filters.DisableFsChecks)
- require.True(t, user2.Filters.DisableFsChecks)
+ _, err = httpdtest.RemoveUser(u1, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveUser(u2, http.StatusOK)
+ assert.NoError(t, err)
+ // Set an empty role
+ form.Set("role", "")
+ b, contentType, _ = getMultipartFormData(form, "", "")
+ req, _ = http.NewRequest(http.MethodPost, webTemplateUser, &b)
+ setJWTCookieForReq(req, token)
+ req.Header.Set("Content-Type", contentType)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusSeeOther, rr)
+
+ u1, _, err = httpdtest.GetUserByUsername(user1, http.StatusOK)
+ assert.NoError(t, err)
+ assert.Equal(t, admin.Role, u1.Role)
+ u2, _, err = httpdtest.GetUserByUsername(user2, http.StatusOK)
+ assert.NoError(t, err)
+ assert.Equal(t, admin.Role, u2.Role)
+
+ _, err = httpdtest.RemoveUser(u1, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveUser(u2, http.StatusOK)
+ assert.NoError(t, err)
+
+ _, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveRole(role1, http.StatusOK)
+ assert.NoError(t, err)
+ _, err = httpdtest.RemoveRole(role2, http.StatusOK)
+ assert.NoError(t, err)
}
func TestUserPlaceholders(t *testing.T) {
token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webUserPath, token)
assert.NoError(t, err)
u := getTestUser()
u.HomeDir = filepath.Join(os.TempDir(), "%username%_%password%")
@@ -21809,7 +23022,7 @@ func TestUserPlaceholders(t *testing.T) {
func TestFolderPlaceholders(t *testing.T) {
token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webFolderPath, token)
assert.NoError(t, err)
folderName := "folderName"
form := make(url.Values)
@@ -21853,7 +23066,7 @@ func TestFolderSaveFromTemplateMock(t *testing.T) {
folder2 := "f2"
token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webTemplateFolder, token)
assert.NoError(t, err)
form := make(url.Values)
form.Set("name", "name")
@@ -21899,12 +23112,12 @@ func TestFolderSaveFromTemplateMock(t *testing.T) {
assert.NoError(t, err)
}
-func TestFolderTemplateMock(t *testing.T) {
+func TestFolderTemplateErrors(t *testing.T) {
folderName := "vfolder-template"
mappedPath := filepath.Join(os.TempDir(), "%name%mapped%name%path")
token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webTemplateFolder, token)
assert.NoError(t, err)
form := make(url.Values)
form.Set("name", folderName)
@@ -21915,7 +23128,6 @@ func TestFolderTemplateMock(t *testing.T) {
form.Set("template_folders[2][tpl_foldername]", "folder3")
form.Set("template_folders[3][tpl_foldername]", "folder1 ")
form.Add("template_folders[3][tpl_foldername]", " ")
- form.Set("form_action", "export_from_template")
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ := http.NewRequest(http.MethodPost, webTemplateFolder, &b)
setJWTCookieForReq(req, token)
@@ -21933,41 +23145,12 @@ func TestFolderTemplateMock(t *testing.T) {
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidForm)
- folder1 := "folder1"
- folder2 := "folder2"
- folder3 := "folder3"
- b, contentType, _ = getMultipartFormData(form, "", "")
- req, _ = http.NewRequest(http.MethodPost, webTemplateFolder, &b)
- setJWTCookieForReq(req, token)
- req.Header.Set("Content-Type", contentType)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr)
-
- var dump dataprovider.BackupData
- err = json.Unmarshal(rr.Body.Bytes(), &dump)
- require.NoError(t, err)
- require.Len(t, dump.Users, 0)
- require.Len(t, dump.Admins, 0)
- require.Len(t, dump.Folders, 3)
- for _, folder := range dump.Folders {
- switch folder.Name {
- case folder1:
- require.Equal(t, "desc folder folder1", folder.Description)
- require.True(t, strings.HasSuffix(folder.MappedPath, "folder1mappedfolder1path"))
- case folder2:
- require.Equal(t, "desc folder folder2", folder.Description)
- require.True(t, strings.HasSuffix(folder.MappedPath, "folder2mappedfolder2path"))
- default:
- require.Equal(t, "desc folder folder3", folder.Description)
- require.True(t, strings.HasSuffix(folder.MappedPath, "folder3mappedfolder3path"))
- }
- }
-
form.Set("fs_provider", "1")
form.Set("s3_bucket", "bucket")
form.Set("s3_region", "us-east-1")
form.Set("s3_access_key", "%name%")
form.Set("s3_access_secret", "pwd%name%")
+ form.Set("s3_sse_customer_key", "key%name%")
form.Set("s3_key_prefix", "base/%name%")
b, contentType, _ = getMultipartFormData(form, "", "")
@@ -21984,43 +23167,6 @@ func TestFolderTemplateMock(t *testing.T) {
form.Set("s3_upload_part_max_time", "0")
form.Set("s3_download_part_size", "6")
form.Set("s3_download_concurrency", "2")
- b, contentType, _ = getMultipartFormData(form, "", "")
- req, _ = http.NewRequest(http.MethodPost, webTemplateFolder, &b)
- setJWTCookieForReq(req, token)
- req.Header.Set("Content-Type", contentType)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr)
-
- dump = dataprovider.BackupData{
- Version: dataprovider.DumpVersion,
- }
- err = json.Unmarshal(rr.Body.Bytes(), &dump)
- require.NoError(t, err)
- require.Len(t, dump.Users, 0)
- require.Len(t, dump.Admins, 0)
- require.Len(t, dump.Folders, 3)
- for _, folder := range dump.Folders {
- switch folder.Name {
- case folder1:
- require.Equal(t, folder1, folder.FsConfig.S3Config.AccessKey)
- err = folder.FsConfig.S3Config.AccessSecret.Decrypt()
- require.NoError(t, err)
- require.Equal(t, fmt.Sprintf("pwd%s", folder1), folder.FsConfig.S3Config.AccessSecret.GetPayload())
- require.Equal(t, path.Join("base", folder1)+"/", folder.FsConfig.S3Config.KeyPrefix)
- case folder2:
- require.Equal(t, folder2, folder.FsConfig.S3Config.AccessKey)
- err = folder.FsConfig.S3Config.AccessSecret.Decrypt()
- require.NoError(t, err)
- require.Equal(t, "pwd"+folder2, folder.FsConfig.S3Config.AccessSecret.GetPayload())
- require.Equal(t, "base/"+folder2+"/", folder.FsConfig.S3Config.KeyPrefix)
- default:
- require.Equal(t, folder3, folder.FsConfig.S3Config.AccessKey)
- err = folder.FsConfig.S3Config.AccessSecret.Decrypt()
- require.NoError(t, err)
- require.Equal(t, "pwd"+folder3, folder.FsConfig.S3Config.AccessSecret.GetPayload())
- require.Equal(t, "base/"+folder3+"/", folder.FsConfig.S3Config.KeyPrefix)
- }
- }
form.Set("template_folders[0][tpl_foldername]", " ")
form.Set("template_folders[1][tpl_foldername]", "")
@@ -22045,12 +23191,67 @@ func TestFolderTemplateMock(t *testing.T) {
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidHomeDir)
}
+func TestFolderTemplatePermission(t *testing.T) {
+ admin := getTestAdmin()
+ admin.Username = altAdminUsername
+ admin.Password = altAdminPassword
+ admin.Permissions = []string{dataprovider.PermAdminChangeUsers, dataprovider.PermAdminAddUsers, dataprovider.PermAdminViewUsers}
+ admin, _, err := httpdtest.AddAdmin(admin, http.StatusCreated)
+ assert.NoError(t, err)
+ // no permission to view or add folders from templates
+ token, err := getJWTWebTokenFromTestServer(altAdminUsername, altAdminPassword)
+ assert.NoError(t, err)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webTemplateUser, token)
+ assert.NoError(t, err)
+
+ req, err := http.NewRequest(http.MethodGet, webTemplateFolder, nil)
+ assert.NoError(t, err)
+ req.RequestURI = webTemplateFolder
+ setJWTCookieForReq(req, token)
+ rr := executeRequest(req)
+ checkResponseCode(t, http.StatusForbidden, rr)
+
+ form := make(url.Values)
+ form.Set("name", "name")
+ form.Set("mapped_path", filepath.Join(os.TempDir(), "%name%"))
+ form.Set("description", "desc folder %name%")
+ form.Set("template_folders[0][tpl_foldername]", "folder1")
+ form.Set("template_folders[1][tpl_foldername]", "folder2")
+ form.Set(csrfFormToken, csrfToken)
+ b, contentType, _ := getMultipartFormData(form, "", "")
+ req, err = http.NewRequest(http.MethodPost, webTemplateFolder, &b)
+ assert.NoError(t, err)
+ setJWTCookieForReq(req, token)
+ req.Header.Set("Content-Type", contentType)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusForbidden, rr)
+
+ admin.Permissions = append(admin.Permissions, dataprovider.PermAdminManageFolders)
+ _, _, err = httpdtest.UpdateAdmin(admin, http.StatusOK)
+ assert.NoError(t, err)
+
+ token, err = getJWTWebTokenFromTestServer(altAdminUsername, altAdminPassword)
+ assert.NoError(t, err)
+ _, err = getCSRFTokenFromInternalPageMock(webTemplateUser, token)
+ assert.NoError(t, err)
+
+ req, err = http.NewRequest(http.MethodGet, webTemplateFolder, nil)
+ assert.NoError(t, err)
+ req.RequestURI = webTemplateFolder
+ setJWTCookieForReq(req, token)
+ rr = executeRequest(req)
+ checkResponseCode(t, http.StatusOK, rr)
+
+ _, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
+ assert.NoError(t, err)
+}
+
func TestWebUserS3Mock(t *testing.T) {
webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webUserPath, webToken)
assert.NoError(t, err)
user := getTestUser()
userAsJSON := getUserAsJSON(t, user)
@@ -22067,6 +23268,7 @@ func TestWebUserS3Mock(t *testing.T) {
user.FsConfig.S3Config.Region = "eu-west-1"
user.FsConfig.S3Config.AccessKey = "access-key"
user.FsConfig.S3Config.AccessSecret = kms.NewPlainSecret("access-secret")
+ user.FsConfig.S3Config.SSECustomerKey = kms.NewPlainSecret("enc-key")
user.FsConfig.S3Config.RoleARN = "arn:aws:iam::123456789012:user/Development/product_1234/*"
user.FsConfig.S3Config.Endpoint = "http://127.0.0.1:9000/path?a=b"
user.FsConfig.S3Config.StorageClass = "Standard"
@@ -22107,6 +23309,7 @@ func TestWebUserS3Mock(t *testing.T) {
form.Set("s3_region", user.FsConfig.S3Config.Region)
form.Set("s3_access_key", user.FsConfig.S3Config.AccessKey)
form.Set("s3_access_secret", user.FsConfig.S3Config.AccessSecret.GetPayload())
+ form.Set("s3_sse_customer_key", user.FsConfig.S3Config.SSECustomerKey.GetPayload())
form.Set("s3_role_arn", user.FsConfig.S3Config.RoleARN)
form.Set("s3_storage_class", user.FsConfig.S3Config.StorageClass)
form.Set("s3_acl", user.FsConfig.S3Config.ACL)
@@ -22231,6 +23434,10 @@ func TestWebUserS3Mock(t *testing.T) {
assert.NotEmpty(t, updateUser.FsConfig.S3Config.AccessSecret.GetPayload())
assert.Empty(t, updateUser.FsConfig.S3Config.AccessSecret.GetKey())
assert.Empty(t, updateUser.FsConfig.S3Config.AccessSecret.GetAdditionalData())
+ assert.Equal(t, sdkkms.SecretStatusSecretBox, updateUser.FsConfig.S3Config.SSECustomerKey.GetStatus())
+ assert.NotEmpty(t, updateUser.FsConfig.S3Config.SSECustomerKey.GetPayload())
+ assert.Empty(t, updateUser.FsConfig.S3Config.SSECustomerKey.GetKey())
+ assert.Empty(t, updateUser.FsConfig.S3Config.SSECustomerKey.GetAdditionalData())
assert.Equal(t, user.Description, updateUser.Description)
assert.True(t, updateUser.Filters.Hooks.PreLoginDisabled)
assert.False(t, updateUser.Filters.Hooks.ExternalAuthDisabled)
@@ -22240,6 +23447,7 @@ func TestWebUserS3Mock(t *testing.T) {
assert.Equal(t, 1, updateUser.Filters.FTPSecurity)
// now check that a redacted password is not saved
form.Set("s3_access_secret", redactedSecret)
+ form.Set("s3_sse_customer_key", redactedSecret)
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
setJWTCookieForReq(req, webToken)
@@ -22258,10 +23466,15 @@ func TestWebUserS3Mock(t *testing.T) {
assert.Equal(t, updateUser.FsConfig.S3Config.AccessSecret.GetPayload(), lastUpdatedUser.FsConfig.S3Config.AccessSecret.GetPayload())
assert.Empty(t, lastUpdatedUser.FsConfig.S3Config.AccessSecret.GetKey())
assert.Empty(t, lastUpdatedUser.FsConfig.S3Config.AccessSecret.GetAdditionalData())
+ assert.Equal(t, sdkkms.SecretStatusSecretBox, lastUpdatedUser.FsConfig.S3Config.SSECustomerKey.GetStatus())
+ assert.Equal(t, updateUser.FsConfig.S3Config.SSECustomerKey.GetPayload(), lastUpdatedUser.FsConfig.S3Config.SSECustomerKey.GetPayload())
+ assert.Empty(t, lastUpdatedUser.FsConfig.S3Config.SSECustomerKey.GetKey())
+ assert.Empty(t, lastUpdatedUser.FsConfig.S3Config.SSECustomerKey.GetAdditionalData())
assert.Equal(t, lastPwdChange, lastUpdatedUser.LastPasswordChange)
// now clear credentials
form.Set("s3_access_key", "")
form.Set("s3_access_secret", "")
+ form.Set("s3_sse_customer_key", "")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webUserPath, user.Username), &b)
setJWTCookieForReq(req, webToken)
@@ -22276,6 +23489,7 @@ func TestWebUserS3Mock(t *testing.T) {
err = render.DecodeJSON(rr.Body, &userGet)
assert.NoError(t, err)
assert.Nil(t, userGet.FsConfig.S3Config.AccessSecret)
+ assert.Nil(t, userGet.FsConfig.S3Config.SSECustomerKey)
req, _ = http.NewRequest(http.MethodDelete, path.Join(userPath, user.Username), nil)
setBearerForReq(req, apiToken)
@@ -22288,7 +23502,7 @@ func TestWebUserGCSMock(t *testing.T) {
assert.NoError(t, err)
apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webUserPath, webToken)
assert.NoError(t, err)
user := getTestUser()
userAsJSON := getUserAsJSON(t, user)
@@ -22416,7 +23630,7 @@ func TestWebUserHTTPFsMock(t *testing.T) {
assert.NoError(t, err)
apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webUserPath, webToken)
assert.NoError(t, err)
user := getTestUser()
userAsJSON := getUserAsJSON(t, user)
@@ -22543,7 +23757,7 @@ func TestWebUserAzureBlobMock(t *testing.T) {
assert.NoError(t, err)
apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webUserPath, webToken)
assert.NoError(t, err)
user := getTestUser()
userAsJSON := getUserAsJSON(t, user)
@@ -22740,7 +23954,7 @@ func TestWebUserCryptMock(t *testing.T) {
assert.NoError(t, err)
apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webUserPath, webToken)
assert.NoError(t, err)
user := getTestUser()
userAsJSON := getUserAsJSON(t, user)
@@ -22847,7 +24061,7 @@ func TestWebUserSFTPFsMock(t *testing.T) {
assert.NoError(t, err)
apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webUserPath, webToken)
assert.NoError(t, err)
user := getTestUser()
userAsJSON := getUserAsJSON(t, user)
@@ -23003,7 +24217,7 @@ func TestWebUserRole(t *testing.T) {
assert.NoError(t, err)
webToken, err := getJWTWebTokenFromTestServer(altAdminUsername, altAdminPassword)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webUserPath, webToken)
assert.NoError(t, err)
user := getTestUser()
form := make(url.Values)
@@ -23066,10 +24280,9 @@ func TestWebEventAction(t *testing.T) {
assert.NoError(t, err)
apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webAdminEventActionPath, webToken)
assert.NoError(t, err)
action := dataprovider.BaseEventAction{
- ID: 81,
Name: "web_action_http",
Description: "http web action",
Type: dataprovider.ActionTypeHTTP,
@@ -23093,7 +24306,7 @@ func TestWebEventAction(t *testing.T) {
Value: "value1",
},
},
- Body: `{"event":"{{Event}}","name":"{{Name}}"}`,
+ Body: `{"event":"{{.Event}}","name":"{{.Name}}"}`,
},
},
}
@@ -23212,12 +24425,12 @@ func TestWebEventAction(t *testing.T) {
form.Del("http_headers[0][http_header_key]")
form.Del("http_headers[0][http_header_val]")
form.Set("multipart_body[0][http_part_name]", "part1")
- form.Set("multipart_body[0][http_part_file]", "{{VirtualPath}}")
+ form.Set("multipart_body[0][http_part_file]", "{{.VirtualPath}}")
form.Set("multipart_body[0][http_part_body]", "")
form.Set("multipart_body[0][http_part_headers]", "X-MyHeader: a:b,c")
form.Set("multipart_body[12][http_part_name]", "part2")
form.Set("multipart_body[12][http_part_headers]", "Content-Type:application/json \r\n")
- form.Set("multipart_body[12][http_part_body]", "{{ObjectData}}")
+ form.Set("multipart_body[12][http_part_body]", "{{.ObjectData}}")
req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name),
bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
@@ -23234,12 +24447,12 @@ func TestWebEventAction(t *testing.T) {
assert.Equal(t, 0, dbAction.Options.HTTPConfig.Timeout)
if assert.Len(t, dbAction.Options.HTTPConfig.Parts, 2) {
assert.Equal(t, "part1", dbAction.Options.HTTPConfig.Parts[0].Name)
- assert.Equal(t, "/{{VirtualPath}}", dbAction.Options.HTTPConfig.Parts[0].Filepath)
+ assert.Equal(t, "/{{.VirtualPath}}", dbAction.Options.HTTPConfig.Parts[0].Filepath)
assert.Empty(t, dbAction.Options.HTTPConfig.Parts[0].Body)
assert.Equal(t, "X-MyHeader", dbAction.Options.HTTPConfig.Parts[0].Headers[0].Key)
assert.Equal(t, "a:b,c", dbAction.Options.HTTPConfig.Parts[0].Headers[0].Value)
assert.Equal(t, "part2", dbAction.Options.HTTPConfig.Parts[1].Name)
- assert.Equal(t, "{{ObjectData}}", dbAction.Options.HTTPConfig.Parts[1].Body)
+ assert.Equal(t, "{{.ObjectData}}", dbAction.Options.HTTPConfig.Parts[1].Body)
assert.Empty(t, dbAction.Options.HTTPConfig.Parts[1].Filepath)
assert.Equal(t, "Content-Type", dbAction.Options.HTTPConfig.Parts[1].Headers[0].Key)
assert.Equal(t, "application/json", dbAction.Options.HTTPConfig.Parts[1].Headers[0].Value)
@@ -23257,6 +24470,10 @@ func TestWebEventAction(t *testing.T) {
},
},
}
+ dataprovider.EnabledActionCommands = []string{action.Options.CmdConfig.Cmd}
+ defer func() {
+ dataprovider.EnabledActionCommands = nil
+ }()
form.Set("type", fmt.Sprintf("%d", action.Type))
req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name),
bytes.NewBuffer([]byte(form.Encode())))
@@ -23468,16 +24685,19 @@ func TestWebEventAction(t *testing.T) {
action.Options.FsConfig = dataprovider.EventActionFilesystemConfig{
Type: dataprovider.FilesystemActionRename,
- Renames: []dataprovider.KeyValue{
+ Renames: []dataprovider.RenameConfig{
{
- Key: "/src",
- Value: "/target",
+ KeyValue: dataprovider.KeyValue{
+ Key: "/src",
+ Value: "/target",
+ },
},
},
}
form.Set("fs_action_type", fmt.Sprintf("%d", action.Options.FsConfig.Type))
form.Set("fs_rename[0][fs_rename_source]", action.Options.FsConfig.Renames[0].Key)
form.Set("fs_rename[0][fs_rename_target]", action.Options.FsConfig.Renames[0].Value)
+ form.Set("fs_rename[0][fs_rename_options][]", "1")
req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name),
bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
@@ -23489,7 +24709,9 @@ func TestWebEventAction(t *testing.T) {
actionGet, _, err = httpdtest.GetEventActionByName(action.Name, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, action.Type, actionGet.Type)
- assert.Len(t, actionGet.Options.FsConfig.Renames, 1)
+ if assert.Len(t, actionGet.Options.FsConfig.Renames, 1) {
+ assert.True(t, actionGet.Options.FsConfig.Renames[0].UpdateModTime)
+ }
action.Options.FsConfig = dataprovider.EventActionFilesystemConfig{
Type: dataprovider.FilesystemActionCopy,
@@ -23611,7 +24833,7 @@ func TestWebEventAction(t *testing.T) {
func TestWebEventRule(t *testing.T) {
webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webAdminEventRulePath, webToken)
assert.NoError(t, err)
a := dataprovider.BaseEventAction{
Name: "web_action",
@@ -23929,7 +25151,7 @@ func TestWebEventRule(t *testing.T) {
func TestWebIPListEntries(t *testing.T) {
webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webUserPath, webToken)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, webIPListPath+"/mode", nil)
@@ -24002,7 +25224,8 @@ func TestWebIPListEntries(t *testing.T) {
form.Set("protocols", "a")
form.Add("protocols", "1")
form.Add("protocols", "4")
- req, err = http.NewRequest(http.MethodPost, webIPListPath+"/2", bytes.NewBuffer([]byte(form.Encode())))
+ req, err = http.NewRequest(http.MethodPost, webIPListPath+"/"+strconv.Itoa(int(entry.Type)),
+ bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
setJWTCookieForReq(req, webToken)
@@ -24115,7 +25338,7 @@ func TestWebIPListEntries(t *testing.T) {
func TestWebRole(t *testing.T) {
webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webAdminRolePath, webToken)
assert.NoError(t, err)
role := getTestRole()
form := make(url.Values)
@@ -24221,84 +25444,12 @@ func TestWebRole(t *testing.T) {
assert.NoError(t, err)
}
-func TestNameParamSingleSlash(t *testing.T) {
- err := dataprovider.Close()
- assert.NoError(t, err)
- err = config.LoadConfig(configDir, "")
- assert.NoError(t, err)
- providerConf := config.GetProviderConf()
- providerConf.NamingRules = 5
- err = dataprovider.Initialize(providerConf, configDir, true)
- assert.NoError(t, err)
-
- webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
- assert.NoError(t, err)
- apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
- assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
- assert.NoError(t, err)
- group := getTestGroup()
- group.Name = "/"
- form := make(url.Values)
- form.Set("name", group.Name)
- form.Set("description", group.Description)
- form.Set("max_sessions", "0")
- form.Set("quota_files", "0")
- form.Set("quota_size", "0")
- form.Set("upload_bandwidth", "0")
- form.Set("download_bandwidth", "0")
- form.Set("upload_data_transfer", "0")
- form.Set("download_data_transfer", "0")
- form.Set("total_data_transfer", "0")
- form.Set("max_upload_file_size", "0")
- form.Set("default_shares_expiration", "0")
- form.Set("max_shares_expiration", "0")
- form.Set("password_expiration", "0")
- form.Set("password_strength", "0")
- form.Set("expires_in", "0")
- form.Set("external_auth_cache_time", "0")
- form.Set(csrfFormToken, csrfToken)
- b, contentType, err := getMultipartFormData(form, "", "")
- assert.NoError(t, err)
- req, err := http.NewRequest(http.MethodPost, webGroupPath, &b)
- assert.NoError(t, err)
- req.Header.Set("Content-Type", contentType)
- setJWTCookieForReq(req, webToken)
- rr := executeRequest(req)
- checkResponseCode(t, http.StatusSeeOther, rr)
-
- groupGet, _, err := httpdtest.GetGroupByName(group.Name, http.StatusOK)
- assert.NoError(t, err)
- assert.Equal(t, "/", groupGet.Name)
- // check strip slash
- req, err = http.NewRequest(http.MethodGet, webGroupsPath+"/", nil)
- assert.NoError(t, err)
- setJWTCookieForReq(req, webToken)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr)
- // cleanup
- req, err = http.NewRequest(http.MethodDelete, groupPath+"/"+url.PathEscape(group.Name), nil)
- assert.NoError(t, err)
- setBearerForReq(req, apiToken)
- rr = executeRequest(req)
- checkResponseCode(t, http.StatusOK, rr)
-
- err = dataprovider.Close()
- assert.NoError(t, err)
- err = config.LoadConfig(configDir, "")
- assert.NoError(t, err)
- providerConf = config.GetProviderConf()
- providerConf.BackupsPath = backupsPath
- err = dataprovider.Initialize(providerConf, configDir, true)
- assert.NoError(t, err)
-}
-
func TestAddWebGroup(t *testing.T) {
webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webGroupPath, webToken)
assert.NoError(t, err)
group := getTestGroup()
group.UserSettings = dataprovider.GroupUserSettings{
@@ -24484,7 +25635,7 @@ func TestAddWebFoldersMock(t *testing.T) {
assert.NoError(t, err)
apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webFolderPath, webToken)
assert.NoError(t, err)
mappedPath := filepath.Clean(os.TempDir())
folderName := filepath.Base(mappedPath)
@@ -24562,7 +25713,7 @@ func TestHTTPFsWebFolderMock(t *testing.T) {
assert.NoError(t, err)
apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webFolderPath, webToken)
assert.NoError(t, err)
mappedPath := filepath.Clean(os.TempDir())
folderName := filepath.Base(mappedPath)
@@ -24657,7 +25808,7 @@ func TestS3WebFolderMock(t *testing.T) {
assert.NoError(t, err)
apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webFolderPath, webToken)
assert.NoError(t, err)
mappedPath := filepath.Clean(os.TempDir())
folderName := filepath.Base(mappedPath)
@@ -24666,6 +25817,7 @@ func TestS3WebFolderMock(t *testing.T) {
S3Region := "eu-west-1"
S3AccessKey := "access-key"
S3AccessSecret := kms.NewPlainSecret("folder-access-secret")
+ S3SSEKey := kms.NewPlainSecret("folder-sse-key")
S3SessionToken := "fake session token"
S3RoleARN := "arn:aws:iam::123456789012:user/Development/product_1234/*"
S3Endpoint := "http://127.0.0.1:9000/path?b=c"
@@ -24687,6 +25839,7 @@ func TestS3WebFolderMock(t *testing.T) {
form.Set("s3_region", S3Region)
form.Set("s3_access_key", S3AccessKey)
form.Set("s3_access_secret", S3AccessSecret.GetPayload())
+ form.Set("s3_sse_customer_key", S3SSEKey.GetPayload())
form.Set("s3_session_token", S3SessionToken)
form.Set("s3_role_arn", S3RoleARN)
form.Set("s3_storage_class", S3StorageClass)
@@ -24734,6 +25887,7 @@ func TestS3WebFolderMock(t *testing.T) {
assert.Equal(t, S3Region, folder.FsConfig.S3Config.Region)
assert.Equal(t, S3AccessKey, folder.FsConfig.S3Config.AccessKey)
assert.NotEmpty(t, folder.FsConfig.S3Config.AccessSecret.GetPayload())
+ assert.NotEmpty(t, folder.FsConfig.S3Config.SSECustomerKey.GetPayload())
assert.Equal(t, S3Endpoint, folder.FsConfig.S3Config.Endpoint)
assert.Equal(t, S3StorageClass, folder.FsConfig.S3Config.StorageClass)
assert.Equal(t, S3ACL, folder.FsConfig.S3Config.ACL)
@@ -24784,6 +25938,7 @@ func TestS3WebFolderMock(t *testing.T) {
assert.Equal(t, S3AccessKey, folder.FsConfig.S3Config.AccessKey)
assert.Equal(t, S3RoleARN, folder.FsConfig.S3Config.RoleARN)
assert.NotEmpty(t, folder.FsConfig.S3Config.AccessSecret.GetPayload())
+ assert.NotEmpty(t, folder.FsConfig.S3Config.SSECustomerKey.GetPayload())
assert.Equal(t, S3Endpoint, folder.FsConfig.S3Config.Endpoint)
assert.Equal(t, S3StorageClass, folder.FsConfig.S3Config.StorageClass)
assert.Equal(t, S3KeyPrefix, folder.FsConfig.S3Config.KeyPrefix)
@@ -24802,7 +25957,7 @@ func TestUpdateWebGroupMock(t *testing.T) {
assert.NoError(t, err)
apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webGroupPath, webToken)
assert.NoError(t, err)
group, _, err := httpdtest.AddGroup(getTestGroup(), http.StatusCreated)
assert.NoError(t, err)
@@ -24907,7 +26062,7 @@ func TestUpdateWebFolderMock(t *testing.T) {
assert.NoError(t, err)
apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webFolderPath, webToken)
assert.NoError(t, err)
folderName := "vfolderupdate"
folderDesc := "updated desc"
@@ -25124,7 +26279,7 @@ func TestAdminForgotPassword(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ loginCookie, csrfToken, err := getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
assert.NoError(t, err)
form := make(url.Values)
@@ -25133,6 +26288,7 @@ func TestAdminForgotPassword(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webAdminForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusForbidden, rr.Code)
@@ -25141,6 +26297,7 @@ func TestAdminForgotPassword(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webAdminForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusOK, rr.Code)
@@ -25148,9 +26305,28 @@ func TestAdminForgotPassword(t *testing.T) {
lastResetCode = ""
form.Set("username", altAdminUsername)
+ // disable the admin
+ admin.Status = 0
+ admin, _, err = httpdtest.UpdateAdmin(admin, http.StatusOK)
+ assert.NoError(t, err)
+
req, err = http.NewRequest(http.MethodPost, webAdminForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr = executeRequest(req)
+ assert.Equal(t, http.StatusFound, rr.Code)
+ assert.Len(t, lastResetCode, 0)
+
+ admin.Status = 1
+ admin, _, err = httpdtest.UpdateAdmin(admin, http.StatusOK)
+ assert.NoError(t, err)
+
+ req, err = http.NewRequest(http.MethodPost, webAdminForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+ assert.NoError(t, err)
+ req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
@@ -25160,6 +26336,7 @@ func TestAdminForgotPassword(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webAdminResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusForbidden, rr.Code)
@@ -25168,6 +26345,7 @@ func TestAdminForgotPassword(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webAdminResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusOK, rr.Code)
@@ -25178,23 +26356,45 @@ func TestAdminForgotPassword(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webAdminResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorChangePwdGeneric)
- // ok
+ // disable the admin
+ admin.Status = 0
+ admin, _, err = httpdtest.UpdateAdmin(admin, http.StatusOK)
+ assert.NoError(t, err)
form.Set("code", lastResetCode)
req, err = http.NewRequest(http.MethodPost, webAdminResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr = executeRequest(req)
+ assert.Equal(t, http.StatusOK, rr.Code)
+ assert.Contains(t, rr.Body.String(), util.I18nErrorChangePwdGeneric)
+
+ admin.Status = 1
+ admin, _, err = httpdtest.UpdateAdmin(admin, http.StatusOK)
+ assert.NoError(t, err)
+ // ok
+ req, err = http.NewRequest(http.MethodPost, webAdminResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+ assert.NoError(t, err)
+ req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
+ form.Set(csrfFormToken, csrfToken)
form.Set("username", altAdminUsername)
req, err = http.NewRequest(http.MethodPost, webAdminForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
@@ -25216,6 +26416,7 @@ func TestAdminForgotPassword(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webAdminForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusOK, rr.Code)
@@ -25230,6 +26431,7 @@ func TestAdminForgotPassword(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webAdminForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusOK, rr.Code)
@@ -25284,22 +26486,25 @@ func TestUserForgotPassword(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
+ loginCookie, csrfToken, err := getCSRFTokenMock(webClientLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
+
form := make(url.Values)
form.Set("username", "")
// no csrf token
req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusForbidden, rr.Code)
// empty username
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
- assert.NoError(t, err)
form.Set(csrfFormToken, csrfToken)
req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusOK, rr.Code)
@@ -25309,23 +26514,38 @@ func TestUserForgotPassword(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorPwdResetForbidded)
+ user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(-1 * time.Hour))
user.Filters.WebClient = []string{sdk.WebClientAPIKeyAuthChangeDisabled}
user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
assert.NoError(t, err)
-
+ // user is expired
lastResetCode = ""
req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr = executeRequest(req)
+ assert.Equal(t, http.StatusFound, rr.Code)
+ assert.Len(t, lastResetCode, 0)
+
+ user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour))
+ user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+ assert.NoError(t, err)
+ req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
assert.GreaterOrEqual(t, len(lastResetCode), 20)
- // no csrf token
+ // no login token
form = make(url.Values)
req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
@@ -25340,6 +26560,7 @@ func TestUserForgotPassword(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusOK, rr.Code)
@@ -25350,6 +26571,7 @@ func TestUserForgotPassword(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusOK, rr.Code)
@@ -25360,19 +26582,38 @@ func TestUserForgotPassword(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr = executeRequest(req)
+ assert.Equal(t, http.StatusOK, rr.Code)
+ assert.Contains(t, rr.Body.String(), util.I18nErrorChangePwdGeneric)
+ // Invalid login condition
+ form.Set("code", lastResetCode)
+ user.Filters.DeniedProtocols = []string{common.ProtocolHTTP}
+ user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+ assert.NoError(t, err)
+ req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorChangePwdGeneric)
// ok
- form.Set("code", lastResetCode)
+ user.Filters.DeniedProtocols = []string{common.ProtocolFTP}
+ user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+ assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
+ loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
+ assert.NoError(t, err)
form = make(url.Values)
form.Set(csrfFormToken, csrfToken)
form.Set("username", user.Username)
@@ -25380,6 +26621,7 @@ func TestUserForgotPassword(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusFound, rr.Code)
@@ -25412,6 +26654,7 @@ func TestUserForgotPassword(t *testing.T) {
req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
assert.Equal(t, http.StatusOK, rr.Code)
@@ -25608,7 +26851,7 @@ func TestAPIForgotPassword(t *testing.T) {
func TestProviderClosedMock(t *testing.T) {
token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webConfigsPath, token)
assert.NoError(t, err)
// create a role admin
role, resp, err := httpdtest.AddRole(getTestRole(), http.StatusCreated)
@@ -25817,7 +27060,7 @@ func TestWebConnectionsMock(t *testing.T) {
checkResponseCode(t, http.StatusForbidden, rr)
assert.Contains(t, rr.Body.String(), "Invalid token")
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ csrfToken, err := getCSRFTokenFromInternalPageMock(webUserPath, token)
assert.NoError(t, err)
req, _ = http.NewRequest(http.MethodDelete, path.Join(webConnectionsPath, "id"), nil)
setJWTCookieForReq(req, token)
@@ -25855,7 +27098,7 @@ func TestGetWebStatusMock(t *testing.T) {
}
func TestStaticFilesMock(t *testing.T) {
- req, err := http.NewRequest(http.MethodGet, "/static/favicon.ico", nil)
+ req, err := http.NewRequest(http.MethodGet, "/static/favicon.png", nil)
assert.NoError(t, err)
rr := executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
@@ -25948,12 +27191,63 @@ func TestSecondFactorRequirements(t *testing.T) {
assert.False(t, user.MustSetSecondFactorForProtocol(common.ProtocolSSH))
}
+func TestIsNameValid(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected bool
+ }{
+ {"simple name", "user", true},
+ {"alphanumeric", "User123", true},
+ {"unicode allowed", "你好", true},
+ {"emoji allowed", "user😊", true},
+ {"name with dot", "file.txt", true},
+ {"name with multiple dots", "archive.tar.gz", true},
+ {"control char", "abc\u0001", false},
+ {"newline", "abc\n", false},
+ {"tab", "abc\t", false},
+ {"slash", "user/name", false},
+ {"backslash", "user\\name", false},
+ {"colon", "user:name", false},
+ {"single dot", ".", false},
+ {"double dot", "..", false},
+ {"dot with suffix allowed", ".hidden", true},
+ {"name ending with dot", "file.", false},
+ {"name ending with space", "file ", false},
+ {"CON", "CON", false},
+ {"con lowercase", "con", false},
+ {"con with extension", "con.txt", false},
+ {"LPT1", "LPT1", false},
+ {"lpt1 lowercase", "lpt1", false},
+ {"COM5 uppercase", "COM5", false},
+ {"com9 with extension", "com9.log", false},
+ {"NUL", "NUL", false},
+ {"Valid because suffix changes base", "con123", true},
+ {"base name split", "aux.pdf", false},
+ {"valid long name", "auxiliary", true},
+ {"space only", " ", false},
+ {"dot inside", "ab.cd.ef", true},
+ {"unicode that ends with dot", "你好.", false},
+ {"unicode that ends with space", "你好 ", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := util.IsNameValid(tt.input)
+ if result != tt.expected {
+ t.Errorf("IsNameValid(%q) = %v, expected %v", tt.input, result, tt.expected)
+ }
+ })
+ }
+}
+
func startOIDCMockServer() {
go func() {
http.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, "OK\n")
})
http.HandleFunc("/auth/realms/sftpgo/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"issuer":"http://127.0.0.1:11111/auth/realms/sftpgo","authorization_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/auth","token_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/token","introspection_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/token/introspect","userinfo_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/userinfo","end_session_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/logout","frontchannel_logout_session_supported":true,"frontchannel_logout_supported":true,"jwks_uri":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/certs","check_session_iframe":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/login-status-iframe.html","grant_types_supported":["authorization_code","implicit","refresh_token","password","client_credentials","urn:ietf:params:oauth:grant-type:device_code","urn:openid:params:grant-type:ciba"],"response_types_supported":["code","none","id_token","token","id_token token","code id_token","code token","code id_token token"],"subject_types_supported":["public","pairwise"],"id_token_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"id_token_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"id_token_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"userinfo_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"request_object_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"request_object_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"request_object_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"response_modes_supported":["query","fragment","form_post","query.jwt","fragment.jwt","form_post.jwt","jwt"],"registration_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/clients-registrations/openid-connect","token_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"token_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"introspection_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"introspection_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"authorization_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"claims_supported":["aud","sub","iss","auth_time","name","given_name","family_name","preferred_username","email","acr"],"claim_types_supported":["normal"],"claims_parameter_supported":true,"scopes_supported":["openid","phone","email","web-origins","offline_access","microprofile-jwt","profile","address","roles"],"request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"code_challenge_methods_supported":["plain","S256"],"tls_client_certificate_bound_access_tokens":true,"revocation_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/revoke","revocation_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"revocation_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"backchannel_logout_supported":true,"backchannel_logout_session_supported":true,"device_authorization_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/auth/device","backchannel_token_delivery_modes_supported":["poll","ping"],"backchannel_authentication_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/ext/ciba/auth","backchannel_authentication_request_signing_alg_values_supported":["PS384","ES384","RS384","ES256","RS256","ES512","PS256","PS512","RS512"],"require_pushed_authorization_requests":false,"pushed_authorization_request_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/ext/par/request","mtls_endpoint_aliases":{"token_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/token","revocation_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/revoke","introspection_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/token/introspect","device_authorization_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/auth/device","registration_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/clients-registrations/openid-connect","userinfo_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/userinfo","pushed_authorization_request_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/ext/par/request","backchannel_authentication_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/ext/ciba/auth"}}`)
})
http.HandleFunc("/404", func(w http.ResponseWriter, _ *http.Request) {
@@ -26092,29 +27386,53 @@ func getUserAsJSON(t *testing.T, user dataprovider.User) []byte {
return json
}
-func getCSRFTokenMock(loginURLPath, remoteAddr string) (string, error) {
- req, err := http.NewRequest(http.MethodGet, loginURLPath, nil)
+func getCSRFTokenFromInternalPageMock(urlPath, token string) (string, error) {
+ req, err := http.NewRequest(http.MethodGet, urlPath, nil)
if err != nil {
return "", err
}
+ req.RequestURI = urlPath
+ setJWTCookieForReq(req, token)
+ rr := executeRequest(req)
+ if rr.Code != http.StatusOK {
+ return "", fmt.Errorf("unexpected status code: %d", rr.Code)
+ }
+ return getCSRFTokenFromBody(rr.Body)
+}
+
+func getCSRFTokenMock(loginURLPath, remoteAddr string) (string, string, error) {
+ req, err := http.NewRequest(http.MethodGet, loginURLPath, nil)
+ if err != nil {
+ return "", "", err
+ }
req.RemoteAddr = remoteAddr
rr := executeRequest(req)
- return getCSRFTokenFromBody(bytes.NewBuffer(rr.Body.Bytes()))
+ cookie := rr.Header().Get("Set-Cookie")
+ if cookie == "" {
+ return "", "", errors.New("unable to get login cookie")
+ }
+ token, err := getCSRFTokenFromBody(bytes.NewBuffer(rr.Body.Bytes()))
+ return cookie, token, err
}
-func getCSRFToken(url string) (string, error) {
+func getCSRFToken(url string) (string, string, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
- return "", err
+ return "", "", err
}
resp, err := httpclient.GetHTTPClient().Do(req)
if err != nil {
- return "", err
+ return "", "", err
+ }
+ cookie := resp.Header.Get("Set-Cookie")
+ if cookie == "" {
+ return "", "", errors.New("no login cookie")
}
defer resp.Body.Close()
- return getCSRFTokenFromBody(resp.Body)
+ token, err := getCSRFTokenFromBody(resp.Body)
+ return cookie, token, err
}
func getCSRFTokenFromBody(body io.Reader) (string, error) {
@@ -26150,6 +27468,10 @@ func getCSRFTokenFromBody(body io.Reader) (string, error) {
f(doc)
+ if csrfToken == "" {
+ return "", errors.New("CSRF token not found")
+ }
+
return csrfToken, nil
}
@@ -26176,6 +27498,10 @@ func setAPIKeyForReq(req *http.Request, apiKey, username string) {
req.Header.Set("X-SFTPGO-API-KEY", apiKey)
}
+func setLoginCookie(req *http.Request, cookie string) {
+ req.Header.Set("Cookie", cookie)
+}
+
func setJWTCookieForReq(req *http.Request, jwtToken string) {
req.RemoteAddr = defaultRemoteAddr
req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", jwtToken))
@@ -26219,13 +27545,14 @@ func getJWTAPIUserTokenFromTestServer(username, password string) (string, error)
}
func getJWTWebToken(username, password string) (string, error) {
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ loginCookie, csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
if err != nil {
return "", err
}
form := getLoginForm(username, password, csrfToken)
req, _ := http.NewRequest(http.MethodPost, httpBaseURL+webLoginPath,
bytes.NewBuffer([]byte(form.Encode())))
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{
Timeout: 10 * time.Second,
@@ -26258,13 +27585,14 @@ func getCookieFromResponse(rr *httptest.ResponseRecorder) (string, error) {
}
func getJWTWebClientTokenFromTestServerWithAddr(username, password, remoteAddr string) (string, error) {
- csrfToken, err := getCSRFTokenMock(webClientLoginPath, remoteAddr)
+ loginCookie, csrfToken, err := getCSRFTokenMock(webClientLoginPath, remoteAddr)
if err != nil {
return "", err
}
form := getLoginForm(username, password, csrfToken)
req, _ := http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode())))
req.RemoteAddr = remoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := executeRequest(req)
if rr.Code != http.StatusFound {
@@ -26274,13 +27602,14 @@ func getJWTWebClientTokenFromTestServerWithAddr(username, password, remoteAddr s
}
func getJWTWebClientTokenFromTestServer(username, password string) (string, error) {
- csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
+ loginCookie, csrfToken, err := getCSRFTokenMock(webClientLoginPath, defaultRemoteAddr)
if err != nil {
return "", err
}
form := getLoginForm(username, password, csrfToken)
req, _ := http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode())))
req.RemoteAddr = defaultRemoteAddr
+ req.Header.Set("Cookie", loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := executeRequest(req)
if rr.Code != http.StatusFound {
@@ -26290,13 +27619,14 @@ func getJWTWebClientTokenFromTestServer(username, password string) (string, erro
}
func getJWTWebTokenFromTestServer(username, password string) (string, error) {
- csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+ loginCookie, csrfToken, err := getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
if err != nil {
return "", err
}
form := getLoginForm(username, password, csrfToken)
req, _ := http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
req.RemoteAddr = defaultRemoteAddr
+ setLoginCookie(req, loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := executeRequest(req)
if rr.Code != http.StatusFound {
@@ -26315,6 +27645,30 @@ func checkResponseCode(t *testing.T, expected int, rr *httptest.ResponseRecorder
assert.Equal(t, expected, rr.Code, rr.Body.String())
}
+func getSftpClient(user dataprovider.User) (*ssh.Client, *sftp.Client, error) {
+ var sftpClient *sftp.Client
+ config := &ssh.ClientConfig{
+ User: user.Username,
+ HostKeyCallback: ssh.InsecureIgnoreHostKey(),
+ Timeout: 5 * time.Second,
+ }
+ if user.Password != "" {
+ config.Auth = []ssh.AuthMethod{ssh.Password(user.Password)}
+ } else {
+ config.Auth = []ssh.AuthMethod{ssh.Password(defaultPassword)}
+ }
+
+ conn, err := ssh.Dial("tcp", sftpServerAddr, config)
+ if err != nil {
+ return conn, sftpClient, err
+ }
+ sftpClient, err = sftp.NewClient(conn)
+ if err != nil {
+ conn.Close()
+ }
+ return conn, sftpClient, err
+}
+
func createTestFile(path string, size int64) error {
baseDir := filepath.Dir(path)
if _, err := os.Stat(baseDir); errors.Is(err, fs.ErrNotExist) {
@@ -26388,6 +27742,23 @@ func isDbDefenderSupported() bool {
}
}
+func createTestPNG(name string, width, height int, imgColor color.Color) error {
+ upLeft := image.Point{0, 0}
+ lowRight := image.Point{width, height}
+ img := image.NewRGBA(image.Rectangle{upLeft, lowRight})
+ for x := 0; x < width; x++ {
+ for y := 0; y < height; y++ {
+ img.Set(x, y, imgColor)
+ }
+ }
+ f, err := os.Create(name)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ return png.Encode(f, img)
+}
+
func BenchmarkSecretDecryption(b *testing.B) {
s := kms.NewPlainSecret("test data")
s.SetAdditionalData("username")
diff --git a/internal/httpd/internal_test.go b/internal/httpd/internal_test.go
index 4bc2399f..987f0263 100644
--- a/internal/httpd/internal_test.go
+++ b/internal/httpd/internal_test.go
@@ -38,20 +38,21 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
- "github.com/go-chi/jwtauth/v5"
+ "github.com/go-jose/go-jose/v4"
+ josejwt "github.com/go-jose/go-jose/v4/jwt"
"github.com/klauspost/compress/zip"
- "github.com/lestrrat-go/jwx/v2/jwa"
- "github.com/lestrrat-go/jwx/v2/jwt"
"github.com/rs/xid"
"github.com/sftpgo/sdk"
sdkkms "github.com/sftpgo/sdk/kms"
"github.com/sftpgo/sdk/plugin/notifier"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "golang.org/x/net/html"
"github.com/drakkan/sftpgo/v2/internal/acme"
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/kms"
"github.com/drakkan/sftpgo/v2/internal/plugin"
"github.com/drakkan/sftpgo/v2/internal/util"
@@ -285,6 +286,7 @@ RKjnkiEZeG4+G91Xu7+HmcBLwV86k5I+tXK9O1Okomr6Zry8oqVcxU5TB6VRS+rA
ubwF00Drdvk2+kDZfxIM137nBiy7wgCJi2Ksm5ihN3dUF6Q0oNPl
-----END RSA PRIVATE KEY-----`
defaultAdminUsername = "admin"
+ defaultAdminPass = "password"
defeaultUsername = "test_user"
)
@@ -305,6 +307,16 @@ func (r *failingWriter) Header() http.Header {
return make(http.Header)
}
+type failingJoseSigner struct{}
+
+func (s *failingJoseSigner) Sign(payload []byte) (*jose.JSONWebSignature, error) {
+ return nil, errors.New("sign test error")
+}
+
+func (s *failingJoseSigner) Options() jose.SignerOptions {
+ return jose.SignerOptions{}
+}
+
func TestShouldBind(t *testing.T) {
c := Conf{
Bindings: []Binding{
@@ -330,9 +342,8 @@ func TestBrandingValidation(t *testing.T) {
b := Binding{
Branding: Branding{
WebAdmin: UIBranding{
- LogoPath: "path1",
- LoginImagePath: "login1.png",
- DefaultCSS: []string{"my.css"},
+ LogoPath: "path1",
+ DefaultCSS: []string{"my.css"},
},
WebClient: UIBranding{
FaviconPath: "favicon1.ico",
@@ -342,14 +353,12 @@ func TestBrandingValidation(t *testing.T) {
},
}
b.checkBranding()
- assert.Equal(t, "/favicon.ico", b.Branding.WebAdmin.FaviconPath)
+ assert.Equal(t, "/favicon.png", b.Branding.WebAdmin.FaviconPath)
assert.Equal(t, "/path1", b.Branding.WebAdmin.LogoPath)
- assert.Equal(t, "/login1.png", b.Branding.WebAdmin.LoginImagePath)
assert.Equal(t, []string{"/my.css"}, b.Branding.WebAdmin.DefaultCSS)
assert.Len(t, b.Branding.WebAdmin.ExtraCSS, 0)
assert.Equal(t, "/favicon1.ico", b.Branding.WebClient.FaviconPath)
assert.Equal(t, path.Join(webStaticFilesPath, "/path2"), b.Branding.WebClient.DisclaimerPath)
- assert.Equal(t, "/img/login_image.png", b.Branding.WebClient.LoginImagePath)
if assert.Len(t, b.Branding.WebClient.ExtraCSS, 1) {
assert.Equal(t, "/1.css", b.Branding.WebClient.ExtraCSS[0])
}
@@ -415,9 +424,80 @@ func TestGCSWebInvalidFormFile(t *testing.T) {
assert.EqualError(t, err, http.ErrNotMultipart.Error())
}
+func TestBrandingInvalidFormFile(t *testing.T) {
+ form := make(url.Values)
+ req, _ := http.NewRequest(http.MethodPost, webConfigsPath, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ err := req.ParseForm()
+ assert.NoError(t, err)
+ _, err = getBrandingConfigFromPostFields(req, &dataprovider.BrandingConfigs{})
+ assert.EqualError(t, err, http.ErrNotMultipart.Error())
+}
+
+func TestTokenDuration(t *testing.T) {
+ assert.Equal(t, shareTokenDuration, getTokenDuration(tokenAudienceWebShare))
+ assert.Equal(t, apiTokenDuration, getTokenDuration(tokenAudienceAPI))
+ assert.Equal(t, apiTokenDuration, getTokenDuration(tokenAudienceAPIUser))
+ assert.Equal(t, cookieTokenDuration, getTokenDuration(tokenAudienceWebAdmin))
+ assert.Equal(t, csrfTokenDuration, getTokenDuration(tokenAudienceCSRF))
+ assert.Equal(t, 20*time.Minute, getTokenDuration(""))
+
+ updateTokensDuration(30, 660, 360)
+ assert.Equal(t, 30*time.Minute, apiTokenDuration)
+ assert.Equal(t, 11*time.Hour, cookieTokenDuration)
+ assert.Equal(t, 11*time.Hour, csrfTokenDuration)
+ assert.Equal(t, 6*time.Hour, shareTokenDuration)
+ assert.Equal(t, 11*time.Hour, getMaxCookieDuration())
+
+ csrfTokenDuration = 1 * time.Hour
+ assert.Equal(t, 11*time.Hour, getMaxCookieDuration())
+}
+
+func TestVerifyCSRFToken(t *testing.T) {
+ server := httpdServer{}
+ err := server.initializeRouter()
+ require.NoError(t, err)
+ req, err := http.NewRequest(http.MethodPost, webAdminEventActionPath, nil)
+ require.NoError(t, err)
+ req = req.WithContext(jwt.NewContext(req.Context(), &jwt.Claims{}, fs.ErrPermission))
+
+ rr := httptest.NewRecorder()
+ tokenString := createCSRFToken(rr, req, server.csrfTokenAuth, "", webBaseAdminPath)
+ assert.NotEmpty(t, tokenString)
+
+ claims, err := jwt.VerifyToken(server.csrfTokenAuth, tokenString)
+ require.NoError(t, err)
+ assert.Empty(t, claims.Ref)
+
+ req.Form = url.Values{}
+ req.Form.Set(csrfFormToken, tokenString)
+ err = verifyCSRFToken(req, server.csrfTokenAuth)
+ assert.ErrorIs(t, err, fs.ErrPermission)
+
+ req, err = http.NewRequest(http.MethodPost, webAdminEventActionPath, nil)
+ require.NoError(t, err)
+ req = req.WithContext(jwt.NewContext(req.Context(), &jwt.Claims{Claims: josejwt.Claims{ID: xid.New().String()}}, nil))
+ req.Form = url.Values{}
+ req.Form.Set(csrfFormToken, tokenString)
+ err = verifyCSRFToken(req, server.csrfTokenAuth)
+ assert.ErrorContains(t, err, "unexpected form token")
+
+ claims = jwt.NewClaims(tokenAudienceCSRF, "", getTokenDuration(tokenAudienceCSRF))
+ tokenString, err = josejwt.Signed(server.csrfTokenAuth.Signer()).Claims(claims).Serialize()
+ assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodPost, webAdminEventActionPath, nil)
+ require.NoError(t, err)
+ req = req.WithContext(jwt.NewContext(req.Context(), &jwt.Claims{Claims: josejwt.Claims{ID: xid.New().String()}}, nil))
+ req.Form = url.Values{}
+ req.Form.Set(csrfFormToken, tokenString)
+ err = verifyCSRFToken(req, server.csrfTokenAuth)
+ assert.ErrorContains(t, err, "the form token is not valid")
+}
+
func TestInvalidToken(t *testing.T) {
server := httpdServer{}
- server.initializeRouter()
+ err := server.initializeRouter()
+ require.NoError(t, err)
admin := dataprovider.Admin{
Username: "admin",
}
@@ -428,7 +508,7 @@ func TestInvalidToken(t *testing.T) {
rctx := chi.NewRouteContext()
rctx.URLParams.Add("username", admin.Username)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
- req = req.WithContext(context.WithValue(req.Context(), jwtauth.ErrorCtxKey, errFake))
+ req = req.WithContext(context.WithValue(req.Context(), jwt.ErrorCtxKey, errFake))
rr := httptest.NewRecorder()
updateAdmin(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
@@ -444,7 +524,7 @@ func TestInvalidToken(t *testing.T) {
assert.NoError(t, err)
req, _ = http.NewRequest(http.MethodPut, "", bytes.NewBuffer(asJSON))
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
- req = req.WithContext(context.WithValue(req.Context(), jwtauth.ErrorCtxKey, errFake))
+ req = req.WithContext(context.WithValue(req.Context(), jwt.ErrorCtxKey, errFake))
rr = httptest.NewRecorder()
changeAdminPassword(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
@@ -909,78 +989,218 @@ func TestInvalidToken(t *testing.T) {
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidToken)
}
+func TestTokenSignatureValidation(t *testing.T) {
+ tokenValidationMode = 0
+ server := httpdServer{
+ binding: Binding{
+ Address: "",
+ Port: 8080,
+ EnableWebAdmin: true,
+ EnableWebClient: true,
+ EnableRESTAPI: true,
+ },
+ enableWebAdmin: true,
+ enableWebClient: true,
+ enableRESTAPI: true,
+ }
+ err := server.initializeRouter()
+ require.NoError(t, err)
+ testServer := httptest.NewServer(server.router)
+ defer testServer.Close()
+
+ rr := httptest.NewRecorder()
+ req, err := http.NewRequest(http.MethodGet, tokenPath, nil)
+ require.NoError(t, err)
+ req.SetBasicAuth(defaultAdminUsername, defaultAdminPass)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code)
+ var resp map[string]any
+ err = json.Unmarshal(rr.Body.Bytes(), &resp)
+ assert.NoError(t, err)
+ accessToken := resp["access_token"]
+ require.NotEmpty(t, accessToken)
+
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodGet, versionPath, nil)
+ require.NoError(t, err)
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code)
+ // change the token validation mode
+ tokenValidationMode = 2
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodGet, versionPath, nil)
+ require.NoError(t, err)
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code)
+ // Now update the admin
+ admin, err := dataprovider.AdminExists(defaultAdminUsername)
+ assert.NoError(t, err)
+ err = dataprovider.UpdateAdmin(&admin, "", "", "")
+ assert.NoError(t, err)
+ // token validation mode is 0, the old token is still valid
+ tokenValidationMode = 0
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodGet, versionPath, nil)
+ require.NoError(t, err)
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code)
+ // change the token validation mode
+ tokenValidationMode = 2
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodGet, versionPath, nil)
+ require.NoError(t, err)
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusUnauthorized, rr.Code)
+ // the token is invalidated, changing the validation mode has no effect
+ tokenValidationMode = 0
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodGet, versionPath, nil)
+ require.NoError(t, err)
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusUnauthorized, rr.Code)
+
+ userPwd := "pwd"
+ user := dataprovider.User{
+ BaseUser: sdk.BaseUser{
+ Username: defeaultUsername,
+ Password: userPwd,
+ HomeDir: filepath.Join(os.TempDir(), defeaultUsername),
+ Status: 1,
+ },
+ }
+ user.Permissions = make(map[string][]string)
+ user.Permissions["/"] = []string{dataprovider.PermAny}
+ err = dataprovider.AddUser(&user, "", "", "")
+ assert.NoError(t, err)
+
+ defer func() {
+ dataprovider.DeleteUser(defeaultUsername, "", "", "") //nolint:errcheck
+ }()
+
+ tokenValidationMode = 2
+ req, err = http.NewRequest(http.MethodGet, webClientLoginPath, nil)
+ require.NoError(t, err)
+ rr = httptest.NewRecorder()
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code)
+ loginCookie := strings.Split(rr.Header().Get("Set-Cookie"), ";")[0]
+ assert.NotEmpty(t, loginCookie)
+ csrfToken, err := getCSRFTokenFromBody(rr.Body)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, csrfToken)
+ // Now login
+ form := make(url.Values)
+ form.Set(csrfFormToken, csrfToken)
+ form.Set("username", defeaultUsername)
+ form.Set("password", userPwd)
+ req, err = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode())))
+ assert.NoError(t, err)
+ req.Header.Set("Cookie", loginCookie)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr = httptest.NewRecorder()
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusFound, rr.Code)
+ userCookie := strings.Split(rr.Header().Get("Set-Cookie"), ";")[0]
+ assert.NotEmpty(t, userCookie)
+ // Test a WebClient page and a JSON API
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
+ require.NoError(t, err)
+ req.Header.Set("Cookie", userCookie)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code)
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodGet, webClientProfilePath, nil)
+ require.NoError(t, err)
+ req.Header.Set("Cookie", userCookie)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code)
+ csrfToken, err = getCSRFTokenFromBody(rr.Body)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, csrfToken)
+
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodGet, webClientFilePath+"?path=missing.txt", nil)
+ require.NoError(t, err)
+ req.Header.Set("Cookie", userCookie)
+ req.Header.Set(csrfHeaderToken, csrfToken)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusNotFound, rr.Code)
+
+ tokenValidationMode = 0
+ err = dataprovider.DeleteUser(defeaultUsername, "", "", "")
+ assert.NoError(t, err)
+
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodGet, webClientFilePath+"?path=missing.txt", nil)
+ require.NoError(t, err)
+ req.Header.Set("Cookie", userCookie)
+ req.Header.Set(csrfHeaderToken, csrfToken)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusNotFound, rr.Code)
+
+ tokenValidationMode = 2
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodGet, webClientFilePath+"?path=missing.txt", nil)
+ require.NoError(t, err)
+ req.Header.Set("Cookie", userCookie)
+ req.Header.Set(csrfHeaderToken, csrfToken)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusFound, rr.Code)
+
+ tokenValidationMode = 0
+}
+
func TestUpdateWebAdminInvalidClaims(t *testing.T) {
server := httpdServer{}
- server.initializeRouter()
+ err := server.initializeRouter()
+ require.NoError(t, err)
rr := httptest.NewRecorder()
admin := dataprovider.Admin{
Username: "",
Password: "password",
}
- c := jwtTokenClaims{
+ c := &jwt.Claims{
Username: admin.Username,
Permissions: admin.Permissions,
- Signature: admin.GetSignature(),
}
- token, err := c.createTokenResponse(server.tokenAuth, tokenAudienceWebAdmin, "")
+ c.Subject = admin.GetSignature()
+ token, err := server.tokenAuth.SignWithParams(c, tokenAudienceWebAdmin, "", 10*time.Minute)
assert.NoError(t, err)
+ resp := c.BuildTokenResponse(token)
+
+ req, err := http.NewRequest(http.MethodGet, webAdminPath, nil)
+ assert.NoError(t, err)
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", resp.Token))
+ parsedToken, err := jwt.VerifyRequest(server.tokenAuth, req, jwt.TokenFromCookie)
+ assert.NoError(t, err)
+ ctx := req.Context()
+ ctx = jwt.NewContext(ctx, parsedToken, err)
+ req = req.WithContext(ctx)
form := make(url.Values)
- form.Set(csrfFormToken, createCSRFToken(""))
+ form.Set(csrfFormToken, createCSRFToken(rr, req, server.csrfTokenAuth, "", webBaseAdminPath))
form.Set("status", "1")
form.Set("default_users_expiration", "30")
- req, _ := http.NewRequest(http.MethodPost, path.Join(webAdminPath, "admin"), bytes.NewBuffer([]byte(form.Encode())))
+ req, err = http.NewRequest(http.MethodPost, path.Join(webAdminPath, "admin"), bytes.NewBuffer([]byte(form.Encode())))
+ assert.NoError(t, err)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("username", "admin")
+ req = req.WithContext(ctx)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", resp.Token))
server.handleWebUpdateAdminPost(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidToken)
}
-func TestRetentionInvalidTokenClaims(t *testing.T) {
- username := "retentionuser"
- user := dataprovider.User{
- BaseUser: sdk.BaseUser{
- Username: username,
- Password: "pwd",
- HomeDir: filepath.Join(os.TempDir(), username),
- Status: 1,
- },
- }
- user.Permissions = make(map[string][]string)
- user.Permissions["/"] = []string{dataprovider.PermAny}
- user.Filters.AllowAPIKeyAuth = true
- err := dataprovider.AddUser(&user, "", "", "")
- assert.NoError(t, err)
- folderRetention := []dataprovider.FolderRetention{
- {
- Path: "/",
- Retention: 0,
- DeleteEmptyDirs: true,
- },
- }
- asJSON, err := json.Marshal(folderRetention)
- assert.NoError(t, err)
- req, _ := http.NewRequest(http.MethodPost, retentionBasePath+"/"+username+"/check?notifications=Email", bytes.NewBuffer(asJSON))
-
- rctx := chi.NewRouteContext()
- rctx.URLParams.Add("username", username)
-
- req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
- req = req.WithContext(context.WithValue(req.Context(), jwtauth.ErrorCtxKey, errors.New("error")))
- rr := httptest.NewRecorder()
- startRetentionCheck(rr, req)
- assert.Equal(t, http.StatusBadRequest, rr.Code)
- assert.Contains(t, rr.Body.String(), "Invalid token claims")
-
- err = dataprovider.DeleteUser(username, "", "", "")
- assert.NoError(t, err)
-}
-
func TestUpdateSMTPSecrets(t *testing.T) {
currentConfigs := &dataprovider.SMTPConfigs{
OAuth2: dataprovider.SMTPOAuth2{
@@ -1021,7 +1241,8 @@ func TestUpdateSMTPSecrets(t *testing.T) {
func TestOAuth2Redirect(t *testing.T) {
server := httpdServer{}
- server.initializeRouter()
+ err := server.initializeRouter()
+ require.NoError(t, err)
rr := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodGet, webOAuth2RedirectPath+"?state=invalid", nil)
@@ -1031,7 +1252,7 @@ func TestOAuth2Redirect(t *testing.T) {
assert.Contains(t, rr.Body.String(), util.I18nOAuth2ErrorTitle)
ip := "127.1.1.4"
- tokenString := createOAuth2Token(xid.New().String(), ip)
+ tokenString := createOAuth2Token(server.csrfTokenAuth, xid.New().String(), ip)
rr = httptest.NewRecorder()
req, err = http.NewRequest(http.MethodGet, webOAuth2RedirectPath+"?state="+tokenString, nil) //nolint:goconst
assert.NoError(t, err)
@@ -1042,57 +1263,48 @@ func TestOAuth2Redirect(t *testing.T) {
}
func TestOAuth2Token(t *testing.T) {
+ server := httpdServer{}
+ err := server.initializeRouter()
+ require.NoError(t, err)
// invalid token
- _, err := verifyOAuth2Token("token", "")
+ _, err = verifyOAuth2Token(server.csrfTokenAuth, "token", "")
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "unable to verify OAuth2 state")
}
// bad audience
- claims := make(map[string]any)
- now := time.Now().UTC()
+ claims := jwt.NewClaims(tokenAudienceAPI, "", getTokenDuration(tokenAudienceAPI))
- claims[jwt.JwtIDKey] = xid.New().String()
- claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second)
- claims[jwt.ExpirationKey] = now.Add(tokenDuration)
- claims[jwt.AudienceKey] = []string{tokenAudienceAPI}
-
- _, tokenString, err := csrfTokenAuth.Encode(claims)
+ tokenString, err := server.csrfTokenAuth.Sign(claims)
assert.NoError(t, err)
- _, err = verifyOAuth2Token(tokenString, "")
+ _, err = verifyOAuth2Token(server.csrfTokenAuth, tokenString, "")
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "invalid OAuth2 state")
}
// bad IP
- tokenString = createOAuth2Token("state", "127.1.1.1")
- _, err = verifyOAuth2Token(tokenString, "127.1.1.2")
+ tokenString = createOAuth2Token(server.csrfTokenAuth, "state", "127.1.1.1")
+ _, err = verifyOAuth2Token(server.csrfTokenAuth, tokenString, "127.1.1.2")
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "invalid OAuth2 state")
}
// ok
state := xid.New().String()
- tokenString = createOAuth2Token(state, "127.1.1.3")
- s, err := verifyOAuth2Token(tokenString, "127.1.1.3")
+ tokenString = createOAuth2Token(server.csrfTokenAuth, state, "127.1.1.3")
+ s, err := verifyOAuth2Token(server.csrfTokenAuth, tokenString, "127.1.1.3")
assert.NoError(t, err)
assert.Equal(t, state, s)
// no jti
- claims = make(map[string]any)
-
- claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second)
- claims[jwt.ExpirationKey] = now.Add(tokenDuration)
- claims[jwt.AudienceKey] = []string{tokenAudienceOAuth2, "127.1.1.4"}
- _, tokenString, err = csrfTokenAuth.Encode(claims)
+ claims = jwt.NewClaims(tokenAudienceOAuth2, "127.1.1.4", getTokenDuration(tokenAudienceOAuth2))
+ tokenString, err = josejwt.Signed(server.csrfTokenAuth.Signer()).Claims(claims).Serialize()
assert.NoError(t, err)
- _, err = verifyOAuth2Token(tokenString, "127.1.1.4")
+ _, err = verifyOAuth2Token(server.csrfTokenAuth, tokenString, "127.1.1.4")
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "invalid OAuth2 state")
}
// encode error
- csrfTokenAuth = jwtauth.New("HT256", util.GenerateRandomBytes(32), nil)
- tokenString = createOAuth2Token(xid.New().String(), "")
+ server.csrfTokenAuth.SetSigner(&failingJoseSigner{})
+ tokenString = createOAuth2Token(server.csrfTokenAuth, xid.New().String(), "")
assert.Empty(t, tokenString)
- server := httpdServer{}
- server.initializeRouter()
rr := httptest.NewRecorder()
testReq := make(map[string]any)
testReq["base_redirect_url"] = "http://localhost:8082"
@@ -1100,50 +1312,50 @@ func TestOAuth2Token(t *testing.T) {
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, webOAuth2TokenPath, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
- handleSMTPOAuth2TokenRequestPost(rr, req)
+ server.handleSMTPOAuth2TokenRequestPost(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
assert.Contains(t, rr.Body.String(), "unable to create state token")
-
- csrfTokenAuth = jwtauth.New(jwa.HS256.String(), util.GenerateRandomBytes(32), nil)
}
func TestCSRFToken(t *testing.T) {
+ server := httpdServer{}
+ err := server.initializeRouter()
+ require.NoError(t, err)
// invalid token
- err := verifyCSRFToken("token", "")
+ req := &http.Request{}
+ err = verifyCSRFToken(req, server.csrfTokenAuth)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "unable to verify form token")
}
// bad audience
- claims := make(map[string]any)
- now := time.Now().UTC()
-
- claims[jwt.JwtIDKey] = xid.New().String()
- claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second)
- claims[jwt.ExpirationKey] = now.Add(tokenDuration)
- claims[jwt.AudienceKey] = []string{tokenAudienceAPI}
-
- _, tokenString, err := csrfTokenAuth.Encode(claims)
+ claims := jwt.NewClaims(tokenAudienceAPI, "", getTokenDuration(tokenAudienceAPI))
+ tokenString, err := server.csrfTokenAuth.Sign(claims)
assert.NoError(t, err)
- err = verifyCSRFToken(tokenString, "")
+ values := url.Values{}
+ values.Set(csrfFormToken, tokenString)
+ req.Form = values
+ err = verifyCSRFToken(req, server.csrfTokenAuth)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "form token is not valid")
}
// bad IP
- tokenString = createCSRFToken("127.1.1.1")
- err = verifyCSRFToken(tokenString, "127.1.1.2")
+ req.RemoteAddr = "127.1.1.1"
+ tokenString = createCSRFToken(httptest.NewRecorder(), req, server.csrfTokenAuth, "", webBaseAdminPath)
+ values.Set(csrfFormToken, tokenString)
+ req.Form = values
+ req.RemoteAddr = "127.1.1.2"
+ err = verifyCSRFToken(req, server.csrfTokenAuth)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "form token is not valid")
}
- claims[jwt.JwtIDKey] = xid.New().String()
- claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second)
- claims[jwt.ExpirationKey] = now.Add(tokenDuration)
- claims[jwt.AudienceKey] = []string{tokenAudienceAPI}
- _, tokenString, err = csrfTokenAuth.Encode(claims)
+ claims = jwt.NewClaims(tokenAudienceAPI, "", getTokenDuration(tokenAudienceAPI))
+ tokenString, err = server.csrfTokenAuth.Sign(claims)
assert.NoError(t, err)
+ assert.NotEmpty(t, tokenString)
- r := GetHTTPRouter(Binding{
+ r, err := GetHTTPRouter(Binding{
Address: "",
Port: 8080,
EnableWebAdmin: true,
@@ -1151,9 +1363,10 @@ func TestCSRFToken(t *testing.T) {
EnableRESTAPI: true,
RenderOpenAPI: true,
})
- fn := verifyCSRFHeader(r)
+ assert.NoError(t, err)
+ fn := server.verifyCSRFHeader(r)
rr := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodDelete, path.Join(userPath, "username"), nil)
+ req, _ = http.NewRequest(http.MethodDelete, path.Join(userPath, "username"), nil)
fn.ServeHTTP(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.Contains(t, rr.Body.String(), "Invalid token")
@@ -1166,18 +1379,22 @@ func TestCSRFToken(t *testing.T) {
assert.Contains(t, rr.Body.String(), "the token is not valid")
// invalid IP
- tokenString = createCSRFToken("172.16.1.2")
+ tokenString = createCSRFToken(httptest.NewRecorder(), req, server.csrfTokenAuth, "", webBaseAdminPath)
req.Header.Set(csrfHeaderToken, tokenString)
+ req.RemoteAddr = "172.16.1.2"
rr = httptest.NewRecorder()
fn.ServeHTTP(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.Contains(t, rr.Body.String(), "the token is not valid")
- csrfTokenAuth = jwtauth.New("PS256", util.GenerateRandomBytes(32), nil)
- tokenString = createCSRFToken("")
+ csrfTokenAuth, err := jwt.NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
+ csrfTokenAuth.SetSigner(&failingJoseSigner{})
+ tokenString = createCSRFToken(httptest.NewRecorder(), req, csrfTokenAuth, "", webBaseAdminPath)
assert.Empty(t, tokenString)
-
- csrfTokenAuth = jwtauth.New(jwa.HS256.String(), util.GenerateRandomBytes(32), nil)
+ rr = httptest.NewRecorder()
+ createLoginCookie(rr, req, csrfTokenAuth, "", webBaseAdminPath, req.RemoteAddr)
+ assert.Empty(t, rr.Header().Get("Set-Cookie"))
}
func TestCreateShareCookieError(t *testing.T) {
@@ -1197,7 +1414,7 @@ func TestCreateShareCookieError(t *testing.T) {
err := dataprovider.AddUser(user, "", "", "")
assert.NoError(t, err)
share := &dataprovider.Share{
- Name: "test share cookie error",
+ Name: "test_share_cookie_error",
ShareID: util.GenerateUniqueID(),
Scope: dataprovider.ShareScopeRead,
Password: pwd,
@@ -1207,20 +1424,44 @@ func TestCreateShareCookieError(t *testing.T) {
err = dataprovider.AddShare(share, "", "", "")
assert.NoError(t, err)
+ tokenAuth, err := jwt.NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
+ tokenAuth.SetSigner(&failingJoseSigner{})
+ csrfTokenAuth, err := jwt.NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
+
server := httpdServer{
- tokenAuth: jwtauth.New("TS256", util.GenerateRandomBytes(32), nil),
+ tokenAuth: tokenAuth,
+ csrfTokenAuth: csrfTokenAuth,
}
+
+ c := jwt.NewClaims(tokenAudienceWebLogin, "127.0.0.1", getTokenDuration(tokenAudienceWebLogin))
+ token, err := server.csrfTokenAuth.Sign(c)
+ assert.NoError(t, err)
+ resp := c.BuildTokenResponse(token)
+ parsedToken, err := jwt.VerifyToken(server.csrfTokenAuth, resp.Token)
+ assert.NoError(t, err)
+
+ req, err := http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, share.ShareID, "login"), nil)
+ assert.NoError(t, err)
+ req.RemoteAddr = "127.0.0.1:4567"
+ ctx := req.Context()
+ ctx = jwt.NewContext(ctx, parsedToken, err)
+ req = req.WithContext(ctx)
+
form := make(url.Values)
form.Set("share_password", pwd)
- form.Set(csrfFormToken, createCSRFToken("127.0.0.1"))
+ form.Set(csrfFormToken, createCSRFToken(httptest.NewRecorder(), req, server.csrfTokenAuth, "", webBaseClientPath))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", share.ShareID)
rr := httptest.NewRecorder()
- req, err := http.NewRequest(http.MethodPost, path.Join(webClientPubSharesPath, share.ShareID, "login"),
+ req, err = http.NewRequest(http.MethodPost, path.Join(webClientPubSharesPath, share.ShareID, "login"),
bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = "127.0.0.1:2345"
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", resp.Token))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req = req.WithContext(ctx)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
server.handleClientShareLoginPost(rr, req)
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
@@ -1231,8 +1472,15 @@ func TestCreateShareCookieError(t *testing.T) {
}
func TestCreateTokenError(t *testing.T) {
+ tokenAuth, err := jwt.NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
+ tokenAuth.SetSigner(&failingJoseSigner{})
+ csrfTokenAuth, err := jwt.NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
+
server := httpdServer{
- tokenAuth: jwtauth.New("PS256", util.GenerateRandomBytes(32), nil),
+ tokenAuth: tokenAuth,
+ csrfTokenAuth: csrfTokenAuth,
}
rr := httptest.NewRecorder()
admin := dataprovider.Admin{
@@ -1256,14 +1504,37 @@ func TestCreateTokenError(t *testing.T) {
server.generateAndSendUserToken(rr, req, "", user)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
+ c := &jwt.Claims{}
+ c.ID = xid.New().String()
+ c.SetExpiry(time.Now().Add(1 * time.Minute))
+ tokenString, err := server.csrfTokenAuth.SignWithParams(c, tokenAudienceAPI, "", getTokenDuration(tokenAudienceAPI))
+ assert.NoError(t, err)
+ token := c.BuildTokenResponse(tokenString)
+
+ req, err = http.NewRequest(http.MethodGet, webAdminLoginPath, nil)
+ assert.NoError(t, err)
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token.Token))
+ parsedToken, err := jwt.VerifyRequest(server.csrfTokenAuth, req, jwt.TokenFromCookie)
+ assert.NoError(t, err)
+ ctx := req.Context()
+ ctx = jwt.NewContext(ctx, parsedToken, err)
+ req = req.WithContext(ctx)
+
rr = httptest.NewRecorder()
form := make(url.Values)
form.Set("username", admin.Username)
form.Set("password", admin.Password)
- form.Set(csrfFormToken, createCSRFToken("127.0.0.1"))
+ form.Set(csrfFormToken, createCSRFToken(rr, req, server.csrfTokenAuth, xid.New().String(), webBaseAdminPath))
+ cookie := rr.Header().Get("Set-Cookie")
+ assert.NotEmpty(t, cookie)
req, _ = http.NewRequest(http.MethodPost, webAdminLoginPath, bytes.NewBuffer([]byte(form.Encode())))
- req.RemoteAddr = "127.0.0.1:1234"
+ req.Header.Set("Cookie", cookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ parsedToken, err = jwt.VerifyRequest(server.csrfTokenAuth, req, jwt.TokenFromCookie)
+ assert.NoError(t, err)
+ ctx = req.Context()
+ ctx = jwt.NewContext(ctx, parsedToken, err)
+ req = req.WithContext(ctx)
server.handleWebAdminLoginPost(rr, req)
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
// req with no content type
@@ -1290,7 +1561,7 @@ func TestCreateTokenError(t *testing.T) {
req, _ = http.NewRequest(http.MethodGet, webAdminLoginPath+"?a=a%C3%A2%G3", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- _, err := getAdminFromPostFields(req)
+ _, err = getAdminFromPostFields(req)
assert.Error(t, err)
req, _ = http.NewRequest(http.MethodPost, webAdminEventActionPath+"?a=a%C3%A2%GG", nil)
@@ -1340,6 +1611,7 @@ func TestCreateTokenError(t *testing.T) {
assert.Equal(t, http.StatusInternalServerError, rr.Code, rr.Body.String())
req, _ = http.NewRequest(http.MethodPost, webAdminTwoFactorPath+"?a=a%C3%AO%GC", bytes.NewBuffer([]byte(form.Encode())))
+ req = req.WithContext(jwt.NewContext(req.Context(), &jwt.Claims{}, nil))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
server.handleWebAdminTwoFactorPost(rr, req)
@@ -1347,6 +1619,7 @@ func TestCreateTokenError(t *testing.T) {
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidForm)
req, _ = http.NewRequest(http.MethodPost, webAdminTwoFactorRecoveryPath+"?a=a%C3%AO%GD", bytes.NewBuffer([]byte(form.Encode())))
+ req = req.WithContext(jwt.NewContext(req.Context(), &jwt.Claims{}, nil))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
server.handleWebAdminTwoFactorRecoveryPost(rr, req)
@@ -1354,6 +1627,7 @@ func TestCreateTokenError(t *testing.T) {
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidForm)
req, _ = http.NewRequest(http.MethodPost, webClientTwoFactorPath+"?a=a%C3%AO%GC", bytes.NewBuffer([]byte(form.Encode())))
+ req = req.WithContext(jwt.NewContext(req.Context(), &jwt.Claims{}, nil))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
server.handleWebClientTwoFactorPost(rr, req)
@@ -1361,6 +1635,7 @@ func TestCreateTokenError(t *testing.T) {
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidForm)
req, _ = http.NewRequest(http.MethodPost, webClientTwoFactorRecoveryPath+"?a=a%C3%AO%GD", bytes.NewBuffer([]byte(form.Encode())))
+ req = req.WithContext(jwt.NewContext(req.Context(), &jwt.Claims{}, nil))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
server.handleWebClientTwoFactorRecoveryPost(rr, req)
@@ -1424,13 +1699,21 @@ func TestCreateTokenError(t *testing.T) {
err = dataprovider.AddUser(&user, "", "", "")
assert.NoError(t, err)
+ req, err = http.NewRequest(http.MethodGet, webClientLoginPath, nil)
+ assert.NoError(t, err)
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token.Token))
+ parsedToken, err = jwt.VerifyRequest(server.csrfTokenAuth, req, jwt.TokenFromCookie)
+ assert.NoError(t, err)
+ ctx = req.Context()
+ ctx = jwt.NewContext(ctx, parsedToken, err)
+ req = req.WithContext(ctx)
+
rr = httptest.NewRecorder()
form = make(url.Values)
form.Set("username", user.Username)
form.Set("password", "clientpwd")
- form.Set(csrfFormToken, createCSRFToken("127.0.0.1"))
+ form.Set(csrfFormToken, createCSRFToken(rr, req, server.csrfTokenAuth, "", webBaseClientPath))
req, _ = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode())))
- req.RemoteAddr = "127.0.0.1:4567"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
server.handleWebClientLoginPost(rr, req)
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
@@ -1458,7 +1741,7 @@ func TestCreateTokenError(t *testing.T) {
}
func TestAPIKeyAuthForbidden(t *testing.T) {
- r := GetHTTPRouter(Binding{
+ r, err := GetHTTPRouter(Binding{
Address: "",
Port: 8080,
EnableWebAdmin: true,
@@ -1466,6 +1749,7 @@ func TestAPIKeyAuthForbidden(t *testing.T) {
EnableRESTAPI: true,
RenderOpenAPI: true,
})
+ require.NoError(t, err)
fn := forbidAPIKeyAuthentication(r)
rr := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, versionPath, nil)
@@ -1475,12 +1759,14 @@ func TestAPIKeyAuthForbidden(t *testing.T) {
}
func TestJWTTokenValidation(t *testing.T) {
- tokenAuth := jwtauth.New(jwa.HS256.String(), util.GenerateRandomBytes(32), nil)
- claims := make(map[string]any)
- claims["username"] = defaultAdminUsername
- claims[jwt.ExpirationKey] = time.Now().UTC().Add(-1 * time.Hour)
- token, _, err := tokenAuth.Encode(claims)
- assert.NoError(t, err)
+ tokenAuth, err := jwt.NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
+ claims := &jwt.Claims{
+ Username: defaultAdminUsername,
+ }
+ claims.SetExpiry(time.Now().UTC().Add(-1 * time.Hour))
+ _, err = tokenAuth.SignWithParams(claims, tokenAudienceWebAdmin, "", getTokenDuration(tokenAudienceWebAdmin))
+ require.NoError(t, err)
server := httpdServer{
binding: Binding{
@@ -1492,19 +1778,20 @@ func TestJWTTokenValidation(t *testing.T) {
RenderOpenAPI: true,
},
}
- server.initializeRouter()
+ err = server.initializeRouter()
+ require.NoError(t, err)
r := server.router
fn := jwtAuthenticatorAPI(r)
rr := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, userPath, nil)
- ctx := jwtauth.NewContext(req.Context(), token, nil)
+ ctx := jwt.NewContext(req.Context(), claims, nil)
fn.ServeHTTP(rr, req.WithContext(ctx))
assert.Equal(t, http.StatusUnauthorized, rr.Code)
fn = jwtAuthenticatorWebAdmin(r)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, webUserPath, nil)
- ctx = jwtauth.NewContext(req.Context(), token, nil)
+ ctx = jwt.NewContext(req.Context(), claims, nil)
fn.ServeHTTP(rr, req.WithContext(ctx))
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webAdminLoginPath, rr.Header().Get("Location"))
@@ -1512,26 +1799,26 @@ func TestJWTTokenValidation(t *testing.T) {
fn = jwtAuthenticatorWebClient(r)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
- ctx = jwtauth.NewContext(req.Context(), token, nil)
+ ctx = jwt.NewContext(req.Context(), claims, nil)
fn.ServeHTTP(rr, req.WithContext(ctx))
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
errTest := errors.New("test error")
- permFn := server.checkPerm(dataprovider.PermAdminAny)
+ permFn := server.checkPerms(dataprovider.PermAdminAny)
fn = permFn(r)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, userPath, nil)
- ctx = jwtauth.NewContext(req.Context(), token, errTest)
+ ctx = jwt.NewContext(req.Context(), claims, errTest)
fn.ServeHTTP(rr, req.WithContext(ctx))
assert.Equal(t, http.StatusBadRequest, rr.Code)
- permFn = server.checkPerm(dataprovider.PermAdminAny)
+ permFn = server.checkPerms(dataprovider.PermAdminAny)
fn = permFn(r)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, webUserPath, nil)
req.RequestURI = webUserPath
- ctx = jwtauth.NewContext(req.Context(), token, errTest)
+ ctx = jwt.NewContext(req.Context(), claims, errTest)
fn.ServeHTTP(rr, req.WithContext(ctx))
assert.Equal(t, http.StatusBadRequest, rr.Code)
@@ -1540,14 +1827,14 @@ func TestJWTTokenValidation(t *testing.T) {
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, nil)
req.RequestURI = webClientProfilePath
- ctx = jwtauth.NewContext(req.Context(), token, errTest)
+ ctx = jwt.NewContext(req.Context(), claims, errTest)
fn.ServeHTTP(rr, req.WithContext(ctx))
assert.Equal(t, http.StatusBadRequest, rr.Code)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPost, userProfilePath, nil)
req.RequestURI = userProfilePath
- ctx = jwtauth.NewContext(req.Context(), token, errTest)
+ ctx = jwt.NewContext(req.Context(), claims, errTest)
fn.ServeHTTP(rr, req.WithContext(ctx))
assert.Equal(t, http.StatusBadRequest, rr.Code)
@@ -1555,7 +1842,7 @@ func TestJWTTokenValidation(t *testing.T) {
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, nil)
req.RequestURI = webClientProfilePath
- ctx = jwtauth.NewContext(req.Context(), token, errTest)
+ ctx = jwt.NewContext(req.Context(), claims, errTest)
fn.ServeHTTP(rr, req.WithContext(ctx))
assert.Equal(t, http.StatusBadRequest, rr.Code)
@@ -1563,50 +1850,56 @@ func TestJWTTokenValidation(t *testing.T) {
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPost, webGroupsPath, nil)
req.RequestURI = webGroupsPath
- ctx = jwtauth.NewContext(req.Context(), token, errTest)
+ ctx = jwt.NewContext(req.Context(), claims, errTest)
fn.ServeHTTP(rr, req.WithContext(ctx))
assert.Equal(t, http.StatusBadRequest, rr.Code)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPost, userSharesPath, nil)
req.RequestURI = userSharesPath
- ctx = jwtauth.NewContext(req.Context(), token, errTest)
+ ctx = jwt.NewContext(req.Context(), claims, errTest)
fn.ServeHTTP(rr, req.WithContext(ctx))
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestUpdateContextFromCookie(t *testing.T) {
+ tokenAuth, err := jwt.NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
server := httpdServer{
- tokenAuth: jwtauth.New(jwa.HS256.String(), util.GenerateRandomBytes(32), nil),
+ tokenAuth: tokenAuth,
}
req, _ := http.NewRequest(http.MethodGet, tokenPath, nil)
- claims := make(map[string]any)
- claims["a"] = "b"
- token, _, err := server.tokenAuth.Encode(claims)
+ claims := jwt.NewClaims(tokenAudienceWebClient, "", getTokenDuration(tokenAudienceWebClient))
+ _, err = server.tokenAuth.Sign(claims)
assert.NoError(t, err)
- ctx := jwtauth.NewContext(req.Context(), token, nil)
- server.updateContextFromCookie(req.WithContext(ctx))
+ ctx := jwt.NewContext(req.Context(), claims, nil)
+ req = server.updateContextFromCookie(req.WithContext(ctx))
+ token, err := jwt.FromContext(req.Context())
+ require.NoError(t, err)
+ require.True(t, token.Audience.Contains(tokenAudienceWebClient))
+ require.NotEmpty(t, token.ID)
}
func TestCookieExpiration(t *testing.T) {
+ tokenAuth, err := jwt.NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
server := httpdServer{
- tokenAuth: jwtauth.New(jwa.HS256.String(), util.GenerateRandomBytes(32), nil),
+ tokenAuth: tokenAuth,
}
- err := errors.New("test error")
+ err = errors.New("test error")
rr := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, tokenPath, nil)
- ctx := jwtauth.NewContext(req.Context(), nil, err)
+ ctx := jwt.NewContext(req.Context(), nil, err)
server.checkCookieExpiration(rr, req.WithContext(ctx))
cookie := rr.Header().Get("Set-Cookie")
assert.Empty(t, cookie)
req, _ = http.NewRequest(http.MethodGet, tokenPath, nil)
- claims := make(map[string]any)
- claims["a"] = "b"
- token, _, err := server.tokenAuth.Encode(claims)
+ claims := jwt.NewClaims(tokenAudienceWebClient, "", getTokenDuration(tokenAudienceWebClient))
+ _, err = server.tokenAuth.Sign(claims)
assert.NoError(t, err)
- ctx = jwtauth.NewContext(req.Context(), token, nil)
+ ctx = jwt.NewContext(req.Context(), claims, nil)
server.checkCookieExpiration(rr, req.WithContext(ctx))
cookie = rr.Header().Get("Set-Cookie")
assert.Empty(t, cookie)
@@ -1616,16 +1909,16 @@ func TestCookieExpiration(t *testing.T) {
Password: "password",
Permissions: []string{dataprovider.PermAdminAny},
}
- claims = make(map[string]any)
- claims[claimUsernameKey] = admin.Username
- claims[claimPermissionsKey] = admin.Permissions
- claims[jwt.SubjectKey] = admin.GetSignature()
- claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute)
- claims[jwt.AudienceKey] = []string{tokenAudienceAPI}
- token, _, err = server.tokenAuth.Encode(claims)
+ claims = jwt.NewClaims(tokenAudienceAPI, "", getTokenDuration(tokenAudienceAPI))
+ claims.Username = admin.Username
+ claims.Permissions = admin.Permissions
+ claims.Subject = admin.GetSignature()
+ claims.SetExpiry(time.Now().Add(1 * time.Minute))
+ _, err = server.tokenAuth.Sign(claims)
assert.NoError(t, err)
+
req, _ = http.NewRequest(http.MethodGet, tokenPath, nil)
- ctx = jwtauth.NewContext(req.Context(), token, nil)
+ ctx = jwt.NewContext(req.Context(), claims, nil)
server.checkCookieExpiration(rr, req.WithContext(ctx))
cookie = rr.Header().Get("Set-Cookie")
assert.Empty(t, cookie)
@@ -1634,7 +1927,7 @@ func TestCookieExpiration(t *testing.T) {
err = dataprovider.AddAdmin(&admin, "", "", "")
assert.NoError(t, err)
req, _ = http.NewRequest(http.MethodGet, tokenPath, nil)
- ctx = jwtauth.NewContext(req.Context(), token, nil)
+ ctx = jwt.NewContext(req.Context(), claims, nil)
server.checkCookieExpiration(rr, req.WithContext(ctx))
cookie = rr.Header().Get("Set-Cookie")
assert.Empty(t, cookie)
@@ -1644,34 +1937,41 @@ func TestCookieExpiration(t *testing.T) {
err = dataprovider.UpdateAdmin(&admin, "", "", "")
assert.NoError(t, err)
req, _ = http.NewRequest(http.MethodGet, tokenPath, nil)
- ctx = jwtauth.NewContext(req.Context(), token, nil)
+ ctx = jwt.NewContext(req.Context(), claims, nil)
server.checkCookieExpiration(rr, req.WithContext(ctx))
cookie = rr.Header().Get("Set-Cookie")
assert.Empty(t, cookie)
admin, err = dataprovider.AdminExists(admin.Username)
assert.NoError(t, err)
- claims = make(map[string]any)
- claims[claimUsernameKey] = admin.Username
- claims[claimPermissionsKey] = admin.Permissions
- claims[jwt.SubjectKey] = admin.GetSignature()
- claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute)
- claims[jwt.AudienceKey] = []string{tokenAudienceAPI}
- token, _, err = server.tokenAuth.Encode(claims)
+ tokenID := xid.New().String()
+ claims = jwt.NewClaims(tokenAudienceAPI, "", getTokenDuration(tokenAudienceAPI))
+ claims.ID = tokenID
+ claims.Username = admin.Username
+ claims.Permissions = admin.Permissions
+ claims.Subject = admin.GetSignature()
+ claims.SetExpiry(time.Now().Add(1 * time.Minute))
+ _, err = server.tokenAuth.Sign(claims)
assert.NoError(t, err)
+
req, _ = http.NewRequest(http.MethodGet, tokenPath, nil)
req.RemoteAddr = "192.168.8.1:1234"
- ctx = jwtauth.NewContext(req.Context(), token, nil)
+ ctx = jwt.NewContext(req.Context(), claims, nil)
server.checkCookieExpiration(rr, req.WithContext(ctx))
cookie = rr.Header().Get("Set-Cookie")
assert.Empty(t, cookie)
req, _ = http.NewRequest(http.MethodGet, tokenPath, nil)
req.RemoteAddr = "172.16.1.12:4567"
- ctx = jwtauth.NewContext(req.Context(), token, nil)
+ ctx = jwt.NewContext(req.Context(), claims, nil)
server.checkCookieExpiration(rr, req.WithContext(ctx))
cookie = rr.Header().Get("Set-Cookie")
assert.True(t, strings.HasPrefix(cookie, "jwt="))
+ req.Header.Set("Cookie", cookie)
+ c, err := jwt.VerifyRequest(server.tokenAuth, req, jwt.TokenFromCookie)
+ if assert.NoError(t, err) {
+ assert.Equal(t, tokenID, c.ID)
+ }
err = dataprovider.DeleteAdmin(admin.Username, "", "", "")
assert.NoError(t, err)
@@ -1689,18 +1989,18 @@ func TestCookieExpiration(t *testing.T) {
user.Permissions = make(map[string][]string)
user.Permissions["/"] = []string{"*"}
- claims = make(map[string]any)
- claims[claimUsernameKey] = user.Username
- claims[claimPermissionsKey] = user.Filters.WebClient
- claims[jwt.SubjectKey] = user.GetSignature()
- claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute)
- claims[jwt.AudienceKey] = []string{tokenAudienceWebClient}
- token, _, err = server.tokenAuth.Encode(claims)
+ claims = jwt.NewClaims(tokenAudienceWebClient, "", getTokenDuration(tokenAudienceWebClient))
+ claims.ID = tokenID
+ claims.Username = user.Username
+ claims.Permissions = user.Filters.WebClient
+ claims.Subject = user.GetSignature()
+ claims.SetExpiry(time.Now().Add(1 * time.Minute))
+ _, err = server.tokenAuth.Sign(claims)
assert.NoError(t, err)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
- ctx = jwtauth.NewContext(req.Context(), token, nil)
+ ctx = jwt.NewContext(req.Context(), claims, nil)
server.checkCookieExpiration(rr, req.WithContext(ctx))
cookie = rr.Header().Get("Set-Cookie")
assert.Empty(t, cookie)
@@ -1708,7 +2008,7 @@ func TestCookieExpiration(t *testing.T) {
err = dataprovider.AddUser(&user, "", "", "")
assert.NoError(t, err)
req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
- ctx = jwtauth.NewContext(req.Context(), token, nil)
+ ctx = jwt.NewContext(req.Context(), claims, nil)
server.checkCookieExpiration(rr, req.WithContext(ctx))
cookie = rr.Header().Get("Set-Cookie")
assert.Empty(t, cookie)
@@ -1721,28 +2021,81 @@ func TestCookieExpiration(t *testing.T) {
user, err = dataprovider.UserExists(user.Username, "")
assert.NoError(t, err)
- claims = make(map[string]any)
- claims[claimUsernameKey] = user.Username
- claims[claimPermissionsKey] = user.Filters.WebClient
- claims[jwt.SubjectKey] = user.GetSignature()
- claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute)
- claims[jwt.AudienceKey] = []string{tokenAudienceWebClient}
- token, _, err = server.tokenAuth.Encode(claims)
+ issuedAt := time.Now().Add(-1 * time.Minute)
+ expiresAt := time.Now().Add(1 * time.Minute)
+
+ claims = jwt.NewClaims(tokenAudienceWebClient, "", getTokenDuration(tokenAudienceWebClient))
+ claims.ID = tokenID
+ claims.Username = user.Username
+ claims.Permissions = user.Filters.WebClient
+ claims.Subject = user.GetSignature()
+ claims.SetExpiry(expiresAt)
+ claims.SetIssuedAt(issuedAt)
+ _, err = server.tokenAuth.Sign(claims)
assert.NoError(t, err)
req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
req.RemoteAddr = "172.16.3.12:4567"
- ctx = jwtauth.NewContext(req.Context(), token, nil)
+ ctx = jwt.NewContext(req.Context(), claims, nil)
server.checkCookieExpiration(rr, req.WithContext(ctx))
cookie = rr.Header().Get("Set-Cookie")
assert.Empty(t, cookie)
req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
req.RemoteAddr = "172.16.4.16:4567"
- ctx = jwtauth.NewContext(req.Context(), token, nil)
+ ctx = jwt.NewContext(req.Context(), claims, nil)
server.checkCookieExpiration(rr, req.WithContext(ctx))
cookie = rr.Header().Get("Set-Cookie")
assert.NotEmpty(t, cookie)
+ req.Header.Set("Cookie", cookie)
+ c, err = jwt.VerifyRequest(server.tokenAuth, req, jwt.TokenFromCookie)
+ if assert.NoError(t, err) {
+ assert.Equal(t, tokenID, c.ID)
+ assert.Equal(t, issuedAt.Unix(), c.IssuedAt.Time().Unix())
+ assert.NotEqual(t, expiresAt.Unix(), c.Expiry.Time().Unix())
+ }
+ // test a cookie issued more that 12 hours ago
+ claims = jwt.NewClaims(tokenAudienceWebClient, "", getTokenDuration(tokenAudienceWebClient))
+ claims.ID = tokenID
+ claims.Username = user.Username
+ claims.Permissions = user.Filters.WebClient
+ claims.Subject = user.GetSignature()
+ claims.SetExpiry(expiresAt)
+ claims.SetIssuedAt(time.Now().Add(-24 * time.Hour))
+ _, err = server.tokenAuth.Sign(claims)
+ assert.NoError(t, err)
+
+ rr = httptest.NewRecorder()
+ req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
+ req.RemoteAddr = "172.16.4.16:6789"
+ ctx = jwt.NewContext(req.Context(), claims, nil)
+ server.checkCookieExpiration(rr, req.WithContext(ctx))
+ cookie = rr.Header().Get("Set-Cookie")
+ assert.Empty(t, cookie)
+
+ // test a disabled user
+ user.Status = 0
+ err = dataprovider.UpdateUser(&user, "", "", "")
+ assert.NoError(t, err)
+ user, err = dataprovider.UserExists(user.Username, "")
+ assert.NoError(t, err)
+
+ claims = jwt.NewClaims(tokenAudienceWebClient, "", getTokenDuration(tokenAudienceWebClient))
+ claims.ID = tokenID
+ claims.Username = user.Username
+ claims.Permissions = user.Filters.WebClient
+ claims.Subject = user.GetSignature()
+ claims.SetExpiry(time.Now().Add(1 * time.Minute))
+ claims.SetIssuedAt(issuedAt)
+ _, err = server.tokenAuth.Sign(claims)
+ assert.NoError(t, err)
+
+ rr = httptest.NewRecorder()
+ req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
+ ctx = jwt.NewContext(req.Context(), claims, nil)
+ server.checkCookieExpiration(rr, req.WithContext(ctx))
+ cookie = rr.Header().Get("Set-Cookie")
+ assert.Empty(t, cookie)
err = dataprovider.DeleteUser(user.Username, "", "", "")
assert.NoError(t, err)
@@ -1769,6 +2122,7 @@ func TestChangePwdValidationErrors(t *testing.T) {
require.Error(t, err)
req, _ := http.NewRequest(http.MethodPut, adminPwdPath, nil)
+ req = req.WithContext(jwt.NewContext(req.Context(), &jwt.Claims{Claims: josejwt.Claims{ID: xid.New().String()}}, nil))
err = doChangeAdminPassword(req, "currentpwd", "newpwd", "newpwd")
assert.Error(t, err)
}
@@ -1776,23 +2130,24 @@ func TestChangePwdValidationErrors(t *testing.T) {
func TestRenderUnexistingFolder(t *testing.T) {
rr := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, folderPath, nil)
- renderFolder(rr, req, "path not mapped", &jwtTokenClaims{}, http.StatusOK)
+ renderFolder(rr, req, "path not mapped", &jwt.Claims{}, http.StatusOK)
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestCloseConnectionHandler(t *testing.T) {
- tokenAuth := jwtauth.New(jwa.HS256.String(), util.GenerateRandomBytes(32), nil)
- claims := make(map[string]any)
- claims["username"] = defaultAdminUsername
- claims[jwt.ExpirationKey] = time.Now().UTC().Add(1 * time.Hour)
- token, _, err := tokenAuth.Encode(claims)
+ tokenAuth, err := jwt.NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
+ claims := jwt.NewClaims(tokenAudienceAPI, "", getTokenDuration(tokenAudienceAPI))
+ claims.Username = defaultAdminUsername
+ claims.SetExpiry(time.Now().UTC().Add(1 * time.Hour))
+ _, err = tokenAuth.Sign(claims)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodDelete, activeConnectionsPath+"/connectionID", nil)
assert.NoError(t, err)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("connectionID", "")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
- req = req.WithContext(context.WithValue(req.Context(), jwtauth.TokenCtxKey, token))
+ req = req.WithContext(context.WithValue(req.Context(), jwt.TokenCtxKey, claims))
rr := httptest.NewRecorder()
handleCloseConnection(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
@@ -1814,7 +2169,7 @@ func TestRenderInvalidTemplate(t *testing.T) {
}
func TestQuotaScanInvalidFs(t *testing.T) {
- user := dataprovider.User{
+ user := &dataprovider.User{
BaseUser: sdk.BaseUser{
Username: "test",
HomeDir: os.TempDir(),
@@ -1987,31 +2342,38 @@ func TestGetUserFromTemplate(t *testing.T) {
}
func TestJWTTokenCleanup(t *testing.T) {
+ tokenAuth, err := jwt.NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
server := httpdServer{
- tokenAuth: jwtauth.New(jwa.HS256.String(), util.GenerateRandomBytes(32), nil),
+ tokenAuth: tokenAuth,
}
admin := dataprovider.Admin{
Username: "newtestadmin",
Password: "password",
Permissions: []string{dataprovider.PermAdminAny},
}
- claims := make(map[string]any)
- claims[claimUsernameKey] = admin.Username
- claims[claimPermissionsKey] = admin.Permissions
- claims[jwt.SubjectKey] = admin.GetSignature()
- claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute)
- _, token, err := server.tokenAuth.Encode(claims)
+ claims := jwt.NewClaims(tokenAudienceAPI, "", getTokenDuration(tokenAudienceAPI))
+ claims.Username = admin.Username
+ claims.Permissions = admin.Permissions
+ claims.Subject = admin.GetSignature()
+ claims.SetExpiry(time.Now().Add(1 * time.Minute))
+ token, err := server.tokenAuth.Sign(claims)
assert.NoError(t, err)
req, _ := http.NewRequest(http.MethodGet, versionPath, nil)
assert.True(t, isTokenInvalidated(req))
- req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", token))
+ fakeToken := "abc"
+ invalidateTokenString(req, fakeToken, -100*time.Millisecond)
+ assert.True(t, invalidatedJWTTokens.Get(fakeToken))
- invalidatedJWTTokens.Add(token, time.Now().Add(-tokenDuration).UTC())
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+
+ invalidatedJWTTokens.Add(token, time.Now().Add(-getTokenDuration(tokenAudienceWebAdmin)).UTC())
require.True(t, isTokenInvalidated(req))
startCleanupTicker(100 * time.Millisecond)
assert.Eventually(t, func() bool { return !isTokenInvalidated(req) }, 1*time.Second, 200*time.Millisecond)
+ assert.False(t, invalidatedJWTTokens.Get(fakeToken))
stopCleanupTicker()
}
@@ -2024,22 +2386,71 @@ func TestDbTokenManager(t *testing.T) {
testToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiV2ViQWRtaW4iLCI6OjEiXSwiZXhwIjoxNjk4NjYwMDM4LCJqdGkiOiJja3ZuazVrYjF1aHUzZXRmZmhyZyIsIm5iZiI6MTY5ODY1ODgwOCwicGVybWlzc2lvbnMiOlsiKiJdLCJzdWIiOiIxNjk3ODIwNDM3NTMyIiwidXNlcm5hbWUiOiJhZG1pbiJ9.LXuFFksvnSuzHqHat6r70yR0jEulNRju7m7SaWrOfy8; csrftoken=mP0C7DqjwpAXsptO2gGCaYBkYw3oNMWB"
key := dbTokenManager.getKey(testToken)
require.Len(t, key, 64)
- dbTokenManager.Add(testToken, time.Now().Add(-tokenDuration).UTC())
+ dbTokenManager.Add(testToken, time.Now().Add(-getTokenDuration(tokenAudienceWebClient)).UTC())
isInvalidated := dbTokenManager.Get(testToken)
assert.True(t, isInvalidated)
dbTokenManager.Cleanup()
isInvalidated = dbTokenManager.Get(testToken)
assert.False(t, isInvalidated)
- dbTokenManager.Add(testToken, time.Now().Add(tokenDuration).UTC())
+ dbTokenManager.Add(testToken, time.Now().Add(getTokenDuration(tokenAudienceWebAdmin)).UTC())
isInvalidated = dbTokenManager.Get(testToken)
assert.True(t, isInvalidated)
dbTokenManager.Cleanup()
isInvalidated = dbTokenManager.Get(testToken)
assert.True(t, isInvalidated)
- err := dataprovider.DeleteSharedSession(key)
+ err := dataprovider.DeleteSharedSession(key, dataprovider.SessionTypeInvalidToken)
assert.NoError(t, err)
}
+func TestDatabaseSharedSessions(t *testing.T) {
+ if !isSharedProviderSupported() {
+ t.Skip("this test it is not available with this provider")
+ }
+ session1 := dataprovider.Session{
+ Key: "1",
+ Data: map[string]string{"a": "b"},
+ Type: dataprovider.SessionTypeOIDCAuth,
+ Timestamp: 10,
+ }
+ err := dataprovider.AddSharedSession(session1)
+ assert.NoError(t, err)
+ // Adding another session with the same key but a different type should work
+ session2 := session1
+ session2.Type = dataprovider.SessionTypeOIDCToken
+ err = dataprovider.AddSharedSession(session2)
+ assert.NoError(t, err)
+ err = dataprovider.DeleteSharedSession(session1.Key, dataprovider.SessionTypeInvalidToken)
+ assert.ErrorIs(t, err, util.ErrNotFound)
+ _, err = dataprovider.GetSharedSession(session1.Key, dataprovider.SessionTypeResetCode)
+ assert.ErrorIs(t, err, util.ErrNotFound)
+ session1Get, err := dataprovider.GetSharedSession(session1.Key, dataprovider.SessionTypeOIDCAuth)
+ assert.NoError(t, err)
+ assert.Equal(t, session1.Timestamp, session1Get.Timestamp)
+ var stored map[string]string
+ err = json.Unmarshal(session1Get.Data.([]byte), &stored)
+ assert.NoError(t, err)
+ assert.Equal(t, session1.Data, stored)
+ session1.Timestamp = 20
+ session1.Data = map[string]string{"c": "d"}
+ err = dataprovider.AddSharedSession(session1)
+ assert.NoError(t, err)
+ session1Get, err = dataprovider.GetSharedSession(session1.Key, dataprovider.SessionTypeOIDCAuth)
+ assert.NoError(t, err)
+ assert.Equal(t, session1.Timestamp, session1Get.Timestamp)
+ stored = make(map[string]string)
+ err = json.Unmarshal(session1Get.Data.([]byte), &stored)
+ assert.NoError(t, err)
+ assert.Equal(t, session1.Data, stored)
+ err = dataprovider.DeleteSharedSession(session1.Key, dataprovider.SessionTypeOIDCAuth)
+ assert.NoError(t, err)
+ err = dataprovider.DeleteSharedSession(session2.Key, dataprovider.SessionTypeOIDCToken)
+ assert.NoError(t, err)
+ _, err = dataprovider.GetSharedSession(session1.Key, dataprovider.SessionTypeOIDCAuth)
+ assert.ErrorIs(t, err, util.ErrNotFound)
+ _, err = dataprovider.GetSharedSession(session2.Key, dataprovider.SessionTypeOIDCToken)
+ assert.ErrorIs(t, err, util.ErrNotFound)
+}
+
func TestAllowedProxyUnixDomainSocket(t *testing.T) {
b := Binding{
Address: filepath.Join(os.TempDir(), "sock"),
@@ -2052,6 +2463,15 @@ func TestAllowedProxyUnixDomainSocket(t *testing.T) {
}
}
+func TestProxyListenerWrapper(t *testing.T) {
+ b := Binding{
+ ProxyMode: 0,
+ }
+ require.Nil(t, b.listenerWrapper())
+ b.ProxyMode = 1
+ require.NotNil(t, b.listenerWrapper())
+}
+
func TestProxyHeaders(t *testing.T) {
username := "adminTest"
password := "testPwd"
@@ -2082,7 +2502,8 @@ func TestProxyHeaders(t *testing.T) {
err = b.parseAllowedProxy()
assert.NoError(t, err)
server := newHttpdServer(b, "", "", CorsConfig{Enabled: true}, "")
- server.initializeRouter()
+ err = server.initializeRouter()
+ require.NoError(t, err)
testServer := httptest.NewServer(server.router)
defer testServer.Close()
@@ -2095,7 +2516,7 @@ func TestProxyHeaders(t *testing.T) {
rr := httptest.NewRecorder()
testServer.Config.Handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
- assert.Contains(t, rr.Body.String(), "login from IP 127.0.0.1 not allowed")
+ assert.NotContains(t, rr.Body.String(), "login from IP 127.0.0.1 not allowed")
req.RemoteAddr = testIP
rr = httptest.NewRecorder()
@@ -2107,34 +2528,95 @@ func TestProxyHeaders(t *testing.T) {
testServer.Config.Handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
+ req, err = http.NewRequest(http.MethodGet, webAdminLoginPath, nil)
+ assert.NoError(t, err)
+ req.RemoteAddr = testIP
+ rr = httptest.NewRecorder()
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+ cookie := rr.Header().Get("Set-Cookie")
+ assert.NotEmpty(t, cookie)
+ req.Header.Set("Cookie", cookie)
+ parsedToken, err := jwt.VerifyRequest(server.csrfTokenAuth, req, jwt.TokenFromCookie)
+ assert.NoError(t, err)
+ ctx := req.Context()
+ ctx = jwt.NewContext(ctx, parsedToken, err)
+ req = req.WithContext(ctx)
+
form := make(url.Values)
form.Set("username", username)
form.Set("password", password)
- form.Set(csrfFormToken, createCSRFToken(testIP))
+ form.Set(csrfFormToken, createCSRFToken(httptest.NewRecorder(), req, server.csrfTokenAuth, "", webBaseAdminPath))
req, err = http.NewRequest(http.MethodPost, webAdminLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = testIP
+ req.Header.Set("Cookie", cookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
testServer.Config.Handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCredentials)
- form.Set(csrfFormToken, createCSRFToken(validForwardedFor))
+ req, err = http.NewRequest(http.MethodGet, webAdminLoginPath, nil)
+ assert.NoError(t, err)
+ req.RemoteAddr = validForwardedFor
+ rr = httptest.NewRecorder()
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+ loginCookie := rr.Header().Get("Set-Cookie")
+ assert.NotEmpty(t, loginCookie)
+ req.Header.Set("Cookie", loginCookie)
+ parsedToken, err = jwt.VerifyRequest(server.csrfTokenAuth, req, jwt.TokenFromCookie)
+ assert.NoError(t, err)
+ ctx = req.Context()
+ ctx = jwt.NewContext(ctx, parsedToken, err)
+ req = req.WithContext(ctx)
+
+ form.Set(csrfFormToken, createCSRFToken(httptest.NewRecorder(), req, server.csrfTokenAuth, "", webBaseAdminPath))
req, err = http.NewRequest(http.MethodPost, webAdminLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = testIP
+ req.Header.Set("Cookie", loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-Forwarded-For", validForwardedFor)
rr = httptest.NewRecorder()
testServer.Config.Handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusFound, rr.Code, rr.Body.String())
- cookie := rr.Header().Get("Set-Cookie")
+ cookie = rr.Header().Get("Set-Cookie")
assert.NotContains(t, cookie, "Secure")
+ // The login cookie is invalidated after a successful login, the same request will fail
req, err = http.NewRequest(http.MethodPost, webAdminLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = testIP
+ req.Header.Set("Cookie", loginCookie)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("X-Forwarded-For", validForwardedFor)
+ rr = httptest.NewRecorder()
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+ assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidCSRF)
+
+ req, err = http.NewRequest(http.MethodGet, webAdminLoginPath, nil)
+ assert.NoError(t, err)
+ req.RemoteAddr = validForwardedFor
+ rr = httptest.NewRecorder()
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+ loginCookie = rr.Header().Get("Set-Cookie")
+ assert.NotEmpty(t, loginCookie)
+ req.Header.Set("Cookie", loginCookie)
+ parsedToken, err = jwt.VerifyRequest(server.csrfTokenAuth, req, jwt.TokenFromCookie)
+ assert.NoError(t, err)
+ ctx = req.Context()
+ ctx = jwt.NewContext(ctx, parsedToken, err)
+ req = req.WithContext(ctx)
+
+ form.Set(csrfFormToken, createCSRFToken(httptest.NewRecorder(), req, server.csrfTokenAuth, "", webBaseAdminPath))
+ req, err = http.NewRequest(http.MethodPost, webAdminLoginPath, bytes.NewBuffer([]byte(form.Encode())))
+ assert.NoError(t, err)
+ req.RemoteAddr = testIP
+ req.Header.Set("Cookie", loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-Forwarded-For", validForwardedFor)
req.Header.Set(xForwardedProto, "https")
@@ -2144,9 +2626,26 @@ func TestProxyHeaders(t *testing.T) {
cookie = rr.Header().Get("Set-Cookie")
assert.Contains(t, cookie, "Secure")
+ req, err = http.NewRequest(http.MethodGet, webAdminLoginPath, nil)
+ assert.NoError(t, err)
+ req.RemoteAddr = validForwardedFor
+ rr = httptest.NewRecorder()
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+ loginCookie = rr.Header().Get("Set-Cookie")
+ assert.NotEmpty(t, loginCookie)
+ req.Header.Set("Cookie", loginCookie)
+ parsedToken, err = jwt.VerifyRequest(server.csrfTokenAuth, req, jwt.TokenFromCookie)
+ assert.NoError(t, err)
+ ctx = req.Context()
+ ctx = jwt.NewContext(ctx, parsedToken, err)
+ req = req.WithContext(ctx)
+
+ form.Set(csrfFormToken, createCSRFToken(httptest.NewRecorder(), req, server.csrfTokenAuth, "", webBaseAdminPath))
req, err = http.NewRequest(http.MethodPost, webAdminLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = testIP
+ req.Header.Set("Cookie", loginCookie)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-Forwarded-For", validForwardedFor)
req.Header.Set(xForwardedProto, "http")
@@ -2170,7 +2669,8 @@ func TestRecoverer(t *testing.T) {
EnableRESTAPI: true,
}
server := newHttpdServer(b, "../static", "", CorsConfig{}, "../openapi")
- server.initializeRouter()
+ err := server.initializeRouter()
+ require.NoError(t, err)
server.router.Get(recoveryPath, func(_ http.ResponseWriter, _ *http.Request) {
panic("panic")
})
@@ -2231,10 +2731,11 @@ func TestCompressorAbortHandler(t *testing.T) {
assert.Equal(t, http.ErrAbortHandler, rcv)
}()
- connection := &Connection{
- BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", "", dataprovider.User{}),
- request: nil,
- }
+ connection := newConnection(
+ common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", "", dataprovider.User{}),
+ nil,
+ nil,
+ )
share := &dataprovider.Share{}
renderCompressedFiles(&failingWriter{}, connection, "", nil, share)
}
@@ -2256,10 +2757,11 @@ func TestZipErrors(t *testing.T) {
}
user.Permissions = make(map[string][]string)
user.Permissions["/"] = []string{dataprovider.PermAny}
- connection := &Connection{
- BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", "", user),
- request: nil,
- }
+ connection := newConnection(
+ common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", "", user),
+ nil,
+ nil,
+ )
testDir := filepath.Join(os.TempDir(), "testDir")
err := os.MkdirAll(testDir, os.ModePerm)
@@ -2271,14 +2773,14 @@ func TestZipErrors(t *testing.T) {
assert.Contains(t, err.Error(), "write error")
}
- err = addZipEntry(wr, connection, "/"+filepath.Base(testDir), "/", 0)
+ err = addZipEntry(wr, connection, "/"+filepath.Base(testDir), "/", nil, 0)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "write error")
}
- err = addZipEntry(wr, connection, "/"+filepath.Base(testDir), "/", 2000)
+ err = addZipEntry(wr, connection, "/"+filepath.Base(testDir), "/", nil, 2000)
assert.ErrorIs(t, err, util.ErrRecursionTooDeep)
- err = addZipEntry(wr, connection, "/"+filepath.Base(testDir), path.Join("/", filepath.Base(testDir), "dir"), 0)
+ err = addZipEntry(wr, connection, "/"+filepath.Base(testDir), path.Join("/", filepath.Base(testDir), "dir"), nil, 0)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "is outside base dir")
}
@@ -2287,14 +2789,14 @@ func TestZipErrors(t *testing.T) {
err = os.WriteFile(testFilePath, util.GenerateRandomBytes(65535), os.ModePerm)
assert.NoError(t, err)
err = addZipEntry(wr, connection, path.Join("/", filepath.Base(testDir), filepath.Base(testFilePath)),
- "/"+filepath.Base(testDir), 0)
+ "/"+filepath.Base(testDir), nil, 0)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "write error")
}
connection.User.Permissions["/"] = []string{dataprovider.PermListItems}
err = addZipEntry(wr, connection, path.Join("/", filepath.Base(testDir), filepath.Base(testFilePath)),
- "/"+filepath.Base(testDir), 0)
+ "/"+filepath.Base(testDir), nil, 0)
assert.ErrorIs(t, err, os.ErrPermission)
// creating a virtual folder to a missing path stat is ok but readdir fails
@@ -2306,14 +2808,14 @@ func TestZipErrors(t *testing.T) {
})
connection.User = user
wr = zip.NewWriter(bytes.NewBuffer(make([]byte, 0)))
- err = addZipEntry(wr, connection, user.VirtualFolders[0].VirtualPath, "/", 0)
+ err = addZipEntry(wr, connection, user.VirtualFolders[0].VirtualPath, "/", nil, 0)
assert.Error(t, err)
user.Filters.FilePatterns = append(user.Filters.FilePatterns, sdk.PatternsFilter{
Path: "/",
DeniedPatterns: []string{"*.zip"},
})
- err = addZipEntry(wr, connection, "/"+filepath.Base(testDir), "/", 0)
+ err = addZipEntry(wr, connection, "/"+filepath.Base(testDir), "/", nil, 0)
assert.ErrorIs(t, err, os.ErrPermission)
err = os.RemoveAll(testDir)
@@ -2329,7 +2831,8 @@ func TestWebAdminRedirect(t *testing.T) {
EnableRESTAPI: true,
}
server := newHttpdServer(b, "../static", "", CorsConfig{}, "../openapi")
- server.initializeRouter()
+ err := server.initializeRouter()
+ require.NoError(t, err)
testServer := httptest.NewServer(server.router)
defer testServer.Close()
@@ -2480,10 +2983,11 @@ func TestConnection(t *testing.T) {
}
user.Permissions = make(map[string][]string)
user.Permissions["/"] = []string{dataprovider.PermAny}
- connection := &Connection{
- BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", "", user),
- request: nil,
- }
+ connection := newConnection(
+ common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", "", user),
+ nil,
+ nil,
+ )
assert.Empty(t, connection.GetClientVersion())
assert.Empty(t, connection.GetRemoteAddress())
assert.Empty(t, connection.GetCommand())
@@ -2504,10 +3008,11 @@ func TestGetFileWriterErrors(t *testing.T) {
}
user.Permissions = make(map[string][]string)
user.Permissions["/"] = []string{dataprovider.PermAny}
- connection := &Connection{
- BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", "", user),
- request: nil,
- }
+ connection := newConnection(
+ common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", "", user),
+ nil,
+ nil,
+ )
_, err := connection.getFileWriter("name")
assert.Error(t, err)
@@ -2520,10 +3025,11 @@ func TestGetFileWriterErrors(t *testing.T) {
},
AccessSecret: kms.NewPlainSecret("secret"),
}
- connection = &Connection{
- BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", "", user),
- request: nil,
- }
+ connection = newConnection(
+ common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", "", user),
+ nil,
+ nil,
+ )
_, err = connection.getFileWriter("/path")
assert.Error(t, err)
}
@@ -2552,10 +3058,11 @@ func TestHTTPDFile(t *testing.T) {
}
user.Permissions = make(map[string][]string)
user.Permissions["/"] = []string{dataprovider.PermAny}
- connection := &Connection{
- BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", "", user),
- request: nil,
- }
+ connection := newConnection(
+ common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", "", user),
+ nil,
+ nil,
+ )
fs, err := user.GetFilesystem("")
assert.NoError(t, err)
@@ -2613,7 +3120,8 @@ func TestChangeUserPwd(t *testing.T) {
func TestWebUserInvalidClaims(t *testing.T) {
server := httpdServer{}
- server.initializeRouter()
+ err := server.initializeRouter()
+ require.NoError(t, err)
rr := httptest.NewRecorder()
user := dataprovider.User{
@@ -2622,79 +3130,81 @@ func TestWebUserInvalidClaims(t *testing.T) {
Password: "pwd",
},
}
- c := jwtTokenClaims{
+ c := &jwt.Claims{
Username: user.Username,
Permissions: nil,
- Signature: user.GetSignature(),
}
- token, err := c.createTokenResponse(server.tokenAuth, tokenAudienceWebClient, "")
+ c.Subject = user.GetSignature()
+ c.SetExpiry(time.Now().Add(10 * time.Minute))
+ c.Audience = []string{tokenAudienceAPI}
+ token, err := server.tokenAuth.Sign(c)
assert.NoError(t, err)
req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil)
- req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token))
server.handleClientGetFiles(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidToken)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, webClientDirsPath, nil)
- req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token))
server.handleClientGetDirContents(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorDirList403)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, webClientDownloadZipPath, nil)
- req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token))
server.handleWebClientDownloadZip(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidToken)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, webClientEditFilePath, nil)
- req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token))
server.handleClientEditFile(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidToken)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, webClientSharePath, nil)
- req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token))
server.handleClientAddShareGet(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidToken)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, webClientSharePath, nil)
- req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token))
server.handleClientUpdateShareGet(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidToken)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPost, webClientSharePath, nil)
- req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token))
server.handleClientAddSharePost(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidToken)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPost, webClientSharePath+"/id", nil)
- req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token))
server.handleClientUpdateSharePost(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidToken)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, webClientSharesPath+jsonAPISuffix, nil)
- req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token))
getAllShares(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidToken)
rr = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodGet, webClientViewPDFPath, nil)
- req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token))
server.handleClientGetPDF(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidToken)
@@ -2702,7 +3212,8 @@ func TestWebUserInvalidClaims(t *testing.T) {
func TestInvalidClaims(t *testing.T) {
server := httpdServer{}
- server.initializeRouter()
+ err := server.initializeRouter()
+ require.NoError(t, err)
rr := httptest.NewRecorder()
user := dataprovider.User{
@@ -2711,19 +3222,31 @@ func TestInvalidClaims(t *testing.T) {
Password: "pwd",
},
}
- c := jwtTokenClaims{
+ c := &jwt.Claims{
Username: user.Username,
Permissions: nil,
- Signature: user.GetSignature(),
}
- token, err := c.createTokenResponse(server.tokenAuth, tokenAudienceWebClient, "")
+ c.Subject = user.GetSignature()
+ token, err := server.tokenAuth.SignWithParams(c, tokenAudienceWebClient, "", getTokenDuration(tokenAudienceWebClient))
assert.NoError(t, err)
+
+ req, err := http.NewRequest(http.MethodGet, webClientProfilePath, nil)
+ assert.NoError(t, err)
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token))
+ parsedToken, err := jwt.VerifyRequest(server.tokenAuth, req, jwt.TokenFromCookie)
+ assert.NoError(t, err)
+ ctx := req.Context()
+ ctx = jwt.NewContext(ctx, parsedToken, err)
+ req = req.WithContext(ctx)
+
form := make(url.Values)
- form.Set(csrfFormToken, createCSRFToken(""))
+ form.Set(csrfFormToken, createCSRFToken(rr, req, server.csrfTokenAuth, "", webBaseClientPath))
form.Set("public_keys", "")
- req, _ := http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
+ req, err = http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
+ assert.NoError(t, err)
+ req = req.WithContext(ctx)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token))
server.handleWebClientProfilePost(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
@@ -2731,21 +3254,34 @@ func TestInvalidClaims(t *testing.T) {
Username: "",
Password: user.Password,
}
- c = jwtTokenClaims{
+ c = &jwt.Claims{
Username: admin.Username,
Permissions: nil,
- Signature: admin.GetSignature(),
}
- token, err = c.createTokenResponse(server.tokenAuth, tokenAudienceWebAdmin, "")
+ c.Subject = admin.GetSignature()
+ token, err = server.tokenAuth.SignWithParams(c, tokenAudienceWebAdmin, "", getTokenDuration(tokenAudienceWebAdmin))
assert.NoError(t, err)
+
+ req, err = http.NewRequest(http.MethodGet, webAdminProfilePath, nil)
+ assert.NoError(t, err)
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token))
+ parsedToken, err = jwt.VerifyRequest(server.tokenAuth, req, jwt.TokenFromCookie)
+ assert.NoError(t, err)
+ ctx = req.Context()
+ ctx = jwt.NewContext(ctx, parsedToken, err)
+ req = req.WithContext(ctx)
+
form = make(url.Values)
- form.Set(csrfFormToken, createCSRFToken(""))
+ form.Set(csrfFormToken, createCSRFToken(rr, req, server.csrfTokenAuth, "", webBaseAdminPath))
form.Set("allow_api_key_auth", "")
- req, _ = http.NewRequest(http.MethodPost, webAdminProfilePath, bytes.NewBuffer([]byte(form.Encode())))
+ req, err = http.NewRequest(http.MethodPost, webAdminProfilePath, bytes.NewBuffer([]byte(form.Encode())))
+ assert.NoError(t, err)
+ req = req.WithContext(ctx)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token))
server.handleWebAdminProfilePost(rr, req)
assert.Equal(t, http.StatusForbidden, rr.Code)
+ assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidToken)
}
func TestTLSReq(t *testing.T) {
@@ -2766,12 +3302,14 @@ func TestSigningKey(t *testing.T) {
server1 := httpdServer{
signingPassphrase: signingPassphrase,
}
- server1.initializeRouter()
+ err := server1.initializeRouter()
+ require.NoError(t, err)
server2 := httpdServer{
signingPassphrase: signingPassphrase,
}
- server2.initializeRouter()
+ err = server2.initializeRouter()
+ require.NoError(t, err)
user := dataprovider.User{
BaseUser: sdk.BaseUser{
@@ -2779,18 +3317,17 @@ func TestSigningKey(t *testing.T) {
Password: "pwd",
},
}
- c := jwtTokenClaims{
+ c := &jwt.Claims{
Username: user.Username,
Permissions: nil,
- Signature: user.GetSignature(),
}
- token, err := c.createTokenResponse(server1.tokenAuth, tokenAudienceWebClient, "")
+ c.Subject = user.GetSignature()
+ token, err := server1.tokenAuth.SignWithParams(c, tokenAudienceWebClient, "", getTokenDuration(tokenAudienceWebClient))
assert.NoError(t, err)
- accessToken := token["access_token"].(string)
- assert.NotEmpty(t, accessToken)
- _, err = server1.tokenAuth.Decode(accessToken)
+ assert.NotEmpty(t, token)
+ _, err = jwt.VerifyToken(server1.tokenAuth, token)
assert.NoError(t, err)
- _, err = server2.tokenAuth.Decode(accessToken)
+ _, err = jwt.VerifyToken(server2.tokenAuth, token)
assert.NoError(t, err)
}
@@ -2919,10 +3456,15 @@ func TestSecureMiddlewareIntegration(t *testing.T) {
Value: "https",
},
},
- STSSeconds: 31536000,
- STSIncludeSubdomains: true,
- STSPreload: true,
- ContentTypeNosniff: true,
+ STSSeconds: 31536000,
+ STSIncludeSubdomains: true,
+ STSPreload: true,
+ ContentTypeNosniff: true,
+ CacheControl: "private",
+ CrossOriginOpenerPolicy: "same-origin",
+ CrossOriginResourcePolicy: "same-site",
+ CrossOriginEmbedderPolicy: "require-corp",
+ ReferrerPolicy: "no-referrer",
},
},
enableWebAdmin: true,
@@ -2934,7 +3476,8 @@ func TestSecureMiddlewareIntegration(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, []string{forwardedHostHeader, xForwardedProto}, server.binding.Security.proxyHeaders)
assert.Equal(t, map[string]string{xForwardedProto: "https"}, server.binding.Security.getHTTPSProxyHeaders())
- server.initializeRouter()
+ err = server.initializeRouter()
+ require.NoError(t, err)
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, webClientLoginPath, nil)
@@ -2942,6 +3485,7 @@ func TestSecureMiddlewareIntegration(t *testing.T) {
r.Host = "127.0.0.1"
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusForbidden, rr.Code)
+ assert.Equal(t, "no-cache, no-store, max-age=0, must-revalidate, private", rr.Header().Get("Cache-Control"))
rr = httptest.NewRecorder()
r.Header.Set(forwardedHostHeader, "www.sftpgo.com")
@@ -2976,6 +3520,10 @@ func TestSecureMiddlewareIntegration(t *testing.T) {
assert.NotEmpty(t, r.Header.Get(forwardedHostHeader))
assert.Equal(t, "max-age=31536000; includeSubDomains; preload", rr.Header().Get("Strict-Transport-Security"))
assert.Equal(t, "nosniff", rr.Header().Get("X-Content-Type-Options"))
+ assert.Equal(t, "require-corp", rr.Header().Get("Cross-Origin-Embedder-Policy"))
+ assert.Equal(t, "same-origin", rr.Header().Get("Cross-Origin-Opener-Policy"))
+ assert.Equal(t, "same-site", rr.Header().Get("Cross-Origin-Resource-Policy"))
+ assert.Equal(t, "no-referrer", rr.Header().Get("Referrer-Policy"))
server.binding.Security.Enabled = false
server.binding.Security.updateProxyHeaders()
@@ -3005,7 +3553,8 @@ func TestRESTAPIDisabled(t *testing.T) {
enableWebClient: true,
enableRESTAPI: false,
}
- server.initializeRouter()
+ err := server.initializeRouter()
+ require.NoError(t, err)
assert.False(t, server.enableRESTAPI)
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, healthzPath, nil)
@@ -3042,26 +3591,34 @@ func TestWebAdminSetupWithInstallCode(t *testing.T) {
enableWebClient: true,
enableRESTAPI: true,
}
- server.initializeRouter()
-
- rr := httptest.NewRecorder()
- r, err := http.NewRequest(http.MethodGet, webAdminSetupPath, nil)
- assert.NoError(t, err)
- server.router.ServeHTTP(rr, r)
- assert.Equal(t, http.StatusOK, rr.Code)
+ err = server.initializeRouter()
+ require.NoError(t, err)
for _, webURL := range []string{"/", webBasePath, webBaseAdminPath, webAdminLoginPath, webClientLoginPath} {
- rr = httptest.NewRecorder()
- r, err = http.NewRequest(http.MethodGet, webURL, nil)
+ rr := httptest.NewRecorder()
+ r, err := http.NewRequest(http.MethodGet, webURL, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webAdminSetupPath, rr.Header().Get("Location"))
}
+ rr := httptest.NewRecorder()
+ r, err := http.NewRequest(http.MethodGet, webAdminSetupPath, nil)
+ assert.NoError(t, err)
+ server.router.ServeHTTP(rr, r)
+ assert.Equal(t, http.StatusOK, rr.Code)
+ cookie := rr.Header().Get("Set-Cookie")
+ r.Header.Set("Cookie", cookie)
+ parsedToken, err := jwt.VerifyRequest(server.csrfTokenAuth, r, jwt.TokenFromCookie)
+ assert.NoError(t, err)
+ ctx := r.Context()
+ ctx = jwt.NewContext(ctx, parsedToken, err)
+ r = r.WithContext(ctx)
+
form := make(url.Values)
- csrfToken := createCSRFToken("")
- form.Set("_form_token", csrfToken)
+ csrfToken := createCSRFToken(rr, r, server.csrfTokenAuth, "", webBaseAdminPath)
+ form.Set(csrfFormToken, csrfToken)
form.Set("install_code", installationCode+"5")
form.Set("username", defaultAdminUsername)
form.Set("password", "password")
@@ -3069,6 +3626,8 @@ func TestWebAdminSetupWithInstallCode(t *testing.T) {
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
+ r = r.WithContext(ctx)
+ r.Header.Set("Cookie", cookie)
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusOK, rr.Code)
@@ -3080,6 +3639,8 @@ func TestWebAdminSetupWithInstallCode(t *testing.T) {
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
+ r = r.WithContext(ctx)
+ r.Header.Set("Cookie", cookie)
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
@@ -3101,12 +3662,6 @@ func TestWebAdminSetupWithInstallCode(t *testing.T) {
return "5678"
})
- rr = httptest.NewRecorder()
- r, err = http.NewRequest(http.MethodGet, webAdminSetupPath, nil)
- assert.NoError(t, err)
- server.router.ServeHTTP(rr, r)
- assert.Equal(t, http.StatusOK, rr.Code)
-
for _, webURL := range []string{"/", webBasePath, webBaseAdminPath, webAdminLoginPath, webClientLoginPath} {
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webURL, nil)
@@ -3116,9 +3671,22 @@ func TestWebAdminSetupWithInstallCode(t *testing.T) {
assert.Equal(t, webAdminSetupPath, rr.Header().Get("Location"))
}
+ rr = httptest.NewRecorder()
+ r, err = http.NewRequest(http.MethodGet, webAdminSetupPath, nil)
+ assert.NoError(t, err)
+ server.router.ServeHTTP(rr, r)
+ assert.Equal(t, http.StatusOK, rr.Code)
+ cookie = rr.Header().Get("Set-Cookie")
+ r.Header.Set("Cookie", cookie)
+ parsedToken, err = jwt.VerifyRequest(server.csrfTokenAuth, r, jwt.TokenFromCookie)
+ assert.NoError(t, err)
+ ctx = r.Context()
+ ctx = jwt.NewContext(ctx, parsedToken, err)
+ r = r.WithContext(ctx)
+
form = make(url.Values)
- csrfToken = createCSRFToken("")
- form.Set("_form_token", csrfToken)
+ csrfToken = createCSRFToken(rr, r, server.csrfTokenAuth, "", webBaseAdminPath)
+ form.Set(csrfFormToken, csrfToken)
form.Set("install_code", installationCode)
form.Set("username", defaultAdminUsername)
form.Set("password", "password")
@@ -3126,6 +3694,8 @@ func TestWebAdminSetupWithInstallCode(t *testing.T) {
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
+ r = r.WithContext(ctx)
+ r.Header.Set("Cookie", cookie)
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusOK, rr.Code)
@@ -3137,6 +3707,8 @@ func TestWebAdminSetupWithInstallCode(t *testing.T) {
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
+ r = r.WithContext(ctx)
+ r.Header.Set("Cookie", cookie)
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
@@ -3193,37 +3765,6 @@ func TestDbResetCodeManager(t *testing.T) {
}
}
-func TestDecodeToken(t *testing.T) {
- nodeID := "nodeID"
- token := map[string]any{
- claimUsernameKey: defaultAdminUsername,
- claimPermissionsKey: []string{dataprovider.PermAdminAny},
- jwt.SubjectKey: "",
- claimNodeID: nodeID,
- claimMustChangePasswordKey: false,
- claimMustSetSecondFactorKey: true,
- }
- c := jwtTokenClaims{}
- c.Decode(token)
- assert.Equal(t, defaultAdminUsername, c.Username)
- assert.Equal(t, nodeID, c.NodeID)
- assert.False(t, c.MustChangePassword)
- assert.True(t, c.MustSetTwoFactorAuth)
-
- token[claimMustChangePasswordKey] = 10
- c = jwtTokenClaims{}
- c.Decode(token)
- assert.False(t, c.MustChangePassword)
-
- token[claimMustChangePasswordKey] = true
- c = jwtTokenClaims{}
- c.Decode(token)
- assert.True(t, c.MustChangePassword)
-
- claims := c.asMap()
- assert.Equal(t, token, claims)
-}
-
func TestEventRoleFilter(t *testing.T) {
defaultVal := "default"
req, err := http.NewRequest(http.MethodGet, fsEventsPath+"?role=role1", nil)
@@ -3354,7 +3895,8 @@ func TestHTTPSRedirect(t *testing.T) {
},
},
}
- server.initializeRouter()
+ err = server.initializeRouter()
+ require.NoError(t, err)
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, path.Join(acmeChallengeURI, tokenName), nil)
@@ -3393,6 +3935,118 @@ func TestHTTPSRedirect(t *testing.T) {
assert.NoError(t, err)
}
+func TestDisabledAdminLoginMethods(t *testing.T) {
+ server := httpdServer{
+ binding: Binding{
+ Address: "",
+ Port: 8080,
+ EnableWebAdmin: true,
+ EnableWebClient: true,
+ EnableRESTAPI: true,
+ DisabledLoginMethods: 20,
+ },
+ enableWebAdmin: true,
+ enableWebClient: true,
+ enableRESTAPI: true,
+ }
+ err := server.initializeRouter()
+ require.NoError(t, err)
+ testServer := httptest.NewServer(server.router)
+ defer testServer.Close()
+
+ rr := httptest.NewRecorder()
+ req, err := http.NewRequest(http.MethodGet, tokenPath, nil)
+ require.NoError(t, err)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusNotFound, rr.Code)
+
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodPost, path.Join(adminPath, defaultAdminUsername, "forgot-password"), nil)
+ require.NoError(t, err)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusNotFound, rr.Code)
+
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodPost, path.Join(adminPath, defaultAdminUsername, "reset-password"), nil)
+ require.NoError(t, err)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusNotFound, rr.Code)
+
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodPost, webAdminLoginPath, nil)
+ require.NoError(t, err)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusMethodNotAllowed, rr.Code)
+
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodPost, webAdminResetPwdPath, nil)
+ require.NoError(t, err)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusNotFound, rr.Code)
+
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodPost, webAdminForgotPwdPath, nil)
+ require.NoError(t, err)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusNotFound, rr.Code)
+}
+
+func TestDisabledUserLoginMethods(t *testing.T) {
+ server := httpdServer{
+ binding: Binding{
+ Address: "",
+ Port: 8080,
+ EnableWebAdmin: true,
+ EnableWebClient: true,
+ EnableRESTAPI: true,
+ DisabledLoginMethods: 40,
+ },
+ enableWebAdmin: true,
+ enableWebClient: true,
+ enableRESTAPI: true,
+ }
+ err := server.initializeRouter()
+ require.NoError(t, err)
+ testServer := httptest.NewServer(server.router)
+ defer testServer.Close()
+
+ rr := httptest.NewRecorder()
+ req, err := http.NewRequest(http.MethodGet, userTokenPath, nil)
+ require.NoError(t, err)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusNotFound, rr.Code)
+
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodPost, userPath+"/user/forgot-password", nil)
+ require.NoError(t, err)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusNotFound, rr.Code)
+
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodPost, userPath+"/user/reset-password", nil)
+ require.NoError(t, err)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusNotFound, rr.Code)
+
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodPost, webClientLoginPath, nil)
+ require.NoError(t, err)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusMethodNotAllowed, rr.Code)
+
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, nil)
+ require.NoError(t, err)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusNotFound, rr.Code)
+
+ rr = httptest.NewRecorder()
+ req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, nil)
+ require.NoError(t, err)
+ testServer.Config.Handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusNotFound, rr.Code)
+}
+
func TestGetLogEventString(t *testing.T) {
assert.Equal(t, "Login failed", getLogEventString(notifier.LogEventTypeLoginFailed))
assert.Equal(t, "Login with non-existent user", getLogEventString(notifier.LogEventTypeLoginNoUser))
@@ -3560,6 +4214,153 @@ func TestI18NErrors(t *testing.T) {
assert.Equal(t, `{"a":"b"}`, errI18n.Args())
}
+func TestConvertEnabledLoginMethods(t *testing.T) {
+ b := Binding{
+ EnabledLoginMethods: 0,
+ DisabledLoginMethods: 1,
+ }
+ b.convertLoginMethods()
+ assert.Equal(t, 1, b.DisabledLoginMethods)
+ b.DisabledLoginMethods = 0
+ b.EnabledLoginMethods = 1
+ b.convertLoginMethods()
+ assert.Equal(t, 14, b.DisabledLoginMethods)
+ b.DisabledLoginMethods = 0
+ b.EnabledLoginMethods = 2
+ b.convertLoginMethods()
+ assert.Equal(t, 13, b.DisabledLoginMethods)
+ b.DisabledLoginMethods = 0
+ b.EnabledLoginMethods = 3
+ b.convertLoginMethods()
+ assert.Equal(t, 12, b.DisabledLoginMethods)
+ b.DisabledLoginMethods = 0
+ b.EnabledLoginMethods = 4
+ b.convertLoginMethods()
+ assert.Equal(t, 11, b.DisabledLoginMethods)
+ b.DisabledLoginMethods = 0
+ b.EnabledLoginMethods = 7
+ b.convertLoginMethods()
+ assert.Equal(t, 8, b.DisabledLoginMethods)
+ b.DisabledLoginMethods = 0
+ b.EnabledLoginMethods = 15
+ b.convertLoginMethods()
+ assert.Equal(t, 0, b.DisabledLoginMethods)
+}
+
+func TestValidateBaseURL(t *testing.T) {
+ tests := []struct {
+ name string
+ inputURL string
+ expectedURL string
+ expectErr bool
+ }{
+ {
+ name: "Valid HTTPS URL",
+ inputURL: "https://sftp.example.com",
+ expectedURL: "https://sftp.example.com",
+ expectErr: false,
+ },
+ {
+ name: "Remove trailing slash",
+ inputURL: "https://sftp.example.com/",
+ expectedURL: "https://sftp.example.com",
+ expectErr: false,
+ },
+ {
+ name: "Remove multiple trailing slashes",
+ inputURL: "http://192.168.1.100:8080///",
+ expectedURL: "http://192.168.1.100:8080",
+ expectErr: false,
+ },
+ {
+ name: "Empty BaseURL (optional case)",
+ inputURL: "",
+ expectedURL: "",
+ expectErr: false,
+ },
+ {
+ name: "Unsupported scheme (FTP)",
+ inputURL: "ftp://files.example.com",
+ expectErr: true,
+ },
+ {
+ name: "Malformed URL string",
+ inputURL: "not-a-url",
+ expectErr: true,
+ },
+ {
+ name: "Missing Host",
+ inputURL: "https://",
+ expectErr: true,
+ },
+ {
+ name: "Preserve path without trailing slash",
+ inputURL: "https://example.com/sftp/",
+ expectedURL: "https://example.com/sftp",
+ expectErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ b := &Binding{
+ BaseURL: tt.inputURL,
+ }
+
+ err := b.validateBaseURL()
+
+ if (err != nil) != tt.expectErr {
+ t.Errorf("validateBaseURL() error = %v, expectErr %v", err, tt.expectErr)
+ return
+ }
+
+ if !tt.expectErr && b.BaseURL != tt.expectedURL {
+ t.Errorf("validateBaseURL() got = %v, want %v", b.BaseURL, tt.expectedURL)
+ }
+ })
+ }
+}
+
+func getCSRFTokenFromBody(body io.Reader) (string, error) {
+ doc, err := html.Parse(body)
+ if err != nil {
+ return "", err
+ }
+
+ var csrfToken string
+ var f func(*html.Node)
+
+ f = func(n *html.Node) {
+ if n.Type == html.ElementNode && n.Data == "input" {
+ var name, value string
+ for _, attr := range n.Attr {
+ if attr.Key == "value" {
+ value = attr.Val
+ }
+ if attr.Key == "name" {
+ name = attr.Val
+ }
+ }
+ if name == csrfFormToken {
+ csrfToken = value
+ return
+ }
+ }
+
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ f(c)
+ }
+ }
+
+ f(doc)
+
+ if csrfToken == "" {
+ return "", errors.New("CSRF token not found")
+ }
+
+ return csrfToken, nil
+}
+
func isSharedProviderSupported() bool {
// SQLite shares the implementation with other SQL-based provider but it makes no sense
// to use it outside test cases
diff --git a/internal/httpd/middleware.go b/internal/httpd/middleware.go
index 8c98f02b..199fa89f 100644
--- a/internal/httpd/middleware.go
+++ b/internal/httpd/middleware.go
@@ -20,15 +20,16 @@ import (
"io/fs"
"net/http"
"net/url"
+ "slices"
"strings"
+ "time"
- "github.com/go-chi/jwtauth/v5"
- "github.com/lestrrat-go/jwx/v2/jwt"
"github.com/rs/xid"
"github.com/sftpgo/sdk"
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/util"
)
@@ -47,7 +48,7 @@ func (k *contextKey) String() string {
}
func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudience) error {
- token, _, err := jwtauth.FromContext(r.Context())
+ token, err := jwt.FromContext(r.Context())
var redirectPath string
if audience == tokenAudienceWebAdmin {
@@ -69,52 +70,48 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi
}
}
- if err != nil || token == nil {
+ if err != nil {
logger.Debug(logSender, "", "error getting jwt token: %v", err)
doRedirect(http.StatusText(http.StatusUnauthorized), err)
return errInvalidToken
}
- err = jwt.Validate(token)
- if err != nil {
- logger.Debug(logSender, "", "error validating jwt token: %v", err)
- doRedirect(http.StatusText(http.StatusUnauthorized), err)
- return errInvalidToken
- }
if isTokenInvalidated(r) {
logger.Debug(logSender, "", "the token has been invalidated")
doRedirect("Your token is no longer valid", nil)
return errInvalidToken
}
// a user with a partial token will be always redirected to the appropriate two factor auth page
- if err := checkPartialAuth(w, r, audience, token.Audience()); err != nil {
+ if err := checkPartialAuth(w, r, audience, token.Audience); err != nil {
return err
}
- if !util.Contains(token.Audience(), audience) {
+ if !token.Audience.Contains(audience) {
logger.Debug(logSender, "", "the token is not valid for audience %q", audience)
doRedirect("Your token audience is not valid", nil)
return errInvalidToken
}
- if tokenValidationMode != tokenValidationNoIPMatch {
- ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if !util.Contains(token.Audience(), ipAddr) {
- logger.Debug(logSender, "", "the token with id %q is not valid for the ip address %q", token.JwtID(), ipAddr)
- doRedirect("Your token is not valid", nil)
- return errInvalidToken
- }
+ ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
+ if err := validateIPForToken(token, ipAddr); err != nil {
+ logger.Debug(logSender, "", "the token with id %q is not valid for the ip address %q", token.ID, ipAddr)
+ doRedirect("Your token is not valid", nil)
+ return err
+ }
+ if err := checkTokenSignature(r, token); err != nil {
+ doRedirect("Your token is no longer valid", nil)
+ return err
}
return nil
}
func (s *httpdServer) validateJWTPartialToken(w http.ResponseWriter, r *http.Request, audience tokenAudience) error {
- token, _, err := jwtauth.FromContext(r.Context())
+ token, err := jwt.FromContext(r.Context())
var notFoundFunc func(w http.ResponseWriter, r *http.Request, err error)
if audience == tokenAudienceWebAdminPartial {
notFoundFunc = s.renderNotFoundPage
} else {
notFoundFunc = s.renderClientNotFoundPage
}
- if err != nil || token == nil || jwt.Validate(token) != nil {
+ if err != nil {
notFoundFunc(w, r, nil)
return errInvalidToken
}
@@ -122,11 +119,17 @@ func (s *httpdServer) validateJWTPartialToken(w http.ResponseWriter, r *http.Req
notFoundFunc(w, r, nil)
return errInvalidToken
}
- if !util.Contains(token.Audience(), audience) {
- logger.Debug(logSender, "", "the token is not valid for audience %q", audience)
+ if !token.Audience.Contains(audience) {
+ logger.Debug(logSender, "", "the partial token with id %q is not valid for audience %q", token.ID, audience)
notFoundFunc(w, r, nil)
return errInvalidToken
}
+ ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
+ if err := validateIPForToken(token, ipAddr); err != nil {
+ logger.Debug(logSender, "", "the partial token with id %q is not valid for the ip address %q", token.ID, ipAddr)
+ notFoundFunc(w, r, nil)
+ return err
+ }
return nil
}
@@ -191,7 +194,7 @@ func jwtAuthenticatorWebClient(next http.Handler) http.Handler {
func (s *httpdServer) checkHTTPUserPerm(perm string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, claims, err := jwtauth.FromContext(r.Context())
+ claims, err := jwt.FromContext(r.Context())
if err != nil {
if isWebRequest(r) {
s.renderClientBadRequestPage(w, r, err)
@@ -200,10 +203,8 @@ func (s *httpdServer) checkHTTPUserPerm(perm string) func(next http.Handler) htt
}
return
}
- tokenClaims := jwtTokenClaims{}
- tokenClaims.Decode(claims)
// for web client perms are negated and not granted
- if tokenClaims.hasPerm(perm) {
+ if claims.HasPerm(perm) {
if isWebRequest(r) {
s.renderClientForbiddenPage(w, r, errors.New("you don't have permission for this action"))
} else {
@@ -220,7 +221,7 @@ func (s *httpdServer) checkHTTPUserPerm(perm string) func(next http.Handler) htt
// checkAuthRequirements checks if the user must set a second factor auth or change the password
func (s *httpdServer) checkAuthRequirements(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, claims, err := jwtauth.FromContext(r.Context())
+ claims, err := jwt.FromContext(r.Context())
if err != nil {
if isWebRequest(r) {
if isWebClientRequest(r) {
@@ -233,13 +234,11 @@ func (s *httpdServer) checkAuthRequirements(next http.Handler) http.Handler {
}
return
}
- tokenClaims := jwtTokenClaims{}
- tokenClaims.Decode(claims)
- if tokenClaims.MustSetTwoFactorAuth || tokenClaims.MustChangePassword {
+ if claims.MustSetTwoFactorAuth || claims.MustChangePassword {
var err error
- if tokenClaims.MustSetTwoFactorAuth {
- if len(tokenClaims.RequiredTwoFactorProtocols) > 0 {
- protocols := strings.Join(tokenClaims.RequiredTwoFactorProtocols, ", ")
+ if claims.MustSetTwoFactorAuth {
+ if len(claims.RequiredTwoFactorProtocols) > 0 {
+ protocols := strings.Join(claims.RequiredTwoFactorProtocols, ", ")
err = util.NewI18nError(
util.NewGenericError(
fmt.Sprintf("Two-factor authentication requirements not met, please configure two-factor authentication for the following protocols: %v",
@@ -295,10 +294,10 @@ func (s *httpdServer) requireBuiltinLogin(next http.Handler) http.Handler {
})
}
-func (s *httpdServer) checkPerm(perm string) func(next http.Handler) http.Handler {
+func (s *httpdServer) checkPerms(perms ...string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, claims, err := jwtauth.FromContext(r.Context())
+ claims, err := jwt.FromContext(r.Context())
if err != nil {
if isWebRequest(r) {
s.renderBadRequestPage(w, r, err)
@@ -307,16 +306,16 @@ func (s *httpdServer) checkPerm(perm string) func(next http.Handler) http.Handle
}
return
}
- tokenClaims := jwtTokenClaims{}
- tokenClaims.Decode(claims)
- if !tokenClaims.hasPerm(perm) {
- if isWebRequest(r) {
- s.renderForbiddenPage(w, r, util.NewI18nError(fs.ErrPermission, util.I18nError403Message))
- } else {
- sendAPIResponse(w, r, nil, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+ for _, perm := range perms {
+ if !claims.HasPerm(perm) {
+ if isWebRequest(r) {
+ s.renderForbiddenPage(w, r, util.NewI18nError(fs.ErrPermission, util.I18nError403Message))
+ } else {
+ sendAPIResponse(w, r, nil, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+ }
+ return
}
- return
}
next.ServeHTTP(w, r)
@@ -324,70 +323,82 @@ func (s *httpdServer) checkPerm(perm string) func(next http.Handler) http.Handle
}
}
-func verifyCSRFHeader(next http.Handler) http.Handler {
+func (s *httpdServer) verifyCSRFHeader(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenString := r.Header.Get(csrfHeaderToken)
- token, err := jwtauth.VerifyToken(csrfTokenAuth, tokenString)
+ token, err := jwt.VerifyToken(s.csrfTokenAuth, tokenString)
if err != nil || token == nil {
logger.Debug(logSender, "", "error validating CSRF header: %v", err)
sendAPIResponse(w, r, err, "Invalid token", http.StatusForbidden)
return
}
- if !util.Contains(token.Audience(), tokenAudienceCSRF) {
+ if !token.Audience.Contains(tokenAudienceCSRF) {
logger.Debug(logSender, "", "error validating CSRF header token audience")
sendAPIResponse(w, r, errors.New("the token is not valid"), "", http.StatusForbidden)
return
}
- if tokenValidationMode != tokenValidationNoIPMatch {
- if !util.Contains(token.Audience(), util.GetIPFromRemoteAddress(r.RemoteAddr)) {
- logger.Debug(logSender, "", "error validating CSRF header IP audience")
- sendAPIResponse(w, r, errors.New("the token is not valid"), "", http.StatusForbidden)
- return
- }
+ if err := validateIPForToken(token, util.GetIPFromRemoteAddress(r.RemoteAddr)); err != nil {
+ logger.Debug(logSender, "", "error validating CSRF header IP audience")
+ sendAPIResponse(w, r, errors.New("the token is not valid"), "", http.StatusForbidden)
+ return
+ }
+ if err := checkCSRFTokenRef(r, token); err != nil {
+ sendAPIResponse(w, r, errors.New("the token is not valid"), "", http.StatusForbidden)
+ return
}
next.ServeHTTP(w, r)
})
}
-func checkNodeToken(tokenAuth *jwtauth.JWTAuth) func(next http.Handler) http.Handler {
+func checkNodeToken(tokenAuth *jwt.Signer) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- token := r.Header.Get(dataprovider.NodeTokenHeader)
- if token == "" {
+ bearer := r.Header.Get(dataprovider.NodeTokenHeader)
+ if bearer == "" {
next.ServeHTTP(w, r)
return
}
- if len(token) > 7 && strings.ToUpper(token[0:6]) == "BEARER" {
- token = token[7:]
+ const prefix = "Bearer "
+ if len(bearer) >= len(prefix) && strings.EqualFold(bearer[:len(prefix)], prefix) {
+ bearer = bearer[len(prefix):]
}
- admin, role, err := dataprovider.AuthenticateNodeToken(token)
+ if invalidatedJWTTokens.Get(bearer) {
+ logger.Debug(logSender, "", "the node token has been invalidated")
+ sendAPIResponse(w, r, fmt.Errorf("the provided token is not valid"), "", http.StatusUnauthorized)
+ return
+ }
+ claims, err := dataprovider.AuthenticateNodeToken(bearer)
if err != nil {
- logger.Debug(logSender, "", "unable to authenticate node token %q: %v", token, err)
+ logger.Debug(logSender, "", "unable to authenticate node token %q: %v", bearer, err)
sendAPIResponse(w, r, fmt.Errorf("the provided token cannot be authenticated"), "", http.StatusUnauthorized)
return
}
- c := jwtTokenClaims{
- Username: admin,
- Permissions: []string{dataprovider.PermAdminViewConnections, dataprovider.PermAdminCloseConnections},
+ defer invalidatedJWTTokens.Add(bearer, time.Now().Add(2*time.Minute).UTC())
+
+ c := &jwt.Claims{
+ Username: claims.Username,
+ Permissions: claims.Permissions,
NodeID: dataprovider.GetNodeName(),
- Role: role,
+ Role: claims.Role,
}
- resp, err := c.createTokenResponse(tokenAuth, tokenAudienceAPI, util.GetIPFromRemoteAddress(r.RemoteAddr))
+
+ token, err := tokenAuth.SignWithParams(c, tokenAudienceAPI, util.GetIPFromRemoteAddress(r.RemoteAddr), getTokenDuration(tokenAudienceAPI))
if err != nil {
sendAPIResponse(w, r, err, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
- r.Header.Set("Authorization", fmt.Sprintf("Bearer %v", resp["access_token"]))
+ resp := c.BuildTokenResponse(token)
+ r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", resp.Token))
next.ServeHTTP(w, r)
})
}
}
-func checkAPIKeyAuth(tokenAuth *jwtauth.JWTAuth, scope dataprovider.APIKeyScope) func(next http.Handler) http.Handler {
+func checkAPIKeyAuth(tokenAuth *jwt.Signer, scope dataprovider.APIKeyScope) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiKey := r.Header.Get("X-SFTPGO-API-KEY")
@@ -411,7 +422,7 @@ func checkAPIKeyAuth(tokenAuth *jwtauth.JWTAuth, scope dataprovider.APIKeyScope)
k, err := dataprovider.APIKeyExists(keyID)
if err != nil {
handleDefenderEventLoginFailed(util.GetIPFromRemoteAddress(r.RemoteAddr), util.NewRecordNotFoundError("invalid api key")) //nolint:errcheck
- logger.Debug(logSender, "invalid api key %q: %v", apiKey, err)
+ logger.Debug(logSender, "", "invalid api key %q: %v", apiKey, err)
sendAPIResponse(w, r, errors.New("the provided api key is not valid"), "", http.StatusBadRequest)
return
}
@@ -440,6 +451,7 @@ func checkAPIKeyAuth(tokenAuth *jwtauth.JWTAuth, scope dataprovider.APIKeyScope)
"", http.StatusUnauthorized)
return
}
+ common.DelayLogin(nil)
} else {
if k.User != "" {
apiUser = k.User
@@ -448,7 +460,7 @@ func checkAPIKeyAuth(tokenAuth *jwtauth.JWTAuth, scope dataprovider.APIKeyScope)
logger.Debug(logSender, "", "unable to authenticate user %q associated with api key %q: %v",
apiUser, apiKey, err)
updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: apiUser}},
- dataprovider.LoginMethodPassword, util.GetIPFromRemoteAddress(r.RemoteAddr), err)
+ dataprovider.LoginMethodPassword, util.GetIPFromRemoteAddress(r.RemoteAddr), err, r)
code := http.StatusUnauthorized
if errors.Is(err, common.ErrInternalFailure) {
code = http.StatusInternalServerError
@@ -458,7 +470,7 @@ func checkAPIKeyAuth(tokenAuth *jwtauth.JWTAuth, scope dataprovider.APIKeyScope)
return
}
updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: apiUser}},
- dataprovider.LoginMethodPassword, util.GetIPFromRemoteAddress(r.RemoteAddr), nil)
+ dataprovider.LoginMethodPassword, util.GetIPFromRemoteAddress(r.RemoteAddr), nil, r)
}
dataprovider.UpdateAPIKeyLastUse(&k) //nolint:errcheck
@@ -469,7 +481,7 @@ func checkAPIKeyAuth(tokenAuth *jwtauth.JWTAuth, scope dataprovider.APIKeyScope)
func forbidAPIKeyAuthentication(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
@@ -483,7 +495,7 @@ func forbidAPIKeyAuthentication(next http.Handler) http.Handler {
})
}
-func authenticateAdminWithAPIKey(username, keyID string, tokenAuth *jwtauth.JWTAuth, r *http.Request) error {
+func authenticateAdminWithAPIKey(username, keyID string, tokenAuth *jwt.Signer, r *http.Request) error {
if username == "" {
return errors.New("the provided key is not associated with any admin and no username was provided")
}
@@ -498,30 +510,32 @@ func authenticateAdminWithAPIKey(username, keyID string, tokenAuth *jwtauth.JWTA
if err := admin.CanLogin(ipAddr); err != nil {
return err
}
- c := jwtTokenClaims{
+ c := &jwt.Claims{
Username: admin.Username,
Permissions: admin.Permissions,
- Signature: admin.GetSignature(),
Role: admin.Role,
APIKeyID: keyID,
}
+ c.Subject = admin.GetSignature()
- resp, err := c.createTokenResponse(tokenAuth, tokenAudienceAPI, ipAddr)
+ token, err := tokenAuth.SignWithParams(c, tokenAudienceAPI, ipAddr, getTokenDuration(tokenAudienceAPI))
if err != nil {
return err
}
- r.Header.Set("Authorization", fmt.Sprintf("Bearer %v", resp["access_token"]))
+ resp := c.BuildTokenResponse(token)
+ r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", resp.Token))
dataprovider.UpdateAdminLastLogin(&admin)
+ common.DelayLogin(nil)
return nil
}
-func authenticateUserWithAPIKey(username, keyID string, tokenAuth *jwtauth.JWTAuth, r *http.Request) error {
+func authenticateUserWithAPIKey(username, keyID string, tokenAuth *jwt.Signer, r *http.Request) error {
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
protocol := common.ProtocolHTTP
if username == "" {
err := errors.New("the provided key is not associated with any user and no username was provided")
updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
- dataprovider.LoginMethodPassword, ipAddr, err)
+ dataprovider.LoginMethodPassword, ipAddr, err, r)
return err
}
if err := common.Config.ExecutePostConnectHook(ipAddr, protocol); err != nil {
@@ -530,57 +544,72 @@ func authenticateUserWithAPIKey(username, keyID string, tokenAuth *jwtauth.JWTAu
user, err := dataprovider.GetUserWithGroupSettings(username, "")
if err != nil {
updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
- dataprovider.LoginMethodPassword, ipAddr, err)
+ dataprovider.LoginMethodPassword, ipAddr, err, r)
return err
}
if !user.Filters.AllowAPIKeyAuth {
err := fmt.Errorf("API key authentication disabled for user %q", user.Username)
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err)
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err, r)
return err
}
if err := user.CheckLoginConditions(); err != nil {
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err)
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err, r)
return err
}
connectionID := fmt.Sprintf("%v_%v", protocol, xid.New().String())
- if err := checkHTTPClientUser(&user, r, connectionID, true); err != nil {
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err)
+ if err := checkHTTPClientUser(&user, r, connectionID, true, false); err != nil {
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err, r)
return err
}
defer user.CloseFs() //nolint:errcheck
err = user.CheckFsRoot(connectionID)
if err != nil {
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure)
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure, r)
return common.ErrInternalFailure
}
- c := jwtTokenClaims{
+ c := &jwt.Claims{
Username: user.Username,
Permissions: user.Filters.WebClient,
- Signature: user.GetSignature(),
Role: user.Role,
APIKeyID: keyID,
}
+ c.Subject = user.GetSignature()
- resp, err := c.createTokenResponse(tokenAuth, tokenAudienceAPIUser, ipAddr)
+ token, err := tokenAuth.SignWithParams(c, tokenAudienceAPIUser, ipAddr, getTokenDuration(tokenAudienceAPIUser))
if err != nil {
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure)
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure, r)
return err
}
- r.Header.Set("Authorization", fmt.Sprintf("Bearer %v", resp["access_token"]))
+ resp := c.BuildTokenResponse(token)
+ r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", resp.Token))
dataprovider.UpdateLastLogin(&user)
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, nil)
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, nil, r)
return nil
}
func checkPartialAuth(w http.ResponseWriter, r *http.Request, audience string, tokenAudience []string) error {
- if audience == tokenAudienceWebAdmin && util.Contains(tokenAudience, tokenAudienceWebAdminPartial) {
+ if audience == tokenAudienceWebAdmin && slices.Contains(tokenAudience, tokenAudienceWebAdminPartial) {
http.Redirect(w, r, webAdminTwoFactorPath, http.StatusFound)
return errInvalidToken
}
- if audience == tokenAudienceWebClient && util.Contains(tokenAudience, tokenAudienceWebClientPartial) {
+ if audience == tokenAudienceWebClient && slices.Contains(tokenAudience, tokenAudienceWebClientPartial) {
http.Redirect(w, r, webClientTwoFactorPath, http.StatusFound)
return errInvalidToken
}
return nil
}
+
+func cacheControlMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate, private")
+ next.ServeHTTP(w, r)
+ })
+}
+
+func cleanCacheControlMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Del("Cache-Control")
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/httpd/oauth2.go b/internal/httpd/oauth2.go
index fa0c3c62..bb3e7806 100644
--- a/internal/httpd/oauth2.go
+++ b/internal/httpd/oauth2.go
@@ -20,7 +20,7 @@ import (
"sync"
"time"
- "github.com/rs/xid"
+ "golang.org/x/oauth2"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
"github.com/drakkan/sftpgo/v2/internal/kms"
@@ -50,16 +50,18 @@ type oauth2PendingAuth struct {
ClientSecret *kms.Secret `json:"client_secret"`
RedirectURL string `json:"redirect_url"`
IssuedAt int64 `json:"issued_at"`
+ Verifier string `json:"verifier"`
}
func newOAuth2PendingAuth(provider int, redirectURL, clientID string, clientSecret *kms.Secret) oauth2PendingAuth {
return oauth2PendingAuth{
- State: xid.New().String(),
+ State: util.GenerateOpaqueString(),
Provider: provider,
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
IssuedAt: util.GetTimeAsMsSinceEpoch(time.Now()),
+ Verifier: oauth2.GenerateVerifier(),
}
}
@@ -134,11 +136,11 @@ func (o *dbOAuth2Manager) addPendingAuth(pendingAuth oauth2PendingAuth) {
}
func (o *dbOAuth2Manager) removePendingAuth(state string) {
- dataprovider.DeleteSharedSession(state) //nolint:errcheck
+ dataprovider.DeleteSharedSession(state, dataprovider.SessionTypeOAuth2Auth) //nolint:errcheck
}
func (o *dbOAuth2Manager) getPendingAuth(state string) (oauth2PendingAuth, error) {
- session, err := dataprovider.GetSharedSession(state)
+ session, err := dataprovider.GetSharedSession(state, dataprovider.SessionTypeOAuth2Auth)
if err != nil {
return oauth2PendingAuth{}, errors.New("oauth2: unable to get the auth request for the specified state")
}
diff --git a/internal/httpd/oauth2_test.go b/internal/httpd/oauth2_test.go
index a676905d..0e488136 100644
--- a/internal/httpd/oauth2_test.go
+++ b/internal/httpd/oauth2_test.go
@@ -86,7 +86,7 @@ func TestDbOAuth2Manager(t *testing.T) {
a, err := m.getPendingAuth(auth.State)
assert.NoError(t, err)
assert.Equal(t, sdkkms.SecretStatusPlain, a.ClientSecret.GetStatus())
- session, err := dataprovider.GetSharedSession(auth.State)
+ session, err := dataprovider.GetSharedSession(auth.State, dataprovider.SessionTypeOAuth2Auth)
assert.NoError(t, err)
authReq := oauth2PendingAuth{}
err = json.Unmarshal(session.Data.([]byte), &authReq)
@@ -107,10 +107,10 @@ func TestDbOAuth2Manager(t *testing.T) {
m.addPendingAuth(auth)
_, err = m.getPendingAuth(auth.State)
assert.Error(t, err)
- _, err = dataprovider.GetSharedSession(auth.State)
+ _, err = dataprovider.GetSharedSession(auth.State, dataprovider.SessionTypeOAuth2Auth)
assert.NoError(t, err)
m.cleanup()
- _, err = dataprovider.GetSharedSession(auth.State)
+ _, err = dataprovider.GetSharedSession(auth.State, dataprovider.SessionTypeOAuth2Auth)
assert.Error(t, err)
_, err = m.decodePendingAuthData("not a byte array")
require.Error(t, err)
@@ -126,7 +126,7 @@ func TestDbOAuth2Manager(t *testing.T) {
}
auth.ClientSecret.SetStatus(sdkkms.SecretStatusSecretBox)
m.addPendingAuth(auth)
- _, err = dataprovider.GetSharedSession(auth.State)
+ _, err = dataprovider.GetSharedSession(auth.State, dataprovider.SessionTypeOAuth2Auth)
assert.Error(t, err)
asJSON, err := json.Marshal(auth)
assert.NoError(t, err)
diff --git a/internal/httpd/oidc.go b/internal/httpd/oidc.go
index b686caab..7c43fac5 100644
--- a/internal/httpd/oidc.go
+++ b/internal/httpd/oidc.go
@@ -20,6 +20,7 @@ import (
"fmt"
"net/http"
"net/url"
+ "slices"
"strings"
"time"
@@ -30,6 +31,7 @@ import (
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
"github.com/drakkan/sftpgo/v2/internal/httpclient"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/util"
)
@@ -37,7 +39,7 @@ import (
const (
oidcCookieKey = "oidc"
adminRoleFieldValue = "admin"
- authStateValidity = 1 * 60 * 1000 // 1 minute
+ authStateValidity = 2 * 60 * 1000 // 2 minutes
tokenUpdateInterval = 3 * 60 * 1000 // 3 minutes
tokenDeleteInterval = 2 * 3600 * 1000 // 2 hours
)
@@ -142,7 +144,7 @@ func (o *OIDC) initialize() error {
if o.RedirectBaseURL == "" {
return errors.New("oidc: redirect base URL cannot be empty")
}
- if !util.Contains(o.Scopes, oidc.ScopeOpenID) {
+ if !slices.Contains(o.Scopes, oidc.ScopeOpenID) {
return fmt.Errorf("oidc: required scope %q is not set", oidc.ScopeOpenID)
}
if o.ClientSecretFile != "" {
@@ -198,33 +200,38 @@ type oidcPendingAuth struct {
Nonce string `json:"nonce"`
Audience tokenAudience `json:"audience"`
IssuedAt int64 `json:"issued_at"`
+ Verifier string `json:"verifier"`
}
func newOIDCPendingAuth(audience tokenAudience) oidcPendingAuth {
return oidcPendingAuth{
- State: xid.New().String(),
- Nonce: xid.New().String(),
+ State: util.GenerateOpaqueString(),
+ Nonce: util.GenerateOpaqueString(),
Audience: audience,
IssuedAt: util.GetTimeAsMsSinceEpoch(time.Now()),
+ Verifier: oauth2.GenerateVerifier(),
}
}
type oidcToken struct {
- AccessToken string `json:"access_token"`
- TokenType string `json:"token_type,omitempty"`
- RefreshToken string `json:"refresh_token,omitempty"`
- ExpiresAt int64 `json:"expires_at,omitempty"`
- SessionID string `json:"session_id"`
- IDToken string `json:"id_token"`
- Nonce string `json:"nonce"`
- Username string `json:"username"`
- Permissions []string `json:"permissions"`
- HideUserPageSections int `json:"hide_user_page_sections,omitempty"`
- TokenRole string `json:"token_role,omitempty"` // SFTPGo role name
- Role any `json:"role"` // oidc user role: SFTPGo user or admin
- CustomFields *map[string]any `json:"custom_fields,omitempty"`
- Cookie string `json:"cookie"`
- UsedAt int64 `json:"used_at"`
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type,omitempty"`
+ RefreshToken string `json:"refresh_token,omitempty"`
+ ExpiresAt int64 `json:"expires_at,omitempty"`
+ SessionID string `json:"session_id"`
+ IDToken string `json:"id_token"`
+ Nonce string `json:"nonce"`
+ Username string `json:"username"`
+ Permissions []string `json:"permissions"`
+ HideUserPageSections int `json:"hide_user_page_sections,omitempty"`
+ MustSetTwoFactorAuth bool `json:"must_set_2fa,omitempty"`
+ MustChangePassword bool `json:"must_change_password,omitempty"`
+ RequiredTwoFactorProtocols []string `json:"required_two_factor_protocols,omitempty"`
+ TokenRole string `json:"token_role,omitempty"` // SFTPGo role name
+ Role any `json:"role"` // oidc user role: SFTPGo user or admin
+ CustomFields *map[string]any `json:"custom_fields,omitempty"`
+ Cookie string `json:"cookie"`
+ UsedAt int64 `json:"used_at"`
}
func (t *oidcToken) parseClaims(claims map[string]any, usernameField, roleField string, customFields []string,
@@ -345,14 +352,15 @@ func (t *oidcToken) refresh(ctx context.Context, config OAuth2Config, verifier O
logger.Debug(logSender, "", "unable to verify refreshed id token for cookie %q: %v", t.Cookie, err)
return err
}
- if idToken.Nonce != t.Nonce {
- logger.Debug(logSender, "", "unable to verify refreshed id token for cookie %q: nonce mismatch", t.Cookie)
+ if idToken.Nonce != "" && idToken.Nonce != t.Nonce {
+ logger.Warn(logSender, "", "unable to verify refreshed id token for cookie %q: nonce mismatch, expected: %q, actual: %q",
+ t.Cookie, t.Nonce, idToken.Nonce)
return errors.New("the refreshed token nonce mismatch")
}
claims := make(map[string]any)
err = idToken.Claims(&claims)
if err != nil {
- logger.Debug(logSender, "", "unable to get refreshed id token claims for cookie %q: %v", t.Cookie, err)
+ logger.Warn(logSender, "", "unable to get refreshed id token claims for cookie %q: %v", t.Cookie, err)
return err
}
sid, ok := claims["sid"].(string)
@@ -391,11 +399,14 @@ func (t *oidcToken) refreshUser(r *http.Request) error {
if err := user.CheckLoginConditions(); err != nil {
return err
}
- if err := checkHTTPClientUser(&user, r, xid.New().String(), true); err != nil {
+ if err := checkHTTPClientUser(&user, r, xid.New().String(), true, false); err != nil {
return err
}
t.Permissions = user.Filters.WebClient
t.TokenRole = user.Role
+ t.MustSetTwoFactorAuth = user.MustSetSecondFactor()
+ t.MustChangePassword = user.MustChangePassword()
+ t.RequiredTwoFactorProtocols = user.Filters.TwoFactorAuthProtocols
return nil
}
@@ -405,7 +416,7 @@ func (t *oidcToken) getUser(r *http.Request) error {
Name: t.Username,
IP: ipAddr,
Protocol: common.ProtocolOIDC,
- Timestamp: time.Now().UnixNano(),
+ Timestamp: time.Now(),
Status: 1,
}
if t.isAdmin() {
@@ -428,6 +439,7 @@ func (t *oidcToken) getUser(r *http.Request) error {
t.TokenRole = admin.Role
t.HideUserPageSections = admin.Filters.Preferences.HideUserPageSections
dataprovider.UpdateAdminLastLogin(admin)
+ common.DelayLogin(nil)
return nil
}
params.Event = common.IDPLoginUser
@@ -443,29 +455,32 @@ func (t *oidcToken) getUser(r *http.Request) error {
user = &u
}
if err := common.Config.ExecutePostConnectHook(ipAddr, common.ProtocolOIDC); err != nil {
- updateLoginMetrics(user, dataprovider.LoginMethodIDP, ipAddr, err)
+ updateLoginMetrics(user, dataprovider.LoginMethodIDP, ipAddr, err, r)
return fmt.Errorf("access denied: %w", err)
}
if err := user.CheckLoginConditions(); err != nil {
- updateLoginMetrics(user, dataprovider.LoginMethodIDP, ipAddr, err)
+ updateLoginMetrics(user, dataprovider.LoginMethodIDP, ipAddr, err, r)
return err
}
connectionID := fmt.Sprintf("%s_%s", common.ProtocolOIDC, xid.New().String())
- if err := checkHTTPClientUser(user, r, connectionID, true); err != nil {
- updateLoginMetrics(user, dataprovider.LoginMethodIDP, ipAddr, err)
+ if err := checkHTTPClientUser(user, r, connectionID, true, true); err != nil {
+ updateLoginMetrics(user, dataprovider.LoginMethodIDP, ipAddr, err, r)
return err
}
defer user.CloseFs() //nolint:errcheck
err = user.CheckFsRoot(connectionID)
if err != nil {
logger.Warn(logSender, connectionID, "unable to check fs root: %v", err)
- updateLoginMetrics(user, dataprovider.LoginMethodIDP, ipAddr, common.ErrInternalFailure)
+ updateLoginMetrics(user, dataprovider.LoginMethodIDP, ipAddr, common.ErrInternalFailure, r)
return err
}
- updateLoginMetrics(user, dataprovider.LoginMethodIDP, ipAddr, nil)
+ updateLoginMetrics(user, dataprovider.LoginMethodIDP, ipAddr, nil, r)
dataprovider.UpdateLastLogin(user)
t.Permissions = user.Filters.WebClient
t.TokenRole = user.Role
+ t.MustSetTwoFactorAuth = user.MustSetSecondFactor()
+ t.MustChangePassword = user.MustChangePassword()
+ t.RequiredTwoFactorProtocols = user.Filters.TwoFactorAuthProtocols
return nil
}
@@ -539,13 +554,20 @@ func (s *httpdServer) oidcTokenAuthenticator(audience tokenAudience) func(next h
if err != nil {
return
}
- jwtTokenClaims := jwtTokenClaims{
- Username: token.Username,
+ claims := jwt.Claims{
+ Username: dataprovider.ConvertName(token.Username),
Permissions: token.Permissions,
Role: token.TokenRole,
HideUserPageSections: token.HideUserPageSections,
}
- _, tokenString, err := jwtTokenClaims.createToken(s.tokenAuth, audience, util.GetIPFromRemoteAddress(r.RemoteAddr))
+ claims.ID = token.Cookie
+ if audience == tokenAudienceWebClient {
+ claims.MustSetTwoFactorAuth = token.MustSetTwoFactorAuth
+ claims.MustChangePassword = token.MustChangePassword
+ claims.RequiredTwoFactorProtocols = token.RequiredTwoFactorProtocols
+ }
+ tokenString, err := s.tokenAuth.SignWithParams(&claims, audience, util.GetIPFromRemoteAddress(r.RemoteAddr),
+ getTokenDuration(audience))
if err != nil {
setFlashMessage(w, r, newFlashMessage("Unable to create cookie", util.I18nError500Message))
if audience == tokenAudienceWebAdmin {
@@ -575,7 +597,7 @@ func (s *httpdServer) oidcLoginRedirect(w http.ResponseWriter, r *http.Request,
pendingAuth := newOIDCPendingAuth(audience)
oidcMgr.addPendingAuth(pendingAuth)
http.Redirect(w, r, s.binding.OIDC.oauth2Config.AuthCodeURL(pendingAuth.State,
- oidc.Nonce(pendingAuth.Nonce)), http.StatusFound)
+ oidc.Nonce(pendingAuth.Nonce), oauth2.S256ChallengeOption(pendingAuth.Verifier)), http.StatusFound)
}
func (s *httpdServer) debugTokenClaims(claims map[string]any, rawIDToken string) {
@@ -593,6 +615,7 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request)
authReq, err := oidcMgr.getPendingAuth(state)
if err != nil {
logger.Debug(logSender, "", "oidc authentication state did not match")
+ oidcMgr.removePendingAuth(state)
s.renderClientMessagePage(w, r, util.I18nInvalidAuthReqTitle, http.StatusBadRequest,
util.NewI18nError(err, util.I18nInvalidAuth), "")
return
@@ -613,7 +636,8 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request)
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
- oauth2Token, err := s.binding.OIDC.oauth2Config.Exchange(ctx, r.URL.Query().Get("code"))
+ oauth2Token, err := s.binding.OIDC.oauth2Config.Exchange(ctx, r.URL.Query().Get("code"),
+ oauth2.VerifierOption(authReq.Verifier))
if err != nil {
logger.Debug(logSender, "", "failed to exchange oidc token: %v", err)
setFlashMessage(w, r, newFlashMessage("Failed to exchange OpenID token", util.I18nOIDCErrTokenExchange))
@@ -660,7 +684,7 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request)
RefreshToken: oauth2Token.RefreshToken,
IDToken: rawIDToken,
Nonce: idToken.Nonce,
- Cookie: xid.New().String(),
+ Cookie: util.GenerateOpaqueString(),
}
if !oauth2Token.Expiry.IsZero() {
token.ExpiresAt = util.GetTimeAsMsSinceEpoch(oauth2Token.Expiry)
@@ -788,7 +812,7 @@ func removeOIDCCookie(w http.ResponseWriter, r *http.Request) {
func canSkipOIDCValidation(r *http.Request) bool {
_, err := r.Cookie(oidcCookieKey)
if err != nil {
- _, err = r.Cookie(jwtCookieKey)
+ _, err = r.Cookie(jwt.CookieKey)
return err == nil
}
return false
diff --git a/internal/httpd/oidc_test.go b/internal/httpd/oidc_test.go
index be04d2ab..4a14c76c 100644
--- a/internal/httpd/oidc_test.go
+++ b/internal/httpd/oidc_test.go
@@ -32,8 +32,6 @@ import (
"unsafe"
"github.com/coreos/go-oidc/v3/oidc"
- "github.com/go-chi/jwtauth/v5"
- "github.com/lestrrat-go/jwx/v2/jwa"
"github.com/rs/xid"
"github.com/sftpgo/sdk"
"github.com/stretchr/testify/assert"
@@ -42,6 +40,7 @@ import (
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/kms"
"github.com/drakkan/sftpgo/v2/internal/util"
"github.com/drakkan/sftpgo/v2/internal/vfs"
@@ -136,12 +135,15 @@ func TestOIDCInitialization(t *testing.T) {
}
func TestOIDCLoginLogout(t *testing.T) {
+ tokenValidationMode = 2
+
oidcMgr, ok := oidcMgr.(*memoryOIDCManager)
require.True(t, ok)
server := getTestOIDCServer()
err := server.binding.OIDC.initialize()
assert.NoError(t, err)
- server.initializeRouter()
+ err = server.initializeRouter()
+ require.NoError(t, err)
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, webOIDCRedirectPath, nil)
@@ -151,8 +153,8 @@ func TestOIDCLoginLogout(t *testing.T) {
assert.Contains(t, rr.Body.String(), util.I18nInvalidAuth)
expiredAuthReq := oidcPendingAuth{
- State: xid.New().String(),
- Nonce: xid.New().String(),
+ State: util.GenerateOpaqueString(),
+ Nonce: util.GenerateOpaqueString(),
Audience: tokenAudienceWebClient,
IssuedAt: util.GetTimeAsMsSinceEpoch(time.Now().Add(-10 * time.Minute)),
}
@@ -553,6 +555,8 @@ func TestOIDCLoginLogout(t *testing.T) {
assert.NoError(t, err)
err = dataprovider.DeleteUser(username, "", "", "")
assert.NoError(t, err)
+
+ tokenValidationMode = 0
}
func TestOIDCRefreshToken(t *testing.T) {
@@ -561,7 +565,7 @@ func TestOIDCRefreshToken(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, webUsersPath, nil)
assert.NoError(t, err)
token := oidcToken{
- Cookie: xid.New().String(),
+ Cookie: util.GenerateOpaqueString(),
AccessToken: xid.New().String(),
TokenType: "Bearer",
ExpiresAt: util.GetTimeAsMsSinceEpoch(time.Now().Add(-1 * time.Minute)),
@@ -627,7 +631,9 @@ func TestOIDCRefreshToken(t *testing.T) {
},
}
verifier = mockOIDCVerifier{
- token: &oidc.IDToken{},
+ token: &oidc.IDToken{
+ Nonce: xid.New().String(), // nonce is different from the expected one
+ },
}
err = token.refresh(context.Background(), &config, &verifier, r)
if assert.Error(t, err) {
@@ -635,7 +641,7 @@ func TestOIDCRefreshToken(t *testing.T) {
}
verifier = mockOIDCVerifier{
token: &oidc.IDToken{
- Nonce: token.Nonce,
+ Nonce: "", // empty token is fine on refresh but claims are not set
},
}
err = token.refresh(context.Background(), &config, &verifier, r)
@@ -663,7 +669,7 @@ func TestOIDCRefreshToken(t *testing.T) {
func TestOIDCRefreshUser(t *testing.T) {
token := oidcToken{
- Cookie: xid.New().String(),
+ Cookie: util.GenerateOpaqueString(),
AccessToken: xid.New().String(),
TokenType: "Bearer",
ExpiresAt: util.GetTimeAsMsSinceEpoch(time.Now().Add(1 * time.Minute)),
@@ -763,7 +769,8 @@ func TestValidateOIDCToken(t *testing.T) {
server := getTestOIDCServer()
err := server.binding.OIDC.initialize()
assert.NoError(t, err)
- server.initializeRouter()
+ err = server.initializeRouter()
+ require.NoError(t, err)
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, webClientLogoutPath, nil)
@@ -777,7 +784,7 @@ func TestValidateOIDCToken(t *testing.T) {
},
}
token := oidcToken{
- Cookie: xid.New().String(),
+ Cookie: util.GenerateOpaqueString(),
AccessToken: xid.New().String(),
ExpiresAt: util.GetTimeAsMsSinceEpoch(time.Now().Add(-2 * time.Minute)),
}
@@ -791,10 +798,10 @@ func TestValidateOIDCToken(t *testing.T) {
oidcMgr.removeToken(token.Cookie)
assert.Len(t, oidcMgr.tokens, 0)
- server.tokenAuth = jwtauth.New("PS256", util.GenerateRandomBytes(32), nil)
+ server.tokenAuth.SetSigner(&failingJoseSigner{})
token = oidcToken{
- Cookie: xid.New().String(),
- AccessToken: xid.New().String(),
+ Cookie: util.GenerateOpaqueString(),
+ AccessToken: util.GenerateUniqueID(),
}
oidcMgr.addToken(token)
rr = httptest.NewRecorder()
@@ -808,7 +815,7 @@ func TestValidateOIDCToken(t *testing.T) {
assert.Len(t, oidcMgr.tokens, 0)
token = oidcToken{
- Cookie: xid.New().String(),
+ Cookie: util.GenerateOpaqueString(),
AccessToken: xid.New().String(),
Role: "admin",
}
@@ -828,16 +835,17 @@ func TestSkipOIDCAuth(t *testing.T) {
server := getTestOIDCServer()
err := server.binding.OIDC.initialize()
assert.NoError(t, err)
- server.initializeRouter()
- jwtTokenClaims := jwtTokenClaims{
- Username: "user",
- }
- _, tokenString, err := jwtTokenClaims.createToken(server.tokenAuth, tokenAudienceWebClient, "")
+ err = server.initializeRouter()
+ require.NoError(t, err)
+
+ claims := jwt.NewClaims(tokenAudienceWebClient, "", getTokenDuration(tokenAudienceWebClient))
+ claims.Username = "user"
+ tokenString, err := server.tokenAuth.Sign(claims)
assert.NoError(t, err)
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, webClientLogoutPath, nil)
assert.NoError(t, err)
- r.Header.Set("Cookie", fmt.Sprintf("%v=%v", jwtCookieKey, tokenString))
+ r.Header.Set("Cookie", fmt.Sprintf("%v=%v", jwt.CookieKey, tokenString))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
@@ -901,7 +909,8 @@ func TestOIDCToken(t *testing.T) {
},
Filters: dataprovider.UserFilters{
BaseUserFilters: sdk.BaseUserFilters{
- DeniedProtocols: []string{common.ProtocolHTTP},
+ DeniedProtocols: []string{common.ProtocolHTTP},
+ DeniedLoginMethods: []string{dataprovider.LoginMethodPassword},
},
},
}
@@ -962,7 +971,8 @@ func TestOIDCImplicitRoles(t *testing.T) {
server.binding.OIDC.ImplicitRoles = true
err := server.binding.OIDC.initialize()
assert.NoError(t, err)
- server.initializeRouter()
+ err = server.initializeRouter()
+ require.NoError(t, err)
authReq := newOIDCPendingAuth(tokenAudienceWebAdmin)
oidcMgr.addPendingAuth(authReq)
@@ -1088,7 +1098,7 @@ func TestMemoryOIDCManager(t *testing.T) {
assert.NoError(t, err)
oidcMgr.removePendingAuth(authReq.State)
require.Len(t, oidcMgr.pendingAuths, 0)
- authReq.IssuedAt = util.GetTimeAsMsSinceEpoch(time.Now().Add(-61 * time.Second))
+ authReq.IssuedAt = util.GetTimeAsMsSinceEpoch(time.Now().Add(-600 * time.Second))
oidcMgr.addPendingAuth(authReq)
require.Len(t, oidcMgr.pendingAuths, 1)
_, err = oidcMgr.getPendingAuth(authReq.State)
@@ -1102,7 +1112,7 @@ func TestMemoryOIDCManager(t *testing.T) {
AccessToken: xid.New().String(),
Nonce: xid.New().String(),
SessionID: xid.New().String(),
- Cookie: xid.New().String(),
+ Cookie: util.GenerateOpaqueString(),
Username: xid.New().String(),
Role: "admin",
Permissions: []string{dataprovider.PermAdminAny},
@@ -1152,7 +1162,7 @@ func TestMemoryOIDCManager(t *testing.T) {
token.UsedAt = usedAt
oidcMgr.tokens[token.Cookie] = token
newToken := oidcToken{
- Cookie: xid.New().String(),
+ Cookie: util.GenerateOpaqueString(),
}
oidcMgr.addToken(newToken)
oidcMgr.cleanup()
@@ -1174,20 +1184,20 @@ func TestOIDCEvMgrIntegration(t *testing.T) {
err = dataprovider.Initialize(newProviderConf, configDir, true)
assert.NoError(t, err)
// add a special chars to check json replacer
- username := `test_"oidc_eventmanager`
+ username := `test_'oidc_eventmanager`
u := map[string]any{
- "username": "{{Name}}",
+ "username": "{{.Name}}",
"status": 1,
- "home_dir": filepath.Join(os.TempDir(), "{{IDPFieldcustom1.sub}}"),
+ "home_dir": filepath.Join(os.TempDir(), "{{.IDPFieldcustom1.sub}}"),
"permissions": map[string][]string{
"/": {dataprovider.PermAny},
},
- "description": "{{IDPFieldcustom2}}",
+ "description": "{{.IDPFieldcustom2}}",
}
userTmpl, err := json.Marshal(u)
require.NoError(t, err)
a := map[string]any{
- "username": "{{Name}}",
+ "username": "{{.Name}}",
"status": 1,
"permissions": []string{dataprovider.PermAdminAny},
}
@@ -1235,7 +1245,8 @@ func TestOIDCEvMgrIntegration(t *testing.T) {
server.binding.OIDC.CustomFields = []string{"custom1.sub", "custom2"}
err = server.binding.OIDC.initialize()
assert.NoError(t, err)
- server.initializeRouter()
+ err = server.initializeRouter()
+ require.NoError(t, err)
// login a user with OIDC
_, err = dataprovider.UserExists(username, "")
assert.ErrorIs(t, err, util.ErrNotFound)
@@ -1372,7 +1383,8 @@ func TestOIDCPreLoginHook(t *testing.T) {
server.binding.OIDC.CustomFields = []string{"field1", "field2"}
err = server.binding.OIDC.initialize()
assert.NoError(t, err)
- server.initializeRouter()
+ err = server.initializeRouter()
+ require.NoError(t, err)
_, err = dataprovider.UserExists(username, "")
assert.ErrorIs(t, err, util.ErrNotFound)
@@ -1543,12 +1555,13 @@ func TestOIDCWithLoginFormsDisabled(t *testing.T) {
server := getTestOIDCServer()
server.binding.OIDC.ImplicitRoles = true
- server.binding.EnabledLoginMethods = 3
+ server.binding.DisabledLoginMethods = 12
server.binding.EnableWebAdmin = true
server.binding.EnableWebClient = true
err := server.binding.OIDC.initialize()
assert.NoError(t, err)
- server.initializeRouter()
+ err = server.initializeRouter()
+ require.NoError(t, err)
// login with an admin user
authReq := newOIDCPendingAuth(tokenAudienceWebAdmin)
oidcMgr.addPendingAuth(authReq)
@@ -1584,12 +1597,9 @@ func TestOIDCWithLoginFormsDisabled(t *testing.T) {
tokenCookie = k
}
// we should be able to create admins without setting a password
- if csrfTokenAuth == nil {
- csrfTokenAuth = jwtauth.New(jwa.HS256.String(), util.GenerateRandomBytes(32), nil)
- }
adminUsername := "testAdmin"
form := make(url.Values)
- form.Set(csrfFormToken, createCSRFToken(""))
+ form.Set(csrfFormToken, createCSRFToken(rr, r, server.csrfTokenAuth, tokenCookie, webBaseAdminPath))
form.Set("username", adminUsername)
form.Set("password", "")
form.Set("status", "1")
@@ -1661,7 +1671,7 @@ func TestDbOIDCManager(t *testing.T) {
}
token := oidcToken{
- Cookie: xid.New().String(),
+ Cookie: util.GenerateOpaqueString(),
AccessToken: xid.New().String(),
TokenType: "Bearer",
RefreshToken: xid.New().String(),
diff --git a/internal/httpd/oidcmanager.go b/internal/httpd/oidcmanager.go
index d0acade6..79336748 100644
--- a/internal/httpd/oidcmanager.go
+++ b/internal/httpd/oidcmanager.go
@@ -167,11 +167,11 @@ func (o *dbOIDCManager) addPendingAuth(pendingAuth oidcPendingAuth) {
}
func (o *dbOIDCManager) removePendingAuth(state string) {
- dataprovider.DeleteSharedSession(state) //nolint:errcheck
+ dataprovider.DeleteSharedSession(state, dataprovider.SessionTypeOIDCAuth) //nolint:errcheck
}
func (o *dbOIDCManager) getPendingAuth(state string) (oidcPendingAuth, error) {
- session, err := dataprovider.GetSharedSession(state)
+ session, err := dataprovider.GetSharedSession(state, dataprovider.SessionTypeOIDCAuth)
if err != nil {
return oidcPendingAuth{}, errors.New("oidc: unable to get the auth request for the specified state")
}
@@ -204,7 +204,7 @@ func (o *dbOIDCManager) addToken(token oidcToken) {
}
func (o *dbOIDCManager) removeToken(cookie string) {
- dataprovider.DeleteSharedSession(cookie) //nolint:errcheck
+ dataprovider.DeleteSharedSession(cookie, dataprovider.SessionTypeOIDCToken) //nolint:errcheck
}
func (o *dbOIDCManager) updateTokenUsage(token oidcToken) {
@@ -215,7 +215,7 @@ func (o *dbOIDCManager) updateTokenUsage(token oidcToken) {
}
func (o *dbOIDCManager) getToken(cookie string) (oidcToken, error) {
- session, err := dataprovider.GetSharedSession(cookie)
+ session, err := dataprovider.GetSharedSession(cookie, dataprovider.SessionTypeOIDCToken)
if err != nil {
return oidcToken{}, errors.New("oidc: unable to get the token for the specified session")
}
diff --git a/internal/httpd/resetcode.go b/internal/httpd/resetcode.go
index 6e680c02..0be7d890 100644
--- a/internal/httpd/resetcode.go
+++ b/internal/httpd/resetcode.go
@@ -110,7 +110,7 @@ func (m *dbResetCodeManager) Add(code *resetCode) error {
}
func (m *dbResetCodeManager) Get(code string) (*resetCode, error) {
- session, err := dataprovider.GetSharedSession(code)
+ session, err := dataprovider.GetSharedSession(code, dataprovider.SessionTypeResetCode)
if err != nil {
return nil, err
}
@@ -132,7 +132,7 @@ func (m *dbResetCodeManager) decodeData(data any) (*resetCode, error) {
}
func (m *dbResetCodeManager) Delete(code string) error {
- return dataprovider.DeleteSharedSession(code)
+ return dataprovider.DeleteSharedSession(code, dataprovider.SessionTypeResetCode)
}
func (m *dbResetCodeManager) Cleanup() {
diff --git a/internal/httpd/resources.go b/internal/httpd/resources.go
index a68a7167..54bc58c9 100644
--- a/internal/httpd/resources.go
+++ b/internal/httpd/resources.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build !bundle
-// +build !bundle
package httpd
diff --git a/internal/httpd/resources_embedded.go b/internal/httpd/resources_embedded.go
index 66ac0a8e..e15bc985 100644
--- a/internal/httpd/resources_embedded.go
+++ b/internal/httpd/resources_embedded.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build bundle
-// +build bundle
package httpd
diff --git a/internal/httpd/server.go b/internal/httpd/server.go
index 0584344f..3fa54c1b 100644
--- a/internal/httpd/server.go
+++ b/internal/httpd/server.go
@@ -16,6 +16,7 @@ package httpd
import (
"context"
+ "crypto/rand"
"crypto/tls"
"crypto/x509"
"errors"
@@ -24,15 +25,16 @@ import (
"net"
"net/http"
"net/url"
+ "path"
"path/filepath"
+ "slices"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
- "github.com/go-chi/jwtauth/v5"
"github.com/go-chi/render"
- "github.com/lestrrat-go/jwx/v2/jwa"
+ "github.com/go-jose/go-jose/v4"
"github.com/rs/cors"
"github.com/rs/xid"
"github.com/sftpgo/sdk"
@@ -41,6 +43,7 @@ import (
"github.com/drakkan/sftpgo/v2/internal/acme"
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/mfa"
"github.com/drakkan/sftpgo/v2/internal/smtp"
@@ -67,7 +70,8 @@ type httpdServer struct {
renderOpenAPI bool
isShared int
router *chi.Mux
- tokenAuth *jwtauth.JWTAuth
+ tokenAuth *jwt.Signer
+ csrfTokenAuth *jwt.Signer
signingPassphrase string
cors CorsConfig
}
@@ -96,12 +100,12 @@ func (s *httpdServer) setShared(value int) {
}
func (s *httpdServer) listenAndServe() error {
- s.initializeRouter()
+ if err := s.initializeRouter(); err != nil {
+ return err
+ }
httpServer := &http.Server{
Handler: s.router,
ReadHeaderTimeout: 30 * time.Second,
- ReadTimeout: 60 * time.Second,
- WriteTimeout: 60 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16, // 64KB
ErrorLog: log.New(&logger.StdLoggerWrapper{Sender: logSender}, "", 0),
@@ -120,14 +124,16 @@ func (s *httpdServer) listenAndServe() error {
httpServer.TLSConfig = config
logger.Debug(logSender, "", "configured TLS cipher suites for binding %q: %v, certID: %v",
s.binding.GetAddress(), httpServer.TLSConfig.CipherSuites, certID)
- if s.binding.ClientAuthType == 1 {
+ if s.binding.isMutualTLSEnabled() {
httpServer.TLSConfig.ClientCAs = certMgr.GetRootCAs()
httpServer.TLSConfig.ClientAuth = tls.RequireAndVerifyClientCert
httpServer.TLSConfig.VerifyConnection = s.verifyTLSConnection
}
- return util.HTTPListenAndServe(httpServer, s.binding.Address, s.binding.Port, true, logSender)
+ return util.HTTPListenAndServe(httpServer, s.binding.Address, s.binding.Port, true,
+ s.binding.listenerWrapper(), logSender)
}
- return util.HTTPListenAndServe(httpServer, s.binding.Address, s.binding.Port, false, logSender)
+ return util.HTTPListenAndServe(httpServer, s.binding.Address, s.binding.Port, false,
+ s.binding.listenerWrapper(), logSender)
}
func (s *httpdServer) verifyTLSConnection(state tls.ConnectionState) error {
@@ -164,14 +170,15 @@ func (s *httpdServer) refreshCookie(next http.Handler) http.Handler {
})
}
-func (s *httpdServer) renderClientLoginPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
+func (s *httpdServer) renderClientLoginPage(w http.ResponseWriter, r *http.Request, err *util.I18nError) {
data := loginPage{
commonBasePage: getCommonBasePage(r),
Title: util.I18nLoginTitle,
CurrentURL: webClientLoginPath,
Error: err,
- CSRFToken: createCSRFToken(ip),
- Branding: s.binding.Branding.WebClient,
+ CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, rand.Text(), webBaseClientPath),
+ Branding: s.binding.webClientBranding(),
+ Languages: s.binding.languages(),
FormDisabled: s.binding.isWebClientLoginFormDisabled(),
CheckRedirect: true,
}
@@ -180,7 +187,7 @@ func (s *httpdServer) renderClientLoginPage(w http.ResponseWriter, r *http.Reque
}
if s.binding.showAdminLoginURL() {
data.AltLoginURL = webAdminLoginPath
- data.AltLoginName = s.binding.Branding.WebAdmin.ShortName
+ data.AltLoginName = s.binding.webAdminBranding().ShortName
}
if smtp.IsEnabled() && !data.FormDisabled {
data.ForgotPwdURL = webClientForgotPwdPath
@@ -193,8 +200,7 @@ func (s *httpdServer) renderClientLoginPage(w http.ResponseWriter, r *http.Reque
func (s *httpdServer) handleWebClientLogout(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
- c := jwtTokenClaims{}
- c.removeCookie(w, r, webBaseClientPath)
+ removeCookie(w, r, webBaseClientPath)
s.logoutOIDCUser(w, r)
http.Redirect(w, r, webClientLoginPath, http.StatusFound)
@@ -206,7 +212,7 @@ func (s *httpdServer) handleWebClientChangePwdPost(w http.ResponseWriter, r *htt
s.renderClientChangePasswordPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
return
}
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), util.GetIPFromRemoteAddress(r.RemoteAddr)); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderClientForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -226,7 +232,7 @@ func (s *httpdServer) handleClientWebLogin(w http.ResponseWriter, r *http.Reques
return
}
msg := getFlashMessage(w, r)
- s.renderClientLoginPage(w, r, msg.getI18nError(), util.GetIPFromRemoteAddress(r.RemoteAddr))
+ s.renderClientLoginPage(w, r, msg.getI18nError())
}
func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Request) {
@@ -234,44 +240,43 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
if err := r.ParseForm(); err != nil {
- s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
+ s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
return
}
protocol := common.ProtocolHTTP
username := strings.TrimSpace(r.Form.Get("username"))
- password := strings.TrimSpace(r.Form.Get("password"))
+ password := r.Form.Get("password")
if username == "" || password == "" {
updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
- dataprovider.LoginMethodPassword, ipAddr, common.ErrNoCredentials)
+ dataprovider.LoginMethodPassword, ipAddr, common.ErrNoCredentials, r)
s.renderClientLoginPage(w, r,
- util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
+ util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
return
}
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyLoginCookieAndCSRFToken(r, s.csrfTokenAuth); err != nil {
updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
- dataprovider.LoginMethodPassword, ipAddr, err)
- s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF), ipAddr)
- return
+ dataprovider.LoginMethodPassword, ipAddr, err, r)
+ s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
}
if err := common.Config.ExecutePostConnectHook(ipAddr, protocol); err != nil {
updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
- dataprovider.LoginMethodPassword, ipAddr, err)
- s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nError403Message), ipAddr)
+ dataprovider.LoginMethodPassword, ipAddr, err, r)
+ s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nError403Message))
return
}
user, err := dataprovider.CheckUserAndPass(username, password, ipAddr, protocol)
if err != nil {
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err)
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err, r)
s.renderClientLoginPage(w, r,
- util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
+ util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
return
}
connectionID := fmt.Sprintf("%v_%v", protocol, xid.New().String())
- if err := checkHTTPClientUser(&user, r, connectionID, true); err != nil {
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err)
- s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nError403Message), ipAddr)
+ if err := checkHTTPClientUser(&user, r, connectionID, true, false); err != nil {
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err, r)
+ s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nError403Message))
return
}
@@ -279,8 +284,8 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
err = user.CheckFsRoot(connectionID)
if err != nil {
logger.Warn(logSender, connectionID, "unable to check fs root: %v", err)
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure)
- s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nErrorFsGeneric), ipAddr)
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure, r)
+ s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nErrorFsGeneric))
return
}
s.loginUser(w, r, &user, connectionID, ipAddr, false, s.renderClientLoginPage)
@@ -292,10 +297,10 @@ func (s *httpdServer) handleWebClientPasswordResetPost(w http.ResponseWriter, r
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
err := r.ParseForm()
if err != nil {
- s.renderClientResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
+ s.renderClientResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
return
}
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyLoginCookieAndCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderClientForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -304,12 +309,12 @@ func (s *httpdServer) handleWebClientPasswordResetPost(w http.ResponseWriter, r
_, user, err := handleResetPassword(r, strings.TrimSpace(r.Form.Get("code")),
newPassword, confirmPassword, false)
if err != nil {
- s.renderClientResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorChangePwdGeneric), ipAddr)
+ s.renderClientResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorChangePwdGeneric))
return
}
connectionID := fmt.Sprintf("%v_%v", getProtocolFromRequest(r), xid.New().String())
- if err := checkHTTPClientUser(user, r, connectionID, true); err != nil {
- s.renderClientResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorDirList403), ipAddr)
+ if err := checkHTTPClientUser(user, r, connectionID, true, false); err != nil {
+ s.renderClientResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorLoginAfterReset))
return
}
@@ -317,7 +322,7 @@ func (s *httpdServer) handleWebClientPasswordResetPost(w http.ResponseWriter, r
err = user.CheckFsRoot(connectionID)
if err != nil {
logger.Warn(logSender, connectionID, "unable to check fs root: %v", err)
- s.renderClientResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorLoginAfterReset), ipAddr)
+ s.renderClientResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorLoginAfterReset))
return
}
s.loginUser(w, r, user, connectionID, ipAddr, false, s.renderClientResetPwdPage)
@@ -325,25 +330,25 @@ func (s *httpdServer) handleWebClientPasswordResetPost(w http.ResponseWriter, r
func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil {
s.renderNotFoundPage(w, r, nil)
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
if err := r.ParseForm(); err != nil {
- s.renderClientTwoFactorRecoveryPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
+ s.renderClientTwoFactorRecoveryPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
return
}
username := claims.Username
recoveryCode := strings.TrimSpace(r.Form.Get("recovery_code"))
if username == "" || recoveryCode == "" {
s.renderClientTwoFactorRecoveryPage(w, r,
- util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
+ util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
return
}
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
- s.renderClientTwoFactorRecoveryPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF), ipAddr)
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
+ s.renderClientTwoFactorRecoveryPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
user, userMerged, err := dataprovider.GetUserVariants(username, "")
@@ -352,12 +357,12 @@ func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter
handleDefenderEventLoginFailed(ipAddr, err) //nolint:errcheck
}
s.renderClientTwoFactorRecoveryPage(w, r,
- util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
+ util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
return
}
- if !userMerged.Filters.TOTPConfig.Enabled || !util.Contains(userMerged.Filters.TOTPConfig.Protocols, common.ProtocolHTTP) {
+ if !userMerged.Filters.TOTPConfig.Enabled || !slices.Contains(userMerged.Filters.TOTPConfig.Protocols, common.ProtocolHTTP) {
s.renderClientTwoFactorPage(w, r, util.NewI18nError(
- util.NewValidationError("two factory authentication is not enabled"), util.I18n2FADisabled), ipAddr)
+ util.NewValidationError("two factory authentication is not enabled"), util.I18n2FADisabled))
return
}
for idx, code := range user.Filters.RecoveryCodes {
@@ -368,7 +373,7 @@ func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter
if code.Secret.GetPayload() == recoveryCode {
if code.Used {
s.renderClientTwoFactorRecoveryPage(w, r,
- util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
+ util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
return
}
user.Filters.RecoveryCodes[idx].Used = true
@@ -386,60 +391,60 @@ func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter
}
handleDefenderEventLoginFailed(ipAddr, dataprovider.ErrInvalidCredentials) //nolint:errcheck
s.renderClientTwoFactorRecoveryPage(w, r,
- util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
+ util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
}
func (s *httpdServer) handleWebClientTwoFactorPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil {
s.renderNotFoundPage(w, r, nil)
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
if err := r.ParseForm(); err != nil {
- s.renderClientTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
+ s.renderClientTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
return
}
username := claims.Username
passcode := strings.TrimSpace(r.Form.Get("passcode"))
if username == "" || passcode == "" {
updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
- dataprovider.LoginMethodPassword, ipAddr, common.ErrNoCredentials)
+ dataprovider.LoginMethodPassword, ipAddr, common.ErrNoCredentials, r)
s.renderClientTwoFactorPage(w, r,
- util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
+ util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
return
}
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
- dataprovider.LoginMethodPassword, ipAddr, err)
- s.renderClientTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF), ipAddr)
+ dataprovider.LoginMethodPassword, ipAddr, err, r)
+ s.renderClientTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
user, err := dataprovider.GetUserWithGroupSettings(username, "")
if err != nil {
updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
- dataprovider.LoginMethodPassword, ipAddr, err)
- s.renderClientTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCredentials), ipAddr)
+ dataprovider.LoginMethodPassword, ipAddr, err, r)
+ s.renderClientTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCredentials))
return
}
- if !user.Filters.TOTPConfig.Enabled || !util.Contains(user.Filters.TOTPConfig.Protocols, common.ProtocolHTTP) {
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure)
- s.renderClientTwoFactorPage(w, r, util.NewI18nError(common.ErrInternalFailure, util.I18n2FADisabled), ipAddr)
+ if !user.Filters.TOTPConfig.Enabled || !slices.Contains(user.Filters.TOTPConfig.Protocols, common.ProtocolHTTP) {
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure, r)
+ s.renderClientTwoFactorPage(w, r, util.NewI18nError(common.ErrInternalFailure, util.I18n2FADisabled))
return
}
err = user.Filters.TOTPConfig.Secret.Decrypt()
if err != nil {
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure)
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure, r)
s.renderClientInternalServerErrorPage(w, r, err)
return
}
match, err := mfa.ValidateTOTPPasscode(user.Filters.TOTPConfig.ConfigName, passcode,
user.Filters.TOTPConfig.Secret.GetPayload())
if !match || err != nil {
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, dataprovider.ErrInvalidCredentials)
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, dataprovider.ErrInvalidCredentials, r)
s.renderClientTwoFactorPage(w, r,
- util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
+ util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
return
}
connectionID := fmt.Sprintf("%s_%s", getProtocolFromRequest(r), xid.New().String())
@@ -449,25 +454,24 @@ func (s *httpdServer) handleWebClientTwoFactorPost(w http.ResponseWriter, r *htt
func (s *httpdServer) handleWebAdminTwoFactorRecoveryPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil {
s.renderNotFoundPage(w, r, nil)
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
if err := r.ParseForm(); err != nil {
- s.renderTwoFactorRecoveryPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
+ s.renderTwoFactorRecoveryPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
return
}
username := claims.Username
recoveryCode := strings.TrimSpace(r.Form.Get("recovery_code"))
if username == "" || recoveryCode == "" {
- s.renderTwoFactorRecoveryPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials),
- ipAddr)
+ s.renderTwoFactorRecoveryPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
return
}
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
- s.renderTwoFactorRecoveryPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF), ipAddr)
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
+ s.renderTwoFactorRecoveryPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
admin, err := dataprovider.AdminExists(username)
@@ -475,12 +479,11 @@ func (s *httpdServer) handleWebAdminTwoFactorRecoveryPost(w http.ResponseWriter,
if errors.Is(err, util.ErrNotFound) {
handleDefenderEventLoginFailed(ipAddr, err) //nolint:errcheck
}
- s.renderTwoFactorRecoveryPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials),
- ipAddr)
+ s.renderTwoFactorRecoveryPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
return
}
if !admin.Filters.TOTPConfig.Enabled {
- s.renderTwoFactorRecoveryPage(w, r, util.NewI18nError(util.NewValidationError("two factory authentication is not enabled"), util.I18n2FADisabled), ipAddr)
+ s.renderTwoFactorRecoveryPage(w, r, util.NewI18nError(util.NewValidationError("two factory authentication is not enabled"), util.I18n2FADisabled))
return
}
for idx, code := range admin.Filters.RecoveryCodes {
@@ -491,7 +494,7 @@ func (s *httpdServer) handleWebAdminTwoFactorRecoveryPost(w http.ResponseWriter,
if code.Secret.GetPayload() == recoveryCode {
if code.Used {
s.renderTwoFactorRecoveryPage(w, r,
- util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
+ util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
return
}
admin.Filters.RecoveryCodes[idx].Used = true
@@ -506,32 +509,30 @@ func (s *httpdServer) handleWebAdminTwoFactorRecoveryPost(w http.ResponseWriter,
}
}
handleDefenderEventLoginFailed(ipAddr, dataprovider.ErrInvalidCredentials) //nolint:errcheck
- s.renderTwoFactorRecoveryPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials),
- ipAddr)
+ s.renderTwoFactorRecoveryPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
}
func (s *httpdServer) handleWebAdminTwoFactorPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil {
s.renderNotFoundPage(w, r, nil)
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
if err := r.ParseForm(); err != nil {
- s.renderTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
+ s.renderTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
return
}
username := claims.Username
passcode := strings.TrimSpace(r.Form.Get("passcode"))
if username == "" || passcode == "" {
- s.renderTwoFactorPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials),
- ipAddr)
+ s.renderTwoFactorPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
return
}
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
- err = handleDefenderEventLoginFailed(ipAddr, err)
- s.renderTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF), ipAddr)
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
+ handleDefenderEventLoginFailed(ipAddr, err) //nolint:errcheck
+ s.renderTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
admin, err := dataprovider.AdminExists(username)
@@ -539,11 +540,11 @@ func (s *httpdServer) handleWebAdminTwoFactorPost(w http.ResponseWriter, r *http
if errors.Is(err, util.ErrNotFound) {
handleDefenderEventLoginFailed(ipAddr, err) //nolint:errcheck
}
- s.renderTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCredentials), ipAddr)
+ s.renderTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCredentials))
return
}
if !admin.Filters.TOTPConfig.Enabled {
- s.renderTwoFactorPage(w, r, util.NewI18nError(common.ErrInternalFailure, util.I18n2FADisabled), ipAddr)
+ s.renderTwoFactorPage(w, r, util.NewI18nError(common.ErrInternalFailure, util.I18n2FADisabled))
return
}
err = admin.Filters.TOTPConfig.Secret.Decrypt()
@@ -555,8 +556,7 @@ func (s *httpdServer) handleWebAdminTwoFactorPost(w http.ResponseWriter, r *http
admin.Filters.TOTPConfig.Secret.GetPayload())
if !match || err != nil {
handleDefenderEventLoginFailed(ipAddr, dataprovider.ErrInvalidCredentials) //nolint:errcheck
- s.renderTwoFactorPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials),
- ipAddr)
+ s.renderTwoFactorPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
return
}
s.loginAdmin(w, r, &admin, true, s.renderTwoFactorPage, ipAddr)
@@ -567,44 +567,43 @@ func (s *httpdServer) handleWebAdminLoginPost(w http.ResponseWriter, r *http.Req
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
if err := r.ParseForm(); err != nil {
- s.renderAdminLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
+ s.renderAdminLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
return
}
username := strings.TrimSpace(r.Form.Get("username"))
password := strings.TrimSpace(r.Form.Get("password"))
if username == "" || password == "" {
- s.renderAdminLoginPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials),
- ipAddr)
+ s.renderAdminLoginPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
return
}
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
- s.renderAdminLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF), ipAddr)
+ if err := verifyLoginCookieAndCSRFToken(r, s.csrfTokenAuth); err != nil {
+ s.renderAdminLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
admin, err := dataprovider.CheckAdminAndPass(username, password, ipAddr)
if err != nil {
handleDefenderEventLoginFailed(ipAddr, err) //nolint:errcheck
- s.renderAdminLoginPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials),
- ipAddr)
+ s.renderAdminLoginPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
return
}
s.loginAdmin(w, r, &admin, false, s.renderAdminLoginPage, ipAddr)
}
-func (s *httpdServer) renderAdminLoginPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
+func (s *httpdServer) renderAdminLoginPage(w http.ResponseWriter, r *http.Request, err *util.I18nError) {
data := loginPage{
commonBasePage: getCommonBasePage(r),
Title: util.I18nLoginTitle,
CurrentURL: webAdminLoginPath,
Error: err,
- CSRFToken: createCSRFToken(ip),
- Branding: s.binding.Branding.WebAdmin,
+ CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, rand.Text(), webBaseAdminPath),
+ Branding: s.binding.webAdminBranding(),
+ Languages: s.binding.languages(),
FormDisabled: s.binding.isWebAdminLoginFormDisabled(),
CheckRedirect: false,
}
if s.binding.showClientLoginURL() {
data.AltLoginURL = webClientLoginPath
- data.AltLoginName = s.binding.Branding.WebClient.ShortName
+ data.AltLoginName = s.binding.webClientBranding().ShortName
}
if smtp.IsEnabled() && !data.FormDisabled {
data.ForgotPwdURL = webAdminForgotPwdPath
@@ -622,13 +621,12 @@ func (s *httpdServer) handleWebAdminLogin(w http.ResponseWriter, r *http.Request
return
}
msg := getFlashMessage(w, r)
- s.renderAdminLoginPage(w, r, msg.getI18nError(), util.GetIPFromRemoteAddress(r.RemoteAddr))
+ s.renderAdminLoginPage(w, r, msg.getI18nError())
}
func (s *httpdServer) handleWebAdminLogout(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- c := jwtTokenClaims{}
- c.removeCookie(w, r, webBaseAdminPath)
+ removeCookie(w, r, webBaseAdminPath)
s.logoutOIDCUser(w, r)
http.Redirect(w, r, webAdminLoginPath, http.StatusFound)
@@ -641,7 +639,7 @@ func (s *httpdServer) handleWebAdminChangePwdPost(w http.ResponseWriter, r *http
s.renderChangePasswordPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
return
}
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), util.GetIPFromRemoteAddress(r.RemoteAddr)); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -660,10 +658,10 @@ func (s *httpdServer) handleWebAdminPasswordResetPost(w http.ResponseWriter, r *
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
err := r.ParseForm()
if err != nil {
- s.renderResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
+ s.renderResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
return
}
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyLoginCookieAndCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -672,7 +670,7 @@ func (s *httpdServer) handleWebAdminPasswordResetPost(w http.ResponseWriter, r *
admin, _, err := handleResetPassword(r, strings.TrimSpace(r.Form.Get("code")),
newPassword, confirmPassword, true)
if err != nil {
- s.renderResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorChangePwdGeneric), ipAddr)
+ s.renderResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorChangePwdGeneric))
return
}
@@ -688,10 +686,10 @@ func (s *httpdServer) handleWebAdminSetupPost(w http.ResponseWriter, r *http.Req
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
err := r.ParseForm()
if err != nil {
- s.renderAdminSetupPage(w, r, "", ipAddr, util.NewI18nError(err, util.I18nErrorInvalidForm))
+ s.renderAdminSetupPage(w, r, "", util.NewI18nError(err, util.I18nErrorInvalidForm))
return
}
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyLoginCookieAndCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -700,7 +698,7 @@ func (s *httpdServer) handleWebAdminSetupPost(w http.ResponseWriter, r *http.Req
confirmPassword := strings.TrimSpace(r.Form.Get("confirm_password"))
installCode := strings.TrimSpace(r.Form.Get("install_code"))
if installationCode != "" && installCode != resolveInstallationCode() {
- s.renderAdminSetupPage(w, r, username, ipAddr,
+ s.renderAdminSetupPage(w, r, username,
util.NewI18nError(
util.NewValidationError(fmt.Sprintf("%v mismatch", installationCodeHint)),
util.I18nErrorSetupInstallCode),
@@ -708,17 +706,17 @@ func (s *httpdServer) handleWebAdminSetupPost(w http.ResponseWriter, r *http.Req
return
}
if username == "" {
- s.renderAdminSetupPage(w, r, username, ipAddr,
+ s.renderAdminSetupPage(w, r, username,
util.NewI18nError(util.NewValidationError("please set a username"), util.I18nError500Message))
return
}
if password == "" {
- s.renderAdminSetupPage(w, r, username, ipAddr,
+ s.renderAdminSetupPage(w, r, username,
util.NewI18nError(util.NewValidationError("please set a password"), util.I18nError500Message))
return
}
if password != confirmPassword {
- s.renderAdminSetupPage(w, r, username, ipAddr,
+ s.renderAdminSetupPage(w, r, username,
util.NewI18nError(errors.New("the two password fields do not match"), util.I18nErrorChangePwdNoMatch))
return
}
@@ -730,7 +728,7 @@ func (s *httpdServer) handleWebAdminSetupPost(w http.ResponseWriter, r *http.Req
}
err = dataprovider.AddAdmin(&admin, username, ipAddr, "")
if err != nil {
- s.renderAdminSetupPage(w, r, username, ipAddr, util.NewI18nError(err, util.I18nError500Message))
+ s.renderAdminSetupPage(w, r, username, util.NewI18nError(err, util.I18nError500Message))
return
}
s.loginAdmin(w, r, &admin, false, nil, ipAddr)
@@ -738,34 +736,32 @@ func (s *httpdServer) handleWebAdminSetupPost(w http.ResponseWriter, r *http.Req
func (s *httpdServer) loginUser(
w http.ResponseWriter, r *http.Request, user *dataprovider.User, connectionID, ipAddr string,
- isSecondFactorAuth bool, errorFunc func(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string),
+ isSecondFactorAuth bool, errorFunc func(w http.ResponseWriter, r *http.Request, err *util.I18nError),
) {
- c := jwtTokenClaims{
+ c := &jwt.Claims{
Username: user.Username,
Permissions: user.Filters.WebClient,
- Signature: user.GetSignature(),
Role: user.Role,
MustSetTwoFactorAuth: user.MustSetSecondFactor(),
MustChangePassword: user.MustChangePassword(),
RequiredTwoFactorProtocols: user.Filters.TwoFactorAuthProtocols,
}
+ c.Subject = user.GetSignature()
audience := tokenAudienceWebClient
- if user.Filters.TOTPConfig.Enabled && util.Contains(user.Filters.TOTPConfig.Protocols, common.ProtocolHTTP) &&
+ if user.Filters.TOTPConfig.Enabled && slices.Contains(user.Filters.TOTPConfig.Protocols, common.ProtocolHTTP) &&
user.CanManageMFA() && !isSecondFactorAuth {
audience = tokenAudienceWebClientPartial
}
- err := c.createAndSetCookie(w, r, s.tokenAuth, audience, ipAddr)
+ err := createAndSetCookie(w, r, c, s.tokenAuth, audience, ipAddr)
if err != nil {
logger.Warn(logSender, connectionID, "unable to set user login cookie %v", err)
- updateLoginMetrics(user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure)
- errorFunc(w, r, util.NewI18nError(err, util.I18nError500Message), ipAddr)
+ updateLoginMetrics(user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure, r)
+ errorFunc(w, r, util.NewI18nError(err, util.I18nError500Message))
return
}
- if isSecondFactorAuth {
- invalidateToken(r)
- }
+ invalidateToken(r)
if audience == tokenAudienceWebClientPartial {
redirectPath := webClientTwoFactorPath
if next := r.URL.Query().Get("next"); strings.HasPrefix(next, webClientFilesPath) {
@@ -774,7 +770,7 @@ func (s *httpdServer) loginUser(
http.Redirect(w, r, redirectPath, http.StatusFound)
return
}
- updateLoginMetrics(user, dataprovider.LoginMethodPassword, ipAddr, err)
+ updateLoginMetrics(user, dataprovider.LoginMethodPassword, ipAddr, err, r)
dataprovider.UpdateLastLogin(user)
if next := r.URL.Query().Get("next"); strings.HasPrefix(next, webClientFilesPath) {
http.Redirect(w, r, next, http.StatusFound)
@@ -785,42 +781,41 @@ func (s *httpdServer) loginUser(
func (s *httpdServer) loginAdmin(
w http.ResponseWriter, r *http.Request, admin *dataprovider.Admin,
- isSecondFactorAuth bool, errorFunc func(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string),
+ isSecondFactorAuth bool, errorFunc func(w http.ResponseWriter, r *http.Request, err *util.I18nError),
ipAddr string,
) {
- c := jwtTokenClaims{
+ c := &jwt.Claims{
Username: admin.Username,
Permissions: admin.Permissions,
Role: admin.Role,
- Signature: admin.GetSignature(),
HideUserPageSections: admin.Filters.Preferences.HideUserPageSections,
MustSetTwoFactorAuth: admin.Filters.RequireTwoFactor && !admin.Filters.TOTPConfig.Enabled,
MustChangePassword: admin.Filters.RequirePasswordChange,
}
+ c.Subject = admin.GetSignature()
audience := tokenAudienceWebAdmin
if admin.Filters.TOTPConfig.Enabled && admin.CanManageMFA() && !isSecondFactorAuth {
audience = tokenAudienceWebAdminPartial
}
- err := c.createAndSetCookie(w, r, s.tokenAuth, audience, ipAddr)
+ err := createAndSetCookie(w, r, c, s.tokenAuth, audience, ipAddr)
if err != nil {
logger.Warn(logSender, "", "unable to set admin login cookie %v", err)
if errorFunc == nil {
- s.renderAdminSetupPage(w, r, admin.Username, ipAddr, util.NewI18nError(err, util.I18nError500Message))
+ s.renderAdminSetupPage(w, r, admin.Username, util.NewI18nError(err, util.I18nError500Message))
return
}
- errorFunc(w, r, util.NewI18nError(err, util.I18nError500Message), ipAddr)
+ errorFunc(w, r, util.NewI18nError(err, util.I18nError500Message))
return
}
- if isSecondFactorAuth {
- invalidateToken(r)
- }
+ invalidateToken(r)
if audience == tokenAudienceWebAdminPartial {
http.Redirect(w, r, webAdminTwoFactorPath, http.StatusFound)
return
}
dataprovider.UpdateAdminLastLogin(admin)
+ common.DelayLogin(nil)
redirectURL := webUsersPath
if errorFunc == nil {
redirectURL = webAdminMFAPath
@@ -841,52 +836,52 @@ func (s *httpdServer) getUserToken(w http.ResponseWriter, r *http.Request) {
protocol := common.ProtocolHTTP
if !ok {
updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
- dataprovider.LoginMethodPassword, ipAddr, common.ErrNoCredentials)
+ dataprovider.LoginMethodPassword, ipAddr, common.ErrNoCredentials, r)
w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
sendAPIResponse(w, r, nil, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
- if username == "" || password == "" {
+ if username == "" || strings.TrimSpace(password) == "" {
updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
- dataprovider.LoginMethodPassword, ipAddr, common.ErrNoCredentials)
+ dataprovider.LoginMethodPassword, ipAddr, common.ErrNoCredentials, r)
w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
sendAPIResponse(w, r, nil, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
if err := common.Config.ExecutePostConnectHook(ipAddr, protocol); err != nil {
updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
- dataprovider.LoginMethodPassword, ipAddr, err)
+ dataprovider.LoginMethodPassword, ipAddr, err, r)
sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
user, err := dataprovider.CheckUserAndPass(username, password, ipAddr, protocol)
if err != nil {
w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err)
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err, r)
sendAPIResponse(w, r, dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized),
http.StatusUnauthorized)
return
}
connectionID := fmt.Sprintf("%v_%v", protocol, xid.New().String())
- if err := checkHTTPClientUser(&user, r, connectionID, true); err != nil {
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err)
+ if err := checkHTTPClientUser(&user, r, connectionID, true, false); err != nil {
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err, r)
sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
- if user.Filters.TOTPConfig.Enabled && util.Contains(user.Filters.TOTPConfig.Protocols, common.ProtocolHTTP) {
+ if user.Filters.TOTPConfig.Enabled && slices.Contains(user.Filters.TOTPConfig.Protocols, common.ProtocolHTTP) {
passcode := r.Header.Get(otpHeaderCode)
if passcode == "" {
logger.Debug(logSender, "", "TOTP enabled for user %q and not passcode provided, authentication refused", user.Username)
w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, dataprovider.ErrInvalidCredentials)
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, dataprovider.ErrInvalidCredentials, r)
sendAPIResponse(w, r, dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized),
http.StatusUnauthorized)
return
}
err = user.Filters.TOTPConfig.Secret.Decrypt()
if err != nil {
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure)
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure, r)
sendAPIResponse(w, r, fmt.Errorf("unable to decrypt TOTP secret: %w", err), http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -895,7 +890,7 @@ func (s *httpdServer) getUserToken(w http.ResponseWriter, r *http.Request) {
if !match || err != nil {
logger.Debug(logSender, "invalid passcode for user %q, match? %v, err: %v", user.Username, match, err)
w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, dataprovider.ErrInvalidCredentials)
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, dataprovider.ErrInvalidCredentials, r)
sendAPIResponse(w, r, dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized),
http.StatusUnauthorized)
return
@@ -906,7 +901,7 @@ func (s *httpdServer) getUserToken(w http.ResponseWriter, r *http.Request) {
err = user.CheckFsRoot(connectionID)
if err != nil {
logger.Warn(logSender, connectionID, "unable to check fs root: %v", err)
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure)
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure, r)
sendAPIResponse(w, r, err, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@@ -915,26 +910,26 @@ func (s *httpdServer) getUserToken(w http.ResponseWriter, r *http.Request) {
}
func (s *httpdServer) generateAndSendUserToken(w http.ResponseWriter, r *http.Request, ipAddr string, user dataprovider.User) {
- c := jwtTokenClaims{
+ c := &jwt.Claims{
Username: user.Username,
Permissions: user.Filters.WebClient,
- Signature: user.GetSignature(),
Role: user.Role,
MustSetTwoFactorAuth: user.MustSetSecondFactor(),
MustChangePassword: user.MustChangePassword(),
RequiredTwoFactorProtocols: user.Filters.TwoFactorAuthProtocols,
}
+ c.Subject = user.GetSignature()
- resp, err := c.createTokenResponse(s.tokenAuth, tokenAudienceAPIUser, ipAddr)
+ token, err := s.tokenAuth.SignWithParams(c, tokenAudienceAPIUser, ipAddr, getTokenDuration(tokenAudienceAPIUser))
if err != nil {
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure)
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure, r)
sendAPIResponse(w, r, err, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
- updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err)
+ updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err, r)
dataprovider.UpdateLastLogin(&user)
- render.JSON(w, r, resp)
+ render.JSON(w, r, c.BuildTokenResponse(token))
}
func (s *httpdServer) getToken(w http.ResponseWriter, r *http.Request) {
@@ -947,9 +942,10 @@ func (s *httpdServer) getToken(w http.ResponseWriter, r *http.Request) {
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
admin, err := dataprovider.CheckAdminAndPass(username, password, ipAddr)
if err != nil {
- err = handleDefenderEventLoginFailed(ipAddr, err)
+ handleDefenderEventLoginFailed(ipAddr, err) //nolint:errcheck
w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
- sendAPIResponse(w, r, err, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+ sendAPIResponse(w, r, dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized),
+ http.StatusUnauthorized)
return
}
if admin.Filters.TOTPConfig.Enabled {
@@ -983,59 +979,64 @@ func (s *httpdServer) getToken(w http.ResponseWriter, r *http.Request) {
}
func (s *httpdServer) generateAndSendToken(w http.ResponseWriter, r *http.Request, admin dataprovider.Admin, ip string) {
- c := jwtTokenClaims{
+ c := &jwt.Claims{
Username: admin.Username,
Permissions: admin.Permissions,
Role: admin.Role,
- Signature: admin.GetSignature(),
MustSetTwoFactorAuth: admin.Filters.RequireTwoFactor && !admin.Filters.TOTPConfig.Enabled,
MustChangePassword: admin.Filters.RequirePasswordChange,
}
+ c.Subject = admin.GetSignature()
- resp, err := c.createTokenResponse(s.tokenAuth, tokenAudienceAPI, ip)
-
+ token, err := s.tokenAuth.SignWithParams(c, tokenAudienceAPI, ip, getTokenDuration(tokenAudienceAPI))
if err != nil {
sendAPIResponse(w, r, err, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
dataprovider.UpdateAdminLastLogin(&admin)
- render.JSON(w, r, resp)
+ common.DelayLogin(nil)
+ render.JSON(w, r, c.BuildTokenResponse(token))
}
func (s *httpdServer) checkCookieExpiration(w http.ResponseWriter, r *http.Request) {
if _, ok := r.Context().Value(oidcTokenKey).(string); ok {
return
}
- token, claims, err := jwtauth.FromContext(r.Context())
+ claims, err := jwt.FromContext(r.Context())
if err != nil {
return
}
- tokenClaims := jwtTokenClaims{}
- tokenClaims.Decode(claims)
- if tokenClaims.Username == "" || tokenClaims.Signature == "" {
+ if claims.Username == "" || claims.Subject == "" {
return
}
- if time.Until(token.Expiration()) > tokenRefreshThreshold {
+ if time.Until(claims.Expiry.Time()) > cookieRefreshThreshold {
return
}
- if util.Contains(token.Audience(), tokenAudienceWebClient) {
- s.refreshClientToken(w, r, tokenClaims)
+ if (time.Since(claims.IssuedAt.Time()) + cookieTokenDuration) > maxTokenDuration {
+ return
+ }
+ if claims.Audience.Contains(tokenAudienceWebClient) {
+ s.refreshClientToken(w, r, claims)
} else {
- s.refreshAdminToken(w, r, tokenClaims)
+ s.refreshAdminToken(w, r, claims)
}
}
-func (s *httpdServer) refreshClientToken(w http.ResponseWriter, r *http.Request, tokenClaims jwtTokenClaims) {
+func (s *httpdServer) refreshClientToken(w http.ResponseWriter, r *http.Request, tokenClaims *jwt.Claims) {
user, err := dataprovider.GetUserWithGroupSettings(tokenClaims.Username, "")
if err != nil {
return
}
- if user.GetSignature() != tokenClaims.Signature {
+ if user.GetSignature() != tokenClaims.Subject {
logger.Debug(logSender, "", "signature mismatch for user %q, unable to refresh cookie", user.Username)
return
}
- if err := checkHTTPClientUser(&user, r, xid.New().String(), true); err != nil {
+ if err := user.CheckLoginConditions(); err != nil {
+ logger.Debug(logSender, "", "unable to refresh cookie for user %q: %v", user.Username, err)
+ return
+ }
+ if err := checkHTTPClientUser(&user, r, xid.New().String(), true, false); err != nil {
logger.Debug(logSender, "", "unable to refresh cookie for user %q: %v", user.Username, err)
return
}
@@ -1043,43 +1044,39 @@ func (s *httpdServer) refreshClientToken(w http.ResponseWriter, r *http.Request,
tokenClaims.Permissions = user.Filters.WebClient
tokenClaims.Role = user.Role
logger.Debug(logSender, "", "cookie refreshed for user %q", user.Username)
- tokenClaims.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebClient, util.GetIPFromRemoteAddress(r.RemoteAddr)) //nolint:errcheck
+ createAndSetCookie(w, r, tokenClaims, s.tokenAuth, tokenAudienceWebClient, util.GetIPFromRemoteAddress(r.RemoteAddr)) //nolint:errcheck
}
-func (s *httpdServer) refreshAdminToken(w http.ResponseWriter, r *http.Request, tokenClaims jwtTokenClaims) {
+func (s *httpdServer) refreshAdminToken(w http.ResponseWriter, r *http.Request, tokenClaims *jwt.Claims) {
admin, err := dataprovider.AdminExists(tokenClaims.Username)
if err != nil {
return
}
- if admin.Status != 1 {
- logger.Debug(logSender, "", "admin %q is disabled, unable to refresh cookie", admin.Username)
- return
- }
- if admin.GetSignature() != tokenClaims.Signature {
+ if admin.GetSignature() != tokenClaims.Subject {
logger.Debug(logSender, "", "signature mismatch for admin %q, unable to refresh cookie", admin.Username)
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if !admin.CanLoginFromIP(ipAddr) {
- logger.Debug(logSender, "", "admin %q cannot login from %v, unable to refresh cookie", admin.Username, r.RemoteAddr)
+ if err := admin.CanLogin(ipAddr); err != nil {
+ logger.Debug(logSender, "", "unable to refresh cookie for admin %q, err: %v", admin.Username, err)
return
}
tokenClaims.Permissions = admin.Permissions
tokenClaims.Role = admin.Role
tokenClaims.HideUserPageSections = admin.Filters.Preferences.HideUserPageSections
logger.Debug(logSender, "", "cookie refreshed for admin %q", admin.Username)
- tokenClaims.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebAdmin, ipAddr) //nolint:errcheck
+ createAndSetCookie(w, r, tokenClaims, s.tokenAuth, tokenAudienceWebAdmin, ipAddr) //nolint:errcheck
}
func (s *httpdServer) updateContextFromCookie(r *http.Request) *http.Request {
- token, _, err := jwtauth.FromContext(r.Context())
- if token == nil || err != nil {
- _, err = r.Cookie(jwtCookieKey)
+ _, err := jwt.FromContext(r.Context())
+ if err != nil {
+ _, err = r.Cookie(jwt.CookieKey)
if err != nil {
return r
}
- token, err = jwtauth.VerifyRequest(s.tokenAuth, r, jwtauth.TokenFromCookie)
- ctx := jwtauth.NewContext(r.Context(), token, err)
+ token, err := jwt.VerifyRequest(s.tokenAuth, r, jwt.TokenFromCookie)
+ ctx := jwt.NewContext(r.Context(), token, err)
return r.WithContext(ctx)
}
return r
@@ -1087,6 +1084,11 @@ func (s *httpdServer) updateContextFromCookie(r *http.Request) *http.Request {
func (s *httpdServer) parseHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ responseControllerDeadlines(
+ http.NewResponseController(w),
+ time.Now().Add(60*time.Second),
+ time.Now().Add(60*time.Second),
+ )
w.Header().Set("Server", version.GetServerVersion("/", false))
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
var ip net.IP
@@ -1188,8 +1190,9 @@ func (s *httpdServer) badHostHandler(w http.ResponseWriter, r *http.Request) {
break
}
}
+ logger.Debug(logSender, "", "the host %q is not allowed", host)
s.sendForbiddenResponse(w, r, util.NewI18nError(
- util.NewGenericError(fmt.Sprintf("The host %q is not allowed", host)),
+ util.NewGenericError(http.StatusText(http.StatusForbidden)),
util.I18nErrorConnectionForbidden,
))
}
@@ -1231,31 +1234,45 @@ func (s *httpdServer) mustCheckPath(r *http.Request) bool {
return !strings.HasPrefix(urlPath, webStaticFilesPath) && !strings.HasPrefix(urlPath, acmeChallengeURI)
}
-func (s *httpdServer) initializeRouter() {
+func (s *httpdServer) initializeRouter() error {
+ signer, err := jwt.NewSigner(jose.HS256, getSigningKey(s.signingPassphrase))
+ if err != nil {
+ return err
+ }
+ csrfSigner, err := jwt.NewSigner(jose.HS256, getSigningKey(s.signingPassphrase))
+ if err != nil {
+ return err
+ }
var hasHTTPSRedirect bool
- s.tokenAuth = jwtauth.New(jwa.HS256.String(), getSigningKey(s.signingPassphrase), nil)
+ s.tokenAuth = signer
+ s.csrfTokenAuth = csrfSigner
s.router = chi.NewRouter()
s.router.Use(middleware.RequestID)
s.router.Use(s.parseHeaders)
s.router.Use(logger.NewStructuredLogger(logger.GetLogger()))
s.router.Use(middleware.Recoverer)
- s.router.Use(middleware.Maybe(s.checkConnection, s.mustCheckPath))
if s.binding.Security.Enabled {
secureMiddleware := secure.New(secure.Options{
- AllowedHosts: s.binding.Security.AllowedHosts,
- AllowedHostsAreRegex: s.binding.Security.AllowedHostsAreRegex,
- HostsProxyHeaders: s.binding.Security.HostsProxyHeaders,
- SSLProxyHeaders: s.binding.Security.getHTTPSProxyHeaders(),
- STSSeconds: s.binding.Security.STSSeconds,
- STSIncludeSubdomains: s.binding.Security.STSIncludeSubdomains,
- STSPreload: s.binding.Security.STSPreload,
- ContentTypeNosniff: s.binding.Security.ContentTypeNosniff,
- ContentSecurityPolicy: s.binding.Security.ContentSecurityPolicy,
- PermissionsPolicy: s.binding.Security.PermissionsPolicy,
- CrossOriginOpenerPolicy: s.binding.Security.CrossOriginOpenerPolicy,
+ AllowedHosts: s.binding.Security.AllowedHosts,
+ AllowedHostsAreRegex: s.binding.Security.AllowedHostsAreRegex,
+ HostsProxyHeaders: s.binding.Security.HostsProxyHeaders,
+ SSLProxyHeaders: s.binding.Security.getHTTPSProxyHeaders(),
+ STSSeconds: s.binding.Security.STSSeconds,
+ STSIncludeSubdomains: s.binding.Security.STSIncludeSubdomains,
+ STSPreload: s.binding.Security.STSPreload,
+ ContentTypeNosniff: s.binding.Security.ContentTypeNosniff,
+ ContentSecurityPolicy: s.binding.Security.ContentSecurityPolicy,
+ PermissionsPolicy: s.binding.Security.PermissionsPolicy,
+ CrossOriginOpenerPolicy: s.binding.Security.CrossOriginOpenerPolicy,
+ CrossOriginResourcePolicy: s.binding.Security.CrossOriginResourcePolicy,
+ CrossOriginEmbedderPolicy: s.binding.Security.CrossOriginEmbedderPolicy,
+ ReferrerPolicy: s.binding.Security.ReferrerPolicy,
})
secureMiddleware.SetBadHostHandler(http.HandlerFunc(s.badHostHandler))
+ if s.binding.Security.CacheControl == "private" {
+ s.router.Use(cacheControlMiddleware)
+ }
s.router.Use(secureMiddleware.Handler)
if s.binding.Security.HTTPSRedirect {
s.router.Use(s.binding.Security.redirectHandler)
@@ -1276,6 +1293,7 @@ func (s *httpdServer) initializeRouter() {
})
s.router.Use(c.Handler)
}
+ s.router.Use(middleware.Maybe(s.checkConnection, s.mustCheckPath))
s.router.Use(middleware.GetHead)
s.router.Use(middleware.Maybe(middleware.StripSlashes, s.mustStripSlash))
@@ -1291,24 +1309,57 @@ func (s *httpdServer) initializeRouter() {
}
}
- if s.enableRESTAPI {
- // share API available to external users
- s.router.Get(sharesPath+"/{id}", s.downloadFromShare) //nolint:goconst
- s.router.Post(sharesPath+"/{id}", s.uploadFilesToShare)
- s.router.Post(sharesPath+"/{id}/{name}", s.uploadFileToShare)
- s.router.With(compressor.Handler).Get(sharesPath+"/{id}/dirs", s.readBrowsableShareContents)
- s.router.Get(sharesPath+"/{id}/files", s.downloadBrowsableSharedFile)
+ s.setupRESTAPIRoutes()
- s.router.Get(tokenPath, s.getToken)
- s.router.Post(adminPath+"/{username}/forgot-password", forgotAdminPassword)
- s.router.Post(adminPath+"/{username}/reset-password", resetAdminPassword)
- s.router.Post(userPath+"/{username}/forgot-password", forgotUserPassword)
- s.router.Post(userPath+"/{username}/reset-password", resetUserPassword)
+ if s.enableWebAdmin || s.enableWebClient {
+ s.router.Group(func(router chi.Router) {
+ router.Use(cleanCacheControlMiddleware)
+ router.Use(compressor.Handler)
+ serveStaticDir(router, webStaticFilesPath, s.staticFilesPath, true)
+ })
+ if s.binding.OIDC.isEnabled() {
+ s.router.Get(webOIDCRedirectPath, s.handleOIDCRedirect)
+ }
+ if s.enableWebClient {
+ s.router.Get(webRootPath, func(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+ s.redirectToWebPath(w, r, webClientLoginPath)
+ })
+ s.router.Get(webBasePath, func(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+ s.redirectToWebPath(w, r, webClientLoginPath)
+ })
+ } else {
+ s.router.Get(webRootPath, func(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+ s.redirectToWebPath(w, r, webAdminLoginPath)
+ })
+ s.router.Get(webBasePath, func(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+ s.redirectToWebPath(w, r, webAdminLoginPath)
+ })
+ }
+ }
+
+ s.setupWebClientRoutes()
+ s.setupWebAdminRoutes()
+ return nil
+}
+
+func (s *httpdServer) setupRESTAPIRoutes() {
+ if s.enableRESTAPI {
+ if !s.binding.isAdminTokenEndpointDisabled() {
+ s.router.Get(tokenPath, s.getToken)
+ s.router.Post(adminPath+"/{username}/forgot-password", forgotAdminPassword)
+ s.router.Post(adminPath+"/{username}/reset-password", resetAdminPassword)
+ }
s.router.Group(func(router chi.Router) {
router.Use(checkNodeToken(s.tokenAuth))
- router.Use(checkAPIKeyAuth(s.tokenAuth, dataprovider.APIKeyScopeAdmin))
- router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromHeader))
+ if !s.binding.isAdminAPIKeyAuthDisabled() {
+ router.Use(checkAPIKeyAuth(s.tokenAuth, dataprovider.APIKeyScopeAdmin))
+ }
+ router.Use(jwt.Verify(s.tokenAuth, jwt.TokenFromHeader))
router.Use(jwtAuthenticatorAPI)
router.Get(versionPath, func(w http.ResponseWriter, r *http.Request) {
@@ -1328,105 +1379,116 @@ func (s *httpdServer) initializeRouter() {
router.With(forbidAPIKeyAuthentication).Get(admin2FARecoveryCodesPath, getRecoveryCodes)
router.With(forbidAPIKeyAuthentication).Post(admin2FARecoveryCodesPath, generateRecoveryCodes)
- router.With(forbidAPIKeyAuthentication, s.checkPerm(dataprovider.PermAdminManageAPIKeys)).
+ router.With(forbidAPIKeyAuthentication, s.checkPerms(dataprovider.PermAdminAny)).
Get(apiKeysPath, getAPIKeys)
- router.With(forbidAPIKeyAuthentication, s.checkPerm(dataprovider.PermAdminManageAPIKeys)).
+ router.With(forbidAPIKeyAuthentication, s.checkPerms(dataprovider.PermAdminAny)).
Post(apiKeysPath, addAPIKey)
- router.With(forbidAPIKeyAuthentication, s.checkPerm(dataprovider.PermAdminManageAPIKeys)).
+ router.With(forbidAPIKeyAuthentication, s.checkPerms(dataprovider.PermAdminAny)).
Get(apiKeysPath+"/{id}", getAPIKeyByID)
- router.With(forbidAPIKeyAuthentication, s.checkPerm(dataprovider.PermAdminManageAPIKeys)).
+ router.With(forbidAPIKeyAuthentication, s.checkPerms(dataprovider.PermAdminAny)).
Put(apiKeysPath+"/{id}", updateAPIKey)
- router.With(forbidAPIKeyAuthentication, s.checkPerm(dataprovider.PermAdminManageAPIKeys)).
+ router.With(forbidAPIKeyAuthentication, s.checkPerms(dataprovider.PermAdminAny)).
Delete(apiKeysPath+"/{id}", deleteAPIKey)
router.Group(func(router chi.Router) {
router.Use(s.checkAuthRequirements)
- router.With(s.checkPerm(dataprovider.PermAdminViewServerStatus)).
+ router.With(s.checkPerms(dataprovider.PermAdminViewServerStatus)).
Get(serverStatusPath, func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
render.JSON(w, r, getServicesStatus())
})
- router.With(s.checkPerm(dataprovider.PermAdminViewConnections)).Get(activeConnectionsPath, getActiveConnections)
- router.With(s.checkPerm(dataprovider.PermAdminCloseConnections)).
+ router.With(s.checkPerms(dataprovider.PermAdminViewConnections)).Get(activeConnectionsPath, getActiveConnections)
+ router.With(s.checkPerms(dataprovider.PermAdminCloseConnections)).
Delete(activeConnectionsPath+"/{connectionID}", handleCloseConnection)
- router.With(s.checkPerm(dataprovider.PermAdminQuotaScans)).Get(quotasBasePath+"/users/scans", getUsersQuotaScans)
- router.With(s.checkPerm(dataprovider.PermAdminQuotaScans)).Post(quotasBasePath+"/users/{username}/scan", startUserQuotaScan)
- router.With(s.checkPerm(dataprovider.PermAdminQuotaScans)).Get(quotasBasePath+"/folders/scans", getFoldersQuotaScans)
- router.With(s.checkPerm(dataprovider.PermAdminQuotaScans)).Post(quotasBasePath+"/folders/{name}/scan", startFolderQuotaScan)
- router.With(s.checkPerm(dataprovider.PermAdminViewUsers)).Get(userPath, getUsers)
- router.With(s.checkPerm(dataprovider.PermAdminAddUsers)).Post(userPath, addUser)
- router.With(s.checkPerm(dataprovider.PermAdminViewUsers)).Get(userPath+"/{username}", getUserByUsername) //nolint:goconst
- router.With(s.checkPerm(dataprovider.PermAdminChangeUsers)).Put(userPath+"/{username}", updateUser)
- router.With(s.checkPerm(dataprovider.PermAdminDeleteUsers)).Delete(userPath+"/{username}", deleteUser)
- router.With(s.checkPerm(dataprovider.PermAdminDisableMFA)).Put(userPath+"/{username}/2fa/disable", disableUser2FA) //nolint:goconst
- router.With(s.checkPerm(dataprovider.PermAdminManageFolders)).Get(folderPath, getFolders)
- router.With(s.checkPerm(dataprovider.PermAdminManageFolders)).Get(folderPath+"/{name}", getFolderByName) //nolint:goconst
- router.With(s.checkPerm(dataprovider.PermAdminManageFolders)).Post(folderPath, addFolder)
- router.With(s.checkPerm(dataprovider.PermAdminManageFolders)).Put(folderPath+"/{name}", updateFolder)
- router.With(s.checkPerm(dataprovider.PermAdminManageFolders)).Delete(folderPath+"/{name}", deleteFolder)
- router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Get(groupPath, getGroups)
- router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Get(groupPath+"/{name}", getGroupByName)
- router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Post(groupPath, addGroup)
- router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Put(groupPath+"/{name}", updateGroup)
- router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Delete(groupPath+"/{name}", deleteGroup)
- router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Get(dumpDataPath, dumpData)
- router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Get(loadDataPath, loadData)
- router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Post(loadDataPath, loadDataFromRequest)
- router.With(s.checkPerm(dataprovider.PermAdminChangeUsers)).Put(quotasBasePath+"/users/{username}/usage",
+ router.With(s.checkPerms(dataprovider.PermAdminQuotaScans)).Get(quotasBasePath+"/users/scans", getUsersQuotaScans)
+ router.With(s.checkPerms(dataprovider.PermAdminQuotaScans)).Post(quotasBasePath+"/users/{username}/scan", startUserQuotaScan)
+ router.With(s.checkPerms(dataprovider.PermAdminQuotaScans)).Get(quotasBasePath+"/folders/scans", getFoldersQuotaScans)
+ router.With(s.checkPerms(dataprovider.PermAdminQuotaScans)).Post(quotasBasePath+"/folders/{name}/scan", startFolderQuotaScan)
+ router.With(s.checkPerms(dataprovider.PermAdminViewUsers)).Get(userPath, getUsers)
+ router.With(s.checkPerms(dataprovider.PermAdminAddUsers)).Post(userPath, addUser)
+ router.With(s.checkPerms(dataprovider.PermAdminViewUsers)).Get(userPath+"/{username}", getUserByUsername) //nolint:goconst
+ router.With(s.checkPerms(dataprovider.PermAdminChangeUsers)).Put(userPath+"/{username}", updateUser)
+ router.With(s.checkPerms(dataprovider.PermAdminDeleteUsers)).Delete(userPath+"/{username}", deleteUser)
+ router.With(s.checkPerms(dataprovider.PermAdminDisableMFA)).Put(userPath+"/{username}/2fa/disable", disableUser2FA) //nolint:goconst
+ router.With(s.checkPerms(dataprovider.PermAdminManageFolders)).Get(folderPath, getFolders)
+ router.With(s.checkPerms(dataprovider.PermAdminManageFolders)).Get(folderPath+"/{name}", getFolderByName) //nolint:goconst
+ router.With(s.checkPerms(dataprovider.PermAdminManageFolders)).Post(folderPath, addFolder)
+ router.With(s.checkPerms(dataprovider.PermAdminManageFolders)).Put(folderPath+"/{name}", updateFolder)
+ router.With(s.checkPerms(dataprovider.PermAdminManageFolders)).Delete(folderPath+"/{name}", deleteFolder)
+ router.With(s.checkPerms(dataprovider.PermAdminManageGroups)).Get(groupPath, getGroups)
+ router.With(s.checkPerms(dataprovider.PermAdminManageGroups)).Get(groupPath+"/{name}", getGroupByName)
+ router.With(s.checkPerms(dataprovider.PermAdminManageGroups)).Post(groupPath, addGroup)
+ router.With(s.checkPerms(dataprovider.PermAdminManageGroups)).Put(groupPath+"/{name}", updateGroup)
+ router.With(s.checkPerms(dataprovider.PermAdminManageGroups)).Delete(groupPath+"/{name}", deleteGroup)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Get(dumpDataPath, dumpData)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Get(loadDataPath, loadData)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(loadDataPath, loadDataFromRequest)
+ router.With(s.checkPerms(dataprovider.PermAdminChangeUsers)).Put(quotasBasePath+"/users/{username}/usage",
updateUserQuotaUsage)
- router.With(s.checkPerm(dataprovider.PermAdminChangeUsers)).Put(quotasBasePath+"/users/{username}/transfer-usage",
+ router.With(s.checkPerms(dataprovider.PermAdminChangeUsers)).Put(quotasBasePath+"/users/{username}/transfer-usage",
updateUserTransferQuotaUsage)
- router.With(s.checkPerm(dataprovider.PermAdminChangeUsers)).Put(quotasBasePath+"/folders/{name}/usage",
+ router.With(s.checkPerms(dataprovider.PermAdminChangeUsers)).Put(quotasBasePath+"/folders/{name}/usage",
updateFolderQuotaUsage)
- router.With(s.checkPerm(dataprovider.PermAdminViewDefender)).Get(defenderHosts, getDefenderHosts)
- router.With(s.checkPerm(dataprovider.PermAdminViewDefender)).Get(defenderHosts+"/{id}", getDefenderHostByID)
- router.With(s.checkPerm(dataprovider.PermAdminManageDefender)).Delete(defenderHosts+"/{id}", deleteDefenderHostByID)
- router.With(s.checkPerm(dataprovider.PermAdminManageAdmins)).Get(adminPath, getAdmins)
- router.With(s.checkPerm(dataprovider.PermAdminManageAdmins)).Post(adminPath, addAdmin)
- router.With(s.checkPerm(dataprovider.PermAdminManageAdmins)).Get(adminPath+"/{username}", getAdminByUsername)
- router.With(s.checkPerm(dataprovider.PermAdminManageAdmins)).Put(adminPath+"/{username}", updateAdmin)
- router.With(s.checkPerm(dataprovider.PermAdminManageAdmins)).Delete(adminPath+"/{username}", deleteAdmin)
- router.With(s.checkPerm(dataprovider.PermAdminDisableMFA)).Put(adminPath+"/{username}/2fa/disable", disableAdmin2FA)
- router.With(s.checkPerm(dataprovider.PermAdminRetentionChecks)).Get(retentionChecksPath, getRetentionChecks)
- router.With(s.checkPerm(dataprovider.PermAdminRetentionChecks)).Post(retentionBasePath+"/{username}/check",
- startRetentionCheck)
- router.With(s.checkPerm(dataprovider.PermAdminViewEvents), compressor.Handler).
+ router.With(s.checkPerms(dataprovider.PermAdminViewDefender)).Get(defenderHosts, getDefenderHosts)
+ router.With(s.checkPerms(dataprovider.PermAdminViewDefender)).Get(defenderHosts+"/{id}", getDefenderHostByID)
+ router.With(s.checkPerms(dataprovider.PermAdminManageDefender)).Delete(defenderHosts+"/{id}", deleteDefenderHostByID)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Get(adminPath, getAdmins)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(adminPath, addAdmin)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Get(adminPath+"/{username}", getAdminByUsername)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Put(adminPath+"/{username}", updateAdmin)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Delete(adminPath+"/{username}", deleteAdmin)
+ router.With(s.checkPerms(dataprovider.PermAdminDisableMFA)).Put(adminPath+"/{username}/2fa/disable", disableAdmin2FA)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Get(retentionChecksPath, getRetentionChecks)
+ router.With(s.checkPerms(dataprovider.PermAdminViewEvents), compressor.Handler).
Get(fsEventsPath, searchFsEvents)
- router.With(s.checkPerm(dataprovider.PermAdminViewEvents), compressor.Handler).
+ router.With(s.checkPerms(dataprovider.PermAdminViewEvents), compressor.Handler).
Get(providerEventsPath, searchProviderEvents)
- router.With(s.checkPerm(dataprovider.PermAdminViewEvents), compressor.Handler).
+ router.With(s.checkPerms(dataprovider.PermAdminViewEvents), compressor.Handler).
Get(logEventsPath, searchLogEvents)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Get(eventActionsPath, getEventActions)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Get(eventActionsPath+"/{name}", getEventActionByName)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Post(eventActionsPath, addEventAction)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Put(eventActionsPath+"/{name}", updateEventAction)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Delete(eventActionsPath+"/{name}", deleteEventAction)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Get(eventRulesPath, getEventRules)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Get(eventRulesPath+"/{name}", getEventRuleByName)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Post(eventRulesPath, addEventRule)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Put(eventRulesPath+"/{name}", updateEventRule)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Delete(eventRulesPath+"/{name}", deleteEventRule)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Post(eventRulesPath+"/run/{name}", runOnDemandRule)
- router.With(s.checkPerm(dataprovider.PermAdminManageRoles)).Get(rolesPath, getRoles)
- router.With(s.checkPerm(dataprovider.PermAdminManageRoles)).Post(rolesPath, addRole)
- router.With(s.checkPerm(dataprovider.PermAdminManageRoles)).Get(rolesPath+"/{name}", getRoleByName)
- router.With(s.checkPerm(dataprovider.PermAdminManageRoles)).Put(rolesPath+"/{name}", updateRole)
- router.With(s.checkPerm(dataprovider.PermAdminManageRoles)).Delete(rolesPath+"/{name}", deleteRole)
- router.With(s.checkPerm(dataprovider.PermAdminManageIPLists), compressor.Handler).Get(ipListsPath+"/{type}", getIPListEntries) //nolint:goconst
- router.With(s.checkPerm(dataprovider.PermAdminManageIPLists)).Post(ipListsPath+"/{type}", addIPListEntry)
- router.With(s.checkPerm(dataprovider.PermAdminManageIPLists)).Get(ipListsPath+"/{type}/{ipornet}", getIPListEntry) //nolint:goconst
- router.With(s.checkPerm(dataprovider.PermAdminManageIPLists)).Put(ipListsPath+"/{type}/{ipornet}", updateIPListEntry)
- router.With(s.checkPerm(dataprovider.PermAdminManageIPLists)).Delete(ipListsPath+"/{type}/{ipornet}", deleteIPListEntry)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Get(eventActionsPath, getEventActions)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Get(eventActionsPath+"/{name}", getEventActionByName)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(eventActionsPath, addEventAction)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Put(eventActionsPath+"/{name}", updateEventAction)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Delete(eventActionsPath+"/{name}", deleteEventAction)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Get(eventRulesPath, getEventRules)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Get(eventRulesPath+"/{name}", getEventRuleByName)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(eventRulesPath, addEventRule)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Put(eventRulesPath+"/{name}", updateEventRule)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Delete(eventRulesPath+"/{name}", deleteEventRule)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(eventRulesPath+"/run/{name}", runOnDemandRule)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Get(rolesPath, getRoles)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(rolesPath, addRole)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Get(rolesPath+"/{name}", getRoleByName)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Put(rolesPath+"/{name}", updateRole)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Delete(rolesPath+"/{name}", deleteRole)
+ router.With(s.checkPerms(dataprovider.PermAdminAny), compressor.Handler).Get(ipListsPath+"/{type}", getIPListEntries) //nolint:goconst
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(ipListsPath+"/{type}", addIPListEntry)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Get(ipListsPath+"/{type}/{ipornet}", getIPListEntry) //nolint:goconst
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Put(ipListsPath+"/{type}/{ipornet}", updateIPListEntry)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Delete(ipListsPath+"/{type}/{ipornet}", deleteIPListEntry)
})
})
- s.router.Get(userTokenPath, s.getUserToken)
+ // share API available to external users
+ s.router.Get(sharesPath+"/{id}", s.downloadFromShare)
+ s.router.Post(sharesPath+"/{id}", s.uploadFilesToShare)
+ s.router.Post(sharesPath+"/{id}/{name}", s.uploadFileToShare)
+ s.router.With(compressor.Handler).Get(sharesPath+"/{id}/dirs", s.readBrowsableShareContents)
+ s.router.Get(sharesPath+"/{id}/files", s.downloadBrowsableSharedFile)
+
+ if !s.binding.isUserTokenEndpointDisabled() {
+ s.router.Get(userTokenPath, s.getUserToken)
+ s.router.Post(userPath+"/{username}/forgot-password", forgotUserPassword)
+ s.router.Post(userPath+"/{username}/reset-password", resetUserPassword)
+ }
s.router.Group(func(router chi.Router) {
- router.Use(checkAPIKeyAuth(s.tokenAuth, dataprovider.APIKeyScopeUser))
- router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromHeader))
+ if !s.binding.isUserAPIKeyAuthDisabled() {
+ router.Use(checkAPIKeyAuth(s.tokenAuth, dataprovider.APIKeyScopeUser))
+ }
+ router.Use(jwt.Verify(s.tokenAuth, jwt.TokenFromHeader))
router.Use(jwtAuthenticatorAPIUser)
router.With(forbidAPIKeyAuthentication).Get(userLogoutPath, s.logout)
@@ -1485,43 +1547,12 @@ func (s *httpdServer) initializeRouter() {
if s.renderOpenAPI {
s.router.Group(func(router chi.Router) {
+ router.Use(cleanCacheControlMiddleware)
router.Use(compressor.Handler)
serveStaticDir(router, webOpenAPIPath, s.openAPIPath, false)
})
}
}
-
- if s.enableWebAdmin || s.enableWebClient {
- s.router.Group(func(router chi.Router) {
- router.Use(compressor.Handler)
- serveStaticDir(router, webStaticFilesPath, s.staticFilesPath, true)
- })
- if s.binding.OIDC.isEnabled() {
- s.router.Get(webOIDCRedirectPath, s.handleOIDCRedirect)
- }
- if s.enableWebClient {
- s.router.Get(webRootPath, func(w http.ResponseWriter, r *http.Request) {
- r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- s.redirectToWebPath(w, r, webClientLoginPath)
- })
- s.router.Get(webBasePath, func(w http.ResponseWriter, r *http.Request) {
- r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- s.redirectToWebPath(w, r, webClientLoginPath)
- })
- } else {
- s.router.Get(webRootPath, func(w http.ResponseWriter, r *http.Request) {
- r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- s.redirectToWebPath(w, r, webAdminLoginPath)
- })
- s.router.Get(webBasePath, func(w http.ResponseWriter, r *http.Request) {
- r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- s.redirectToWebPath(w, r, webAdminLoginPath)
- })
- }
- }
-
- s.setupWebClientRoutes()
- s.setupWebAdminRoutes()
}
func (s *httpdServer) setupWebClientRoutes() {
@@ -1530,32 +1561,48 @@ func (s *httpdServer) setupWebClientRoutes() {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
http.Redirect(w, r, webClientLoginPath, http.StatusFound)
})
+ s.router.With(cleanCacheControlMiddleware).Get(path.Join(webStaticFilesPath, "branding/webclient/logo.png"),
+ func(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+ renderPNGImage(w, r, dbBrandingConfig.getWebClientLogo())
+ })
+ s.router.With(cleanCacheControlMiddleware).Get(path.Join(webStaticFilesPath, "branding/webclient/favicon.png"),
+ func(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+ renderPNGImage(w, r, dbBrandingConfig.getWebClientFavicon())
+ })
s.router.Get(webClientLoginPath, s.handleClientWebLogin)
if s.binding.OIDC.isEnabled() && !s.binding.isWebClientOIDCLoginDisabled() {
s.router.Get(webClientOIDCLoginPath, s.handleWebClientOIDCLogin)
}
if !s.binding.isWebClientLoginFormDisabled() {
- s.router.Post(webClientLoginPath, s.handleWebClientLoginPost)
+ s.router.With(jwt.Verify(s.csrfTokenAuth, jwt.TokenFromCookie)).
+ Post(webClientLoginPath, s.handleWebClientLoginPost)
s.router.Get(webClientForgotPwdPath, s.handleWebClientForgotPwd)
- s.router.Post(webClientForgotPwdPath, s.handleWebClientForgotPwdPost)
- s.router.Get(webClientResetPwdPath, s.handleWebClientPasswordReset)
- s.router.Post(webClientResetPwdPath, s.handleWebClientPasswordResetPost)
- s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
+ s.router.With(jwt.Verify(s.csrfTokenAuth, jwt.TokenFromCookie)).
+ Post(webClientForgotPwdPath, s.handleWebClientForgotPwdPost)
+ s.router.With(jwt.Verify(s.csrfTokenAuth, jwt.TokenFromCookie)).
+ Get(webClientResetPwdPath, s.handleWebClientPasswordReset)
+ s.router.With(jwt.Verify(s.csrfTokenAuth, jwt.TokenFromCookie)).
+ Post(webClientResetPwdPath, s.handleWebClientPasswordResetPost)
+ s.router.With(jwt.Verify(s.tokenAuth, jwt.TokenFromCookie),
s.jwtAuthenticatorPartial(tokenAudienceWebClientPartial)).
Get(webClientTwoFactorPath, s.handleWebClientTwoFactor)
- s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
+ s.router.With(jwt.Verify(s.tokenAuth, jwt.TokenFromCookie),
s.jwtAuthenticatorPartial(tokenAudienceWebClientPartial)).
Post(webClientTwoFactorPath, s.handleWebClientTwoFactorPost)
- s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
+ s.router.With(jwt.Verify(s.tokenAuth, jwt.TokenFromCookie),
s.jwtAuthenticatorPartial(tokenAudienceWebClientPartial)).
Get(webClientTwoFactorRecoveryPath, s.handleWebClientTwoFactorRecovery)
- s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
+ s.router.With(jwt.Verify(s.tokenAuth, jwt.TokenFromCookie),
s.jwtAuthenticatorPartial(tokenAudienceWebClientPartial)).
Post(webClientTwoFactorRecoveryPath, s.handleWebClientTwoFactorRecoveryPost)
}
// share routes available to external users
s.router.Get(webClientPubSharesPath+"/{id}/login", s.handleClientShareLoginGet)
- s.router.Post(webClientPubSharesPath+"/{id}/login", s.handleClientShareLoginPost)
+ s.router.With(jwt.Verify(s.csrfTokenAuth, jwt.TokenFromCookie)).
+ Post(webClientPubSharesPath+"/{id}/login", s.handleClientShareLoginPost)
+ s.router.Get(webClientPubSharesPath+"/{id}/logout", s.handleClientShareLogout)
s.router.Get(webClientPubSharesPath+"/{id}", s.downloadFromShare)
s.router.Post(webClientPubSharesPath+"/{id}/partial", s.handleClientSharePartialDownload)
s.router.Get(webClientPubSharesPath+"/{id}/browse", s.handleShareGetFiles)
@@ -1572,32 +1619,32 @@ func (s *httpdServer) setupWebClientRoutes() {
if s.binding.OIDC.isEnabled() {
router.Use(s.oidcTokenAuthenticator(tokenAudienceWebClient))
}
- router.Use(jwtauth.Verify(s.tokenAuth, tokenFromContext, jwtauth.TokenFromCookie))
+ router.Use(jwt.Verify(s.tokenAuth, oidcTokenFromContext, jwt.TokenFromCookie))
router.Use(jwtAuthenticatorWebClient)
router.Get(webClientLogoutPath, s.handleWebClientLogout)
router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientFilesPath, s.handleClientGetFiles)
router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientViewPDFPath, s.handleClientViewPDF)
router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientGetPDFPath, s.handleClientGetPDF)
- router.With(s.checkAuthRequirements, s.refreshCookie, verifyCSRFHeader).Get(webClientFilePath, getUserFile)
- router.With(s.checkAuthRequirements, s.refreshCookie, verifyCSRFHeader).Get(webClientTasksPath+"/{id}",
+ router.With(s.checkAuthRequirements, s.refreshCookie, s.verifyCSRFHeader).Get(webClientFilePath, getUserFile)
+ router.With(s.checkAuthRequirements, s.refreshCookie, s.verifyCSRFHeader).Get(webClientTasksPath+"/{id}",
getWebTask)
- router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+ router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), s.verifyCSRFHeader).
Post(webClientFilePath, uploadUserFile)
- router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+ router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), s.verifyCSRFHeader).
Post(webClientExistPath, s.handleClientCheckExist)
router.With(s.checkAuthRequirements, s.refreshCookie).Get(webClientEditFilePath, s.handleClientEditFile)
- router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+ router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), s.verifyCSRFHeader).
Delete(webClientFilesPath, deleteUserFile)
router.With(s.checkAuthRequirements, compressor.Handler, s.refreshCookie).
Get(webClientDirsPath, s.handleClientGetDirContents)
- router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+ router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), s.verifyCSRFHeader).
Post(webClientDirsPath, createUserDir)
- router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+ router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), s.verifyCSRFHeader).
Delete(webClientDirsPath, taskDeleteDir)
- router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+ router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), s.verifyCSRFHeader).
Post(webClientFileActionsPath+"/move", taskRenameFsEntry)
- router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
+ router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientWriteDisabled), s.verifyCSRFHeader).
Post(webClientFileActionsPath+"/copy", taskCopyFsEntry)
router.With(s.checkAuthRequirements, s.refreshCookie).
Post(webClientDownloadZipPath, s.handleWebClientDownloadZip)
@@ -1613,15 +1660,15 @@ func (s *httpdServer) setupWebClientRoutes() {
Get(webClientMFAPath, s.handleWebClientMFA)
router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), s.refreshCookie).
Get(webClientMFAPath+"/qrcode", getQRCode)
- router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader).
+ router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), s.verifyCSRFHeader).
Post(webClientTOTPGeneratePath, generateTOTPSecret)
- router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader).
+ router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), s.verifyCSRFHeader).
Post(webClientTOTPValidatePath, validateTOTPPasscode)
- router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader).
+ router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), s.verifyCSRFHeader).
Post(webClientTOTPSavePath, saveTOTPConfig)
- router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader, s.refreshCookie).
+ router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), s.verifyCSRFHeader, s.refreshCookie).
Get(webClientRecoveryCodesPath, getRecoveryCodes)
- router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader).
+ router.With(s.checkHTTPUserPerm(sdk.WebClientMFADisabled), s.verifyCSRFHeader).
Post(webClientRecoveryCodesPath, generateRecoveryCodes)
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled), compressor.Handler, s.refreshCookie).
Get(webClientSharesPath+jsonAPISuffix, getAllShares)
@@ -1635,7 +1682,7 @@ func (s *httpdServer) setupWebClientRoutes() {
Get(webClientSharePath+"/{id}", s.handleClientUpdateShareGet)
router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
Post(webClientSharePath+"/{id}", s.handleClientUpdateSharePost)
- router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled), verifyCSRFHeader).
+ router.With(s.checkAuthRequirements, s.checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.verifyCSRFHeader).
Delete(webClientSharePath+"/{id}", deleteShare)
})
}
@@ -1647,38 +1694,53 @@ func (s *httpdServer) setupWebAdminRoutes() {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
s.redirectToWebPath(w, r, webAdminLoginPath)
})
+ s.router.With(cleanCacheControlMiddleware).Get(path.Join(webStaticFilesPath, "branding/webadmin/logo.png"),
+ func(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+ renderPNGImage(w, r, dbBrandingConfig.getWebAdminLogo())
+ })
+ s.router.With(cleanCacheControlMiddleware).Get(path.Join(webStaticFilesPath, "branding/webadmin/favicon.png"),
+ func(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+ renderPNGImage(w, r, dbBrandingConfig.getWebAdminFavicon())
+ })
s.router.Get(webAdminLoginPath, s.handleWebAdminLogin)
if s.binding.OIDC.hasRoles() && !s.binding.isWebAdminOIDCLoginDisabled() {
s.router.Get(webAdminOIDCLoginPath, s.handleWebAdminOIDCLogin)
}
s.router.Get(webOAuth2RedirectPath, s.handleOAuth2TokenRedirect)
s.router.Get(webAdminSetupPath, s.handleWebAdminSetupGet)
- s.router.Post(webAdminSetupPath, s.handleWebAdminSetupPost)
+ s.router.With(jwt.Verify(s.csrfTokenAuth, jwt.TokenFromCookie)).
+ Post(webAdminSetupPath, s.handleWebAdminSetupPost)
if !s.binding.isWebAdminLoginFormDisabled() {
- s.router.Post(webAdminLoginPath, s.handleWebAdminLoginPost)
- s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
+ s.router.With(jwt.Verify(s.csrfTokenAuth, jwt.TokenFromCookie)).
+ Post(webAdminLoginPath, s.handleWebAdminLoginPost)
+ s.router.With(jwt.Verify(s.tokenAuth, jwt.TokenFromCookie),
s.jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)).
Get(webAdminTwoFactorPath, s.handleWebAdminTwoFactor)
- s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
+ s.router.With(jwt.Verify(s.tokenAuth, jwt.TokenFromCookie),
s.jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)).
Post(webAdminTwoFactorPath, s.handleWebAdminTwoFactorPost)
- s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
+ s.router.With(jwt.Verify(s.tokenAuth, jwt.TokenFromCookie),
s.jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)).
Get(webAdminTwoFactorRecoveryPath, s.handleWebAdminTwoFactorRecovery)
- s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
+ s.router.With(jwt.Verify(s.tokenAuth, jwt.TokenFromCookie),
s.jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)).
Post(webAdminTwoFactorRecoveryPath, s.handleWebAdminTwoFactorRecoveryPost)
s.router.Get(webAdminForgotPwdPath, s.handleWebAdminForgotPwd)
- s.router.Post(webAdminForgotPwdPath, s.handleWebAdminForgotPwdPost)
- s.router.Get(webAdminResetPwdPath, s.handleWebAdminPasswordReset)
- s.router.Post(webAdminResetPwdPath, s.handleWebAdminPasswordResetPost)
+ s.router.With(jwt.Verify(s.csrfTokenAuth, jwt.TokenFromCookie)).
+ Post(webAdminForgotPwdPath, s.handleWebAdminForgotPwdPost)
+ s.router.With(jwt.Verify(s.csrfTokenAuth, jwt.TokenFromCookie)).
+ Get(webAdminResetPwdPath, s.handleWebAdminPasswordReset)
+ s.router.With(jwt.Verify(s.csrfTokenAuth, jwt.TokenFromCookie)).
+ Post(webAdminResetPwdPath, s.handleWebAdminPasswordResetPost)
}
s.router.Group(func(router chi.Router) {
if s.binding.OIDC.isEnabled() {
router.Use(s.oidcTokenAuthenticator(tokenAudienceWebAdmin))
}
- router.Use(jwtauth.Verify(s.tokenAuth, tokenFromContext, jwtauth.TokenFromCookie))
+ router.Use(jwt.Verify(s.tokenAuth, oidcTokenFromContext, jwt.TokenFromCookie))
router.Use(jwtAuthenticatorWebAdmin)
router.Get(webLogoutPath, s.handleWebAdminLogout)
@@ -1690,167 +1752,168 @@ func (s *httpdServer) setupWebAdminRoutes() {
router.With(s.refreshCookie, s.requireBuiltinLogin).Get(webAdminMFAPath, s.handleWebAdminMFA)
router.With(s.refreshCookie, s.requireBuiltinLogin).Get(webAdminMFAPath+"/qrcode", getQRCode)
- router.With(verifyCSRFHeader, s.requireBuiltinLogin).Post(webAdminTOTPGeneratePath, generateTOTPSecret)
- router.With(verifyCSRFHeader, s.requireBuiltinLogin).Post(webAdminTOTPValidatePath, validateTOTPPasscode)
- router.With(verifyCSRFHeader, s.requireBuiltinLogin).Post(webAdminTOTPSavePath, saveTOTPConfig)
- router.With(verifyCSRFHeader, s.requireBuiltinLogin, s.refreshCookie).Get(webAdminRecoveryCodesPath,
+ router.With(s.verifyCSRFHeader, s.requireBuiltinLogin).Post(webAdminTOTPGeneratePath, generateTOTPSecret)
+ router.With(s.verifyCSRFHeader, s.requireBuiltinLogin).Post(webAdminTOTPValidatePath, validateTOTPPasscode)
+ router.With(s.verifyCSRFHeader, s.requireBuiltinLogin).Post(webAdminTOTPSavePath, saveTOTPConfig)
+ router.With(s.verifyCSRFHeader, s.requireBuiltinLogin, s.refreshCookie).Get(webAdminRecoveryCodesPath,
getRecoveryCodes)
- router.With(verifyCSRFHeader, s.requireBuiltinLogin).Post(webAdminRecoveryCodesPath, generateRecoveryCodes)
+ router.With(s.verifyCSRFHeader, s.requireBuiltinLogin).Post(webAdminRecoveryCodesPath, generateRecoveryCodes)
router.Group(func(router chi.Router) {
router.Use(s.checkAuthRequirements)
- router.With(s.checkPerm(dataprovider.PermAdminViewUsers), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminViewUsers), s.refreshCookie).
Get(webUsersPath, s.handleGetWebUsers)
- router.With(s.checkPerm(dataprovider.PermAdminViewUsers), compressor.Handler, s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminViewUsers), compressor.Handler, s.refreshCookie).
Get(webUsersPath+jsonAPISuffix, getAllUsers)
- router.With(s.checkPerm(dataprovider.PermAdminAddUsers), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAddUsers), s.refreshCookie).
Get(webUserPath, s.handleWebAddUserGet)
- router.With(s.checkPerm(dataprovider.PermAdminChangeUsers), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminChangeUsers), s.refreshCookie).
Get(webUserPath+"/{username}", s.handleWebUpdateUserGet)
- router.With(s.checkPerm(dataprovider.PermAdminAddUsers)).Post(webUserPath, s.handleWebAddUserPost)
- router.With(s.checkPerm(dataprovider.PermAdminChangeUsers)).Post(webUserPath+"/{username}",
+ router.With(s.checkPerms(dataprovider.PermAdminAddUsers)).Post(webUserPath, s.handleWebAddUserPost)
+ router.With(s.checkPerms(dataprovider.PermAdminChangeUsers)).Post(webUserPath+"/{username}",
s.handleWebUpdateUserPost)
- router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminManageGroups), s.refreshCookie).
Get(webGroupsPath, s.handleWebGetGroups)
- router.With(s.checkPerm(dataprovider.PermAdminManageGroups), compressor.Handler, s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminManageGroups), compressor.Handler, s.refreshCookie).
Get(webGroupsPath+jsonAPISuffix, getAllGroups)
- router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminManageGroups), s.refreshCookie).
Get(webGroupPath, s.handleWebAddGroupGet)
- router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Post(webGroupPath, s.handleWebAddGroupPost)
- router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminManageGroups)).Post(webGroupPath, s.handleWebAddGroupPost)
+ router.With(s.checkPerms(dataprovider.PermAdminManageGroups), s.refreshCookie).
Get(webGroupPath+"/{name}", s.handleWebUpdateGroupGet)
- router.With(s.checkPerm(dataprovider.PermAdminManageGroups)).Post(webGroupPath+"/{name}",
+ router.With(s.checkPerms(dataprovider.PermAdminManageGroups)).Post(webGroupPath+"/{name}",
s.handleWebUpdateGroupPost)
- router.With(s.checkPerm(dataprovider.PermAdminManageGroups), verifyCSRFHeader).
+ router.With(s.checkPerms(dataprovider.PermAdminManageGroups), s.verifyCSRFHeader).
Delete(webGroupPath+"/{name}", deleteGroup)
- router.With(s.checkPerm(dataprovider.PermAdminViewConnections), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminViewConnections), s.refreshCookie).
Get(webConnectionsPath, s.handleWebGetConnections)
- router.With(s.checkPerm(dataprovider.PermAdminViewConnections), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminViewConnections), s.refreshCookie).
Get(webConnectionsPath+jsonAPISuffix, getActiveConnections)
- router.With(s.checkPerm(dataprovider.PermAdminManageFolders), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminManageFolders), s.refreshCookie).
Get(webFoldersPath, s.handleWebGetFolders)
- router.With(s.checkPerm(dataprovider.PermAdminManageFolders), compressor.Handler, s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminManageFolders), compressor.Handler, s.refreshCookie).
Get(webFoldersPath+jsonAPISuffix, getAllFolders)
- router.With(s.checkPerm(dataprovider.PermAdminManageFolders), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminManageFolders), s.refreshCookie).
Get(webFolderPath, s.handleWebAddFolderGet)
- router.With(s.checkPerm(dataprovider.PermAdminManageFolders)).Post(webFolderPath, s.handleWebAddFolderPost)
- router.With(s.checkPerm(dataprovider.PermAdminViewServerStatus), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminManageFolders)).Post(webFolderPath, s.handleWebAddFolderPost)
+ router.With(s.checkPerms(dataprovider.PermAdminViewServerStatus), s.refreshCookie).
Get(webStatusPath, s.handleWebGetStatus)
- router.With(s.checkPerm(dataprovider.PermAdminManageAdmins), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.refreshCookie).
Get(webAdminsPath, s.handleGetWebAdmins)
- router.With(s.checkPerm(dataprovider.PermAdminManageAdmins), compressor.Handler, s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), compressor.Handler, s.refreshCookie).
Get(webAdminsPath+jsonAPISuffix, getAllAdmins)
- router.With(s.checkPerm(dataprovider.PermAdminManageAdmins), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.refreshCookie).
Get(webAdminPath, s.handleWebAddAdminGet)
- router.With(s.checkPerm(dataprovider.PermAdminManageAdmins), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.refreshCookie).
Get(webAdminPath+"/{username}", s.handleWebUpdateAdminGet)
- router.With(s.checkPerm(dataprovider.PermAdminManageAdmins)).Post(webAdminPath, s.handleWebAddAdminPost)
- router.With(s.checkPerm(dataprovider.PermAdminManageAdmins)).Post(webAdminPath+"/{username}",
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(webAdminPath, s.handleWebAddAdminPost)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(webAdminPath+"/{username}",
s.handleWebUpdateAdminPost)
- router.With(s.checkPerm(dataprovider.PermAdminManageAdmins), verifyCSRFHeader).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.verifyCSRFHeader).
Delete(webAdminPath+"/{username}", deleteAdmin)
- router.With(s.checkPerm(dataprovider.PermAdminDisableMFA), verifyCSRFHeader).
+ router.With(s.checkPerms(dataprovider.PermAdminDisableMFA), s.verifyCSRFHeader).
Put(webAdminPath+"/{username}/2fa/disable", disableAdmin2FA)
- router.With(s.checkPerm(dataprovider.PermAdminCloseConnections), verifyCSRFHeader).
+ router.With(s.checkPerms(dataprovider.PermAdminCloseConnections), s.verifyCSRFHeader).
Delete(webConnectionsPath+"/{connectionID}", handleCloseConnection)
- router.With(s.checkPerm(dataprovider.PermAdminManageFolders), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminManageFolders), s.refreshCookie).
Get(webFolderPath+"/{name}", s.handleWebUpdateFolderGet)
- router.With(s.checkPerm(dataprovider.PermAdminManageFolders)).Post(webFolderPath+"/{name}",
+ router.With(s.checkPerms(dataprovider.PermAdminManageFolders)).Post(webFolderPath+"/{name}",
s.handleWebUpdateFolderPost)
- router.With(s.checkPerm(dataprovider.PermAdminManageFolders), verifyCSRFHeader).
+ router.With(s.checkPerms(dataprovider.PermAdminManageFolders), s.verifyCSRFHeader).
Delete(webFolderPath+"/{name}", deleteFolder)
- router.With(s.checkPerm(dataprovider.PermAdminQuotaScans), verifyCSRFHeader).
+ router.With(s.checkPerms(dataprovider.PermAdminQuotaScans), s.verifyCSRFHeader).
Post(webScanVFolderPath+"/{name}", startFolderQuotaScan)
- router.With(s.checkPerm(dataprovider.PermAdminDeleteUsers), verifyCSRFHeader).
+ router.With(s.checkPerms(dataprovider.PermAdminDeleteUsers), s.verifyCSRFHeader).
Delete(webUserPath+"/{username}", deleteUser)
- router.With(s.checkPerm(dataprovider.PermAdminDisableMFA), verifyCSRFHeader).
+ router.With(s.checkPerms(dataprovider.PermAdminDisableMFA), s.verifyCSRFHeader).
Put(webUserPath+"/{username}/2fa/disable", disableUser2FA)
- router.With(s.checkPerm(dataprovider.PermAdminQuotaScans), verifyCSRFHeader).
+ router.With(s.checkPerms(dataprovider.PermAdminQuotaScans), s.verifyCSRFHeader).
Post(webQuotaScanPath+"/{username}", startUserQuotaScan)
- router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Get(webMaintenancePath, s.handleWebMaintenance)
- router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Get(webBackupPath, dumpData)
- router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Post(webRestorePath, s.handleWebRestore)
- router.With(s.checkPerm(dataprovider.PermAdminManageSystem), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Get(webMaintenancePath, s.handleWebMaintenance)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Get(webBackupPath, dumpData)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(webRestorePath, s.handleWebRestore)
+ router.With(s.checkPerms(dataprovider.PermAdminAddUsers, dataprovider.PermAdminChangeUsers), s.refreshCookie).
Get(webTemplateUser, s.handleWebTemplateUserGet)
- router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Post(webTemplateUser, s.handleWebTemplateUserPost)
- router.With(s.checkPerm(dataprovider.PermAdminManageSystem), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAddUsers, dataprovider.PermAdminChangeUsers)).
+ Post(webTemplateUser, s.handleWebTemplateUserPost)
+ router.With(s.checkPerms(dataprovider.PermAdminManageFolders), s.refreshCookie).
Get(webTemplateFolder, s.handleWebTemplateFolderGet)
- router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Post(webTemplateFolder, s.handleWebTemplateFolderPost)
- router.With(s.checkPerm(dataprovider.PermAdminViewDefender)).Get(webDefenderPath, s.handleWebDefenderPage)
- router.With(s.checkPerm(dataprovider.PermAdminViewDefender)).Get(webDefenderHostsPath, getDefenderHosts)
- router.With(s.checkPerm(dataprovider.PermAdminManageDefender)).Delete(webDefenderHostsPath+"/{id}",
- deleteDefenderHostByID)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), compressor.Handler, s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminManageFolders)).Post(webTemplateFolder, s.handleWebTemplateFolderPost)
+ router.With(s.checkPerms(dataprovider.PermAdminViewDefender)).Get(webDefenderPath, s.handleWebDefenderPage)
+ router.With(s.checkPerms(dataprovider.PermAdminViewDefender)).Get(webDefenderHostsPath, getDefenderHosts)
+ router.With(s.checkPerms(dataprovider.PermAdminManageDefender), s.verifyCSRFHeader).
+ Delete(webDefenderHostsPath+"/{id}", deleteDefenderHostByID)
+ router.With(s.checkPerms(dataprovider.PermAdminAny), compressor.Handler, s.refreshCookie).
Get(webAdminEventActionsPath+jsonAPISuffix, getAllActions)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.refreshCookie).
Get(webAdminEventActionsPath, s.handleWebGetEventActions)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.refreshCookie).
Get(webAdminEventActionPath, s.handleWebAddEventActionGet)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Post(webAdminEventActionPath,
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(webAdminEventActionPath,
s.handleWebAddEventActionPost)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.refreshCookie).
Get(webAdminEventActionPath+"/{name}", s.handleWebUpdateEventActionGet)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Post(webAdminEventActionPath+"/{name}",
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(webAdminEventActionPath+"/{name}",
s.handleWebUpdateEventActionPost)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), verifyCSRFHeader).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.verifyCSRFHeader).
Delete(webAdminEventActionPath+"/{name}", deleteEventAction)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), compressor.Handler, s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), compressor.Handler, s.refreshCookie).
Get(webAdminEventRulesPath+jsonAPISuffix, getAllRules)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.refreshCookie).
Get(webAdminEventRulesPath, s.handleWebGetEventRules)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.refreshCookie).
Get(webAdminEventRulePath, s.handleWebAddEventRuleGet)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Post(webAdminEventRulePath,
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(webAdminEventRulePath,
s.handleWebAddEventRulePost)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.refreshCookie).
Get(webAdminEventRulePath+"/{name}", s.handleWebUpdateEventRuleGet)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules)).Post(webAdminEventRulePath+"/{name}",
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(webAdminEventRulePath+"/{name}",
s.handleWebUpdateEventRulePost)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), verifyCSRFHeader).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.verifyCSRFHeader).
Delete(webAdminEventRulePath+"/{name}", deleteEventRule)
- router.With(s.checkPerm(dataprovider.PermAdminManageEventRules), verifyCSRFHeader).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.verifyCSRFHeader).
Post(webAdminEventRulePath+"/run/{name}", runOnDemandRule)
- router.With(s.checkPerm(dataprovider.PermAdminManageRoles), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.refreshCookie).
Get(webAdminRolesPath, s.handleWebGetRoles)
- router.With(s.checkPerm(dataprovider.PermAdminManageRoles), compressor.Handler, s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), compressor.Handler, s.refreshCookie).
Get(webAdminRolesPath+jsonAPISuffix, getAllRoles)
- router.With(s.checkPerm(dataprovider.PermAdminManageRoles), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.refreshCookie).
Get(webAdminRolePath, s.handleWebAddRoleGet)
- router.With(s.checkPerm(dataprovider.PermAdminManageRoles)).Post(webAdminRolePath, s.handleWebAddRolePost)
- router.With(s.checkPerm(dataprovider.PermAdminManageRoles), s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(webAdminRolePath, s.handleWebAddRolePost)
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.refreshCookie).
Get(webAdminRolePath+"/{name}", s.handleWebUpdateRoleGet)
- router.With(s.checkPerm(dataprovider.PermAdminManageRoles)).Post(webAdminRolePath+"/{name}",
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(webAdminRolePath+"/{name}",
s.handleWebUpdateRolePost)
- router.With(s.checkPerm(dataprovider.PermAdminManageRoles), verifyCSRFHeader).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.verifyCSRFHeader).
Delete(webAdminRolePath+"/{name}", deleteRole)
- router.With(s.checkPerm(dataprovider.PermAdminViewEvents), s.refreshCookie).Get(webEventsPath,
+ router.With(s.checkPerms(dataprovider.PermAdminViewEvents), s.refreshCookie).Get(webEventsPath,
s.handleWebGetEvents)
- router.With(s.checkPerm(dataprovider.PermAdminViewEvents), compressor.Handler, s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminViewEvents), compressor.Handler, s.refreshCookie).
Get(webEventsFsSearchPath, searchFsEvents)
- router.With(s.checkPerm(dataprovider.PermAdminViewEvents), compressor.Handler, s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminViewEvents), compressor.Handler, s.refreshCookie).
Get(webEventsProviderSearchPath, searchProviderEvents)
- router.With(s.checkPerm(dataprovider.PermAdminViewEvents), compressor.Handler, s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminViewEvents), compressor.Handler, s.refreshCookie).
Get(webEventsLogSearchPath, searchLogEvents)
- router.With(s.checkPerm(dataprovider.PermAdminManageIPLists)).Get(webIPListsPath, s.handleWebIPListsPage)
- router.With(s.checkPerm(dataprovider.PermAdminManageIPLists), compressor.Handler, s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Get(webIPListsPath, s.handleWebIPListsPage)
+ router.With(s.checkPerms(dataprovider.PermAdminAny), compressor.Handler, s.refreshCookie).
Get(webIPListsPath+"/{type}", getIPListEntries)
- router.With(s.checkPerm(dataprovider.PermAdminManageIPLists), s.refreshCookie).Get(webIPListPath+"/{type}",
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.refreshCookie).Get(webIPListPath+"/{type}",
s.handleWebAddIPListEntryGet)
- router.With(s.checkPerm(dataprovider.PermAdminManageIPLists)).Post(webIPListPath+"/{type}",
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(webIPListPath+"/{type}",
s.handleWebAddIPListEntryPost)
- router.With(s.checkPerm(dataprovider.PermAdminManageIPLists), s.refreshCookie).Get(webIPListPath+"/{type}/{ipornet}",
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.refreshCookie).Get(webIPListPath+"/{type}/{ipornet}",
s.handleWebUpdateIPListEntryGet)
- router.With(s.checkPerm(dataprovider.PermAdminManageIPLists)).Post(webIPListPath+"/{type}/{ipornet}",
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(webIPListPath+"/{type}/{ipornet}",
s.handleWebUpdateIPListEntryPost)
- router.With(s.checkPerm(dataprovider.PermAdminManageIPLists), verifyCSRFHeader).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.verifyCSRFHeader).
Delete(webIPListPath+"/{type}/{ipornet}", deleteIPListEntry)
- router.With(s.checkPerm(dataprovider.PermAdminManageSystem), s.refreshCookie).Get(webConfigsPath, s.handleWebConfigs)
- router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Post(webConfigsPath, s.handleWebConfigsPost)
- router.With(s.checkPerm(dataprovider.PermAdminManageSystem), verifyCSRFHeader, s.refreshCookie).
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.refreshCookie).Get(webConfigsPath, s.handleWebConfigs)
+ router.With(s.checkPerms(dataprovider.PermAdminAny)).Post(webConfigsPath, s.handleWebConfigsPost)
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.verifyCSRFHeader, s.refreshCookie).
Post(webConfigsPath+"/smtp/test", testSMTPConfig)
- router.With(s.checkPerm(dataprovider.PermAdminManageSystem), verifyCSRFHeader, s.refreshCookie).
- Post(webOAuth2TokenPath, handleSMTPOAuth2TokenRequestPost)
+ router.With(s.checkPerms(dataprovider.PermAdminAny), s.verifyCSRFHeader, s.refreshCookie).
+ Post(webOAuth2TokenPath, s.handleSMTPOAuth2TokenRequestPost)
})
})
}
diff --git a/internal/httpd/token.go b/internal/httpd/token.go
index 93413281..4a0f9873 100644
--- a/internal/httpd/token.go
+++ b/internal/httpd/token.go
@@ -86,7 +86,7 @@ func (m *dbTokenManager) Add(token string, expiresAt time.Time) {
func (m *dbTokenManager) Get(token string) bool {
key := m.getKey(token)
- _, err := dataprovider.GetSharedSession(key)
+ _, err := dataprovider.GetSharedSession(key, dataprovider.SessionTypeInvalidToken)
return err == nil
}
diff --git a/internal/httpd/web.go b/internal/httpd/web.go
index d4bc68f5..da7e7542 100644
--- a/internal/httpd/web.go
+++ b/internal/httpd/web.go
@@ -27,23 +27,20 @@ import (
)
const (
- pageMFATitle = "Two-factor authentication"
- pageTwoFactorTitle = "Two-Factor authentication"
- pageTwoFactorRecoveryTitle = "Two-Factor recovery"
- webDateTimeFormat = "2006-01-02 15:04:05" // YYYY-MM-DD HH:MM:SS
- redactedSecret = "[**redacted**]"
- csrfFormToken = "_form_token"
- csrfHeaderToken = "X-CSRF-TOKEN"
- templateCommonDir = "common"
- templateTwoFactor = "twofactor.html"
- templateTwoFactorRecovery = "twofactor-recovery.html"
- templateForgotPassword = "forgot-password.html"
- templateResetPassword = "reset-password.html"
- templateChangePwd = "changepassword.html"
- templateMessage = "message.html"
- templateCommonBase = "base.html"
- templateCommonBaseLogin = "baselogin.html"
- templateCommonLogin = "login.html"
+ webDateTimeFormat = "2006-01-02 15:04:05" // YYYY-MM-DD HH:MM:SS
+ redactedSecret = "[**redacted**]"
+ csrfFormToken = "_form_token"
+ csrfHeaderToken = "X-CSRF-TOKEN"
+ templateCommonDir = "common"
+ templateTwoFactor = "twofactor.html"
+ templateTwoFactorRecovery = "twofactor-recovery.html"
+ templateForgotPassword = "forgot-password.html"
+ templateResetPassword = "reset-password.html"
+ templateChangePwd = "changepassword.html"
+ templateMessage = "message.html"
+ templateCommonBase = "base.html"
+ templateCommonBaseLogin = "baselogin.html"
+ templateCommonLogin = "login.html"
)
var (
@@ -67,43 +64,50 @@ type loginPage struct {
OpenIDLoginURL string
Title string
Branding UIBranding
+ Languages []string
FormDisabled bool
CheckRedirect bool
}
type twoFactorPage struct {
commonBasePage
- CurrentURL string
- Error *util.I18nError
- CSRFToken string
- RecoveryURL string
- Title string
- Branding UIBranding
+ CurrentURL string
+ Error *util.I18nError
+ CSRFToken string
+ RecoveryURL string
+ Title string
+ Branding UIBranding
+ Languages []string
+ CheckRedirect bool
}
type forgotPwdPage struct {
commonBasePage
- CurrentURL string
- Error *util.I18nError
- CSRFToken string
- LoginURL string
- Title string
- Branding UIBranding
+ CurrentURL string
+ Error *util.I18nError
+ CSRFToken string
+ LoginURL string
+ Title string
+ Branding UIBranding
+ Languages []string
+ CheckRedirect bool
}
type resetPwdPage struct {
commonBasePage
- CurrentURL string
- Error *util.I18nError
- CSRFToken string
- LoginURL string
- Title string
- Branding UIBranding
+ CurrentURL string
+ Error *util.I18nError
+ CSRFToken string
+ LoginURL string
+ Title string
+ Branding UIBranding
+ Languages []string
+ CheckRedirect bool
}
func getSliceFromDelimitedValues(values, delimiter string) []string {
result := []string{}
- for _, v := range strings.Split(values, delimiter) {
+ for v := range strings.SplitSeq(values, delimiter) {
cleaned := strings.TrimSpace(v)
if cleaned != "" {
result = append(result, cleaned)
diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go
index f8ffa63f..a565c05e 100644
--- a/internal/httpd/webadmin.go
+++ b/internal/httpd/webadmin.go
@@ -16,6 +16,7 @@ package httpd
import (
"context"
+ "crypto/rand"
"encoding/json"
"errors"
"fmt"
@@ -25,18 +26,21 @@ import (
"net/url"
"os"
"path/filepath"
+ "slices"
"sort"
"strconv"
"strings"
"time"
- "github.com/go-chi/render"
"github.com/sftpgo/sdk"
sdkkms "github.com/sftpgo/sdk/kms"
+ "golang.org/x/oauth2"
"github.com/drakkan/sftpgo/v2/internal/acme"
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/ftpd"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/kms"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/mfa"
@@ -44,6 +48,7 @@ import (
"github.com/drakkan/sftpgo/v2/internal/smtp"
"github.com/drakkan/sftpgo/v2/internal/util"
"github.com/drakkan/sftpgo/v2/internal/vfs"
+ "github.com/drakkan/sftpgo/v2/internal/webdavd"
)
type userPageMode int
@@ -70,37 +75,36 @@ const (
)
const (
- templateAdminDir = "webadmin"
- templateBase = "base.html"
- templateFsConfig = "fsconfig.html"
- templateSharedComponents = "sharedcomponents.html"
- templateUsers = "users.html"
- templateUser = "user.html"
- templateAdmins = "admins.html"
- templateAdmin = "admin.html"
- templateConnections = "connections.html"
- templateGroups = "groups.html"
- templateGroup = "group.html"
- templateFolders = "folders.html"
- templateFolder = "folder.html"
- templateEventRules = "eventrules.html"
- templateEventRule = "eventrule.html"
- templateEventActions = "eventactions.html"
- templateEventAction = "eventaction.html"
- templateRoles = "roles.html"
- templateRole = "role.html"
- templateEvents = "events.html"
- templateStatus = "status.html"
- templateDefender = "defender.html"
- templateIPLists = "iplists.html"
- templateIPList = "iplist.html"
- templateConfigs = "configs.html"
- templateProfile = "profile.html"
- templateMaintenance = "maintenance.html"
- templateMFA = "mfa.html"
- templateSetup = "adminsetup.html"
- defaultQueryLimit = 1000
- inversePatternType = "inverse"
+ templateAdminDir = "webadmin"
+ templateBase = "base.html"
+ templateFsConfig = "fsconfig.html"
+ templateUsers = "users.html"
+ templateUser = "user.html"
+ templateAdmins = "admins.html"
+ templateAdmin = "admin.html"
+ templateConnections = "connections.html"
+ templateGroups = "groups.html"
+ templateGroup = "group.html"
+ templateFolders = "folders.html"
+ templateFolder = "folder.html"
+ templateEventRules = "eventrules.html"
+ templateEventRule = "eventrule.html"
+ templateEventActions = "eventactions.html"
+ templateEventAction = "eventaction.html"
+ templateRoles = "roles.html"
+ templateRole = "role.html"
+ templateEvents = "events.html"
+ templateStatus = "status.html"
+ templateDefender = "defender.html"
+ templateIPLists = "iplists.html"
+ templateIPList = "iplist.html"
+ templateConfigs = "configs.html"
+ templateProfile = "profile.html"
+ templateMaintenance = "maintenance.html"
+ templateMFA = "mfa.html"
+ templateSetup = "adminsetup.html"
+ defaultQueryLimit = 1000
+ inversePatternType = "inverse"
)
var (
@@ -150,7 +154,9 @@ type basePage struct {
HasSearcher bool
HasExternalLogin bool
LoggedUser *dataprovider.Admin
+ IsLoggedToShare bool
Branding UIBranding
+ Languages []string
}
type statusPage struct {
@@ -184,6 +190,7 @@ type userPage struct {
Roles []dataprovider.Role
CanImpersonate bool
FsWrapper fsWrapper
+ CanUseTLSCerts bool
}
type adminPage struct {
@@ -257,6 +264,8 @@ type setupPage struct {
HideSupportLink bool
Title string
Branding UIBranding
+ Languages []string
+ CheckRedirect bool
}
type folderPage struct {
@@ -290,13 +299,14 @@ type rolePage struct {
type eventActionPage struct {
basePage
- Action dataprovider.BaseEventAction
- ActionTypes []dataprovider.EnumMapping
- FsActions []dataprovider.EnumMapping
- HTTPMethods []string
- RedactedSecret string
- Error *util.I18nError
- Mode genericPageMode
+ Action dataprovider.BaseEventAction
+ ActionTypes []dataprovider.EnumMapping
+ FsActions []dataprovider.EnumMapping
+ HTTPMethods []string
+ EnabledCommands []string
+ RedactedSecret string
+ Error *util.I18nError
+ Mode genericPageMode
}
type eventRulePage struct {
@@ -327,6 +337,7 @@ type configsPage struct {
RedactedSecret string
OAuth2TokenURL string
OAuth2RedirectURL string
+ WebClientBranding UIBranding
Error *util.I18nError
}
@@ -338,9 +349,10 @@ type messagePage struct {
}
type userTemplateFields struct {
- Username string
- Password string
- PublicKeys []string
+ Username string
+ Password string
+ PublicKeys []string
+ RequirePwdChange bool
}
func loadAdminTemplates(templatesPath string) {
@@ -612,10 +624,10 @@ func isServerManagerResource(currentURL string) bool {
currentURL == webConfigsPath
}
-func (s *httpdServer) getBasePageData(title, currentURL string, r *http.Request) basePage {
+func (s *httpdServer) getBasePageData(title, currentURL string, w http.ResponseWriter, r *http.Request) basePage {
var csrfToken string
if currentURL != "" {
- csrfToken = createCSRFToken(util.GetIPFromRemoteAddress(r.RemoteAddr))
+ csrfToken = createCSRFToken(w, r, s.csrfTokenAuth, "", webBaseAdminPath)
}
return basePage{
commonBasePage: getCommonBasePage(r),
@@ -660,7 +672,8 @@ func (s *httpdServer) getBasePageData(title, currentURL string, r *http.Request)
HasSearcher: plugin.Handler.HasSearcher(),
HasExternalLogin: isLoggedInWithOIDC(r),
CSRFToken: csrfToken,
- Branding: s.binding.Branding.WebAdmin,
+ Branding: s.binding.webAdminBranding(),
+ Languages: s.binding.languages(),
}
}
@@ -675,7 +688,7 @@ func (s *httpdServer) renderMessagePageWithString(w http.ResponseWriter, r *http
err error, message, text string,
) {
data := messagePage{
- basePage: s.getBasePageData(title, "", r),
+ basePage: s.getBasePageData(title, "", w, r),
Error: getI18nError(err),
Success: message,
Text: text,
@@ -710,60 +723,64 @@ func (s *httpdServer) renderNotFoundPage(w http.ResponseWriter, r *http.Request,
util.NewI18nError(err, util.I18nError404Message), "")
}
-func (s *httpdServer) renderForgotPwdPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
+func (s *httpdServer) renderForgotPwdPage(w http.ResponseWriter, r *http.Request, err *util.I18nError) {
data := forgotPwdPage{
commonBasePage: getCommonBasePage(r),
CurrentURL: webAdminForgotPwdPath,
Error: err,
- CSRFToken: createCSRFToken(ip),
+ CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, rand.Text(), webBaseAdminPath),
LoginURL: webAdminLoginPath,
Title: util.I18nForgotPwdTitle,
- Branding: s.binding.Branding.WebAdmin,
+ Branding: s.binding.webAdminBranding(),
+ Languages: s.binding.languages(),
}
renderAdminTemplate(w, templateForgotPassword, data)
}
-func (s *httpdServer) renderResetPwdPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
+func (s *httpdServer) renderResetPwdPage(w http.ResponseWriter, r *http.Request, err *util.I18nError) {
data := resetPwdPage{
commonBasePage: getCommonBasePage(r),
CurrentURL: webAdminResetPwdPath,
Error: err,
- CSRFToken: createCSRFToken(ip),
+ CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, "", webBaseAdminPath),
LoginURL: webAdminLoginPath,
Title: util.I18nResetPwdTitle,
- Branding: s.binding.Branding.WebAdmin,
+ Branding: s.binding.webAdminBranding(),
+ Languages: s.binding.languages(),
}
renderAdminTemplate(w, templateResetPassword, data)
}
-func (s *httpdServer) renderTwoFactorPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
+func (s *httpdServer) renderTwoFactorPage(w http.ResponseWriter, r *http.Request, err *util.I18nError) {
data := twoFactorPage{
commonBasePage: getCommonBasePage(r),
- Title: pageTwoFactorTitle,
+ Title: util.I18n2FATitle,
CurrentURL: webAdminTwoFactorPath,
Error: err,
- CSRFToken: createCSRFToken(ip),
+ CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, "", webBaseAdminPath),
RecoveryURL: webAdminTwoFactorRecoveryPath,
- Branding: s.binding.Branding.WebAdmin,
+ Branding: s.binding.webAdminBranding(),
+ Languages: s.binding.languages(),
}
renderAdminTemplate(w, templateTwoFactor, data)
}
-func (s *httpdServer) renderTwoFactorRecoveryPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
+func (s *httpdServer) renderTwoFactorRecoveryPage(w http.ResponseWriter, r *http.Request, err *util.I18nError) {
data := twoFactorPage{
commonBasePage: getCommonBasePage(r),
- Title: pageTwoFactorRecoveryTitle,
+ Title: util.I18n2FATitle,
CurrentURL: webAdminTwoFactorRecoveryPath,
Error: err,
- CSRFToken: createCSRFToken(ip),
- Branding: s.binding.Branding.WebAdmin,
+ CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, "", webBaseAdminPath),
+ Branding: s.binding.webAdminBranding(),
+ Languages: s.binding.languages(),
}
renderAdminTemplate(w, templateTwoFactorRecovery, data)
}
func (s *httpdServer) renderMFAPage(w http.ResponseWriter, r *http.Request) {
data := mfaPage{
- basePage: s.getBasePageData(pageMFATitle, webAdminMFAPath, r),
+ basePage: s.getBasePageData(util.I18n2FATitle, webAdminMFAPath, w, r),
TOTPConfigs: mfa.GetAvailableTOTPConfigNames(),
GenerateTOTPURL: webAdminTOTPGeneratePath,
ValidateTOTPURL: webAdminTOTPValidatePath,
@@ -782,7 +799,7 @@ func (s *httpdServer) renderMFAPage(w http.ResponseWriter, r *http.Request) {
func (s *httpdServer) renderProfilePage(w http.ResponseWriter, r *http.Request, err error) {
data := profilePage{
- basePage: s.getBasePageData(util.I18nProfileTitle, webAdminProfilePath, r),
+ basePage: s.getBasePageData(util.I18nProfileTitle, webAdminProfilePath, w, r),
Error: getI18nError(err),
}
admin, err := dataprovider.AdminExists(data.LoggedUser.Username)
@@ -799,7 +816,7 @@ func (s *httpdServer) renderProfilePage(w http.ResponseWriter, r *http.Request,
func (s *httpdServer) renderChangePasswordPage(w http.ResponseWriter, r *http.Request, err *util.I18nError) {
data := changePasswordPage{
- basePage: s.getBasePageData(util.I18nChangePwdTitle, webChangeAdminPwdPath, r),
+ basePage: s.getBasePageData(util.I18nChangePwdTitle, webChangeAdminPwdPath, w, r),
Error: err,
}
@@ -808,7 +825,7 @@ func (s *httpdServer) renderChangePasswordPage(w http.ResponseWriter, r *http.Re
func (s *httpdServer) renderMaintenancePage(w http.ResponseWriter, r *http.Request, err error) {
data := maintenancePage{
- basePage: s.getBasePageData(util.I18nMaintenanceTitle, webMaintenancePath, r),
+ basePage: s.getBasePageData(util.I18nMaintenanceTitle, webMaintenancePath, w, r),
BackupPath: webBackupPath,
RestorePath: webRestorePath,
Error: getI18nError(err),
@@ -830,30 +847,32 @@ func (s *httpdServer) renderConfigsPage(w http.ResponseWriter, r *http.Request,
configs.ACME.HTTP01Challenge.Port = 80
}
data := configsPage{
- basePage: s.getBasePageData(util.I18nConfigsTitle, webConfigsPath, r),
+ basePage: s.getBasePageData(util.I18nConfigsTitle, webConfigsPath, w, r),
Configs: configs,
ConfigSection: section,
RedactedSecret: redactedSecret,
OAuth2TokenURL: webOAuth2TokenPath,
OAuth2RedirectURL: webOAuth2RedirectPath,
+ WebClientBranding: s.binding.webClientBranding(),
Error: getI18nError(err),
}
renderAdminTemplate(w, templateConfigs, data)
}
-func (s *httpdServer) renderAdminSetupPage(w http.ResponseWriter, r *http.Request, username, ip string, err *util.I18nError) {
+func (s *httpdServer) renderAdminSetupPage(w http.ResponseWriter, r *http.Request, username string, err *util.I18nError) {
data := setupPage{
commonBasePage: getCommonBasePage(r),
Title: util.I18nSetupTitle,
CurrentURL: webAdminSetupPath,
- CSRFToken: createCSRFToken(ip),
+ CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, rand.Text(), webBaseAdminPath),
Username: username,
HasInstallationCode: installationCode != "",
InstallationCodeHint: installationCodeHint,
HideSupportLink: hideSupportLink,
Error: err,
- Branding: s.binding.Branding.WebAdmin,
+ Branding: s.binding.webAdminBranding(),
+ Languages: s.binding.languages(),
}
renderAdminTemplate(w, templateSetup, data)
@@ -876,7 +895,7 @@ func (s *httpdServer) renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Re
title = util.I18nUpdateAdminTitle
}
data := adminPage{
- basePage: s.getBasePageData(title, currentURL, r),
+ basePage: s.getBasePageData(title, currentURL, w, r),
Admin: admin,
Groups: groups,
Roles: roles,
@@ -917,7 +936,7 @@ func (s *httpdServer) renderUserPage(w http.ResponseWriter, r *http.Request, use
}
}
user.FsConfig.RedactedSecret = redactedSecret
- basePage := s.getBasePageData(title, currentURL, r)
+ basePage := s.getBasePageData(title, currentURL, w, r)
if (mode == userPageModeAdd || mode == userPageModeTemplate) && len(user.Groups) == 0 && admin != nil {
for _, group := range admin.Groups {
user.Groups = append(user.Groups, sdk.GroupMapping{
@@ -957,6 +976,7 @@ func (s *httpdServer) renderUserPage(w http.ResponseWriter, r *http.Request, use
Groups: groups,
Roles: roles,
CanImpersonate: os.Getuid() == 0,
+ CanUseTLSCerts: ftpd.GetStatus().IsActive || webdavd.GetStatus().IsActive,
FsWrapper: fsWrapper{
Filesystem: user.FsConfig,
IsUserPage: true,
@@ -982,7 +1002,7 @@ func (s *httpdServer) renderIPListPage(w http.ResponseWriter, r *http.Request, e
currentURL = fmt.Sprintf("%s/%d/%s", webIPListPath, entry.Type, url.PathEscape(entry.IPOrNet))
}
data := ipListPage{
- basePage: s.getBasePageData(title, currentURL, r),
+ basePage: s.getBasePageData(title, currentURL, w, r),
Error: getI18nError(err),
Entry: &entry,
Mode: mode,
@@ -1003,7 +1023,7 @@ func (s *httpdServer) renderRolePage(w http.ResponseWriter, r *http.Request, rol
currentURL = fmt.Sprintf("%s/%s", webAdminRolePath, url.PathEscape(role.Name))
}
data := rolePage{
- basePage: s.getBasePageData(title, currentURL, r),
+ basePage: s.getBasePageData(title, currentURL, w, r),
Error: getI18nError(err),
Role: &role,
Mode: mode,
@@ -1033,7 +1053,7 @@ func (s *httpdServer) renderGroupPage(w http.ResponseWriter, r *http.Request, gr
group.UserSettings.FsConfig.SetEmptySecretsIfNil()
data := groupPage{
- basePage: s.getBasePageData(title, currentURL, r),
+ basePage: s.getBasePageData(title, currentURL, w, r),
Error: getI18nError(err),
Group: &group,
Mode: mode,
@@ -1078,14 +1098,15 @@ func (s *httpdServer) renderEventActionPage(w http.ResponseWriter, r *http.Reque
}
data := eventActionPage{
- basePage: s.getBasePageData(title, currentURL, r),
- Action: action,
- ActionTypes: dataprovider.EventActionTypes,
- FsActions: dataprovider.FsActionTypes,
- HTTPMethods: dataprovider.SupportedHTTPActionMethods,
- RedactedSecret: redactedSecret,
- Error: getI18nError(err),
- Mode: mode,
+ basePage: s.getBasePageData(title, currentURL, w, r),
+ Action: action,
+ ActionTypes: dataprovider.EventActionTypes,
+ FsActions: dataprovider.FsActionTypes,
+ HTTPMethods: dataprovider.SupportedHTTPActionMethods,
+ EnabledCommands: dataprovider.EnabledActionCommands,
+ RedactedSecret: redactedSecret,
+ Error: getI18nError(err),
+ Mode: mode,
}
renderAdminTemplate(w, templateEventAction, data)
}
@@ -1108,7 +1129,7 @@ func (s *httpdServer) renderEventRulePage(w http.ResponseWriter, r *http.Request
}
data := eventRulePage{
- basePage: s.getBasePageData(title, currentURL, r),
+ basePage: s.getBasePageData(title, currentURL, w, r),
Rule: rule,
TriggerTypes: dataprovider.EventTriggerTypes,
Actions: actions,
@@ -1142,7 +1163,7 @@ func (s *httpdServer) renderFolderPage(w http.ResponseWriter, r *http.Request, f
folder.FsConfig.SetEmptySecretsIfNil()
data := folderPage{
- basePage: s.getBasePageData(title, currentURL, r),
+ basePage: s.getBasePageData(title, currentURL, w, r),
Error: getI18nError(err),
Folder: folder,
Mode: mode,
@@ -1206,9 +1227,10 @@ func getUsersForTemplate(r *http.Request) []userTemplateFields {
users[username] = true
res = append(res, userTemplateFields{
- Username: username,
- Password: password,
- PublicKeys: []string{publicKey},
+ Username: username,
+ Password: password,
+ PublicKeys: []string{publicKey},
+ RequirePwdChange: r.Form.Get("tpl_require_password_change") != "",
})
}
@@ -1484,13 +1506,13 @@ func getFiltersFromUserPostFields(r *http.Request) (sdk.BaseUserFilters, error)
filters.PasswordStrength = passwordStrength
filters.AccessTime = getAccessTimeRestrictionsFromPostFields(r)
hooks := r.Form["hooks"]
- if util.Contains(hooks, "external_auth_disabled") {
+ if slices.Contains(hooks, "external_auth_disabled") {
filters.Hooks.ExternalAuthDisabled = true
}
- if util.Contains(hooks, "pre_login_disabled") {
+ if slices.Contains(hooks, "pre_login_disabled") {
filters.Hooks.PreLoginDisabled = true
}
- if util.Contains(hooks, "check_password_disabled") {
+ if slices.Contains(hooks, "check_password_disabled") {
filters.Hooks.CheckPasswordDisabled = true
}
filters.IsAnonymous = r.Form.Get("is_anonymous") != ""
@@ -1524,6 +1546,7 @@ func getS3Config(r *http.Request) (vfs.S3FsConfig, error) {
config.AccessKey = strings.TrimSpace(r.Form.Get("s3_access_key"))
config.RoleARN = strings.TrimSpace(r.Form.Get("s3_role_arn"))
config.AccessSecret = getSecretFromFormField(r, "s3_access_secret")
+ config.SSECustomerKey = getSecretFromFormField(r, "s3_sse_customer_key")
config.Endpoint = strings.TrimSpace(r.Form.Get("s3_endpoint"))
config.StorageClass = strings.TrimSpace(r.Form.Get("s3_storage_class"))
config.ACL = strings.TrimSpace(r.Form.Get("s3_acl"))
@@ -1580,7 +1603,7 @@ func getGCSConfig(r *http.Request) (vfs.GCSFsConfig, error) {
config.AutomaticCredentials = 0
}
credentials, _, err := r.FormFile("gcs_credential_file")
- if err == http.ErrMissingFile {
+ if errors.Is(err, http.ErrMissingFile) {
return config, nil
}
if err != nil {
@@ -1849,6 +1872,10 @@ func getS3FsFromTemplate(fsConfig vfs.S3FsConfig, replacements map[string]string
payload := replacePlaceholders(fsConfig.AccessSecret.GetPayload(), replacements)
fsConfig.AccessSecret = kms.NewPlainSecret(payload)
}
+ if fsConfig.SSECustomerKey != nil && fsConfig.SSECustomerKey.IsPlain() {
+ payload := replacePlaceholders(fsConfig.SSECustomerKey.GetPayload(), replacements)
+ fsConfig.SSECustomerKey = kms.NewPlainSecret(payload)
+ }
return fsConfig
}
@@ -1886,6 +1913,7 @@ func getUserFromTemplate(user dataprovider.User, template userTemplateFields) da
user.Username = template.Username
user.Password = template.Password
user.PublicKeys = template.PublicKeys
+ user.Filters.RequirePasswordChange = template.RequirePwdChange
replacements := make(map[string]string)
replacements["%username%"] = user.Username
if user.Password != "" && !user.IsPasswordHashed() {
@@ -1967,6 +1995,13 @@ func updateRepeaterFormFields(r *http.Request) {
}
continue
}
+ if hasPrefixAndSuffix(k, "additional_emails[", "][additional_email]") {
+ email := strings.TrimSpace(r.Form.Get(k))
+ if email != "" {
+ r.Form.Add("additional_emails", email)
+ }
+ continue
+ }
if hasPrefixAndSuffix(k, "virtual_folders[", "][vfolder_path]") {
base, _ := strings.CutSuffix(k, "[vfolder_path]")
r.Form.Add("vfolder_path", strings.TrimSpace(r.Form.Get(k)))
@@ -2100,6 +2135,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
Filters: dataprovider.UserFilters{
BaseUserFilters: filters,
RequirePasswordChange: r.Form.Get("require_password_change") != "",
+ AdditionalEmails: r.Form["additional_emails"],
},
VirtualFolders: getVirtualFoldersFromPostFields(r),
FsConfig: fsConfig,
@@ -2196,6 +2232,28 @@ func getKeyValsFromPostFields(r *http.Request, key, val string) []dataprovider.K
return res
}
+func getRenameConfigsFromPostFields(r *http.Request) []dataprovider.RenameConfig {
+ var res []dataprovider.RenameConfig
+ keys := r.Form["fs_rename_source"]
+ values := r.Form["fs_rename_target"]
+
+ for idx, k := range keys {
+ v := values[idx]
+ if k != "" && v != "" {
+ opts := r.Form["fs_rename_options"+strconv.Itoa(idx)]
+ res = append(res, dataprovider.RenameConfig{
+ KeyValue: dataprovider.KeyValue{
+ Key: k,
+ Value: v,
+ },
+ UpdateModTime: slices.Contains(opts, "1"),
+ })
+ }
+ }
+
+ return res
+}
+
func getFoldersRetentionFromPostFields(r *http.Request) ([]dataprovider.FolderRetention, error) {
var res []dataprovider.FolderRetention
paths := r.Form["folder_retention_path"]
@@ -2211,7 +2269,7 @@ func getFoldersRetentionFromPostFields(r *http.Request) ([]dataprovider.FolderRe
res = append(res, dataprovider.FolderRetention{
Path: p,
Retention: retention,
- DeleteEmptyDirs: util.Contains(opts, "1"),
+ DeleteEmptyDirs: slices.Contains(opts, "1"),
})
}
}
@@ -2305,6 +2363,8 @@ func updateRepeaterFormActionFields(r *http.Request) {
base, _ := strings.CutSuffix(k, "[fs_rename_source]")
r.Form.Add("fs_rename_source", strings.TrimSpace(r.Form.Get(k)))
r.Form.Add("fs_rename_target", strings.TrimSpace(r.Form.Get(base+"[fs_rename_target]")))
+ r.Form["fs_rename_options"+strconv.Itoa(len(r.Form["fs_rename_source"])-1)] =
+ r.Form[base+"[fs_rename_options][]"]
continue
}
if hasPrefixAndSuffix(k, "fs_copy[", "][fs_copy_source]") {
@@ -2393,7 +2453,7 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven
},
FsConfig: dataprovider.EventActionFilesystemConfig{
Type: fsActionType,
- Renames: getKeyValsFromPostFields(r, "fs_rename_source", "fs_rename_target"),
+ Renames: getRenameConfigsFromPostFields(r),
Deletes: getSliceFromDelimitedValues(r.Form.Get("fs_delete_paths"), ","),
MkDirs: getSliceFromDelimitedValues(r.Form.Get("fs_mkdir_paths"), ","),
Exist: getSliceFromDelimitedValues(r.Form.Get("fs_exist_paths"), ","),
@@ -2516,6 +2576,13 @@ func getEventRuleConditionsFromPostFields(r *http.Request) (dataprovider.EventCo
if err != nil {
return dataprovider.EventConditions{}, util.NewI18nError(fmt.Errorf("invalid max file size: %w", err), util.I18nErrorInvalidMaxSize)
}
+ var eventStatuses []int
+ for _, s := range r.Form["fs_statuses"] {
+ status, err := strconv.ParseInt(s, 10, 32)
+ if err == nil {
+ eventStatuses = append(eventStatuses, int(status))
+ }
+ }
conditions := dataprovider.EventConditions{
FsEvents: r.Form["fs_events"],
ProviderEvents: r.Form["provider_events"],
@@ -2527,6 +2594,7 @@ func getEventRuleConditionsFromPostFields(r *http.Request) (dataprovider.EventCo
RoleNames: roleNames,
FsPaths: fsPaths,
Protocols: r.Form["fs_protocols"],
+ EventStatuses: eventStatuses,
ProviderObjects: r.Form["provider_objects"],
MinFileSize: minFileSize,
MaxFileSize: maxFileSize,
@@ -2553,9 +2621,9 @@ func getEventRuleActionsFromPostFields(r *http.Request) []dataprovider.EventActi
},
Order: order + 1,
Options: dataprovider.EventActionOptions{
- IsFailureAction: util.Contains(options, "1"),
- StopOnFailure: util.Contains(options, "2"),
- ExecuteSync: util.Contains(options, "3"),
+ IsFailureAction: slices.Contains(options, "1"),
+ StopOnFailure: slices.Contains(options, "2"),
+ ExecuteSync: slices.Contains(options, "3"),
},
})
}
@@ -2758,31 +2826,95 @@ func getSMTPConfigsFromPostFields(r *http.Request) *dataprovider.SMTPConfigs {
}
}
+func getImageInputBytes(r *http.Request, fieldName, removeFieldName string, defaultVal []byte) ([]byte, error) {
+ var result []byte
+ remove := r.Form.Get(removeFieldName)
+ if remove == "" || remove == "0" {
+ result = defaultVal
+ }
+ f, _, err := r.FormFile(fieldName)
+ if err != nil {
+ if errors.Is(err, http.ErrMissingFile) {
+ return result, nil
+ }
+ return nil, err
+ }
+ defer f.Close()
+
+ return io.ReadAll(f)
+}
+
+func getBrandingConfigFromPostFields(r *http.Request, config *dataprovider.BrandingConfigs) (
+ *dataprovider.BrandingConfigs, error,
+) {
+ if config == nil {
+ config = &dataprovider.BrandingConfigs{}
+ }
+ adminLogo, err := getImageInputBytes(r, "branding_webadmin_logo", "branding_webadmin_logo_remove", config.WebAdmin.Logo)
+ if err != nil {
+ return nil, util.NewI18nError(err, util.I18nErrorInvalidForm)
+ }
+ adminFavicon, err := getImageInputBytes(r, "branding_webadmin_favicon", "branding_webadmin_favicon_remove",
+ config.WebAdmin.Favicon)
+ if err != nil {
+ return nil, util.NewI18nError(err, util.I18nErrorInvalidForm)
+ }
+ clientLogo, err := getImageInputBytes(r, "branding_webclient_logo", "branding_webclient_logo_remove",
+ config.WebClient.Logo)
+ if err != nil {
+ return nil, util.NewI18nError(err, util.I18nErrorInvalidForm)
+ }
+ clientFavicon, err := getImageInputBytes(r, "branding_webclient_favicon", "branding_webclient_favicon_remove",
+ config.WebClient.Favicon)
+ if err != nil {
+ return nil, util.NewI18nError(err, util.I18nErrorInvalidForm)
+ }
+
+ branding := &dataprovider.BrandingConfigs{
+ WebAdmin: dataprovider.BrandingConfig{
+ Name: strings.TrimSpace(r.Form.Get("branding_webadmin_name")),
+ ShortName: strings.TrimSpace(r.Form.Get("branding_webadmin_short_name")),
+ Logo: adminLogo,
+ Favicon: adminFavicon,
+ DisclaimerName: strings.TrimSpace(r.Form.Get("branding_webadmin_disclaimer_name")),
+ DisclaimerURL: strings.TrimSpace(r.Form.Get("branding_webadmin_disclaimer_url")),
+ },
+ WebClient: dataprovider.BrandingConfig{
+ Name: strings.TrimSpace(r.Form.Get("branding_webclient_name")),
+ ShortName: strings.TrimSpace(r.Form.Get("branding_webclient_short_name")),
+ Logo: clientLogo,
+ Favicon: clientFavicon,
+ DisclaimerName: strings.TrimSpace(r.Form.Get("branding_webclient_disclaimer_name")),
+ DisclaimerURL: strings.TrimSpace(r.Form.Get("branding_webclient_disclaimer_url")),
+ },
+ }
+ return branding, nil
+}
+
func (s *httpdServer) handleWebAdminForgotPwd(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
if !smtp.IsEnabled() {
s.renderNotFoundPage(w, r, errors.New("this page does not exist"))
return
}
- s.renderForgotPwdPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr))
+ s.renderForgotPwdPage(w, r, nil)
}
func (s *httpdServer) handleWebAdminForgotPwdPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
err := r.ParseForm()
if err != nil {
- s.renderForgotPwdPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
+ s.renderForgotPwdPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
return
}
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyLoginCookieAndCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
err = handleForgotPassword(r, r.Form.Get("username"), true)
if err != nil {
- s.renderForgotPwdPage(w, r, util.NewI18nError(err, util.I18nErrorPwdResetGeneric), ipAddr)
+ s.renderForgotPwdPage(w, r, util.NewI18nError(err, util.I18nErrorPwdResetGeneric))
return
}
http.Redirect(w, r, webAdminResetPwdPath, http.StatusFound)
@@ -2794,17 +2926,17 @@ func (s *httpdServer) handleWebAdminPasswordReset(w http.ResponseWriter, r *http
s.renderNotFoundPage(w, r, errors.New("this page does not exist"))
return
}
- s.renderResetPwdPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr))
+ s.renderResetPwdPage(w, r, nil)
}
func (s *httpdServer) handleWebAdminTwoFactor(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- s.renderTwoFactorPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr))
+ s.renderTwoFactorPage(w, r, nil)
}
func (s *httpdServer) handleWebAdminTwoFactorRecovery(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- s.renderTwoFactorRecoveryPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr))
+ s.renderTwoFactorRecoveryPage(w, r, nil)
}
func (s *httpdServer) handleWebAdminMFA(w http.ResponseWriter, r *http.Request) {
@@ -2830,11 +2962,11 @@ func (s *httpdServer) handleWebAdminProfilePost(w http.ResponseWriter, r *http.R
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderProfilePage(w, r, util.NewI18nError(err, util.I18nErrorInvalidToken))
return
@@ -2862,7 +2994,7 @@ func (s *httpdServer) handleWebMaintenance(w http.ResponseWriter, r *http.Reques
func (s *httpdServer) handleWebRestore(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, MaxRestoreSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -2875,7 +3007,7 @@ func (s *httpdServer) handleWebRestore(w http.ResponseWriter, r *http.Request) {
defer r.MultipartForm.RemoveAll() //nolint:errcheck
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -2915,7 +3047,7 @@ func (s *httpdServer) handleWebRestore(w http.ResponseWriter, r *http.Request) {
func getAllAdmins(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, nil, util.I18nErrorInvalidToken, http.StatusForbidden)
return
@@ -2936,7 +3068,7 @@ func getAllAdmins(w http.ResponseWriter, r *http.Request) {
func (s *httpdServer) handleGetWebAdmins(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- data := s.getBasePageData(util.I18nAdminsTitle, webAdminsPath, r)
+ data := s.getBasePageData(util.I18nAdminsTitle, webAdminsPath, w, r)
renderAdminTemplate(w, templateAdmins, data)
}
@@ -2946,7 +3078,7 @@ func (s *httpdServer) handleWebAdminSetupGet(w http.ResponseWriter, r *http.Requ
http.Redirect(w, r, webAdminLoginPath, http.StatusFound)
return
}
- s.renderAdminSetupPage(w, r, "", util.GetIPFromRemoteAddress(r.RemoteAddr), nil)
+ s.renderAdminSetupPage(w, r, "", nil)
}
func (s *httpdServer) handleWebAddAdminGet(w http.ResponseWriter, r *http.Request) {
@@ -2973,7 +3105,7 @@ func (s *httpdServer) handleWebUpdateAdminGet(w http.ResponseWriter, r *http.Req
func (s *httpdServer) handleWebAddAdminPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -2983,11 +3115,15 @@ func (s *httpdServer) handleWebAddAdminPost(w http.ResponseWriter, r *http.Reque
s.renderAddUpdateAdminPage(w, r, &admin, err, true)
return
}
- if admin.Password == "" && s.binding.isWebAdminLoginFormDisabled() {
+ if admin.Password == "" {
+ // Administrators can be used with OpenID Connect or for authentication
+ // via API key, in these cases the password is not necessary, we create
+ // a non-usable one. This feature is only useful for WebAdmin, in REST
+ // API you can create an unusable password externally.
admin.Password = util.GenerateUniqueID()
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -3018,7 +3154,7 @@ func (s *httpdServer) handleWebUpdateAdminPost(w http.ResponseWriter, r *http.Re
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -3029,15 +3165,15 @@ func (s *httpdServer) handleWebUpdateAdminPost(w http.ResponseWriter, r *http.Re
}
updatedAdmin.Filters.TOTPConfig = admin.Filters.TOTPConfig
updatedAdmin.Filters.RecoveryCodes = admin.Filters.RecoveryCodes
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderAddUpdateAdminPage(w, r, &updatedAdmin, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken), false)
return
}
if username == claims.Username {
- if claims.isCriticalPermRemoved(updatedAdmin.Permissions) {
+ if !util.SlicesEqual(admin.Permissions, updatedAdmin.Permissions) {
s.renderAddUpdateAdminPage(w, r, &updatedAdmin,
- util.NewI18nError(errors.New("you cannot remove these permissions to yourself"),
+ util.NewI18nError(errors.New("you cannot change your permissions"),
util.I18nErrorAdminSelfPerms,
), false)
return
@@ -3071,7 +3207,7 @@ func (s *httpdServer) handleWebUpdateAdminPost(w http.ResponseWriter, r *http.Re
func (s *httpdServer) handleWebDefenderPage(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
data := defenderHostsPage{
- basePage: s.getBasePageData(util.I18nDefenderTitle, webDefenderPath, r),
+ basePage: s.getBasePageData(util.I18nDefenderTitle, webDefenderPath, w, r),
DefenderHostsURL: webDefenderHostsPath,
}
@@ -3080,7 +3216,7 @@ func (s *httpdServer) handleWebDefenderPage(w http.ResponseWriter, r *http.Reque
func getAllUsers(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, nil, util.I18nErrorInvalidToken, http.StatusForbidden)
return
@@ -3100,12 +3236,12 @@ func getAllUsers(w http.ResponseWriter, r *http.Request) {
func (s *httpdServer) handleGetWebUsers(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
}
- data := s.getBasePageData(util.I18nUsersTitle, webUsersPath, r)
+ data := s.getBasePageData(util.I18nUsersTitle, webUsersPath, w, r)
renderAdminTemplate(w, templateUsers, data)
}
@@ -3130,7 +3266,7 @@ func (s *httpdServer) handleWebTemplateFolderGet(w http.ResponseWriter, r *http.
func (s *httpdServer) handleWebTemplateFolderPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -3144,7 +3280,7 @@ func (s *httpdServer) handleWebTemplateFolderPost(w http.ResponseWriter, r *http
defer r.MultipartForm.RemoveAll() //nolint:errcheck
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -3159,7 +3295,6 @@ func (s *httpdServer) handleWebTemplateFolderPost(w http.ResponseWriter, r *http
templateFolder.FsConfig = fsConfig
var dump dataprovider.BackupData
- dump.Version = dataprovider.DumpVersion
foldersFields := getFoldersForTemplate(r)
for _, tmpl := range foldersFields {
@@ -3179,12 +3314,6 @@ func (s *httpdServer) handleWebTemplateFolderPost(w http.ResponseWriter, r *http
), "")
return
}
- if r.Form.Get("form_action") == "export_from_template" {
- w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"sftpgo-%v-folders-from-template.json\"",
- len(dump.Folders)))
- render.JSON(w, r, dump)
- return
- }
if err = RestoreFolders(dump.Folders, "", 1, 0, claims.Username, ipAddr, claims.Role); err != nil {
s.renderMessagePage(w, r, util.I18nTemplateFolderTitle, getRespStatus(err), err, "")
return
@@ -3207,6 +3336,7 @@ func (s *httpdServer) handleWebTemplateUserGet(w http.ResponseWriter, r *http.Re
user.SetEmptySecrets()
user.PublicKeys = nil
user.Email = ""
+ user.Filters.AdditionalEmails = nil
user.Description = ""
if user.ExpirationDate == 0 && admin.Filters.Preferences.DefaultUsersExpiration > 0 {
user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(admin.Filters.Preferences.DefaultUsersExpiration)))
@@ -3233,7 +3363,7 @@ func (s *httpdServer) handleWebTemplateUserGet(w http.ResponseWriter, r *http.Re
func (s *httpdServer) handleWebTemplateUserPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -3244,13 +3374,12 @@ func (s *httpdServer) handleWebTemplateUserPost(w http.ResponseWriter, r *http.R
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
var dump dataprovider.BackupData
- dump.Version = dataprovider.DumpVersion
userTmplFields := getUsersForTemplate(r)
for _, tmpl := range userTmplFields {
@@ -3259,14 +3388,10 @@ func (s *httpdServer) handleWebTemplateUserPost(w http.ResponseWriter, r *http.R
s.renderMessagePage(w, r, util.I18nTemplateUserTitle, http.StatusBadRequest, err, "")
return
}
- // to create a template the "manage_system" permission is required, so role admins cannot use
- // this method, we don't need to force the role
- dump.Users = append(dump.Users, u)
- for _, folder := range u.VirtualFolders {
- if !dump.HasFolder(folder.Name) {
- dump.Folders = append(dump.Folders, folder.BaseVirtualFolder)
- }
+ if claims.Role != "" {
+ u.Role = claims.Role
}
+ dump.Users = append(dump.Users, u)
}
if len(dump.Users) == 0 {
@@ -3277,12 +3402,6 @@ func (s *httpdServer) handleWebTemplateUserPost(w http.ResponseWriter, r *http.R
), "")
return
}
- if r.Form.Get("form_action") == "export_from_template" {
- w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"sftpgo-%v-users-from-template.json\"",
- len(dump.Users)))
- render.JSON(w, r, dump)
- return
- }
if err = RestoreUsers(dump.Users, "", 1, 0, claims.Username, ipAddr, claims.Role); err != nil {
s.renderMessagePage(w, r, util.I18nTemplateUserTitle, getRespStatus(err), err, "")
return
@@ -3312,7 +3431,7 @@ func (s *httpdServer) handleWebAddUserGet(w http.ResponseWriter, r *http.Request
func (s *httpdServer) handleWebUpdateUserGet(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -3330,7 +3449,7 @@ func (s *httpdServer) handleWebUpdateUserGet(w http.ResponseWriter, r *http.Requ
func (s *httpdServer) handleWebAddUserPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -3341,14 +3460,15 @@ func (s *httpdServer) handleWebAddUserPost(w http.ResponseWriter, r *http.Reques
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
user = getUserFromTemplate(user, userTemplateFields{
- Username: user.Username,
- Password: user.Password,
- PublicKeys: user.PublicKeys,
+ Username: user.Username,
+ Password: user.Password,
+ PublicKeys: user.PublicKeys,
+ RequirePwdChange: user.Filters.RequirePasswordChange,
})
if claims.Role != "" {
user.Role = claims.Role
@@ -3367,7 +3487,7 @@ func (s *httpdServer) handleWebAddUserPost(w http.ResponseWriter, r *http.Reques
func (s *httpdServer) handleWebUpdateUserPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -3387,7 +3507,7 @@ func (s *httpdServer) handleWebUpdateUserPost(w http.ResponseWriter, r *http.Req
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -3403,9 +3523,10 @@ func (s *httpdServer) handleWebUpdateUserPost(w http.ResponseWriter, r *http.Req
updateEncryptedSecrets(&updatedUser.FsConfig, &user.FsConfig)
updatedUser = getUserFromTemplate(updatedUser, userTemplateFields{
- Username: updatedUser.Username,
- Password: updatedUser.Password,
- PublicKeys: updatedUser.PublicKeys,
+ Username: updatedUser.Username,
+ Password: updatedUser.Password,
+ PublicKeys: updatedUser.PublicKeys,
+ RequirePwdChange: updatedUser.Filters.RequirePasswordChange,
})
if claims.Role != "" {
updatedUser.Role = claims.Role
@@ -3425,7 +3546,7 @@ func (s *httpdServer) handleWebUpdateUserPost(w http.ResponseWriter, r *http.Req
func (s *httpdServer) handleWebGetStatus(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
data := statusPage{
- basePage: s.getBasePageData(util.I18nStatusTitle, webStatusPath, r),
+ basePage: s.getBasePageData(util.I18nStatusTitle, webStatusPath, w, r),
Status: getServicesStatus(),
}
renderAdminTemplate(w, templateStatus, data)
@@ -3433,13 +3554,13 @@ func (s *httpdServer) handleWebGetStatus(w http.ResponseWriter, r *http.Request)
func (s *httpdServer) handleWebGetConnections(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
}
- data := s.getBasePageData(util.I18nSessionsTitle, webConnectionsPath, r)
+ data := s.getBasePageData(util.I18nSessionsTitle, webConnectionsPath, w, r)
renderAdminTemplate(w, templateConnections, data)
}
@@ -3450,7 +3571,7 @@ func (s *httpdServer) handleWebAddFolderGet(w http.ResponseWriter, r *http.Reque
func (s *httpdServer) handleWebAddFolderPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -3464,7 +3585,7 @@ func (s *httpdServer) handleWebAddFolderPost(w http.ResponseWriter, r *http.Requ
defer r.MultipartForm.RemoveAll() //nolint:errcheck
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -3502,7 +3623,7 @@ func (s *httpdServer) handleWebUpdateFolderGet(w http.ResponseWriter, r *http.Re
func (s *httpdServer) handleWebUpdateFolderPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -3525,7 +3646,7 @@ func (s *httpdServer) handleWebUpdateFolderPost(w http.ResponseWriter, r *http.R
defer r.MultipartForm.RemoveAll() //nolint:errcheck
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -3588,7 +3709,7 @@ func getAllFolders(w http.ResponseWriter, r *http.Request) {
func (s *httpdServer) handleWebGetFolders(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- data := s.getBasePageData(util.I18nFoldersTitle, webFoldersPath, r)
+ data := s.getBasePageData(util.I18nFoldersTitle, webFoldersPath, w, r)
renderAdminTemplate(w, templateFolders, data)
}
@@ -3626,7 +3747,7 @@ func getAllGroups(w http.ResponseWriter, r *http.Request) {
func (s *httpdServer) handleWebGetGroups(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- data := s.getBasePageData(util.I18nGroupsTitle, webGroupsPath, r)
+ data := s.getBasePageData(util.I18nGroupsTitle, webGroupsPath, w, r)
renderAdminTemplate(w, templateGroups, data)
}
@@ -3637,7 +3758,7 @@ func (s *httpdServer) handleWebAddGroupGet(w http.ResponseWriter, r *http.Reques
func (s *httpdServer) handleWebAddGroupPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -3648,7 +3769,7 @@ func (s *httpdServer) handleWebAddGroupPost(w http.ResponseWriter, r *http.Reque
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -3675,7 +3796,7 @@ func (s *httpdServer) handleWebUpdateGroupGet(w http.ResponseWriter, r *http.Req
func (s *httpdServer) handleWebUpdateGroupPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -3695,7 +3816,7 @@ func (s *httpdServer) handleWebUpdateGroupPost(w http.ResponseWriter, r *http.Re
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -3748,7 +3869,7 @@ func getAllActions(w http.ResponseWriter, r *http.Request) {
func (s *httpdServer) handleWebGetEventActions(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- data := s.getBasePageData(util.I18nActionsTitle, webAdminEventActionsPath, r)
+ data := s.getBasePageData(util.I18nActionsTitle, webAdminEventActionsPath, w, r)
renderAdminTemplate(w, templateEventActions, data)
}
@@ -3762,7 +3883,7 @@ func (s *httpdServer) handleWebAddEventActionGet(w http.ResponseWriter, r *http.
func (s *httpdServer) handleWebAddEventActionPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -3773,7 +3894,7 @@ func (s *httpdServer) handleWebAddEventActionPost(w http.ResponseWriter, r *http
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -3799,7 +3920,7 @@ func (s *httpdServer) handleWebUpdateEventActionGet(w http.ResponseWriter, r *ht
func (s *httpdServer) handleWebUpdateEventActionPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -3819,7 +3940,7 @@ func (s *httpdServer) handleWebUpdateEventActionPost(w http.ResponseWriter, r *h
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -3858,7 +3979,7 @@ func getAllRules(w http.ResponseWriter, r *http.Request) {
func (s *httpdServer) handleWebGetEventRules(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- data := s.getBasePageData(util.I18nRulesTitle, webAdminEventRulesPath, r)
+ data := s.getBasePageData(util.I18nRulesTitle, webAdminEventRulesPath, w, r)
renderAdminTemplate(w, templateEventRules, data)
}
@@ -3873,7 +3994,7 @@ func (s *httpdServer) handleWebAddEventRuleGet(w http.ResponseWriter, r *http.Re
func (s *httpdServer) handleWebAddEventRulePost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -3884,7 +4005,7 @@ func (s *httpdServer) handleWebAddEventRulePost(w http.ResponseWriter, r *http.R
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- err = verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr)
+ err = verifyCSRFToken(r, s.csrfTokenAuth)
if err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
@@ -3911,7 +4032,7 @@ func (s *httpdServer) handleWebUpdateEventRuleGet(w http.ResponseWriter, r *http
func (s *httpdServer) handleWebUpdateEventRulePost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -3931,7 +4052,7 @@ func (s *httpdServer) handleWebUpdateEventRulePost(w http.ResponseWriter, r *htt
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -3978,7 +4099,7 @@ func getAllRoles(w http.ResponseWriter, r *http.Request) {
func (s *httpdServer) handleWebGetRoles(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- data := s.getBasePageData(util.I18nRolesTitle, webAdminRolesPath, r)
+ data := s.getBasePageData(util.I18nRolesTitle, webAdminRolesPath, w, r)
renderAdminTemplate(w, templateRoles, data)
}
@@ -3995,13 +4116,13 @@ func (s *httpdServer) handleWebAddRolePost(w http.ResponseWriter, r *http.Reques
s.renderRolePage(w, r, role, genericPageModeAdd, err)
return
}
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -4027,7 +4148,7 @@ func (s *httpdServer) handleWebUpdateRoleGet(w http.ResponseWriter, r *http.Requ
func (s *httpdServer) handleWebUpdateRolePost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -4047,7 +4168,7 @@ func (s *httpdServer) handleWebUpdateRolePost(w http.ResponseWriter, r *http.Req
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -4065,7 +4186,7 @@ func (s *httpdServer) handleWebGetEvents(w http.ResponseWriter, r *http.Request)
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
data := eventsPage{
- basePage: s.getBasePageData(util.I18nEventsTitle, webEventsPath, r),
+ basePage: s.getBasePageData(util.I18nEventsTitle, webEventsPath, w, r),
FsEventsSearchURL: webEventsFsSearchPath,
ProviderEventsSearchURL: webEventsProviderSearchPath,
LogEventsSearchURL: webEventsLogSearchPath,
@@ -4077,7 +4198,7 @@ func (s *httpdServer) handleWebIPListsPage(w http.ResponseWriter, r *http.Reques
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
rtlStatus, rtlProtocols := common.Config.GetRateLimitersStatus()
data := ipListsPage{
- basePage: s.getBasePageData(util.I18nIPListsTitle, webIPListsPath, r),
+ basePage: s.getBasePageData(util.I18nIPListsTitle, webIPListsPath, w, r),
RateLimitersStatus: rtlStatus,
RateLimitersProtocols: strings.Join(rtlProtocols, ", "),
IsAllowListEnabled: common.Config.IsAllowListEnabled(),
@@ -4109,13 +4230,13 @@ func (s *httpdServer) handleWebAddIPListEntryPost(w http.ResponseWriter, r *http
return
}
entry.Type = listType
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -4146,7 +4267,7 @@ func (s *httpdServer) handleWebUpdateIPListEntryGet(w http.ResponseWriter, r *ht
func (s *httpdServer) handleWebUpdateIPListEntryPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -4170,7 +4291,7 @@ func (s *httpdServer) handleWebUpdateIPListEntryPost(w http.ResponseWriter, r *h
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -4196,7 +4317,7 @@ func (s *httpdServer) handleWebConfigs(w http.ResponseWriter, r *http.Request) {
func (s *httpdServer) handleWebConfigsPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -4206,13 +4327,15 @@ func (s *httpdServer) handleWebConfigsPost(w http.ResponseWriter, r *http.Reques
s.renderInternalServerErrorPage(w, r, err)
return
}
- err = r.ParseForm()
+ err = r.ParseMultipartForm(maxRequestSize)
if err != nil {
s.renderBadRequestPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
return
}
+ defer r.MultipartForm.RemoveAll() //nolint:errcheck
+
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -4236,6 +4359,15 @@ func (s *httpdServer) handleWebConfigsPost(w http.ResponseWriter, r *http.Reques
smtpConfigs := getSMTPConfigsFromPostFields(r)
updateSMTPSecrets(smtpConfigs, configs.SMTP)
configs.SMTP = smtpConfigs
+ case "branding_submit":
+ configSection = 4
+ brandingConfigs, err := getBrandingConfigFromPostFields(r, configs.Branding)
+ configs.Branding = brandingConfigs
+ if err != nil {
+ logger.Info(logSender, "", "unable to get branding config: %v", err)
+ s.renderConfigsPage(w, r, configs, err, configSection)
+ return
+ }
default:
s.renderBadRequestPage(w, r, errors.New("unsupported form action"))
return
@@ -4246,15 +4378,22 @@ func (s *httpdServer) handleWebConfigsPost(w http.ResponseWriter, r *http.Reques
s.renderConfigsPage(w, r, configs, err, configSection)
return
}
- if configSection == 3 {
+ postConfigsUpdate(configSection, configs)
+ s.renderMessagePage(w, r, util.I18nConfigsTitle, http.StatusOK, nil, util.I18nConfigsOK)
+}
+
+func postConfigsUpdate(section int, configs dataprovider.Configs) {
+ switch section {
+ case 3:
err := configs.SMTP.TryDecrypt()
if err == nil {
smtp.Activate(configs.SMTP)
} else {
logger.Error(logSender, "", "unable to decrypt SMTP configuration, cannot activate configuration: %v", err)
}
+ case 4:
+ dbBrandingConfig.Set(configs.Branding)
}
- s.renderMessagePage(w, r, util.I18nConfigsTitle, http.StatusOK, nil, util.I18nConfigsOK)
}
func (s *httpdServer) handleOAuth2TokenRedirect(w http.ResponseWriter, r *http.Request) {
@@ -4262,20 +4401,21 @@ func (s *httpdServer) handleOAuth2TokenRedirect(w http.ResponseWriter, r *http.R
stateToken := r.URL.Query().Get("state")
- state, err := verifyOAuth2Token(stateToken, util.GetIPFromRemoteAddress(r.RemoteAddr))
+ state, err := verifyOAuth2Token(s.csrfTokenAuth, stateToken, util.GetIPFromRemoteAddress(r.RemoteAddr))
if err != nil {
s.renderMessagePage(w, r, util.I18nOAuth2ErrorTitle, http.StatusBadRequest, err, "")
return
}
- defer oauth2Mgr.removePendingAuth(state)
-
pendingAuth, err := oauth2Mgr.getPendingAuth(state)
if err != nil {
+ oauth2Mgr.removePendingAuth(state)
s.renderMessagePage(w, r, util.I18nOAuth2ErrorTitle, http.StatusInternalServerError,
util.NewI18nError(err, util.I18nOAuth2ErrorValidateState), "")
return
}
+ oauth2Mgr.removePendingAuth(state)
+
oauth2Config := smtp.OAuth2Config{
Provider: pendingAuth.Provider,
ClientID: pendingAuth.ClientID,
@@ -4286,7 +4426,7 @@ func (s *httpdServer) handleOAuth2TokenRedirect(w http.ResponseWriter, r *http.R
cfg := oauth2Config.GetOAuth2()
cfg.RedirectURL = pendingAuth.RedirectURL
- token, err := cfg.Exchange(ctx, r.URL.Query().Get("code"))
+ token, err := cfg.Exchange(ctx, r.URL.Query().Get("code"), oauth2.VerifierOption(pendingAuth.Verifier))
if err != nil {
s.renderMessagePage(w, r, util.I18nOAuth2ErrorTitle, http.StatusInternalServerError,
util.NewI18nError(err, util.I18nOAuth2ErrTokenExchange), "")
@@ -4306,6 +4446,9 @@ func (s *httpdServer) handleOAuth2TokenRedirect(w http.ResponseWriter, r *http.R
}
func updateSMTPSecrets(newConfigs, currentConfigs *dataprovider.SMTPConfigs) {
+ if currentConfigs == nil {
+ currentConfigs = &dataprovider.SMTPConfigs{}
+ }
if newConfigs.Password.IsNotPlainAndNotEmpty() {
newConfigs.Password = currentConfigs.Password
}
diff --git a/internal/httpd/webclient.go b/internal/httpd/webclient.go
index 10fd2c49..71aba2ce 100644
--- a/internal/httpd/webclient.go
+++ b/internal/httpd/webclient.go
@@ -16,6 +16,7 @@ package httpd
import (
"bytes"
+ "crypto/rand"
"encoding/json"
"errors"
"fmt"
@@ -27,6 +28,7 @@ import (
"os"
"path"
"path/filepath"
+ "slices"
"strconv"
"strings"
"time"
@@ -37,6 +39,7 @@ import (
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
+ "github.com/drakkan/sftpgo/v2/internal/jwt"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/mfa"
"github.com/drakkan/sftpgo/v2/internal/smtp"
@@ -81,21 +84,23 @@ func isZeroTime(t time.Time) bool {
type baseClientPage struct {
commonBasePage
- Title string
- CurrentURL string
- FilesURL string
- SharesURL string
- ShareURL string
- ProfileURL string
- PingURL string
- ChangePwdURL string
- LogoutURL string
- LoginURL string
- EditURL string
- MFAURL string
- CSRFToken string
- LoggedUser *dataprovider.User
- Branding UIBranding
+ Title string
+ CurrentURL string
+ FilesURL string
+ SharesURL string
+ ShareURL string
+ ProfileURL string
+ PingURL string
+ ChangePwdURL string
+ LogoutURL string
+ LoginURL string
+ EditURL string
+ MFAURL string
+ CSRFToken string
+ LoggedUser *dataprovider.User
+ IsLoggedToShare bool
+ Branding UIBranding
+ Languages []string
}
type dirMapping struct {
@@ -105,9 +110,10 @@ type dirMapping struct {
type viewPDFPage struct {
commonBasePage
- Title string
- URL string
- Branding UIBranding
+ Title string
+ URL string
+ Branding UIBranding
+ Languages []string
}
type editFilePage struct {
@@ -141,15 +147,18 @@ type filesPage struct {
Error *util.I18nError
Paths []dirMapping
QuotaUsage *userQuotaUsage
+ KeepAliveInterval int
}
type shareLoginPage struct {
commonBasePage
- CurrentURL string
- Error *util.I18nError
- CSRFToken string
- Title string
- Branding UIBranding
+ CurrentURL string
+ Error *util.I18nError
+ CSRFToken string
+ Title string
+ Branding UIBranding
+ Languages []string
+ CheckRedirect bool
}
type shareDownloadPage struct {
@@ -172,13 +181,15 @@ type clientMessagePage struct {
type clientProfilePage struct {
baseClientPage
- PublicKeys []string
- TLSCerts []string
- CanSubmit bool
- AllowAPIKeyAuth bool
- Email string
- Description string
- Error *util.I18nError
+ PublicKeys []string
+ TLSCerts []string
+ CanSubmit bool
+ AllowAPIKeyAuth bool
+ Email string
+ AdditionalEmails []string
+ AdditionalEmailsString string
+ Description string
+ Error *util.I18nError
}
type changeClientPasswordPage struct {
@@ -201,6 +212,7 @@ type clientMFAPage struct {
type clientSharesPage struct {
baseClientPage
BasePublicSharesURL string
+ BaseURL string
}
type clientSharePage struct {
@@ -523,28 +535,30 @@ func loadClientTemplates(templatesPath string) {
clientTemplates[templateShareDownload] = shareDownloadTmpl
}
-func (s *httpdServer) getBaseClientPageData(title, currentURL string, r *http.Request) baseClientPage {
+func (s *httpdServer) getBaseClientPageData(title, currentURL string, w http.ResponseWriter, r *http.Request) baseClientPage {
var csrfToken string
if currentURL != "" {
- csrfToken = createCSRFToken(util.GetIPFromRemoteAddress(r.RemoteAddr))
+ csrfToken = createCSRFToken(w, r, s.csrfTokenAuth, "", webBaseClientPath)
}
data := baseClientPage{
- commonBasePage: getCommonBasePage(r),
- Title: title,
- CurrentURL: currentURL,
- FilesURL: webClientFilesPath,
- SharesURL: webClientSharesPath,
- ShareURL: webClientSharePath,
- ProfileURL: webClientProfilePath,
- PingURL: webClientPingPath,
- ChangePwdURL: webChangeClientPwdPath,
- LogoutURL: webClientLogoutPath,
- EditURL: webClientEditFilePath,
- MFAURL: webClientMFAPath,
- CSRFToken: csrfToken,
- LoggedUser: getUserFromToken(r),
- Branding: s.binding.Branding.WebClient,
+ commonBasePage: getCommonBasePage(r),
+ Title: title,
+ CurrentURL: currentURL,
+ FilesURL: webClientFilesPath,
+ SharesURL: webClientSharesPath,
+ ShareURL: webClientSharePath,
+ ProfileURL: webClientProfilePath,
+ PingURL: webClientPingPath,
+ ChangePwdURL: webChangeClientPwdPath,
+ LogoutURL: webClientLogoutPath,
+ EditURL: webClientEditFilePath,
+ MFAURL: webClientMFAPath,
+ CSRFToken: csrfToken,
+ LoggedUser: getUserFromToken(r),
+ IsLoggedToShare: false,
+ Branding: s.binding.webClientBranding(),
+ Languages: s.binding.languages(),
}
if !strings.HasPrefix(r.RequestURI, webClientPubSharesPath) {
data.LoginURL = webClientLoginPath
@@ -552,40 +566,44 @@ func (s *httpdServer) getBaseClientPageData(title, currentURL string, r *http.Re
return data
}
-func (s *httpdServer) renderClientForgotPwdPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
+func (s *httpdServer) renderClientForgotPwdPage(w http.ResponseWriter, r *http.Request, err *util.I18nError) {
data := forgotPwdPage{
commonBasePage: getCommonBasePage(r),
CurrentURL: webClientForgotPwdPath,
Error: err,
- CSRFToken: createCSRFToken(ip),
+ CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, rand.Text(), webBaseClientPath),
LoginURL: webClientLoginPath,
Title: util.I18nForgotPwdTitle,
- Branding: s.binding.Branding.WebClient,
+ Branding: s.binding.webClientBranding(),
+ Languages: s.binding.languages(),
}
renderClientTemplate(w, templateForgotPassword, data)
}
-func (s *httpdServer) renderClientResetPwdPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
+func (s *httpdServer) renderClientResetPwdPage(w http.ResponseWriter, r *http.Request, err *util.I18nError) {
data := resetPwdPage{
commonBasePage: getCommonBasePage(r),
CurrentURL: webClientResetPwdPath,
Error: err,
- CSRFToken: createCSRFToken(ip),
+ CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, "", webBaseClientPath),
LoginURL: webClientLoginPath,
Title: util.I18nResetPwdTitle,
- Branding: s.binding.Branding.WebClient,
+ Branding: s.binding.webClientBranding(),
+ Languages: s.binding.languages(),
}
renderClientTemplate(w, templateResetPassword, data)
}
-func (s *httpdServer) renderShareLoginPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
+func (s *httpdServer) renderShareLoginPage(w http.ResponseWriter, r *http.Request, err *util.I18nError) {
data := shareLoginPage{
commonBasePage: getCommonBasePage(r),
Title: util.I18nShareLoginTitle,
CurrentURL: r.RequestURI,
Error: err,
- CSRFToken: createCSRFToken(ip),
- Branding: s.binding.Branding.WebClient,
+ CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, rand.Text(), webBaseClientPath),
+ Branding: s.binding.webClientBranding(),
+ Languages: s.binding.languages(),
+ CheckRedirect: false,
}
renderClientTemplate(w, templateShareLogin, data)
}
@@ -599,7 +617,7 @@ func renderClientTemplate(w http.ResponseWriter, tmplName string, data any) {
func (s *httpdServer) renderClientMessagePage(w http.ResponseWriter, r *http.Request, title string, statusCode int, err error, message string) {
data := clientMessagePage{
- baseClientPage: s.getBaseClientPageData(title, "", r),
+ baseClientPage: s.getBaseClientPageData(title, "", w, r),
Error: getI18nError(err),
Success: message,
}
@@ -627,15 +645,16 @@ func (s *httpdServer) renderClientNotFoundPage(w http.ResponseWriter, r *http.Re
util.NewI18nError(err, util.I18nError404Message), "")
}
-func (s *httpdServer) renderClientTwoFactorPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
+func (s *httpdServer) renderClientTwoFactorPage(w http.ResponseWriter, r *http.Request, err *util.I18nError) {
data := twoFactorPage{
commonBasePage: getCommonBasePage(r),
- Title: pageTwoFactorTitle,
+ Title: util.I18n2FATitle,
CurrentURL: webClientTwoFactorPath,
Error: err,
- CSRFToken: createCSRFToken(ip),
+ CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, "", webBaseClientPath),
RecoveryURL: webClientTwoFactorRecoveryPath,
- Branding: s.binding.Branding.WebClient,
+ Branding: s.binding.webClientBranding(),
+ Languages: s.binding.languages(),
}
if next := r.URL.Query().Get("next"); strings.HasPrefix(next, webClientFilesPath) {
data.CurrentURL += "?next=" + url.QueryEscape(next)
@@ -643,21 +662,22 @@ func (s *httpdServer) renderClientTwoFactorPage(w http.ResponseWriter, r *http.R
renderClientTemplate(w, templateTwoFactor, data)
}
-func (s *httpdServer) renderClientTwoFactorRecoveryPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
+func (s *httpdServer) renderClientTwoFactorRecoveryPage(w http.ResponseWriter, r *http.Request, err *util.I18nError) {
data := twoFactorPage{
commonBasePage: getCommonBasePage(r),
- Title: pageTwoFactorRecoveryTitle,
+ Title: util.I18n2FATitle,
CurrentURL: webClientTwoFactorRecoveryPath,
Error: err,
- CSRFToken: createCSRFToken(ip),
- Branding: s.binding.Branding.WebClient,
+ CSRFToken: createCSRFToken(w, r, s.csrfTokenAuth, "", webBaseClientPath),
+ Branding: s.binding.webClientBranding(),
+ Languages: s.binding.languages(),
}
renderClientTemplate(w, templateTwoFactorRecovery, data)
}
func (s *httpdServer) renderClientMFAPage(w http.ResponseWriter, r *http.Request) {
data := clientMFAPage{
- baseClientPage: s.getBaseClientPageData(util.I18n2FATitle, webClientMFAPath, r),
+ baseClientPage: s.getBaseClientPageData(util.I18n2FATitle, webClientMFAPath, w, r),
TOTPConfigs: mfa.GetAvailableTOTPConfigNames(),
GenerateTOTPURL: webClientTOTPGeneratePath,
ValidateTOTPURL: webClientTOTPValidatePath,
@@ -681,7 +701,7 @@ func (s *httpdServer) renderEditFilePage(w http.ResponseWriter, r *http.Request,
title = util.I18nEditFileTitle
}
data := editFilePage{
- baseClientPage: s.getBaseClientPageData(title, webClientEditFilePath, r),
+ baseClientPage: s.getBaseClientPageData(title, webClientEditFilePath, w, r),
Path: fileName,
Name: path.Base(fileName),
CurrentDir: path.Dir(fileName),
@@ -701,8 +721,11 @@ func (s *httpdServer) renderAddUpdateSharePage(w http.ResponseWriter, r *http.Re
currentURL = fmt.Sprintf("%v/%v", webClientSharePath, url.PathEscape(share.ShareID))
title = util.I18nShareUpdateTitle
}
+ if share.IsPasswordHashed() {
+ share.Password = redactedSecret
+ }
data := clientSharePage{
- baseClientPage: s.getBaseClientPageData(title, currentURL, r),
+ baseClientPage: s.getBaseClientPageData(title, currentURL, w, r),
Share: share,
Error: err,
IsAdd: isAdd,
@@ -736,9 +759,11 @@ func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Reque
err *util.I18nError, share dataprovider.Share,
) {
currentURL := path.Join(webClientPubSharesPath, share.ShareID, "browse")
- baseData := s.getBaseClientPageData(util.I18nSharedFilesTitle, currentURL, r)
+ baseData := s.getBaseClientPageData(util.I18nSharedFilesTitle, currentURL, w, r)
baseData.FilesURL = currentURL
baseSharePath := path.Join(webClientPubSharesPath, share.ShareID)
+ baseData.LogoutURL = path.Join(webClientPubSharesPath, share.ShareID, "logout")
+ baseData.IsLoggedToShare = share.Password != ""
data := filesPage{
baseClientPage: baseData,
@@ -762,32 +787,44 @@ func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Reque
CanCopy: false,
Paths: getDirMapping(dirName, currentURL),
QuotaUsage: newUserQuotaUsage(&dataprovider.User{}),
+ KeepAliveInterval: int(cookieRefreshThreshold / time.Millisecond),
}
renderClientTemplate(w, templateClientFiles, data)
}
-func (s *httpdServer) renderShareDownloadPage(w http.ResponseWriter, r *http.Request, downloadLink string) {
+func (s *httpdServer) renderShareDownloadPage(w http.ResponseWriter, r *http.Request, share *dataprovider.Share,
+ downloadLink string,
+) {
data := shareDownloadPage{
- baseClientPage: s.getBaseClientPageData(util.I18nShareDownloadTitle, "", r),
+ baseClientPage: s.getBaseClientPageData(util.I18nShareDownloadTitle, "", w, r),
DownloadLink: downloadLink,
}
+ data.LogoutURL = ""
+ if share.Password != "" {
+ data.LogoutURL = path.Join(webClientPubSharesPath, share.ShareID, "logout")
+ }
+
renderClientTemplate(w, templateShareDownload, data)
}
-func (s *httpdServer) renderUploadToSharePage(w http.ResponseWriter, r *http.Request, share dataprovider.Share) {
+func (s *httpdServer) renderUploadToSharePage(w http.ResponseWriter, r *http.Request, share *dataprovider.Share) {
currentURL := path.Join(webClientPubSharesPath, share.ShareID, "upload")
data := shareUploadPage{
- baseClientPage: s.getBaseClientPageData(util.I18nShareUploadTitle, currentURL, r),
- Share: &share,
+ baseClientPage: s.getBaseClientPageData(util.I18nShareUploadTitle, currentURL, w, r),
+ Share: share,
UploadBasePath: path.Join(webClientPubSharesPath, share.ShareID),
}
+ data.LogoutURL = ""
+ if share.Password != "" {
+ data.LogoutURL = path.Join(webClientPubSharesPath, share.ShareID, "logout")
+ }
renderClientTemplate(w, templateUploadToShare, data)
}
func (s *httpdServer) renderFilesPage(w http.ResponseWriter, r *http.Request, dirName string,
err *util.I18nError, user *dataprovider.User) {
data := filesPage{
- baseClientPage: s.getBaseClientPageData(util.I18nFilesTitle, webClientFilesPath, r),
+ baseClientPage: s.getBaseClientPageData(util.I18nFilesTitle, webClientFilesPath, w, r),
Error: err,
CurrentDir: url.QueryEscape(dirName),
DownloadURL: webClientDownloadZipPath,
@@ -807,13 +844,14 @@ func (s *httpdServer) renderFilesPage(w http.ResponseWriter, r *http.Request, di
ShareUploadBaseURL: "",
Paths: getDirMapping(dirName, webClientFilesPath),
QuotaUsage: newUserQuotaUsage(user),
+ KeepAliveInterval: int(cookieRefreshThreshold / time.Millisecond),
}
renderClientTemplate(w, templateClientFiles, data)
}
func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Request, err *util.I18nError) {
data := clientProfilePage{
- baseClientPage: s.getBaseClientPageData(util.I18nProfileTitle, webClientProfilePath, r),
+ baseClientPage: s.getBaseClientPageData(util.I18nProfileTitle, webClientProfilePath, w, r),
Error: err,
}
user, userMerged, errUser := dataprovider.GetUserVariants(data.LoggedUser.Username, "")
@@ -825,6 +863,8 @@ func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Req
data.TLSCerts = user.Filters.TLSCerts
data.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth
data.Email = user.Email
+ data.AdditionalEmails = user.Filters.AdditionalEmails
+ data.AdditionalEmailsString = strings.Join(data.AdditionalEmails, ", ")
data.Description = user.Description
data.CanSubmit = userMerged.CanUpdateProfile()
renderClientTemplate(w, templateClientProfile, data)
@@ -832,7 +872,7 @@ func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Req
func (s *httpdServer) renderClientChangePasswordPage(w http.ResponseWriter, r *http.Request, err *util.I18nError) {
data := changeClientPasswordPage{
- baseClientPage: s.getBaseClientPageData(util.I18nChangePwdTitle, webChangeClientPwdPath, r),
+ baseClientPage: s.getBaseClientPageData(util.I18nChangePwdTitle, webChangeClientPwdPath, w, r),
Error: err,
}
@@ -841,7 +881,7 @@ func (s *httpdServer) renderClientChangePasswordPage(w http.ResponseWriter, r *h
func (s *httpdServer) handleWebClientDownloadZip(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxMultipartMem)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderClientForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -850,8 +890,7 @@ func (s *httpdServer) handleWebClientDownloadZip(w http.ResponseWriter, r *http.
s.renderClientBadRequestPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
return
}
- ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderClientForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -866,15 +905,12 @@ func (s *httpdServer) handleWebClientDownloadZip(w http.ResponseWriter, r *http.
connID := xid.New().String()
protocol := getProtocolFromRequest(r)
connectionID := fmt.Sprintf("%v_%v", protocol, connID)
- if err := checkHTTPClientUser(&user, r, connectionID, false); err != nil {
+ if err := checkHTTPClientUser(&user, r, connectionID, false, false); err != nil {
s.renderClientForbiddenPage(w, r, err)
return
}
- connection := &Connection{
- BaseConnection: common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r),
- r.RemoteAddr, user),
- request: r,
- }
+ baseConn := common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r), r.RemoteAddr, user)
+ connection := newConnection(baseConn, w, r)
if err = common.Connections.Add(connection); err != nil {
s.renderClientMessagePage(w, r, util.I18nError429Title, http.StatusTooManyRequests,
util.NewI18nError(err, util.I18nError429Message), "")
@@ -973,7 +1009,7 @@ func (s *httpdServer) handleShareGetDirContents(w http.ResponseWriter, r *http.R
}
defer lister.Close()
- dataGetter := func(limit, _ int) ([]byte, int, error) {
+ dataGetter := func(limit, offset int) ([]byte, int, error) {
contents, err := lister.Next(limit)
if errors.Is(err, io.EOF) {
err = nil
@@ -982,11 +1018,12 @@ func (s *httpdServer) handleShareGetDirContents(w http.ResponseWriter, r *http.R
return nil, 0, err
}
results := make([]map[string]any, 0, len(contents))
- for _, info := range contents {
+ for idx, info := range contents {
if !info.Mode().IsDir() && !info.Mode().IsRegular() {
continue
}
res := make(map[string]any)
+ res["id"] = offset + idx + 1
if info.IsDir() {
res["type"] = "1"
res["size"] = ""
@@ -1023,7 +1060,7 @@ func (s *httpdServer) handleClientUploadToShare(w http.ResponseWriter, r *http.R
http.Redirect(w, r, path.Join(webClientPubSharesPath, share.ShareID, "browse"), http.StatusFound)
return
}
- s.renderUploadToSharePage(w, r, share)
+ s.renderUploadToSharePage(w, r, &share)
}
func (s *httpdServer) handleShareGetFiles(w http.ResponseWriter, r *http.Request) {
@@ -1088,7 +1125,8 @@ func (s *httpdServer) handleShareViewPDF(w http.ResponseWriter, r *http.Request)
Title: path.Base(name),
URL: fmt.Sprintf("%s?path=%s&_=%d", path.Join(webClientPubSharesPath, share.ShareID, "getpdf"),
url.QueryEscape(name), time.Now().UTC().Unix()),
- Branding: s.binding.Branding.WebClient,
+ Branding: s.binding.webClientBranding(),
+ Languages: s.binding.languages(),
}
renderClientTemplate(w, templateClientViewPDF, data)
}
@@ -1140,7 +1178,7 @@ func (s *httpdServer) handleShareGetPDF(w http.ResponseWriter, r *http.Request)
func (s *httpdServer) handleClientGetDirContents(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, nil, util.I18nErrorDirList403, http.StatusForbidden)
return
@@ -1155,15 +1193,12 @@ func (s *httpdServer) handleClientGetDirContents(w http.ResponseWriter, r *http.
connID := xid.New().String()
protocol := getProtocolFromRequest(r)
connectionID := fmt.Sprintf("%s_%s", protocol, connID)
- if err := checkHTTPClientUser(&user, r, connectionID, false); err != nil {
+ if err := checkHTTPClientUser(&user, r, connectionID, false, false); err != nil {
sendAPIResponse(w, r, err, getI18NErrorString(err, util.I18nErrorDirList403), http.StatusForbidden)
return
}
- connection := &Connection{
- BaseConnection: common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r),
- r.RemoteAddr, user),
- request: r,
- }
+ baseConn := common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r), r.RemoteAddr, user)
+ connection := newConnection(baseConn, w, r)
if err = common.Connections.Add(connection); err != nil {
sendAPIResponse(w, r, err, util.I18nErrorDirList429, http.StatusTooManyRequests)
return
@@ -1180,7 +1215,7 @@ func (s *httpdServer) handleClientGetDirContents(w http.ResponseWriter, r *http.
defer lister.Close()
dirTree := r.URL.Query().Get("dirtree") == "1"
- dataGetter := func(limit, _ int) ([]byte, int, error) {
+ dataGetter := func(limit, offset int) ([]byte, int, error) {
contents, err := lister.Next(limit)
if errors.Is(err, io.EOF) {
err = nil
@@ -1189,8 +1224,9 @@ func (s *httpdServer) handleClientGetDirContents(w http.ResponseWriter, r *http.
return nil, 0, err
}
results := make([]map[string]any, 0, len(contents))
- for _, info := range contents {
+ for idx, info := range contents {
res := make(map[string]any)
+ res["id"] = offset + idx + 1
res["url"] = getFileObjectURL(name, info.Name(), webClientFilesPath)
if info.IsDir() {
res["type"] = "1"
@@ -1228,7 +1264,7 @@ func (s *httpdServer) handleClientGetDirContents(w http.ResponseWriter, r *http.
func (s *httpdServer) handleClientGetFiles(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderClientForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -1244,15 +1280,12 @@ func (s *httpdServer) handleClientGetFiles(w http.ResponseWriter, r *http.Reques
connID := xid.New().String()
protocol := getProtocolFromRequest(r)
connectionID := fmt.Sprintf("%v_%v", protocol, connID)
- if err := checkHTTPClientUser(&user, r, connectionID, false); err != nil {
+ if err := checkHTTPClientUser(&user, r, connectionID, false, false); err != nil {
s.renderClientForbiddenPage(w, r, err)
return
}
- connection := &Connection{
- BaseConnection: common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r),
- r.RemoteAddr, user),
- request: r,
- }
+ baseConn := common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r), r.RemoteAddr, user)
+ connection := newConnection(baseConn, w, r)
if err = common.Connections.Add(connection); err != nil {
s.renderClientMessagePage(w, r, util.I18nError429Title, http.StatusTooManyRequests,
util.NewI18nError(err, util.I18nError429Message), "")
@@ -1289,7 +1322,7 @@ func (s *httpdServer) handleClientGetFiles(w http.ResponseWriter, r *http.Reques
func (s *httpdServer) handleClientEditFile(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderClientForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -1305,15 +1338,12 @@ func (s *httpdServer) handleClientEditFile(w http.ResponseWriter, r *http.Reques
connID := xid.New().String()
protocol := getProtocolFromRequest(r)
connectionID := fmt.Sprintf("%v_%v", protocol, connID)
- if err := checkHTTPClientUser(&user, r, connectionID, false); err != nil {
+ if err := checkHTTPClientUser(&user, r, connectionID, false, false); err != nil {
s.renderClientForbiddenPage(w, r, err)
return
}
- connection := &Connection{
- BaseConnection: common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r),
- r.RemoteAddr, user),
- request: r,
- }
+ baseConn := common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r), r.RemoteAddr, user)
+ connection := newConnection(baseConn, w, r)
if err = common.Connections.Add(connection); err != nil {
s.renderClientMessagePage(w, r, util.I18nError429Title, http.StatusTooManyRequests,
util.NewI18nError(err, util.I18nError429Message), "")
@@ -1368,7 +1398,7 @@ func (s *httpdServer) handleClientEditFile(w http.ResponseWriter, r *http.Reques
func (s *httpdServer) handleClientAddShareGet(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderClientForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -1410,7 +1440,7 @@ func (s *httpdServer) handleClientAddShareGet(w http.ResponseWriter, r *http.Req
func (s *httpdServer) handleClientUpdateShareGet(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderClientForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -1418,7 +1448,6 @@ func (s *httpdServer) handleClientUpdateShareGet(w http.ResponseWriter, r *http.
shareID := getURLParam(r, "id")
share, err := dataprovider.ShareExists(shareID, claims.Username)
if err == nil {
- share.HideConfidentialData()
s.renderAddUpdateSharePage(w, r, &share, nil, false)
} else if errors.Is(err, util.ErrNotFound) {
s.renderClientNotFoundPage(w, r, err)
@@ -1429,7 +1458,7 @@ func (s *httpdServer) handleClientUpdateShareGet(w http.ResponseWriter, r *http.
func (s *httpdServer) handleClientAddSharePost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderClientForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -1440,7 +1469,7 @@ func (s *httpdServer) handleClientAddSharePost(w http.ResponseWriter, r *http.Re
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderClientForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -1449,7 +1478,7 @@ func (s *httpdServer) handleClientAddSharePost(w http.ResponseWriter, r *http.Re
share.LastUseAt = 0
share.Username = claims.Username
if share.Password == "" {
- if util.Contains(claims.Permissions, sdk.WebClientShareNoPasswordDisabled) {
+ if slices.Contains(claims.Permissions, sdk.WebClientShareNoPasswordDisabled) {
s.renderAddUpdateSharePage(w, r, share,
util.NewI18nError(util.NewValidationError("You are not allowed to share files/folders without password"), util.I18nErrorShareNoPwd),
true)
@@ -1488,7 +1517,7 @@ func (s *httpdServer) handleClientAddSharePost(w http.ResponseWriter, r *http.Re
func (s *httpdServer) handleClientUpdateSharePost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderClientForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -1508,7 +1537,7 @@ func (s *httpdServer) handleClientUpdateSharePost(w http.ResponseWriter, r *http
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderClientForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
@@ -1518,7 +1547,7 @@ func (s *httpdServer) handleClientUpdateSharePost(w http.ResponseWriter, r *http
updatedShare.Password = share.Password
}
if updatedShare.Password == "" {
- if util.Contains(claims.Permissions, sdk.WebClientShareNoPasswordDisabled) {
+ if slices.Contains(claims.Permissions, sdk.WebClientShareNoPasswordDisabled) {
s.renderAddUpdateSharePage(w, r, updatedShare,
util.NewI18nError(util.NewValidationError("You are not allowed to share files/folders without password"), util.I18nErrorShareNoPwd),
false)
@@ -1557,7 +1586,7 @@ func (s *httpdServer) handleClientUpdateSharePost(w http.ResponseWriter, r *http
func getAllShares(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, nil, util.I18nErrorInvalidToken, http.StatusForbidden)
return
@@ -1579,8 +1608,9 @@ func (s *httpdServer) handleClientGetShares(w http.ResponseWriter, r *http.Reque
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
data := clientSharesPage{
- baseClientPage: s.getBaseClientPageData(util.I18nSharesTitle, webClientSharesPath, r),
+ baseClientPage: s.getBaseClientPageData(util.I18nSharesTitle, webClientSharesPath, w, r),
BasePublicSharesURL: webClientPubSharesPath,
+ BaseURL: s.binding.BaseURL,
}
renderClientTemplate(w, templateClientShares, data)
}
@@ -1603,11 +1633,11 @@ func (s *httpdServer) handleWebClientProfilePost(w http.ResponseWriter, r *http.
return
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderClientForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderClientForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -1646,6 +1676,15 @@ func (s *httpdServer) handleWebClientProfilePost(w http.ResponseWriter, r *http.
if userMerged.CanChangeInfo() {
user.Email = strings.TrimSpace(r.Form.Get("email"))
user.Description = r.Form.Get("description")
+ for k := range r.Form {
+ if hasPrefixAndSuffix(k, "additional_emails[", "][additional_email]") {
+ email := strings.TrimSpace(r.Form.Get(k))
+ if email != "" {
+ r.Form.Add("additional_emails", email)
+ }
+ }
+ }
+ user.Filters.AdditionalEmails = r.Form["additional_emails"]
}
err = dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, ipAddr, user.Role)
if err != nil {
@@ -1662,12 +1701,12 @@ func (s *httpdServer) handleWebClientMFA(w http.ResponseWriter, r *http.Request)
func (s *httpdServer) handleWebClientTwoFactor(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- s.renderClientTwoFactorPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr))
+ s.renderClientTwoFactorPage(w, r, nil)
}
func (s *httpdServer) handleWebClientTwoFactorRecovery(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- s.renderClientTwoFactorRecoveryPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr))
+ s.renderClientTwoFactorRecoveryPage(w, r, nil)
}
func getShareFromPostFields(r *http.Request) (*dataprovider.Share, error) {
@@ -1719,26 +1758,25 @@ func (s *httpdServer) handleWebClientForgotPwd(w http.ResponseWriter, r *http.Re
s.renderClientNotFoundPage(w, r, errors.New("this page does not exist"))
return
}
- s.renderClientForgotPwdPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr))
+ s.renderClientForgotPwdPage(w, r, nil)
}
func (s *httpdServer) handleWebClientForgotPwdPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
- ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
err := r.ParseForm()
if err != nil {
- s.renderClientForgotPwdPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
+ s.renderClientForgotPwdPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
return
}
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
+ if err := verifyLoginCookieAndCSRFToken(r, s.csrfTokenAuth); err != nil {
s.renderClientForbiddenPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
username := strings.TrimSpace(r.Form.Get("username"))
err = handleForgotPassword(r, username, false)
if err != nil {
- s.renderClientForgotPwdPage(w, r, util.NewI18nError(err, util.I18nErrorPwdResetGeneric), ipAddr)
+ s.renderClientForgotPwdPage(w, r, util.NewI18nError(err, util.I18nErrorPwdResetGeneric))
return
}
http.Redirect(w, r, webClientResetPwdPath, http.StatusFound)
@@ -1750,7 +1788,7 @@ func (s *httpdServer) handleWebClientPasswordReset(w http.ResponseWriter, r *htt
s.renderClientNotFoundPage(w, r, errors.New("this page does not exist"))
return
}
- s.renderClientResetPwdPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr))
+ s.renderClientResetPwdPage(w, r, nil)
}
func (s *httpdServer) handleClientViewPDF(w http.ResponseWriter, r *http.Request) {
@@ -1765,14 +1803,15 @@ func (s *httpdServer) handleClientViewPDF(w http.ResponseWriter, r *http.Request
commonBasePage: getCommonBasePage(r),
Title: path.Base(name),
URL: fmt.Sprintf("%s?path=%s&_=%d", webClientGetPDFPath, url.QueryEscape(name), time.Now().UTC().Unix()),
- Branding: s.binding.Branding.WebClient,
+ Branding: s.binding.webClientBranding(),
+ Languages: s.binding.languages(),
}
renderClientTemplate(w, templateClientViewPDF, data)
}
func (s *httpdServer) handleClientGetPDF(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
s.renderClientForbiddenPage(w, r, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken))
return
@@ -1793,15 +1832,12 @@ func (s *httpdServer) handleClientGetPDF(w http.ResponseWriter, r *http.Request)
connID := xid.New().String()
protocol := getProtocolFromRequest(r)
connectionID := fmt.Sprintf("%v_%v", protocol, connID)
- if err := checkHTTPClientUser(&user, r, connectionID, false); err != nil {
+ if err := checkHTTPClientUser(&user, r, connectionID, false, false); err != nil {
s.renderClientForbiddenPage(w, r, err)
return
}
- connection := &Connection{
- BaseConnection: common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r),
- r.RemoteAddr, user),
- request: r,
- }
+ baseConn := common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r), r.RemoteAddr, user)
+ connection := newConnection(baseConn, w, r)
if err = common.Connections.Add(connection); err != nil {
s.renderClientMessagePage(w, r, util.I18nError429Title, http.StatusTooManyRequests,
util.NewI18nError(err, util.I18nError429Message), "")
@@ -1853,43 +1889,46 @@ func (s *httpdServer) ensurePDF(w http.ResponseWriter, r *http.Request, name str
func (s *httpdServer) handleClientShareLoginGet(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
- s.renderShareLoginPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr))
+ s.renderShareLoginPage(w, r, nil)
}
func (s *httpdServer) handleClientShareLoginPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
if err := r.ParseForm(); err != nil {
- s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
+ s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
return
}
- if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
- s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF), ipAddr)
+ if err := verifyLoginCookieAndCSRFToken(r, s.csrfTokenAuth); err != nil {
+ s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF))
return
}
+ invalidateToken(r)
shareID := getURLParam(r, "id")
share, err := dataprovider.ShareExists(shareID, "")
if err != nil {
- s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCredentials), ipAddr)
+ s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCredentials))
return
}
match, err := share.CheckCredentials(strings.TrimSpace(r.Form.Get("share_password")))
if !match || err != nil {
- s.renderShareLoginPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials),
- ipAddr)
- return
- }
- c := jwtTokenClaims{
- Username: shareID,
- }
- err = c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebShare, ipAddr)
- if err != nil {
- s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nError500Message), ipAddr)
+ s.renderShareLoginPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
return
}
next := path.Clean(r.URL.Query().Get("next"))
baseShareURL := path.Join(webClientPubSharesPath, share.ShareID)
isRedirect, redirectTo := checkShareRedirectURL(next, baseShareURL)
+ c := &jwt.Claims{
+ Username: shareID,
+ }
+ if isRedirect {
+ c.Ref = next
+ }
+ err = createAndSetCookie(w, r, c, s.tokenAuth, tokenAudienceWebShare, ipAddr)
+ if err != nil {
+ s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nError500Message))
+ return
+ }
if isRedirect {
http.Redirect(w, r, redirectTo, http.StatusFound)
return
@@ -1897,6 +1936,22 @@ func (s *httpdServer) handleClientShareLoginPost(w http.ResponseWriter, r *http.
s.renderClientMessagePage(w, r, util.I18nSharedFilesTitle, http.StatusOK, nil, util.I18nShareLoginOK)
}
+func (s *httpdServer) handleClientShareLogout(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
+
+ shareID := getURLParam(r, "id")
+ ctx, claims, err := s.getShareClaims(r, shareID)
+ if err != nil {
+ s.renderClientMessagePage(w, r, util.I18nShareAccessErrorTitle, http.StatusForbidden,
+ util.NewI18nError(err, util.I18nErrorInvalidToken), "")
+ return
+ }
+ removeCookie(w, r.WithContext(ctx), webBaseClientPath)
+
+ redirectURL := path.Join(webClientPubSharesPath, shareID, fmt.Sprintf("login?next=%s", url.QueryEscape(claims.Ref)))
+ http.Redirect(w, r, redirectURL, http.StatusFound)
+}
+
func (s *httpdServer) handleClientSharedFile(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead}
@@ -1908,7 +1963,7 @@ func (s *httpdServer) handleClientSharedFile(w http.ResponseWriter, r *http.Requ
if r.URL.RawQuery != "" {
query = "?" + r.URL.RawQuery
}
- s.renderShareDownloadPage(w, r, path.Join(webClientPubSharesPath, share.ShareID)+query)
+ s.renderShareDownloadPage(w, r, &share, path.Join(webClientPubSharesPath, share.ShareID)+query)
}
func (s *httpdServer) handleClientCheckExist(w http.ResponseWriter, r *http.Request) {
@@ -1983,7 +2038,7 @@ func doCheckExist(w http.ResponseWriter, r *http.Request, connection *Connection
}
existing := make([]map[string]any, 0)
for _, info := range contents {
- if util.Contains(filesList.Files, info.Name()) {
+ if slices.Contains(filesList.Files, info.Name()) {
res := make(map[string]any)
res["name"] = info.Name()
if info.IsDir() {
@@ -2031,7 +2086,7 @@ func checkShareRedirectURL(next, base string) (bool, string) {
func getWebTask(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
- claims, err := getTokenClaims(r)
+ claims, err := jwt.FromContext(r.Context())
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
return
diff --git a/internal/httpd/webtask.go b/internal/httpd/webtask.go
index 8375c9c0..0c328c06 100644
--- a/internal/httpd/webtask.go
+++ b/internal/httpd/webtask.go
@@ -93,7 +93,7 @@ func (m *dbTaskManager) Add(data webTaskData) error {
}
func (m *dbTaskManager) Get(ID string) (webTaskData, error) {
- sess, err := dataprovider.GetSharedSession(ID)
+ sess, err := dataprovider.GetSharedSession(ID, dataprovider.SessionTypeWebTask)
if err != nil {
return webTaskData{}, err
}
diff --git a/internal/httpdtest/httpdtest.go b/internal/httpdtest/httpdtest.go
index 382e2ca0..bacfa675 100644
--- a/internal/httpdtest/httpdtest.go
+++ b/internal/httpdtest/httpdtest.go
@@ -25,6 +25,7 @@ import (
"net/http"
"net/url"
"path"
+ "slices"
"strconv"
"strings"
@@ -36,7 +37,6 @@ import (
"github.com/drakkan/sftpgo/v2/internal/httpclient"
"github.com/drakkan/sftpgo/v2/internal/httpd"
"github.com/drakkan/sftpgo/v2/internal/kms"
- "github.com/drakkan/sftpgo/v2/internal/util"
"github.com/drakkan/sftpgo/v2/internal/version"
"github.com/drakkan/sftpgo/v2/internal/vfs"
)
@@ -58,7 +58,6 @@ const (
adminPath = "/api/v2/admins"
adminPwdPath = "/api/v2/admin/changepwd"
apiKeysPath = "/api/v2/apikeys"
- retentionBasePath = "/api/v2/retention/users"
retentionChecksPath = "/api/v2/retention/users/checks"
eventActionsPath = "/api/v2/eventactions"
eventRulesPath = "/api/v2/eventrules"
@@ -1173,20 +1172,6 @@ func GetRetentionChecks(expectedStatusCode int) ([]common.ActiveRetentionChecks,
return checks, body, err
}
-// StartRetentionCheck starts a new retention check
-func StartRetentionCheck(username string, retention []dataprovider.FolderRetention, expectedStatusCode int) ([]byte, error) {
- var body []byte
- asJSON, _ := json.Marshal(retention)
- resp, err := sendHTTPRequest(http.MethodPost, buildURLRelativeToBase(retentionBasePath, username, "check"),
- bytes.NewBuffer(asJSON), "application/json", getDefaultToken())
- if err != nil {
- return body, err
- }
- defer resp.Body.Close()
- body, _ = getResponseBody(resp)
- return body, checkResponse(resp.StatusCode, expectedStatusCode)
-}
-
// GetConnections returns status and stats for active SFTP/SCP connections
func GetConnections(expectedStatusCode int) ([]common.ConnectionStatus, []byte, error) {
var connections []common.ConnectionStatus
@@ -1662,7 +1647,7 @@ func compareConditionPatternOptions(expected, actual []dataprovider.ConditionPat
return nil
}
-func checkEventConditionOptions(expected, actual dataprovider.ConditionOptions) error {
+func checkEventConditionOptions(expected, actual dataprovider.ConditionOptions) error { //nolint:gocyclo
if err := compareConditionPatternOptions(expected.Names, actual.Names); err != nil {
return errors.New("condition names mismatch")
}
@@ -1679,15 +1664,23 @@ func checkEventConditionOptions(expected, actual dataprovider.ConditionOptions)
return errors.New("condition protocols mismatch")
}
for _, v := range expected.Protocols {
- if !util.Contains(actual.Protocols, v) {
+ if !slices.Contains(actual.Protocols, v) {
return errors.New("condition protocols content mismatch")
}
}
+ if len(expected.EventStatuses) != len(actual.EventStatuses) {
+ return errors.New("condition statuses mismatch")
+ }
+ for _, v := range expected.EventStatuses {
+ if !slices.Contains(actual.EventStatuses, v) {
+ return errors.New("condition statuses content mismatch")
+ }
+ }
if len(expected.ProviderObjects) != len(actual.ProviderObjects) {
return errors.New("condition provider objects mismatch")
}
for _, v := range expected.ProviderObjects {
- if !util.Contains(actual.ProviderObjects, v) {
+ if !slices.Contains(actual.ProviderObjects, v) {
return errors.New("condition provider objects content mismatch")
}
}
@@ -1705,7 +1698,7 @@ func checkEventConditions(expected, actual dataprovider.EventConditions) error {
return errors.New("fs events mismatch")
}
for _, v := range expected.FsEvents {
- if !util.Contains(actual.FsEvents, v) {
+ if !slices.Contains(actual.FsEvents, v) {
return errors.New("fs events content mismatch")
}
}
@@ -1713,7 +1706,7 @@ func checkEventConditions(expected, actual dataprovider.EventConditions) error {
return errors.New("provider events mismatch")
}
for _, v := range expected.ProviderEvents {
- if !util.Contains(actual.ProviderEvents, v) {
+ if !slices.Contains(actual.ProviderEvents, v) {
return errors.New("provider events content mismatch")
}
}
@@ -1948,7 +1941,7 @@ func checkAdmin(expected, actual *dataprovider.Admin) error {
return errors.New("permissions mismatch")
}
for _, p := range expected.Permissions {
- if !util.Contains(actual.Permissions, p) {
+ if !slices.Contains(actual.Permissions, p) {
return errors.New("permissions content mismatch")
}
}
@@ -1966,7 +1959,7 @@ func compareAdminFilters(expected, actual dataprovider.AdminFilters) error {
return errors.New("allow list mismatch")
}
for _, v := range expected.AllowList {
- if !util.Contains(actual.AllowList, v) {
+ if !slices.Contains(actual.AllowList, v) {
return errors.New("allow list content mismatch")
}
}
@@ -2029,6 +2022,9 @@ func checkUser(expected *dataprovider.User, actual *dataprovider.User) error {
if expected.Email != actual.Email {
return errors.New("email mismatch")
}
+ if !slices.Equal(expected.Filters.AdditionalEmails, actual.Filters.AdditionalEmails) {
+ return errors.New("additional emails mismatch")
+ }
if expected.Filters.RequirePasswordChange != actual.Filters.RequirePasswordChange {
return errors.New("require_password_change mismatch")
}
@@ -2057,7 +2053,7 @@ func compareUserPermissions(expected map[string][]string, actual map[string][]st
for dir, perms := range expected {
if actualPerms, ok := actual[dir]; ok {
for _, v := range actualPerms {
- if !util.Contains(perms, v) {
+ if !slices.Contains(perms, v) {
return errors.New("permissions contents mismatch")
}
}
@@ -2188,6 +2184,9 @@ func compareS3Config(expected *vfs.Filesystem, actual *vfs.Filesystem) error { /
if err := checkEncryptedSecret(expected.S3Config.AccessSecret, actual.S3Config.AccessSecret); err != nil {
return fmt.Errorf("fs S3 access secret mismatch: %v", err)
}
+ if err := checkEncryptedSecret(expected.S3Config.SSECustomerKey, actual.S3Config.SSECustomerKey); err != nil {
+ return fmt.Errorf("fs S3 SSE customer key mismatch: %v", err)
+ }
if expected.S3Config.Endpoint != actual.S3Config.Endpoint {
return errors.New("fs S3 endpoint mismatch")
}
@@ -2310,7 +2309,7 @@ func compareSFTPFsConfig(expected *vfs.Filesystem, actual *vfs.Filesystem) error
return errors.New("SFTPFs fingerprints mismatch")
}
for _, value := range actual.SFTPConfig.Fingerprints {
- if !util.Contains(expected.SFTPConfig.Fingerprints, value) {
+ if !slices.Contains(expected.SFTPConfig.Fingerprints, value) {
return errors.New("SFTPFs fingerprints mismatch")
}
}
@@ -2401,27 +2400,27 @@ func checkEncryptedSecret(expected, actual *kms.Secret) error {
func compareUserFilterSubStructs(expected sdk.BaseUserFilters, actual sdk.BaseUserFilters) error {
for _, IPMask := range expected.AllowedIP {
- if !util.Contains(actual.AllowedIP, IPMask) {
+ if !slices.Contains(actual.AllowedIP, IPMask) {
return errors.New("allowed IP contents mismatch")
}
}
for _, IPMask := range expected.DeniedIP {
- if !util.Contains(actual.DeniedIP, IPMask) {
+ if !slices.Contains(actual.DeniedIP, IPMask) {
return errors.New("denied IP contents mismatch")
}
}
for _, method := range expected.DeniedLoginMethods {
- if !util.Contains(actual.DeniedLoginMethods, method) {
+ if !slices.Contains(actual.DeniedLoginMethods, method) {
return errors.New("denied login methods contents mismatch")
}
}
for _, protocol := range expected.DeniedProtocols {
- if !util.Contains(actual.DeniedProtocols, protocol) {
+ if !slices.Contains(actual.DeniedProtocols, protocol) {
return errors.New("denied protocols contents mismatch")
}
}
for _, options := range expected.WebClient {
- if !util.Contains(actual.WebClient, options) {
+ if !slices.Contains(actual.WebClient, options) {
return errors.New("web client options contents mismatch")
}
}
@@ -2430,7 +2429,7 @@ func compareUserFilterSubStructs(expected sdk.BaseUserFilters, actual sdk.BaseUs
return errors.New("TLS certs mismatch")
}
for _, cert := range expected.TLSCerts {
- if !util.Contains(actual.TLSCerts, cert) {
+ if !slices.Contains(actual.TLSCerts, cert) {
return errors.New("TLS certs content mismatch")
}
}
@@ -2527,7 +2526,7 @@ func checkFilterMatch(expected []string, actual []string) bool {
return false
}
for _, e := range expected {
- if !util.Contains(actual, strings.ToLower(e)) {
+ if !slices.Contains(actual, strings.ToLower(e)) {
return false
}
}
@@ -2570,7 +2569,7 @@ func compareUserBandwidthLimitFilters(expected sdk.BaseUserFilters, actual sdk.B
return errors.New("bandwidth filters sources mismatch")
}
for _, source := range actual.BandwidthLimits[idx].Sources {
- if !util.Contains(l.Sources, source) {
+ if !slices.Contains(l.Sources, source) {
return errors.New("bandwidth filters source mismatch")
}
}
@@ -2601,9 +2600,28 @@ func compareUserFilePatternsFilters(expected sdk.BaseUserFilters, actual sdk.Bas
return nil
}
+func compareRenameConfigs(expected, actual []dataprovider.RenameConfig) error {
+ if len(expected) != len(actual) {
+ return errors.New("rename configs mismatch")
+ }
+ for _, ex := range expected {
+ found := false
+ for _, ac := range actual {
+ if ac.Key == ex.Key && ac.Value == ex.Value && ac.UpdateModTime == ex.UpdateModTime {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return errors.New("rename configs mismatch")
+ }
+ }
+ return nil
+}
+
func compareKeyValues(expected, actual []dataprovider.KeyValue) error {
if len(expected) != len(actual) {
- return errors.New("kay values mismatch")
+ return errors.New("key values mismatch")
}
for _, ex := range expected {
found := false
@@ -2614,7 +2632,7 @@ func compareKeyValues(expected, actual []dataprovider.KeyValue) error {
}
}
if !found {
- return errors.New("kay values mismatch")
+ return errors.New("key values mismatch")
}
}
return nil
@@ -2680,7 +2698,7 @@ func compareEventActionEmailConfigFields(expected, actual dataprovider.EventActi
return errors.New("email recipients mismatch")
}
for _, v := range expected.Recipients {
- if !util.Contains(actual.Recipients, v) {
+ if !slices.Contains(actual.Recipients, v) {
return errors.New("email recipients content mismatch")
}
}
@@ -2688,7 +2706,7 @@ func compareEventActionEmailConfigFields(expected, actual dataprovider.EventActi
return errors.New("email bcc mismatch")
}
for _, v := range expected.Bcc {
- if !util.Contains(actual.Bcc, v) {
+ if !slices.Contains(actual.Bcc, v) {
return errors.New("email bcc content mismatch")
}
}
@@ -2705,7 +2723,7 @@ func compareEventActionEmailConfigFields(expected, actual dataprovider.EventActi
return errors.New("email attachments mismatch")
}
for _, v := range expected.Attachments {
- if !util.Contains(actual.Attachments, v) {
+ if !slices.Contains(actual.Attachments, v) {
return errors.New("email attachments content mismatch")
}
}
@@ -2720,7 +2738,7 @@ func compareEventActionFsCompressFields(expected, actual dataprovider.EventActio
return errors.New("fs compress paths mismatch")
}
for _, v := range expected.Paths {
- if !util.Contains(actual.Paths, v) {
+ if !slices.Contains(actual.Paths, v) {
return errors.New("fs compress paths content mismatch")
}
}
@@ -2731,7 +2749,7 @@ func compareEventActionFsConfigFields(expected, actual dataprovider.EventActionF
if expected.Type != actual.Type {
return errors.New("fs type mismatch")
}
- if err := compareKeyValues(expected.Renames, actual.Renames); err != nil {
+ if err := compareRenameConfigs(expected.Renames, actual.Renames); err != nil {
return errors.New("fs renames mismatch")
}
if err := compareKeyValues(expected.Copy, actual.Copy); err != nil {
@@ -2741,7 +2759,7 @@ func compareEventActionFsConfigFields(expected, actual dataprovider.EventActionF
return errors.New("fs deletes mismatch")
}
for _, v := range expected.Deletes {
- if !util.Contains(actual.Deletes, v) {
+ if !slices.Contains(actual.Deletes, v) {
return errors.New("fs deletes content mismatch")
}
}
@@ -2749,7 +2767,7 @@ func compareEventActionFsConfigFields(expected, actual dataprovider.EventActionF
return errors.New("fs mkdirs mismatch")
}
for _, v := range expected.MkDirs {
- if !util.Contains(actual.MkDirs, v) {
+ if !slices.Contains(actual.MkDirs, v) {
return errors.New("fs mkdir content mismatch")
}
}
@@ -2757,7 +2775,7 @@ func compareEventActionFsConfigFields(expected, actual dataprovider.EventActionF
return errors.New("fs exist mismatch")
}
for _, v := range expected.Exist {
- if !util.Contains(actual.Exist, v) {
+ if !slices.Contains(actual.Exist, v) {
return errors.New("fs exist content mismatch")
}
}
@@ -2788,7 +2806,7 @@ func compareEventActionCmdConfigFields(expected, actual dataprovider.EventAction
return errors.New("cmd args mismatch")
}
for _, v := range expected.Args {
- if !util.Contains(actual.Args, v) {
+ if !slices.Contains(actual.Args, v) {
return errors.New("cmd args content mismatch")
}
}
diff --git a/internal/jwt/jwt.go b/internal/jwt/jwt.go
new file mode 100644
index 00000000..01493bc4
--- /dev/null
+++ b/internal/jwt/jwt.go
@@ -0,0 +1,268 @@
+// Copyright (C) 2025 Nicola Murino
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Package jwt provides functionality for creating, parsing, and validating
+// JSON Web Tokens (JWT) used in authentication and authorization workflows.
+package jwt
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/go-jose/go-jose/v4"
+ "github.com/go-jose/go-jose/v4/jwt"
+ "github.com/rs/xid"
+)
+
+const (
+ CookieKey = "jwt"
+)
+
+var (
+ TokenCtxKey = &contextKey{"Token"}
+ ErrorCtxKey = &contextKey{"Error"}
+)
+
+// contextKey is a value for use with context.WithValue. It's used as
+// a pointer so it fits in an interface{} without allocation. This technique
+// for defining context keys was copied from Go 1.7's new use of context in net/http.
+type contextKey struct {
+ name string
+}
+
+func (k *contextKey) String() string {
+ return "jwt context value " + k.name
+}
+
+func NewClaims(audience, ip string, duration time.Duration) *Claims {
+ now := time.Now()
+ claims := &Claims{}
+ claims.IssuedAt = jwt.NewNumericDate(now)
+ claims.NotBefore = jwt.NewNumericDate(now.Add(-10 * time.Second))
+ claims.Expiry = jwt.NewNumericDate(now.Add(duration))
+ claims.Audience = []string{audience, ip}
+ return claims
+}
+
+type Claims struct {
+ jwt.Claims
+ Username string `json:"username,omitempty"`
+ Permissions []string `json:"permissions,omitempty"`
+ Role string `json:"role,omitempty"`
+ APIKeyID string `json:"api_key,omitempty"`
+ NodeID string `json:"node_id,omitempty"`
+ MustSetTwoFactorAuth bool `json:"2fa_required,omitempty"`
+ MustChangePassword bool `json:"chpwd,omitempty"`
+ RequiredTwoFactorProtocols []string `json:"2fa_protos,omitempty"`
+ HideUserPageSections int `json:"hus,omitempty"`
+ Ref string `json:"ref,omitempty"`
+}
+
+func (c *Claims) SetIssuedAt(t time.Time) {
+ c.IssuedAt = jwt.NewNumericDate(t)
+}
+
+func (c *Claims) SetNotBefore(t time.Time) {
+ c.NotBefore = jwt.NewNumericDate(t)
+}
+
+func (c *Claims) SetExpiry(t time.Time) {
+ c.Expiry = jwt.NewNumericDate(t)
+}
+
+func (c *Claims) HasPerm(perm string) bool {
+ for _, p := range c.Permissions {
+ if p == "*" || p == perm {
+ return true
+ }
+ }
+ return false
+}
+
+func (c *Claims) HasAnyAudience(audiences []string) bool {
+ for _, a := range c.Audience {
+ if slices.Contains(audiences, a) {
+ return true
+ }
+ }
+ return false
+}
+
+func (c *Claims) GenerateTokenResponse(signer *Signer) (TokenResponse, error) {
+ token, err := signer.Sign(c)
+ if err != nil {
+ return TokenResponse{}, err
+ }
+ return c.BuildTokenResponse(token), nil
+}
+
+func (c *Claims) BuildTokenResponse(token string) TokenResponse {
+ return TokenResponse{Token: token, Expiry: c.Expiry.Time().UTC().Format(time.RFC3339)}
+}
+
+type TokenResponse struct {
+ Token string `json:"access_token"`
+ Expiry string `json:"expires_at"`
+}
+
+func NewSigner(algo jose.SignatureAlgorithm, key any) (*Signer, error) {
+ opts := (&jose.SignerOptions{}).WithType("JWT")
+ signer, err := jose.NewSigner(jose.SigningKey{Algorithm: algo, Key: key}, opts)
+ if err != nil {
+ return nil, err
+ }
+ return &Signer{
+ signer: signer,
+ algo: []jose.SignatureAlgorithm{algo},
+ key: key,
+ }, nil
+}
+
+type Signer struct {
+ algo []jose.SignatureAlgorithm
+ signer jose.Signer
+ key any
+}
+
+func (s *Signer) Sign(claims *Claims) (string, error) {
+ if claims.ID == "" {
+ claims.ID = xid.New().String()
+ }
+ if claims.IssuedAt == nil {
+ claims.IssuedAt = jwt.NewNumericDate(time.Now())
+ }
+ if claims.NotBefore == nil {
+ claims.NotBefore = jwt.NewNumericDate(time.Now().Add(-10 * time.Second))
+ }
+ if claims.Expiry == nil {
+ return "", errors.New("expiration must be set")
+ }
+ if len(claims.Audience) == 0 {
+ return "", errors.New("audience must be set")
+ }
+
+ return jwt.Signed(s.signer).Claims(claims).Serialize()
+}
+
+func (s *Signer) Signer() jose.Signer {
+ return s.signer
+}
+
+func (s *Signer) SetSigner(signer jose.Signer) {
+ s.signer = signer
+}
+
+func (s *Signer) SignWithParams(claims *Claims, audience, ip string, duration time.Duration) (string, error) {
+ claims.Expiry = jwt.NewNumericDate(time.Now().Add(duration))
+ claims.Audience = []string{audience, ip}
+ return s.Sign(claims)
+}
+
+func NewContext(ctx context.Context, claims *Claims, err error) context.Context {
+ ctx = context.WithValue(ctx, TokenCtxKey, claims)
+ ctx = context.WithValue(ctx, ErrorCtxKey, err)
+ return ctx
+}
+
+func FromContext(ctx context.Context) (*Claims, error) {
+ val := ctx.Value(TokenCtxKey)
+ token, ok := val.(*Claims)
+ if !ok && val != nil {
+ return nil, fmt.Errorf("invalid type for TokenCtxKey: %T", val)
+ }
+
+ valErr := ctx.Value(ErrorCtxKey)
+ err, ok := valErr.(error)
+ if !ok && valErr != nil {
+ return nil, fmt.Errorf("invalid type for ErrorCtxKey: %T", valErr)
+ }
+ if token == nil {
+ return nil, errors.New("no token found")
+ }
+
+ return token, err
+}
+
+func Verify(s *Signer, findTokenFns ...func(r *http.Request) string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ hfn := func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ token, err := VerifyRequest(s, r, findTokenFns...)
+ ctx = NewContext(ctx, token, err)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ }
+ return http.HandlerFunc(hfn)
+ }
+}
+
+func VerifyRequest(s *Signer, r *http.Request, findTokenFns ...func(r *http.Request) string) (*Claims, error) {
+ var tokenString string
+ for _, fn := range findTokenFns {
+ tokenString = fn(r)
+ if tokenString != "" {
+ break
+ }
+ }
+ if tokenString == "" {
+ return nil, errors.New("no token found")
+ }
+ return VerifyToken(s, tokenString)
+}
+
+func VerifyToken(s *Signer, payload string) (*Claims, error) {
+ return VerifyTokenWithKey(payload, s.algo, s.key)
+}
+
+func VerifyTokenWithKey(payload string, algo []jose.SignatureAlgorithm, key any) (*Claims, error) {
+ token, err := jwt.ParseSigned(payload, algo)
+ if err != nil {
+ return nil, err
+ }
+ var claims Claims
+ err = token.Claims(key, &claims)
+ if err != nil {
+ return nil, err
+ }
+ if err := claims.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil {
+ return nil, err
+ }
+ return &claims, nil
+}
+
+// TokenFromCookie tries to retrieve the token string from a cookie named
+// "jwt".
+func TokenFromCookie(r *http.Request) string {
+ cookie, err := r.Cookie(CookieKey)
+ if err != nil {
+ return ""
+ }
+ return cookie.Value
+}
+
+// TokenFromHeader tries to retrieve the token string from the
+// "Authorization" request header: "Authorization: BEARER T".
+func TokenFromHeader(r *http.Request) string {
+ // Get token from authorization header.
+ bearer := r.Header.Get("Authorization")
+ const prefix = "Bearer "
+ if len(bearer) >= len(prefix) && strings.EqualFold(bearer[:len(prefix)], prefix) {
+ return bearer[len(prefix):]
+ }
+ return ""
+}
diff --git a/internal/jwt/jwt_test.go b/internal/jwt/jwt_test.go
new file mode 100644
index 00000000..cc7fcddb
--- /dev/null
+++ b/internal/jwt/jwt_test.go
@@ -0,0 +1,255 @@
+package jwt
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io/fs"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/go-jose/go-jose/v4"
+ "github.com/go-jose/go-jose/v4/jwt"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/drakkan/sftpgo/v2/internal/util"
+)
+
+type failingJoseSigner struct{}
+
+func (s *failingJoseSigner) Sign(payload []byte) (*jose.JSONWebSignature, error) {
+ return nil, errors.New("sign test error")
+}
+
+func (s *failingJoseSigner) Options() jose.SignerOptions {
+ return jose.SignerOptions{}
+}
+
+func TestJWTToken(t *testing.T) {
+ s, err := NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
+ username := util.GenerateUniqueID()
+ claims := Claims{
+ Username: username,
+ Claims: jwt.Claims{
+ Audience: jwt.Audience{"test"},
+ Expiry: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
+ NotBefore: jwt.NewNumericDate(time.Now()),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ },
+ }
+ token, err := s.Sign(&claims)
+ require.NoError(t, err)
+ require.NotEmpty(t, token)
+
+ parsed, err := VerifyToken(s, token)
+ require.NoError(t, err)
+ require.Equal(t, username, parsed.Username)
+
+ ja1, err := NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
+
+ token, err = ja1.Sign(&claims)
+ require.NoError(t, err)
+ require.NotEmpty(t, token)
+ _, err = VerifyToken(s, token)
+ require.Error(t, err)
+ _, err = VerifyToken(ja1, token)
+ require.NoError(t, err)
+}
+
+func TestClaims(t *testing.T) {
+ claims := NewClaims(util.GenerateUniqueID(), "", 10*time.Minute)
+ s, err := NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
+ token, err := s.Sign(claims)
+ require.NoError(t, err)
+ assert.NotEmpty(t, token)
+ assert.NotNil(t, claims.Expiry)
+ assert.NotNil(t, claims.IssuedAt)
+ assert.NotNil(t, claims.NotBefore)
+
+ claims = &Claims{
+ Permissions: []string{"myperm"},
+ }
+ claims.SetExpiry(time.Now().Add(1 * time.Minute))
+ claims.Audience = []string{"testaudience"}
+ _, err = s.Sign(claims)
+ assert.NoError(t, err)
+ assert.NotNil(t, claims.IssuedAt)
+ assert.NotNil(t, claims.NotBefore)
+ assert.True(t, claims.HasAnyAudience([]string{util.GenerateUniqueID(), util.GenerateUniqueID(), "testaudience"}))
+ assert.False(t, claims.HasAnyAudience([]string{util.GenerateUniqueID()}))
+ assert.True(t, claims.HasPerm("myperm"))
+ assert.False(t, claims.HasPerm(util.GenerateUniqueID()))
+ resp, err := claims.GenerateTokenResponse(s)
+ require.NoError(t, err)
+ assert.NotEmpty(t, resp.Token)
+ assert.Equal(t, claims.Expiry.Time().UTC().Format(time.RFC3339), resp.Expiry)
+ claims.SetIssuedAt(time.Now())
+ claims.SetNotBefore(time.Now().Add(10 * time.Minute))
+ token, err = s.SignWithParams(claims, util.GenerateUniqueID(), "127.0.0.1", time.Minute)
+ assert.NoError(t, err)
+ _, err = VerifyToken(s, token)
+ assert.ErrorContains(t, err, "nbf")
+ claims = &Claims{}
+ _, err = s.Sign(claims)
+ assert.ErrorContains(t, err, "expiration must be set")
+ claims.SetExpiry(time.Now())
+ _, err = s.Sign(claims)
+ assert.ErrorContains(t, err, "audience must be set")
+ claims = &Claims{}
+ _, err = s.SignWithParams(claims, util.GenerateUniqueID(), "", time.Minute)
+ assert.NoError(t, err)
+}
+
+func TestClaimsPermissions(t *testing.T) {
+ c := Claims{
+ Permissions: []string{"*"},
+ }
+ assert.True(t, c.HasPerm(util.GenerateUniqueID()))
+ c.Permissions = []string{"list"}
+ assert.False(t, c.HasPerm(util.GenerateUniqueID()))
+ assert.True(t, c.HasPerm("list"))
+}
+
+func TestErrors(t *testing.T) {
+ s, err := NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
+ _, err = VerifyToken(s, util.GenerateUniqueID())
+ assert.Error(t, err)
+ claims := &Claims{}
+ claims.SetExpiry(time.Now().Add(-1 * time.Minute))
+ token, err := jwt.Signed(s.Signer()).Claims(claims).Serialize()
+ assert.NoError(t, err)
+ _, err = VerifyToken(s, token)
+ assert.ErrorContains(t, err, "exp")
+ claims.SetExpiry(time.Now().Add(2 * time.Minute))
+ claims.SetIssuedAt(time.Now().Add(1 * time.Minute))
+ token, err = jwt.Signed(s.Signer()).Claims(claims).Serialize()
+ assert.NoError(t, err)
+ _, err = VerifyToken(s, token)
+ assert.ErrorContains(t, err, "iat")
+ claims.SetIssuedAt(time.Now())
+ claims.SetNotBefore(time.Now().Add(1 * time.Minute))
+ token, err = jwt.Signed(s.Signer()).Claims(claims).Serialize()
+ assert.NoError(t, err)
+ _, err = VerifyToken(s, token)
+ assert.ErrorContains(t, err, "nbf")
+
+ s.SetSigner(&failingJoseSigner{})
+ claims = NewClaims(util.GenerateUniqueID(), "", time.Minute)
+ _, err = s.Sign(claims)
+ assert.Error(t, err)
+ _, err = claims.GenerateTokenResponse(s)
+ assert.Error(t, err)
+ // Wrong algorithm
+ _, err = NewSigner("PS256", util.GenerateRandomBytes(32))
+ assert.Error(t, err)
+}
+
+func TestTokenFromRequest(t *testing.T) {
+ claims := NewClaims(util.GenerateUniqueID(), "", 10*time.Minute)
+ s, err := NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
+ token, err := s.Sign(claims)
+ require.NoError(t, err)
+ assert.NotEmpty(t, token)
+ req, err := http.NewRequest(http.MethodGet, "/", nil)
+ require.NoError(t, err)
+ req.Header.Set("Cookie", fmt.Sprintf("jwt=%s", token))
+ cookie := TokenFromCookie(req)
+ assert.Equal(t, token, cookie)
+ req, err = http.NewRequest(http.MethodGet, "/", nil)
+ require.NoError(t, err)
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+ _, err = VerifyRequest(s, req, TokenFromHeader)
+ assert.NoError(t, err)
+ req.Header.Set("Authorization", token)
+ assert.Empty(t, TokenFromHeader(req))
+ assert.Empty(t, TokenFromCookie(req))
+ _, err = VerifyRequest(s, req, TokenFromCookie)
+ assert.ErrorContains(t, err, "no token found")
+}
+
+func TestContext(t *testing.T) {
+ claims := &Claims{
+ Username: util.GenerateUniqueID(),
+ }
+ s, err := NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
+ token, err := s.SignWithParams(claims, util.GenerateUniqueID(), "", time.Minute)
+ require.NoError(t, err)
+
+ req, err := http.NewRequest(http.MethodGet, "/", nil)
+ require.NoError(t, err)
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+
+ h := Verify(s, TokenFromHeader)
+ wrapped := h(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ token, err := FromContext(r.Context())
+ assert.Nil(t, err)
+ assert.Equal(t, claims.Username, token.Username)
+ w.WriteHeader(http.StatusOK)
+ }))
+ rr := httptest.NewRecorder()
+ wrapped.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ _, err = FromContext(context.Background())
+ assert.ErrorContains(t, err, "no token found")
+
+ ctx := NewContext(context.Background(), &Claims{}, fs.ErrClosed)
+ _, err = FromContext(ctx)
+ assert.Equal(t, fs.ErrClosed, err)
+
+ ctx = context.WithValue(context.Background(), TokenCtxKey, "1")
+ _, err = FromContext(ctx)
+ assert.ErrorContains(t, err, "invalid type for TokenCtxKey")
+
+ ctx = context.WithValue(context.Background(), ErrorCtxKey, 2)
+ _, err = FromContext(ctx)
+ assert.ErrorContains(t, err, "invalid type for ErrorCtxKey")
+ claims = NewClaims(util.GenerateUniqueID(), "127.1.1.1", time.Minute)
+ _, err = s.Sign(claims)
+ require.NoError(t, err)
+ ctx = context.WithValue(context.Background(), TokenCtxKey, claims)
+ claimsFromContext, err := FromContext(ctx)
+ assert.NoError(t, err)
+ assert.Equal(t, claims, claimsFromContext)
+
+ assert.Equal(t, "jwt context value Token", TokenCtxKey.String())
+}
+
+func TestValidationLeeway(t *testing.T) {
+ s, err := NewSigner(jose.HS256, util.GenerateRandomBytes(32))
+ require.NoError(t, err)
+ claims := &Claims{}
+ claims.Audience = []string{util.GenerateUniqueID()}
+ claims.SetIssuedAt(time.Now().Add(10 * time.Second)) // issued at in the future
+ claims.SetExpiry(time.Now().Add(10 * time.Second))
+ token, err := s.Sign(claims)
+ require.NoError(t, err)
+ _, err = VerifyToken(s, token)
+ assert.NoError(t, err)
+
+ claims = &Claims{}
+ claims.Audience = []string{util.GenerateUniqueID()}
+ claims.SetExpiry(time.Now().Add(-10 * time.Second)) // expired
+ token, err = s.Sign(claims)
+ require.NoError(t, err)
+ _, err = VerifyToken(s, token)
+ assert.NoError(t, err)
+
+ claims = &Claims{}
+ claims.Audience = []string{util.GenerateUniqueID()}
+ claims.SetExpiry(time.Now().Add(30 * time.Second))
+ claims.SetNotBefore(time.Now().Add(10 * time.Second)) // not before in the future
+ token, err = s.Sign(claims)
+ require.NoError(t, err)
+ _, err = VerifyToken(s, token)
+ assert.NoError(t, err)
+}
diff --git a/internal/kms/kms.go b/internal/kms/kms.go
index acd9f84e..914af4ea 100644
--- a/internal/kms/kms.go
+++ b/internal/kms/kms.go
@@ -73,7 +73,8 @@ var (
// ErrInvalidSecret defines the error to return if a secret is not valid
ErrInvalidSecret = errors.New("invalid secret")
validSecretStatuses = []string{sdkkms.SecretStatusPlain, sdkkms.SecretStatusAES256GCM, sdkkms.SecretStatusSecretBox,
- sdkkms.SecretStatusVaultTransit, sdkkms.SecretStatusAWS, sdkkms.SecretStatusGCP, sdkkms.SecretStatusRedacted}
+ sdkkms.SecretStatusVaultTransit, sdkkms.SecretStatusAWS, sdkkms.SecretStatusGCP, sdkkms.SecretStatusAzureKeyVault,
+ sdkkms.SecretStatusOracleKeyVault, sdkkms.SecretStatusRedacted}
config Configuration
secretProviders = make(map[string]registeredSecretProvider)
)
diff --git a/internal/logger/hclog.go b/internal/logger/hclog.go
index 87216389..b05afe15 100644
--- a/internal/logger/hclog.go
+++ b/internal/logger/hclog.go
@@ -29,6 +29,11 @@ type HCLogAdapter struct {
// Log emits a message and key/value pairs at a provided log level
func (l *HCLogAdapter) Log(level hclog.Level, msg string, args ...any) {
+ // Workaround to avoid logging plugin arguments that may contain sensitive data.
+ // Check everytime we update go-plugin library.
+ if msg == "starting plugin" {
+ return
+ }
var ev *zerolog.Event
switch level {
case hclog.Info:
diff --git a/internal/logger/journald.go b/internal/logger/journald.go
deleted file mode 100644
index fb63d0c2..00000000
--- a/internal/logger/journald.go
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2019 Nicola Murino
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published
-// by the Free Software Foundation, version 3.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-//go:build linux
-// +build linux
-
-package logger
-
-import (
- "github.com/rs/zerolog"
- "github.com/rs/zerolog/journald"
-)
-
-// InitJournalDLogger configures the logger to write to journald
-func InitJournalDLogger(level zerolog.Level) {
- logger = zerolog.New(journald.NewJournalDWriter()).Level(level)
- consoleLogger = zerolog.Nop()
-}
diff --git a/internal/logger/journald_nolinux.go b/internal/logger/journald_nolinux.go
deleted file mode 100644
index 242b11d0..00000000
--- a/internal/logger/journald_nolinux.go
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2019 Nicola Murino
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published
-// by the Free Software Foundation, version 3.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-//go:build !linux
-// +build !linux
-
-package logger
-
-import "github.com/rs/zerolog"
-
-// InitJournalDLogger configures the logger to write to journald
-func InitJournalDLogger(level zerolog.Level) {
- InitStdErrLogger(level)
-}
diff --git a/internal/logger/lego.go b/internal/logger/lego.go
index 4827d022..0503e536 100644
--- a/internal/logger/lego.go
+++ b/internal/logger/lego.go
@@ -28,10 +28,10 @@ type LegoAdapter struct {
// Fatal emits a log at Error level
func (l *LegoAdapter) Fatal(args ...any) {
if l.LogToConsole {
- ErrorToConsole(fmt.Sprint(args...))
+ ErrorToConsole("%s", fmt.Sprint(args...))
return
}
- Log(LevelError, legoLogSender, "", fmt.Sprint(args...))
+ Log(LevelError, legoLogSender, "", "%s", fmt.Sprint(args...))
}
// Fatalln is the same as Fatal
@@ -51,10 +51,10 @@ func (l *LegoAdapter) Fatalf(format string, args ...any) {
// Print emits a log at Info level
func (l *LegoAdapter) Print(args ...any) {
if l.LogToConsole {
- InfoToConsole(fmt.Sprint(args...))
+ InfoToConsole("%s", fmt.Sprint(args...))
return
}
- Log(LevelInfo, legoLogSender, "", fmt.Sprint(args...))
+ Log(LevelInfo, legoLogSender, "", "%s", fmt.Sprint(args...))
}
// Println is the same as Print
diff --git a/internal/logger/logger.go b/internal/logger/logger.go
index a5191ee1..59ff863f 100644
--- a/internal/logger/logger.go
+++ b/internal/logger/logger.go
@@ -29,9 +29,7 @@ import (
"os"
"path/filepath"
"time"
- "unsafe"
- ftpserverlog "github.com/fclairamb/go-log"
"github.com/rs/zerolog"
lumberjack "gopkg.in/natefinch/lumberjack.v2"
)
@@ -203,9 +201,15 @@ func ErrorToConsole(format string, v ...any) {
// TransferLog logs uploads or downloads
func TransferLog(operation, path string, elapsed int64, size int64, user, connectionID, protocol, localAddr,
- remoteAddr, ftpMode string,
+ remoteAddr, ftpMode string, err error,
) {
- ev := logger.Info().
+ var ev *zerolog.Event
+ if err != nil {
+ ev = logger.Error()
+ } else {
+ ev = logger.Info()
+ }
+ ev.
Timestamp().
Str("sender", operation).
Str("local_addr", localAddr).
@@ -219,7 +223,7 @@ func TransferLog(operation, path string, elapsed int64, size int64, user, connec
if ftpMode != "" {
ev.Str("ftp_mode", ftpMode)
}
- ev.Send()
+ ev.AnErr("error", err).Send()
}
// CommandLog logs an SFTP/SCP/SSH command
@@ -262,6 +266,26 @@ func ConnectionFailedLog(user, ip, loginType, protocol, errorString string) {
Send()
}
+// LoginLog logs successful logins.
+func LoginLog(user, ip, loginMethod, protocol, connectionID, clientVersion string, encrypted bool, info string) {
+ ev := logger.Info()
+ ev.Timestamp().
+ Str("sender", "login").
+ Str("ip", ip).
+ Str("username", user).
+ Str("method", loginMethod).
+ Str("protocol", protocol)
+ if connectionID != "" {
+ ev.Str("connection_id", connectionID)
+ }
+ ev.Str("client", clientVersion).
+ Bool("encrypted", encrypted)
+ if info != "" {
+ ev.Str("info", info)
+ }
+ ev.Send()
+}
+
func isLogFilePathValid(logFilePath string) bool {
cleanInput := filepath.Clean(logFilePath)
if cleanInput == "." || cleanInput == ".." {
@@ -284,7 +308,7 @@ func (l *StdLoggerWrapper) Write(p []byte) (n int, err error) {
p = p[0 : n-1]
}
- Log(LevelError, l.Sender, "", bytesToString(p))
+ Log(LevelError, l.Sender, "", "%s", p)
return
}
@@ -356,20 +380,3 @@ func (l *LeveledLogger) Warn(msg string, keysAndValues ...any) {
func (l *LeveledLogger) Panic(msg string, keysAndValues ...any) {
l.Error(msg, keysAndValues...)
}
-
-// With returns a LeveledLogger with additional context specific keyvals
-func (l *LeveledLogger) With(keysAndValues ...any) ftpserverlog.Logger {
- return &LeveledLogger{
- Sender: l.Sender,
- additionalKeyVals: append(l.additionalKeyVals, keysAndValues...),
- }
-}
-
-func bytesToString(b []byte) string {
- // unsafe.SliceData relies on cap whereas we want to rely on len
- if len(b) == 0 {
- return ""
- }
- // https://github.com/golang/go/blob/4ed358b57efdad9ed710be7f4fc51495a7620ce2/src/strings/builder.go#L41
- return unsafe.String(unsafe.SliceData(b), len(b))
-}
diff --git a/internal/logger/request_logger.go b/internal/logger/request_logger.go
index fa269d7e..325f44b9 100644
--- a/internal/logger/request_logger.go
+++ b/internal/logger/request_logger.go
@@ -15,6 +15,7 @@
package logger
import (
+ "crypto/tls"
"fmt"
"net"
"net/http"
@@ -50,17 +51,21 @@ func NewStructuredLogger(logger *zerolog.Logger) func(next http.Handler) http.Ha
// NewLogEntry creates a new log entry for an HTTP request
func (l *StructuredLogger) NewLogEntry(r *http.Request) middleware.LogEntry {
scheme := "http"
+ cipherSuite := ""
if r.TLS != nil {
scheme = "https"
+ cipherSuite = tls.CipherSuiteName(r.TLS.CipherSuite)
}
fields := map[string]any{
- "local_addr": getLocalAddress(r),
- "remote_addr": r.RemoteAddr,
- "proto": r.Proto,
- "method": r.Method,
- "user_agent": r.UserAgent(),
- "uri": fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI)}
+ "local_addr": getLocalAddress(r),
+ "remote_addr": r.RemoteAddr,
+ "proto": r.Proto,
+ "method": r.Method,
+ "user_agent": r.UserAgent(),
+ "uri": fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI),
+ "cipher_suite": cipherSuite,
+ }
reqID := middleware.GetReqID(r.Context())
if reqID != "" {
diff --git a/internal/logger/slog.go b/internal/logger/slog.go
new file mode 100644
index 00000000..e9a544d0
--- /dev/null
+++ b/internal/logger/slog.go
@@ -0,0 +1,97 @@
+// Copyright (C) 2025 Nicola Murino
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package logger
+
+import (
+ "context"
+ "log/slog"
+ "slices"
+
+ "github.com/rs/zerolog"
+)
+
+// slogAdapter is an adapter for slog.Handler
+type slogAdapter struct {
+ sender string
+ attrs []slog.Attr
+}
+
+// NewSlogAdapter creates a slog.Handler adapter
+func NewSlogAdapter(sender string, attrs []slog.Attr) *slogAdapter {
+ return &slogAdapter{
+ sender: sender,
+ attrs: attrs,
+ }
+}
+
+func (l *slogAdapter) Enabled(ctx context.Context, level slog.Level) bool {
+ // Log level is handled by our implementation
+ return true
+}
+
+func (l *slogAdapter) Handle(ctx context.Context, r slog.Record) error {
+ var ev *zerolog.Event
+ switch r.Level {
+ case slog.LevelDebug:
+ ev = logger.Debug()
+ case slog.LevelInfo:
+ ev = logger.Info()
+ case slog.LevelWarn:
+ ev = logger.Warn()
+ case slog.LevelError:
+ ev = logger.Error()
+ default:
+ ev = logger.Debug()
+ }
+
+ ev.Timestamp()
+ if l.sender != "" {
+ ev.Str("sender", l.sender)
+ }
+
+ addSlogAttr := func(a slog.Attr) {
+ if a.Key == "time" {
+ return
+ }
+ ev.Any(a.Key, a.Value.Any())
+ }
+
+ for _, a := range l.attrs {
+ addSlogAttr(a)
+ }
+
+ r.Attrs(func(a slog.Attr) bool {
+ addSlogAttr(a)
+ return true
+ })
+
+ ev.Msg(r.Message)
+
+ return nil
+}
+
+func (l *slogAdapter) WithAttrs(attrs []slog.Attr) slog.Handler {
+ newHandler := *l
+ newHandler.attrs = slices.Concat(l.attrs, attrs)
+ return &newHandler
+}
+
+func (l *slogAdapter) WithGroup(name string) slog.Handler {
+ newHandler := *l
+ if name != "" {
+ newHandler.sender = name
+ }
+ return &newHandler
+}
diff --git a/internal/metric/metric.go b/internal/metric/metric.go
index f192225d..4e3e95d8 100644
--- a/internal/metric/metric.go
+++ b/internal/metric/metric.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build !nometrics
-// +build !nometrics
// Package metric provides Prometheus metrics support
package metric
diff --git a/internal/metric/metric_disabled.go b/internal/metric/metric_disabled.go
index 63369703..78bacfdd 100644
--- a/internal/metric/metric_disabled.go
+++ b/internal/metric/metric_disabled.go
@@ -1,5 +1,18 @@
+// Copyright (C) 2019 Nicola Murino
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, version 3.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
//go:build nometrics
-// +build nometrics
package metric
diff --git a/internal/plugin/kms.go b/internal/plugin/kms.go
index 964e0cdb..b4ed5be1 100644
--- a/internal/plugin/kms.go
+++ b/internal/plugin/kms.go
@@ -17,6 +17,7 @@ package plugin
import (
"fmt"
"path/filepath"
+ "slices"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-plugin"
@@ -25,13 +26,13 @@ import (
"github.com/drakkan/sftpgo/v2/internal/kms"
"github.com/drakkan/sftpgo/v2/internal/logger"
- "github.com/drakkan/sftpgo/v2/internal/util"
)
var (
- validKMSSchemes = []string{sdkkms.SchemeAWS, sdkkms.SchemeGCP, sdkkms.SchemeVaultTransit, sdkkms.SchemeAzureKeyVault}
+ validKMSSchemes = []string{sdkkms.SchemeAWS, sdkkms.SchemeGCP, sdkkms.SchemeVaultTransit,
+ sdkkms.SchemeAzureKeyVault, sdkkms.SchemeOracleKeyVault}
validKMSEncryptedStatuses = []string{sdkkms.SecretStatusVaultTransit, sdkkms.SecretStatusAWS, sdkkms.SecretStatusGCP,
- sdkkms.SecretStatusAzureKeyVault}
+ sdkkms.SecretStatusAzureKeyVault, sdkkms.SecretStatusOracleKeyVault}
)
// KMSConfig defines configuration parameters for kms plugins
@@ -41,10 +42,10 @@ type KMSConfig struct {
}
func (c *KMSConfig) validate() error {
- if !util.Contains(validKMSSchemes, c.Scheme) {
+ if !slices.Contains(validKMSSchemes, c.Scheme) {
return fmt.Errorf("invalid kms scheme: %v", c.Scheme)
}
- if !util.Contains(validKMSEncryptedStatuses, c.EncryptedStatus) {
+ if !slices.Contains(validKMSEncryptedStatuses, c.EncryptedStatus) {
return fmt.Errorf("invalid kms encrypted status: %v", c.EncryptedStatus)
}
return nil
diff --git a/internal/plugin/notifier.go b/internal/plugin/notifier.go
index 0e9ac9ec..2a37c462 100644
--- a/internal/plugin/notifier.go
+++ b/internal/plugin/notifier.go
@@ -16,6 +16,7 @@ package plugin
import (
"fmt"
+ "slices"
"sync"
"time"
@@ -24,7 +25,6 @@ import (
"github.com/sftpgo/sdk/plugin/notifier"
"github.com/drakkan/sftpgo/v2/internal/logger"
- "github.com/drakkan/sftpgo/v2/internal/util"
)
// NotifierConfig defines configuration parameters for notifiers plugins
@@ -50,97 +50,19 @@ func (c *NotifierConfig) hasActions() bool {
return false
}
-type eventsQueue struct {
- sync.RWMutex
+type notifierPlugin struct {
+ config Config
+ notifier notifier.Notifier
+ client *plugin.Client
+ mu sync.RWMutex
fsEvents []*notifier.FsEvent
providerEvents []*notifier.ProviderEvent
logEvents []*notifier.LogEvent
}
-func (q *eventsQueue) addFsEvent(event *notifier.FsEvent) {
- q.Lock()
- defer q.Unlock()
-
- q.fsEvents = append(q.fsEvents, event)
-}
-
-func (q *eventsQueue) addProviderEvent(event *notifier.ProviderEvent) {
- q.Lock()
- defer q.Unlock()
-
- q.providerEvents = append(q.providerEvents, event)
-}
-
-func (q *eventsQueue) addLogEvent(event *notifier.LogEvent) {
- q.Lock()
- defer q.Unlock()
-
- q.logEvents = append(q.logEvents, event)
-}
-
-func (q *eventsQueue) popFsEvent() *notifier.FsEvent {
- q.Lock()
- defer q.Unlock()
-
- if len(q.fsEvents) == 0 {
- return nil
- }
- truncLen := len(q.fsEvents) - 1
- ev := q.fsEvents[truncLen]
- q.fsEvents[truncLen] = nil
- q.fsEvents = q.fsEvents[:truncLen]
-
- return ev
-}
-
-func (q *eventsQueue) popProviderEvent() *notifier.ProviderEvent {
- q.Lock()
- defer q.Unlock()
-
- if len(q.providerEvents) == 0 {
- return nil
- }
- truncLen := len(q.providerEvents) - 1
- ev := q.providerEvents[truncLen]
- q.providerEvents[truncLen] = nil
- q.providerEvents = q.providerEvents[:truncLen]
-
- return ev
-}
-
-func (q *eventsQueue) popLogEvent() *notifier.LogEvent {
- q.Lock()
- defer q.Unlock()
-
- if len(q.logEvents) == 0 {
- return nil
- }
- truncLen := len(q.logEvents) - 1
- ev := q.logEvents[truncLen]
- q.logEvents[truncLen] = nil
- q.logEvents = q.logEvents[:truncLen]
-
- return ev
-}
-
-func (q *eventsQueue) getSize() int {
- q.RLock()
- defer q.RUnlock()
-
- return len(q.providerEvents) + len(q.fsEvents) + len(q.logEvents)
-}
-
-type notifierPlugin struct {
- config Config
- notifier notifier.Notifier
- client *plugin.Client
- queue *eventsQueue
-}
-
func newNotifierPlugin(config Config) (*notifierPlugin, error) {
p := ¬ifierPlugin{
config: config,
- queue: &eventsQueue{},
}
if err := p.initialize(); err != nil {
logger.Warn(logSender, "", "unable to create notifier plugin: %v, config %+v", err, config)
@@ -180,7 +102,7 @@ func (p *notifierPlugin) initialize() error {
Managed: false,
Logger: &logger.HCLogAdapter{
Logger: hclog.New(&hclog.LoggerOptions{
- Name: fmt.Sprintf("%v.%v", logSender, notifier.PluginName),
+ Name: fmt.Sprintf("%s.%s", logSender, notifier.PluginName),
Level: pluginsLogLevel,
DisableTime: true,
}),
@@ -204,6 +126,34 @@ func (p *notifierPlugin) initialize() error {
return nil
}
+func (p *notifierPlugin) queueSize() int {
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+
+ return len(p.providerEvents) + len(p.fsEvents) + len(p.logEvents)
+}
+
+func (p *notifierPlugin) queueFsEvent(ev *notifier.FsEvent) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ p.fsEvents = append(p.fsEvents, ev)
+}
+
+func (p *notifierPlugin) queueProviderEvent(ev *notifier.ProviderEvent) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ p.providerEvents = append(p.providerEvents, ev)
+}
+
+func (p *notifierPlugin) queueLogEvent(ev *notifier.LogEvent) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ p.logEvents = append(p.logEvents, ev)
+}
+
func (p *notifierPlugin) canQueueEvent(timestamp int64) bool {
if p.config.NotifierOptions.RetryMaxTime == 0 {
return false
@@ -214,107 +164,105 @@ func (p *notifierPlugin) canQueueEvent(timestamp int64) bool {
return false
}
if p.config.NotifierOptions.RetryQueueMaxSize > 0 {
- return p.queue.getSize() < p.config.NotifierOptions.RetryQueueMaxSize
+ return p.queueSize() < p.config.NotifierOptions.RetryQueueMaxSize
}
return true
}
func (p *notifierPlugin) notifyFsAction(event *notifier.FsEvent) {
- if !util.Contains(p.config.NotifierOptions.FsEvents, event.Action) {
+ if !slices.Contains(p.config.NotifierOptions.FsEvents, event.Action) {
return
}
-
- go func() {
- Handler.addTask()
- defer Handler.removeTask()
-
- p.sendFsEvent(event)
- }()
+ p.sendFsEvent(event)
}
func (p *notifierPlugin) notifyProviderAction(event *notifier.ProviderEvent, object Renderer) {
- if !util.Contains(p.config.NotifierOptions.ProviderEvents, event.Action) ||
- !util.Contains(p.config.NotifierOptions.ProviderObjects, event.ObjectType) {
+ if !slices.Contains(p.config.NotifierOptions.ProviderEvents, event.Action) ||
+ !slices.Contains(p.config.NotifierOptions.ProviderObjects, event.ObjectType) {
return
}
-
- go func() {
- Handler.addTask()
- defer Handler.removeTask()
-
- objectAsJSON, err := object.RenderAsJSON(event.Action != "delete")
- if err != nil {
- logger.Warn(logSender, "", "unable to render user as json for action %v: %v", event.Action, err)
- return
- }
- event.ObjectData = objectAsJSON
- p.sendProviderEvent(event)
- }()
+ p.sendProviderEvent(event, object)
}
func (p *notifierPlugin) notifyLogEvent(event *notifier.LogEvent) {
- go func() {
+ p.sendLogEvent(event)
+}
+
+func (p *notifierPlugin) sendFsEvent(ev *notifier.FsEvent) {
+ go func(event *notifier.FsEvent) {
Handler.addTask()
defer Handler.removeTask()
- p.sendLogEvent(event)
- }()
+ if err := p.notifier.NotifyFsEvent(event); err != nil {
+ logger.Warn(logSender, "", "unable to send fs action notification to plugin %v: %v", p.config.Cmd, err)
+ if p.canQueueEvent(event.Timestamp) {
+ p.queueFsEvent(event)
+ }
+ }
+ }(ev)
}
-func (p *notifierPlugin) sendFsEvent(event *notifier.FsEvent) {
- if err := p.notifier.NotifyFsEvent(event); err != nil {
- logger.Warn(logSender, "", "unable to send fs action notification to plugin %v: %v", p.config.Cmd, err)
- if p.canQueueEvent(event.Timestamp) {
- p.queue.addFsEvent(event)
+func (p *notifierPlugin) sendProviderEvent(ev *notifier.ProviderEvent, object Renderer) {
+ go func(event *notifier.ProviderEvent) {
+ Handler.addTask()
+ defer Handler.removeTask()
+
+ if object != nil {
+ objectAsJSON, err := object.RenderAsJSON(event.Action != "delete")
+ if err != nil {
+ logger.Error(logSender, "", "unable to render user as json for action %q: %v", event.Action, err)
+ } else {
+ event.ObjectData = objectAsJSON
+ }
}
- }
+
+ if err := p.notifier.NotifyProviderEvent(event); err != nil {
+ logger.Warn(logSender, "", "unable to send user action notification to plugin %v: %v", p.config.Cmd, err)
+ if p.canQueueEvent(event.Timestamp) {
+ p.queueProviderEvent(event)
+ }
+ }
+ }(ev)
}
-func (p *notifierPlugin) sendProviderEvent(event *notifier.ProviderEvent) {
- if err := p.notifier.NotifyProviderEvent(event); err != nil {
- logger.Warn(logSender, "", "unable to send user action notification to plugin %v: %v", p.config.Cmd, err)
- if p.canQueueEvent(event.Timestamp) {
- p.queue.addProviderEvent(event)
- }
- }
-}
+func (p *notifierPlugin) sendLogEvent(ev *notifier.LogEvent) {
+ go func(event *notifier.LogEvent) {
+ Handler.addTask()
+ defer Handler.removeTask()
-func (p *notifierPlugin) sendLogEvent(event *notifier.LogEvent) {
- if err := p.notifier.NotifyLogEvent(event); err != nil {
- logger.Warn(logSender, "", "unable to send log event to plugin %v: %v", p.config.Cmd, err)
- if p.canQueueEvent(event.Timestamp) {
- p.queue.addLogEvent(event)
+ if err := p.notifier.NotifyLogEvent(event); err != nil {
+ logger.Warn(logSender, "", "unable to send log event to plugin %v: %v", p.config.Cmd, err)
+ if p.canQueueEvent(event.Timestamp) {
+ p.queueLogEvent(event)
+ }
}
- }
+ }(ev)
}
func (p *notifierPlugin) sendQueuedEvents() {
- queueSize := p.queue.getSize()
+ queueSize := p.queueSize()
if queueSize == 0 {
return
}
- logger.Debug(logSender, "", "check queued events for notifier %q, events size: %v", p.config.Cmd, queueSize)
- fsEv := p.queue.popFsEvent()
- for fsEv != nil {
- go func(ev *notifier.FsEvent) {
- p.sendFsEvent(ev)
- }(fsEv)
- fsEv = p.queue.popFsEvent()
- }
+ p.mu.Lock()
+ defer p.mu.Unlock()
- providerEv := p.queue.popProviderEvent()
- for providerEv != nil {
- go func(ev *notifier.ProviderEvent) {
- p.sendProviderEvent(ev)
- }(providerEv)
- providerEv = p.queue.popProviderEvent()
+ logger.Debug(logSender, "", "send queued events for notifier %q, events size: %v", p.config.Cmd, queueSize)
+
+ for _, ev := range p.fsEvents {
+ p.sendFsEvent(ev)
}
- logEv := p.queue.popLogEvent()
- for logEv != nil {
- go func(ev *notifier.LogEvent) {
- p.sendLogEvent(ev)
- }(logEv)
- logEv = p.queue.popLogEvent()
+ p.fsEvents = nil
+
+ for _, ev := range p.providerEvents {
+ p.sendProviderEvent(ev, nil)
}
- logger.Debug(logSender, "", "queued events sent for notifier %q, new events size: %v", p.config.Cmd, p.queue.getSize())
+ p.providerEvents = nil
+
+ for _, ev := range p.logEvents {
+ p.sendLogEvent(ev)
+ }
+ p.logEvents = nil
+
+ logger.Debug(logSender, "", "%d queued events sent for notifier %q,", queueSize, p.config.Cmd)
}
diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go
index 91f65a8b..94479c81 100644
--- a/internal/plugin/plugin.go
+++ b/internal/plugin/plugin.go
@@ -24,6 +24,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "slices"
"strings"
"sync"
"sync/atomic"
@@ -118,7 +119,9 @@ func (c *Config) getEnvVarPrefix() string {
return c.EnvPrefix
}
- prefix := strings.ToUpper(filepath.Base(c.Cmd)) + "_"
+ baseName := filepath.Base(c.Cmd)
+ name := strings.TrimSuffix(baseName, filepath.Ext(baseName))
+ prefix := strings.ToUpper(name) + "_"
return strings.ReplaceAll(prefix, "-", "_")
}
@@ -141,7 +144,7 @@ func (c *Config) getCommand() *exec.Cmd {
}
logger.Debug(logSender, "", "additional env vars for plugin %q: %+v", c.Cmd, c.EnvVars)
for _, key := range c.EnvVars {
- cmd.Env = append(cmd.Env, os.Getenv(key))
+ cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, os.Getenv(key)))
}
return cmd
}
@@ -225,7 +228,7 @@ func initializePlugins() error {
kmsID++
kms.RegisterSecretProvider(config.KMSOptions.Scheme, config.KMSOptions.EncryptedStatus,
Handler.Configs[idx].newKMSPluginSecretProvider)
- logger.Info(logSender, "", "registered secret provider for scheme: %v, encrypted status: %v",
+ logger.Info(logSender, "", "registered secret provider for scheme %q, encrypted status %q",
config.KMSOptions.Scheme, config.KMSOptions.EncryptedStatus)
case auth.PluginName:
plugin, err := newAuthPlugin(config)
@@ -334,11 +337,11 @@ func (m *Manager) NotifyLogEvent(event notifier.LogEventType, protocol, username
var e *notifier.LogEvent
for _, n := range m.notifiers {
- if util.Contains(n.config.NotifierOptions.LogEvents, int(event)) {
+ if slices.Contains(n.config.NotifierOptions.LogEvents, int(event)) {
if e == nil {
message := ""
if err != nil {
- message = err.Error()
+ message = strings.Trim(err.Error(), "\x00")
}
e = ¬ifier.LogEvent{
@@ -639,7 +642,9 @@ func (m *Manager) restartNotifierPlugin(config Config, idx int) {
}
m.notifLock.Lock()
- plugin.queue = m.notifiers[idx].queue
+ plugin.fsEvents = m.notifiers[idx].fsEvents
+ plugin.providerEvents = m.notifiers[idx].providerEvents
+ plugin.logEvents = m.notifiers[idx].logEvents
m.notifiers[idx] = plugin
m.notifLock.Unlock()
plugin.sendQueuedEvents()
diff --git a/internal/service/awscontainer.go b/internal/service/awscontainer.go
deleted file mode 100644
index 481085e4..00000000
--- a/internal/service/awscontainer.go
+++ /dev/null
@@ -1,182 +0,0 @@
-// Copyright (C) 2019 Nicola Murino
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published
-// by the Free Software Foundation, version 3.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-//go:build awscontainer
-// +build awscontainer
-
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "time"
-
- "github.com/aws/aws-sdk-go-v2/aws"
- awsconfig "github.com/aws/aws-sdk-go-v2/config"
- "github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
- "github.com/aws/aws-sdk-go-v2/service/marketplacemetering"
- "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
- "github.com/google/uuid"
-
- "github.com/drakkan/sftpgo/v2/internal/config"
- "github.com/drakkan/sftpgo/v2/internal/dataprovider"
- "github.com/drakkan/sftpgo/v2/internal/httpd"
- "github.com/drakkan/sftpgo/v2/internal/logger"
- "github.com/drakkan/sftpgo/v2/internal/util"
-)
-
-const (
- installCodeName = "SFTPGo_Installation_Code"
-)
-
-var (
- awsProductCode = ""
-)
-
-func registerAWSContainer(disableAWSInstallationCode bool) error {
- if awsProductCode == "" {
- return errors.New("product code not set")
- }
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
-
- cfg, err := getAWSConfig(ctx)
- if err != nil {
- return fmt.Errorf("unable to get config to register AWS container: %w", err)
- }
- if !disableAWSInstallationCode {
- if err := setInstallationCode(cfg); err != nil {
- return err
- }
- }
- requestNonce, err := uuid.NewRandom()
- if err != nil {
- return fmt.Errorf("unable to generate nonce for metering API: %w", err)
- }
- svc := marketplacemetering.NewFromConfig(cfg)
- result, err := svc.RegisterUsage(ctx, &marketplacemetering.RegisterUsageInput{
- ProductCode: aws.String(awsProductCode),
- PublicKeyVersion: aws.Int32(1),
- Nonce: aws.String(requestNonce.String()),
- })
- if err != nil {
- return fmt.Errorf("unable to register API operation for AWSMarketplace Metering: %w", err)
- }
- logger.Debug(logSender, "", "API operation for AWSMarketplace Metering registered, token %q",
- util.GetStringFromPointer(result.Signature))
- return nil
-}
-
-func getAWSConfig(ctx context.Context) (aws.Config, error) {
- cfg, err := awsconfig.LoadDefaultConfig(ctx)
- if err != nil {
- return cfg, fmt.Errorf("unable to get config to register AWS container: %w", err)
- }
- if cfg.Region == "" {
- svc := imds.NewFromConfig(cfg)
- region, err := svc.GetRegion(ctx, &imds.GetRegionInput{})
- if err == nil {
- logger.Debug(logSender, "", "AWS region from imds %q", region.Region)
- cfg.Region = region.Region
- } else {
- logger.Warn(logSender, "", "unable to get region from imds, continuing anyway, error: %v", err)
- }
- }
- return cfg, nil
-}
-
-func setInstallationCode(cfg aws.Config) error {
- if dataprovider.HasAdmin() {
- return nil
- }
- installationCode := util.GenerateUniqueID()
- requestToken, err := uuid.NewRandom()
- if err != nil {
- return fmt.Errorf("unable to generate client request token: %w", err)
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
-
- svc := secretsmanager.NewFromConfig(cfg)
- _, err = svc.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
- SecretId: aws.String(installCodeName),
- })
- if err == nil {
- // update existing secret
- result, err := svc.UpdateSecret(ctx, &secretsmanager.UpdateSecretInput{
- SecretId: aws.String(installCodeName),
- ClientRequestToken: aws.String(requestToken.String()),
- SecretString: aws.String(installationCode),
- })
- if err != nil {
- return fmt.Errorf("unable to update installation code: %w", err)
- }
- logger.Debug(logSender, "", "installation code updated, secret name %q, arn %q, version id %q",
- util.GetStringFromPointer(result.Name), util.GetStringFromPointer(result.ARN),
- util.GetStringFromPointer(result.VersionId))
- } else {
- // create new secret
- logger.Debug(logSender, "", "unable to get the current installation secret, trying to create a new one, error: %v", err)
- result, err := svc.CreateSecret(ctx, &secretsmanager.CreateSecretInput{
- Name: aws.String(installCodeName),
- ClientRequestToken: aws.String(requestToken.String()),
- SecretString: aws.String(installationCode),
- })
- if err != nil {
- return fmt.Errorf("unable to create installation code: %w", err)
- }
- logger.Debug(logSender, "", "installation code set, secret name %q, arn %q, version id %q",
- util.GetStringFromPointer(result.Name), util.GetStringFromPointer(result.ARN),
- util.GetStringFromPointer(result.VersionId))
- }
- httpdConfig := config.GetHTTPDConfig()
- httpdConfig.Setup.InstallationCode = installationCode
- httpdConfig.Setup.InstallationCodeHint = "Installation code stored in Secrets Manager"
- config.SetHTTPDConfig(httpdConfig)
- httpd.SetInstallationCodeResolver(resolveInstallationCode)
-
- return nil
-}
-
-// function called to validate the user provided secret
-func resolveInstallationCode(defaultInstallationCode string) string {
- logger.Debug(logSender, "", "resolving installation code")
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
-
- cfg, err := getAWSConfig(ctx)
- if err != nil {
- logger.Error(logSender, "", "unable to get config to resolve installation code: %v", err)
- return defaultInstallationCode
- }
-
- svc := secretsmanager.NewFromConfig(cfg)
- result, err := svc.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
- SecretId: aws.String(installCodeName),
- })
- if err != nil {
- logger.Error(logSender, "", "unable to resolve installation code: %v", err)
- return defaultInstallationCode
- }
-
- resolvedCode := util.GetStringFromPointer(result.SecretString)
- if resolvedCode == "" {
- logger.Error(logSender, "", "resolved installation code is empty")
- return defaultInstallationCode
- }
- logger.Debug(logSender, "", "installation code resolved")
- return resolvedCode
-}
diff --git a/internal/service/awscontainer_disabled.go b/internal/service/awscontainer_disabled.go
deleted file mode 100644
index 130fa775..00000000
--- a/internal/service/awscontainer_disabled.go
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright (C) 2019 Nicola Murino
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published
-// by the Free Software Foundation, version 3.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-//go:build !awscontainer
-// +build !awscontainer
-
-package service
-
-func registerAWSContainer(_ bool) error {
- return nil
-}
diff --git a/internal/service/service.go b/internal/service/service.go
index 62bc17df..6cb9db8b 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -39,7 +39,6 @@ const (
)
var (
- chars = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
graceTime int
)
@@ -89,7 +88,7 @@ func (s *Service) initLogger() {
}
// Start initializes and starts the service
-func (s *Service) Start(disableAWSInstallationCode bool) error {
+func (s *Service) Start() error {
s.initLogger()
logger.Info(logSender, "", "starting SFTPGo %s, config dir: %s, config file: %s, log max size: %d log max backups: %d "+
"log max age: %d log level: %s, log compress: %t, log utc time: %t, load data from: %q, grace time: %d secs",
@@ -104,13 +103,13 @@ func (s *Service) Start(disableAWSInstallationCode bool) error {
}
}
if !config.HasServicesToStart() {
- infoString := "no service configured, nothing to do"
+ const infoString = "no service configured, nothing to do"
logger.Info(logSender, "", infoString)
logger.InfoToConsole(infoString)
return errors.New(infoString)
}
- if err := s.initializeServices(disableAWSInstallationCode); err != nil {
+ if err := s.initializeServices(); err != nil {
return err
}
@@ -120,7 +119,7 @@ func (s *Service) Start(disableAWSInstallationCode bool) error {
return nil
}
-func (s *Service) initializeServices(disableAWSInstallationCode bool) error {
+func (s *Service) initializeServices() error {
providerConf := config.GetProviderConf()
kmsConfig := config.GetKMSConfig()
err := kmsConfig.Initialize()
@@ -129,6 +128,13 @@ func (s *Service) initializeServices(disableAWSInstallationCode bool) error {
logger.ErrorToConsole("unable to initialize KMS: %v", err)
return err
}
+ // We may have KMS plugins and their schema needs to be registered before
+ // initializing the data provider which may contain KMS secrets.
+ if err := plugin.Initialize(config.GetPluginsConfig(), s.LogLevel); err != nil {
+ logger.Error(logSender, "", "unable to initialize plugin system: %v", err)
+ logger.ErrorToConsole("unable to initialize plugin system: %v", err)
+ return err
+ }
mfaConfig := config.GetMFAConfig()
err = mfaConfig.Initialize()
if err != nil {
@@ -142,11 +148,6 @@ func (s *Service) initializeServices(disableAWSInstallationCode bool) error {
logger.ErrorToConsole("error initializing data provider: %v", err)
return err
}
- if err := plugin.Initialize(config.GetPluginsConfig(), s.LogLevel); err != nil {
- logger.Error(logSender, "", "unable to initialize plugin system: %v", err)
- logger.ErrorToConsole("unable to initialize plugin system: %v", err)
- return err
- }
smtpConfig := config.GetSMTPConfig()
err = smtpConfig.Initialize(s.ConfigDir, s.PortableMode != 1)
if err != nil {
@@ -178,12 +179,6 @@ func (s *Service) initializeServices(disableAWSInstallationCode bool) error {
}
}
- if err := registerAWSContainer(disableAWSInstallationCode); err != nil {
- logger.Error(logSender, "", "error registering AWS container: %v", err)
- logger.ErrorToConsole("error registering AWS container: %v", err)
- return err
- }
-
httpConfig := config.GetHTTPConfig()
err = httpConfig.Initialize(s.ConfigDir)
if err != nil {
diff --git a/internal/service/service_portable.go b/internal/service/service_portable.go
index 06b05f7a..91d5fc79 100644
--- a/internal/service/service_portable.go
+++ b/internal/service/service_portable.go
@@ -13,13 +13,13 @@
// along with this program. If not, see .
//go:build !noportable
-// +build !noportable
package service
import (
"fmt"
"math/rand"
+ "slices"
"strings"
"github.com/sftpgo/sdk"
@@ -69,7 +69,7 @@ func (s *Service) StartPortableMode(sftpdPort, ftpPort, webdavPort, httpPort int
configurePortableWebDAVService(webdavPort, webDavCert, webDavKey)
configurePortableHTTPService(httpPort, httpsCert, httpsKey)
- err = s.Start(true)
+ err = s.Start()
if err != nil {
return err
}
@@ -95,24 +95,24 @@ func (s *Service) StartPortableMode(sftpdPort, ftpPort, webdavPort, httpPort int
func (s *Service) getServiceOptionalInfoString() string {
var info strings.Builder
if config.GetSFTPDConfig().Bindings[0].IsValid() {
- info.WriteString(fmt.Sprintf("SFTP port: %v ", config.GetSFTPDConfig().Bindings[0].Port))
+ fmt.Fprintf(&info, "SFTP port: %d ", config.GetSFTPDConfig().Bindings[0].Port)
}
if config.GetFTPDConfig().Bindings[0].IsValid() {
- info.WriteString(fmt.Sprintf("FTP port: %v ", config.GetFTPDConfig().Bindings[0].Port))
+ fmt.Fprintf(&info, "FTP port: %d ", config.GetFTPDConfig().Bindings[0].Port)
}
if config.GetWebDAVDConfig().Bindings[0].IsValid() {
scheme := "http"
if config.GetWebDAVDConfig().CertificateFile != "" && config.GetWebDAVDConfig().CertificateKeyFile != "" {
scheme = "https"
}
- info.WriteString(fmt.Sprintf("WebDAV URL: %v://:%v/ ", scheme, config.GetWebDAVDConfig().Bindings[0].Port))
+ fmt.Fprintf(&info, "WebDAV URL: %v://:%v/ ", scheme, config.GetWebDAVDConfig().Bindings[0].Port)
}
if config.GetHTTPDConfig().Bindings[0].IsValid() {
scheme := "http"
if config.GetHTTPDConfig().CertificateFile != "" && config.GetHTTPDConfig().CertificateKeyFile != "" {
scheme = "https"
}
- info.WriteString(fmt.Sprintf("WebClient URL: %v://:%v/ ", scheme, config.GetHTTPDConfig().Bindings[0].Port))
+ fmt.Fprintf(&info, "WebClient URL: %s://:%d/ ", scheme, config.GetHTTPDConfig().Bindings[0].Port)
}
return info.String()
}
@@ -144,16 +144,15 @@ func (s *Service) configurePortableUser() string {
printablePassword = "[redacted]"
}
if len(s.PortableUser.PublicKeys) == 0 && s.PortableUser.Password == "" {
- var b strings.Builder
- for i := 0; i < 16; i++ {
- b.WriteRune(chars[rand.Intn(len(chars))])
- }
- s.PortableUser.Password = b.String()
+ s.PortableUser.Password = util.GenerateUniqueID()
printablePassword = s.PortableUser.Password
}
s.PortableUser.Filters.WebClient = []string{sdk.WebClientSharesDisabled, sdk.WebClientInfoChangeDisabled,
sdk.WebClientPubKeyChangeDisabled, sdk.WebClientPasswordChangeDisabled, sdk.WebClientAPIKeyAuthChangeDisabled,
- sdk.WebClientMFADisabled,
+ sdk.WebClientMFADisabled, sdk.WebClientPasswordResetDisabled, sdk.WebClientTLSCertChangeDisabled,
+ }
+ if !s.PortableUser.HasAnyPerm([]string{dataprovider.PermUpload, dataprovider.PermOverwrite}, "/") {
+ s.PortableUser.Filters.WebClient = append(s.PortableUser.Filters.WebClient, sdk.WebClientWriteDisabled)
}
s.configurePortableSecrets()
return printablePassword
@@ -211,7 +210,7 @@ func configurePortableSFTPService(port int, enabledSSHCommands []string) {
} else {
sftpdConf.Bindings[0].Port = 0
}
- if util.Contains(enabledSSHCommands, "*") {
+ if slices.Contains(enabledSSHCommands, "*") {
sftpdConf.EnabledSSHCommands = sftpd.GetSupportedSSHCommands()
} else {
sftpdConf.EnabledSSHCommands = enabledSSHCommands
diff --git a/internal/service/service_windows.go b/internal/service/service_windows.go
index bb1f7627..16be97ae 100644
--- a/internal/service/service_windows.go
+++ b/internal/service/service_windows.go
@@ -108,7 +108,7 @@ func (s *WindowsService) Execute(args []string, r <-chan svc.ChangeRequest, chan
changes <- svc.Status{State: svc.StartPending}
go func() {
- if err := s.Service.Start(false); err != nil {
+ if err := s.Service.Start(); err != nil {
logger.Error(logSender, "", "Windows service failed to start, error: %v", err)
s.Service.Error = err
s.Service.Shutdown <- true
diff --git a/internal/service/signals_unix.go b/internal/service/signals_unix.go
index 22b0c3f1..cecbea9f 100644
--- a/internal/service/signals_unix.go
+++ b/internal/service/signals_unix.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build !windows
-// +build !windows
package service
diff --git a/internal/sftpd/cmd_windows.go b/internal/sftpd/cmd_windows.go
deleted file mode 100644
index 825045bf..00000000
--- a/internal/sftpd/cmd_windows.go
+++ /dev/null
@@ -1,23 +0,0 @@
-// Copyright (C) 2019 Nicola Murino
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published
-// by the Free Software Foundation, version 3.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package sftpd
-
-import (
- "os/exec"
-)
-
-func wrapCmd(cmd *exec.Cmd, _, _ int) *exec.Cmd {
- return cmd
-}
diff --git a/internal/sftpd/handler.go b/internal/sftpd/handler.go
index c551da1b..ca8575f5 100644
--- a/internal/sftpd/handler.go
+++ b/internal/sftpd/handler.go
@@ -19,6 +19,7 @@ import (
"net"
"os"
"path"
+ "strings"
"time"
"github.com/pkg/sftp"
@@ -37,11 +38,10 @@ type Connection struct {
// client's version string
ClientVersion string
// Remote address for this connection
- RemoteAddr net.Addr
- LocalAddr net.Addr
- channel io.ReadWriteCloser
- command string
- folderPrefix string
+ RemoteAddr net.Addr
+ LocalAddr net.Addr
+ channel io.ReadWriteCloser
+ command string
}
// GetClientVersion returns the connected client's version
@@ -73,10 +73,15 @@ func (c *Connection) GetCommand() string {
// Fileread creates a reader for a file on the system and returns the reader back.
func (c *Connection) Fileread(request *sftp.Request) (io.ReaderAt, error) {
c.UpdateLastActivity()
+ updateRequestPaths(request)
if !c.User.HasPerm(dataprovider.PermDownload, path.Dir(request.Filepath)) {
return nil, sftp.ErrSSHFxPermissionDenied
}
+ if err := common.Connections.IsNewTransferAllowed(c.User.Username); err != nil {
+ c.Log(logger.LevelInfo, "denying file read due to transfer count limits")
+ return nil, c.GetPermissionDeniedError()
+ }
transferQuota := c.GetTransferQuota()
if !transferQuota.HasDownloadSpace() {
c.Log(logger.LevelInfo, "denying file read due to quota limits")
@@ -121,8 +126,14 @@ func (c *Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) {
return c.handleFilewrite(request)
}
-func (c *Connection) handleFilewrite(request *sftp.Request) (sftp.WriterAtReaderAt, error) {
+func (c *Connection) handleFilewrite(request *sftp.Request) (sftp.WriterAtReaderAt, error) { //nolint:gocyclo
c.UpdateLastActivity()
+ updateRequestPaths(request)
+
+ if err := common.Connections.IsNewTransferAllowed(c.User.Username); err != nil {
+ c.Log(logger.LevelInfo, "denying file write due to transfer count limits")
+ return nil, c.GetPermissionDeniedError()
+ }
if ok, _ := c.User.IsFileAllowed(request.Filepath); !ok {
c.Log(logger.LevelWarn, "writing file %q is not allowed", request.Filepath)
@@ -181,6 +192,7 @@ func (c *Connection) handleFilewrite(request *sftp.Request) (sftp.WriterAtReader
// or writing to those files.
func (c *Connection) Filecmd(request *sftp.Request) error {
c.UpdateLastActivity()
+ updateRequestPaths(request)
switch request.Method {
case "Setstat":
@@ -213,6 +225,7 @@ func (c *Connection) Filecmd(request *sftp.Request) error {
// a directory as well as perform file/folder stat calls.
func (c *Connection) Filelist(request *sftp.Request) (sftp.ListerAt, error) {
c.UpdateLastActivity()
+ updateRequestPaths(request)
switch request.Method {
case "List":
@@ -221,10 +234,10 @@ func (c *Connection) Filelist(request *sftp.Request) (sftp.ListerAt, error) {
return nil, err
}
modTime := time.Unix(0, 0)
- if request.Filepath != "/" || c.folderPrefix != "" {
- lister.Add(vfs.NewFileInfo("..", true, 0, modTime, false))
+ if request.Filepath != "/" {
+ lister.Prepend(vfs.NewFileInfo("..", true, 0, modTime, false))
}
- lister.Add(vfs.NewFileInfo(".", true, 0, modTime, false))
+ lister.Prepend(vfs.NewFileInfo(".", true, 0, modTime, false))
return lister, nil
case "Stat":
if !c.User.HasPerm(dataprovider.PermListItems, path.Dir(request.Filepath)) {
@@ -244,6 +257,7 @@ func (c *Connection) Filelist(request *sftp.Request) (sftp.ListerAt, error) {
// Readlink implements the ReadlinkFileLister interface
func (c *Connection) Readlink(filePath string) (string, error) {
+ filePath = util.CleanPath(filePath)
if err := c.canReadLink(filePath); err != nil {
return "", err
}
@@ -268,6 +282,7 @@ func (c *Connection) Readlink(filePath string) (string, error) {
// Lstat implements LstatFileLister interface
func (c *Connection) Lstat(request *sftp.Request) (sftp.ListerAt, error) {
c.UpdateLastActivity()
+ updateRequestPaths(request)
if !c.User.HasPerm(dataprovider.PermListItems, path.Dir(request.Filepath)) {
return nil, sftp.ErrSSHFxPermissionDenied
@@ -283,15 +298,14 @@ func (c *Connection) Lstat(request *sftp.Request) (sftp.ListerAt, error) {
// RealPath implements the RealPathFileLister interface
func (c *Connection) RealPath(p string) (string, error) {
- if !c.User.HasPerm(dataprovider.PermListItems, path.Dir(p)) {
- return "", sftp.ErrSSHFxPermissionDenied
- }
-
if c.User.Filters.StartDirectory == "" {
p = util.CleanPath(p)
} else {
p = util.CleanPathWithBase(c.User.Filters.StartDirectory, p)
}
+ if !c.User.HasPerm(dataprovider.PermListItems, path.Dir(p)) {
+ return "", sftp.ErrSSHFxPermissionDenied
+ }
fs, fsPath, err := c.GetFsAndResolvedPath(p)
if err != nil {
return "", err
@@ -309,6 +323,7 @@ func (c *Connection) RealPath(p string) (string, error) {
// StatVFS implements StatVFSFileCmder interface
func (c *Connection) StatVFS(r *sftp.Request) (*sftp.StatVFS, error) {
c.UpdateLastActivity()
+ updateRequestPaths(r)
// we are assuming that r.Filepath is a dir, this could be wrong but should
// not produce any side effect here.
@@ -458,7 +473,7 @@ func (c *Connection) handleSFTPUploadToExistingFile(fs vfs.Fs, pflags sftp.FileO
}
if common.Config.IsAtomicUploadEnabled() && fs.IsAtomicUploadSupported() {
- _, _, err = fs.Rename(resolvedPath, filePath)
+ _, _, err = fs.Rename(resolvedPath, filePath, 0)
if err != nil {
c.Log(logger.LevelError, "error renaming existing file for atomic upload, source: %q, dest: %q, err: %+v",
resolvedPath, filePath, err)
@@ -560,13 +575,10 @@ func (c *Connection) getStatVFSFromQuotaResult(fs vfs.Fs, name string, quotaResu
func (c *Connection) updateQuotaAfterTruncate(requestPath string, fileSize int64) {
vfolder, err := c.User.GetVirtualFolderForPath(path.Dir(requestPath))
if err == nil {
- dataprovider.UpdateVirtualFolderQuota(&vfolder.BaseVirtualFolder, 0, -fileSize, false) //nolint:errcheck
- if vfolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&c.User, 0, -fileSize, false) //nolint:errcheck
- }
- } else {
- dataprovider.UpdateUserQuota(&c.User, 0, -fileSize, false) //nolint:errcheck
+ dataprovider.UpdateUserFolderQuota(&vfolder, &c.User, 0, -fileSize, false)
+ return
}
+ dataprovider.UpdateUserQuota(&c.User, 0, -fileSize, false) //nolint:errcheck
}
func getOSOpenFlags(requestFlags sftp.FileOpenFlags) (flags int) {
@@ -591,3 +603,15 @@ func getOSOpenFlags(requestFlags sftp.FileOpenFlags) (flags int) {
}
return osFlags
}
+
+func updateRequestPaths(request *sftp.Request) {
+ if request.Method == "Symlink" {
+ request.Filepath = path.Clean(strings.ReplaceAll(request.Filepath, "\\", "/"))
+ } else {
+ request.Filepath = util.CleanPath(request.Filepath)
+ }
+
+ if request.Target != "" {
+ request.Target = util.CleanPath(request.Target)
+ }
+}
diff --git a/internal/sftpd/internal_test.go b/internal/sftpd/internal_test.go
index bc96b68a..44e93faa 100644
--- a/internal/sftpd/internal_test.go
+++ b/internal/sftpd/internal_test.go
@@ -16,6 +16,7 @@ package sftpd
import (
"bytes"
+ "context"
"errors"
"fmt"
"io"
@@ -24,6 +25,7 @@ import (
"os"
"path/filepath"
"runtime"
+ "slices"
"testing"
"time"
@@ -145,7 +147,7 @@ func (fs MockOsFs) Remove(name string, _ bool) error {
}
// Rename renames (moves) source to target
-func (fs MockOsFs) Rename(source, target string) (int, int64, error) {
+func (fs MockOsFs) Rename(source, target string, _ int) (int, int64, error) {
if fs.err != nil {
return -1, -1, fs.err
}
@@ -269,6 +271,7 @@ func TestReadWriteErrors(t *testing.T) {
err = os.Remove(testfile)
assert.NoError(t, err)
assert.Len(t, conn.GetTransfers(), 0)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func TestUnsupportedListOP(t *testing.T) {
@@ -374,8 +377,9 @@ func TestWithInvalidHome(t *testing.T) {
c := Connection{
BaseConnection: common.NewBaseConnection("", common.ProtocolSFTP, "", "", u),
}
- _, err = fs.ResolvePath("../upper_path")
- assert.Error(t, err, "tested path is not a home subdir")
+ resolved, err := fs.ResolvePath("../upper_path")
+ assert.NoError(t, err)
+ assert.Equal(t, filepath.Join(u.HomeDir, "upper_path"), resolved)
_, err = c.StatVFS(&sftp.Request{
Method: "StatVFS",
Filepath: "../unresolvable-path",
@@ -418,7 +422,7 @@ func TestSupportedSSHCommands(t *testing.T) {
assert.Equal(t, len(supportedSSHCommands), len(cmds))
for _, c := range cmds {
- assert.True(t, util.Contains(supportedSSHCommands, c))
+ assert.True(t, slices.Contains(supportedSSHCommands, c))
}
}
@@ -525,14 +529,6 @@ func TestSSHCommandErrors(t *testing.T) {
err = cmd.handle()
assert.Error(t, err, "ssh command must fail, we are requesting an invalid path")
- cmd = sshCommand{
- command: "git-receive-pack",
- connection: &connection,
- args: []string{"/../../testrepo"},
- }
- err = cmd.handle()
- assert.Error(t, err, "ssh command must fail, we are requesting an invalid path")
-
user = dataprovider.User{}
user.Permissions = map[string][]string{
"/": {dataprovider.PermAny},
@@ -543,43 +539,6 @@ func TestSSHCommandErrors(t *testing.T) {
cmd.connection.User = user
_, err = cmd.connection.User.GetFilesystem("123")
assert.NoError(t, err)
- err = cmd.handle()
- assert.EqualError(t, err, common.ErrQuotaExceeded.Error())
-
- cmd.connection.User.QuotaFiles = 0
- cmd.connection.User.UsedQuotaFiles = 0
- cmd.connection.User.Permissions = make(map[string][]string)
- cmd.connection.User.Permissions["/"] = []string{dataprovider.PermListItems}
- err = cmd.handle()
- assert.EqualError(t, err, common.ErrPermissionDenied.Error())
-
- cmd.connection.User.Permissions["/"] = []string{dataprovider.PermAny}
- cmd.command = "invalid_command"
- command, err := cmd.getSystemCommand()
- assert.NoError(t, err)
-
- err = cmd.executeSystemCommand(command)
- assert.Error(t, err, "invalid command must fail")
-
- command, err = cmd.getSystemCommand()
- assert.NoError(t, err)
-
- _, err = command.cmd.StderrPipe()
- assert.NoError(t, err)
-
- err = cmd.executeSystemCommand(command)
- assert.Error(t, err, "command must fail, pipe was already assigned")
-
- err = cmd.executeSystemCommand(command)
- assert.Error(t, err, "command must fail, pipe was already assigned")
-
- command, err = cmd.getSystemCommand()
- assert.NoError(t, err)
-
- _, err = command.cmd.StdoutPipe()
- assert.NoError(t, err)
- err = cmd.executeSystemCommand(command)
- assert.Error(t, err, "command must fail, pipe was already assigned")
cmd = sshCommand{
command: "sftpgo-remove",
@@ -597,23 +556,6 @@ func TestSSHCommandErrors(t *testing.T) {
err = cmd.handle()
assert.Error(t, err, "ssh command must fail, we are requesting an invalid path")
- cmd.connection.User.HomeDir = filepath.Clean(os.TempDir())
-
- cmd = sshCommand{
- command: "sftpgo-copy",
- connection: &connection,
- args: []string{"src", "dst"},
- }
-
- cmd.connection.User.Permissions = make(map[string][]string)
- cmd.connection.User.Permissions["/"] = []string{dataprovider.PermAny}
-
- common.WaitForTransfers(1)
- _, err = cmd.getSystemCommand()
- if assert.Error(t, err) {
- assert.Contains(t, err.Error(), common.ErrShuttingDown.Error())
- }
-
err = common.Initialize(common.Config, 0)
assert.NoError(t, err)
}
@@ -654,38 +596,6 @@ func TestCommandsWithExtensionsFilter(t *testing.T) {
}
err := cmd.handleHashCommands()
assert.EqualError(t, err, common.ErrPermissionDenied.Error())
-
- cmd = sshCommand{
- command: "rsync",
- connection: connection,
- args: []string{"--server", "-vlogDtprze.iLsfxC", ".", "/"},
- }
- _, err = cmd.getSystemCommand()
- assert.EqualError(t, err, errUnsupportedConfig.Error())
-
- cmd = sshCommand{
- command: "git-receive-pack",
- connection: connection,
- args: []string{"/subdir"},
- }
- _, err = cmd.getSystemCommand()
- assert.EqualError(t, err, errUnsupportedConfig.Error())
-
- cmd = sshCommand{
- command: "git-receive-pack",
- connection: connection,
- args: []string{"/subdir/dir"},
- }
- _, err = cmd.getSystemCommand()
- assert.EqualError(t, err, errUnsupportedConfig.Error())
-
- cmd = sshCommand{
- command: "git-receive-pack",
- connection: connection,
- args: []string{"/adir/subdir"},
- }
- _, err = cmd.getSystemCommand()
- assert.NoError(t, err)
}
func TestSSHCommandsRemoteFs(t *testing.T) {
@@ -716,17 +626,7 @@ func TestSSHCommandsRemoteFs(t *testing.T) {
args: []string{},
}
- command, err := cmd.getSystemCommand()
- assert.NoError(t, err)
-
- err = cmd.executeSystemCommand(command)
- assert.Error(t, err, "command must fail for a non local filesystem")
- cmd = sshCommand{
- command: "sftpgo-copy",
- connection: connection,
- args: []string{},
- }
- err = cmd.handleSFTPGoCopy()
+ err := cmd.handleSFTPGoCopy()
assert.Error(t, err)
cmd = sshCommand{
command: "sftpgo-remove",
@@ -775,282 +675,12 @@ func TestSSHCmdGetFsErrors(t *testing.T) {
assert.NoError(t, err)
}
-func TestGitVirtualFolders(t *testing.T) {
- permissions := make(map[string][]string)
- permissions["/"] = []string{dataprovider.PermAny}
- user := dataprovider.User{
- BaseUser: sdk.BaseUser{
- Permissions: permissions,
- HomeDir: os.TempDir(),
- },
- }
- conn := &Connection{
- BaseConnection: common.NewBaseConnection("", common.ProtocolSFTP, "", "", user),
- }
- cmd := sshCommand{
- command: "git-receive-pack",
- connection: conn,
- args: []string{"/vdir"},
- }
- cmd.connection.User.VirtualFolders = append(cmd.connection.User.VirtualFolders, vfs.VirtualFolder{
- BaseVirtualFolder: vfs.BaseVirtualFolder{
- MappedPath: os.TempDir(),
- },
- VirtualPath: "/vdir",
- })
- _, err := cmd.getSystemCommand()
- assert.NoError(t, err)
- cmd.args = []string{"/"}
- _, err = cmd.getSystemCommand()
- assert.EqualError(t, err, errUnsupportedConfig.Error())
- cmd.args = []string{"/vdir1"}
- _, err = cmd.getSystemCommand()
- assert.NoError(t, err)
-
- cmd.connection.User.VirtualFolders = nil
- cmd.connection.User.VirtualFolders = append(cmd.connection.User.VirtualFolders, vfs.VirtualFolder{
- BaseVirtualFolder: vfs.BaseVirtualFolder{
- MappedPath: os.TempDir(),
- },
- VirtualPath: "/vdir",
- })
- cmd.args = []string{"/vdir/subdir"}
- _, err = cmd.getSystemCommand()
- assert.NoError(t, err)
-
- cmd.args = []string{"/adir/subdir"}
- _, err = cmd.getSystemCommand()
- assert.NoError(t, err)
-}
-
-func TestRsyncOptions(t *testing.T) {
- permissions := make(map[string][]string)
- permissions["/"] = []string{dataprovider.PermAny}
- user := dataprovider.User{
- BaseUser: sdk.BaseUser{
- Permissions: permissions,
- HomeDir: os.TempDir(),
- },
- }
- conn := &Connection{
- BaseConnection: common.NewBaseConnection("", common.ProtocolSFTP, "", "", user),
- }
- sshCmd := sshCommand{
- command: "rsync",
- connection: conn,
- args: []string{"--server", "-vlogDtprze.iLsfxC", ".", "/"},
- }
- cmd, err := sshCmd.getSystemCommand()
- assert.NoError(t, err)
- assert.True(t, util.Contains(cmd.cmd.Args, "--safe-links"),
- "--safe-links must be added if the user has the create symlinks permission")
-
- permissions["/"] = []string{dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermCreateDirs,
- dataprovider.PermListItems, dataprovider.PermOverwrite, dataprovider.PermDelete, dataprovider.PermRename}
- user.Permissions = permissions
-
- conn = &Connection{
- BaseConnection: common.NewBaseConnection("", common.ProtocolSFTP, "", "", user),
- }
- sshCmd = sshCommand{
- command: "rsync",
- connection: conn,
- args: []string{"--server", "-vlogDtprze.iLsfxC", ".", "/"},
- }
- cmd, err = sshCmd.getSystemCommand()
- assert.NoError(t, err)
- assert.True(t, util.Contains(cmd.cmd.Args, "--munge-links"),
- "--munge-links must be added if the user has the create symlinks permission")
-
- sshCmd.connection.User.VirtualFolders = append(sshCmd.connection.User.VirtualFolders, vfs.VirtualFolder{
- BaseVirtualFolder: vfs.BaseVirtualFolder{
- MappedPath: os.TempDir(),
- },
- VirtualPath: "/vdir",
- })
- _, err = sshCmd.getSystemCommand()
- assert.EqualError(t, err, errUnsupportedConfig.Error())
-}
-
-func TestSystemCommandSizeForPath(t *testing.T) {
- permissions := make(map[string][]string)
- permissions["/"] = []string{dataprovider.PermAny}
- user := dataprovider.User{
- BaseUser: sdk.BaseUser{
- Permissions: permissions,
- HomeDir: os.TempDir(),
- },
- }
- fs, err := user.GetFilesystem("123")
- assert.NoError(t, err)
- conn := &Connection{
- BaseConnection: common.NewBaseConnection("", common.ProtocolSFTP, "", "", user),
- }
- sshCmd := sshCommand{
- command: "rsync",
- connection: conn,
- args: []string{"--server", "-vlogDtprze.iLsfxC", ".", "/"},
- }
- _, _, err = sshCmd.getSizeForPath(fs, "missing path")
- assert.NoError(t, err)
- testDir := filepath.Join(os.TempDir(), "dir")
- err = os.MkdirAll(testDir, os.ModePerm)
- assert.NoError(t, err)
- testFile := filepath.Join(testDir, "testfile")
- err = os.WriteFile(testFile, []byte("test content"), os.ModePerm)
- assert.NoError(t, err)
- err = os.Symlink(testFile, testFile+".link")
- assert.NoError(t, err)
- numFiles, size, err := sshCmd.getSizeForPath(fs, testFile+".link")
- assert.NoError(t, err)
- assert.Equal(t, 0, numFiles)
- assert.Equal(t, int64(0), size)
- numFiles, size, err = sshCmd.getSizeForPath(fs, testFile)
- assert.NoError(t, err)
- assert.Equal(t, 1, numFiles)
- assert.Equal(t, int64(12), size)
- if runtime.GOOS != osWindows {
- err = os.Chmod(testDir, 0001)
- assert.NoError(t, err)
- _, _, err = sshCmd.getSizeForPath(fs, testFile)
- assert.Error(t, err)
- err = os.Chmod(testDir, os.ModePerm)
- assert.NoError(t, err)
- }
- err = os.RemoveAll(testDir)
- assert.NoError(t, err)
-}
-
-func TestSystemCommandErrors(t *testing.T) {
- buf := make([]byte, 65535)
- stdErrBuf := make([]byte, 65535)
- readErr := fmt.Errorf("test read error")
- writeErr := fmt.Errorf("test write error")
- mockSSHChannel := MockChannel{
- Buffer: bytes.NewBuffer(buf),
- StdErrBuffer: bytes.NewBuffer(stdErrBuf),
- ReadError: nil,
- WriteError: writeErr,
- }
- permissions := make(map[string][]string)
- permissions["/"] = []string{dataprovider.PermAny}
- homeDir := filepath.Join(os.TempDir(), "adir")
- err := os.MkdirAll(homeDir, os.ModePerm)
- assert.NoError(t, err)
- err = os.WriteFile(filepath.Join(homeDir, "afile"), []byte("content"), os.ModePerm)
- assert.NoError(t, err)
- user := dataprovider.User{
- BaseUser: sdk.BaseUser{
- Permissions: permissions,
- HomeDir: homeDir,
- },
- }
- fs, err := user.GetFilesystem("123")
- assert.NoError(t, err)
- connection := &Connection{
- BaseConnection: common.NewBaseConnection("", common.ProtocolSFTP, "", "", user),
- channel: &mockSSHChannel,
- }
- var sshCmd sshCommand
- if runtime.GOOS == osWindows {
- sshCmd = sshCommand{
- command: "dir",
- connection: connection,
- args: []string{"/"},
- }
- } else {
- sshCmd = sshCommand{
- command: "ls",
- connection: connection,
- args: []string{"/"},
- }
- }
- systemCmd, err := sshCmd.getSystemCommand()
- assert.NoError(t, err)
-
- systemCmd.cmd.Dir = os.TempDir()
- // FIXME: the command completes but the fake client is unable to read the response
- // no error is reported in this case. We can see that the expected code is executed
- // reading the test coverage
- sshCmd.executeSystemCommand(systemCmd) //nolint:errcheck
-
- mockSSHChannel = MockChannel{
- Buffer: bytes.NewBuffer(buf),
- StdErrBuffer: bytes.NewBuffer(stdErrBuf),
- ReadError: readErr,
- WriteError: nil,
- }
- sshCmd.connection.channel = &mockSSHChannel
- baseTransfer := common.NewBaseTransfer(nil, sshCmd.connection.BaseConnection, nil, "", "", "",
- common.TransferUpload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{})
- transfer := newTransfer(baseTransfer, nil, nil, nil)
- destBuff := make([]byte, 65535)
- dst := bytes.NewBuffer(destBuff)
- _, err = transfer.copyFromReaderToWriter(dst, sshCmd.connection.channel)
- assert.EqualError(t, err, readErr.Error())
-
- mockSSHChannel = MockChannel{
- Buffer: bytes.NewBuffer(buf),
- StdErrBuffer: bytes.NewBuffer(stdErrBuf),
- ReadError: nil,
- WriteError: nil,
- }
- sshCmd.connection.channel = &mockSSHChannel
- transfer.MaxWriteSize = 1
- _, err = transfer.copyFromReaderToWriter(dst, sshCmd.connection.channel)
- assert.True(t, transfer.Connection.IsQuotaExceededError(err))
-
- mockSSHChannel = MockChannel{
- Buffer: bytes.NewBuffer(buf),
- StdErrBuffer: bytes.NewBuffer(stdErrBuf),
- ReadError: nil,
- WriteError: nil,
- ShortWriteErr: true,
- }
- sshCmd.connection.channel = &mockSSHChannel
- _, err = transfer.copyFromReaderToWriter(sshCmd.connection.channel, dst)
- assert.EqualError(t, err, io.ErrShortWrite.Error())
- transfer.MaxWriteSize = -1
- _, err = transfer.copyFromReaderToWriter(sshCmd.connection.channel, dst)
- assert.True(t, transfer.Connection.IsQuotaExceededError(err))
-
- baseTransfer = common.NewBaseTransfer(nil, sshCmd.connection.BaseConnection, nil, "", "", "",
- common.TransferDownload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{
- AllowedDLSize: 1,
- })
- transfer = newTransfer(baseTransfer, nil, nil, nil)
- mockSSHChannel = MockChannel{
- Buffer: bytes.NewBuffer(buf),
- StdErrBuffer: bytes.NewBuffer(stdErrBuf),
- ReadError: nil,
- WriteError: nil,
- }
- sshCmd.connection.channel = &mockSSHChannel
- _, err = transfer.copyFromReaderToWriter(dst, sshCmd.connection.channel)
- if assert.Error(t, err) {
- assert.Contains(t, err.Error(), common.ErrReadQuotaExceeded.Error())
- }
-
- err = os.RemoveAll(homeDir)
- assert.NoError(t, err)
-}
-
func TestCommandGetFsError(t *testing.T) {
user := dataprovider.User{
FsConfig: vfs.Filesystem{
Provider: sdk.CryptedFilesystemProvider,
},
}
- conn := &Connection{
- BaseConnection: common.NewBaseConnection("", common.ProtocolSFTP, "", "", user),
- }
- sshCmd := sshCommand{
- command: "rsync",
- connection: conn,
- args: []string{"--server", "-vlogDtprze.iLsfxC", ".", "/"},
- }
- _, err := sshCmd.getSystemCommand()
- assert.Error(t, err)
buf := make([]byte, 65535)
stdErrBuf := make([]byte, 65535)
@@ -1059,7 +689,7 @@ func TestCommandGetFsError(t *testing.T) {
StdErrBuffer: bytes.NewBuffer(stdErrBuf),
ReadError: nil,
}
- conn = &Connection{
+ conn := &Connection{
BaseConnection: common.NewBaseConnection("", common.ProtocolSCP, "", "", user),
channel: &mockSSHChannel,
}
@@ -1071,7 +701,7 @@ func TestCommandGetFsError(t *testing.T) {
},
}
- err = scpCommand.handleRecursiveUpload()
+ err := scpCommand.handleRecursiveUpload()
assert.Error(t, err)
err = scpCommand.handleDownload("")
assert.Error(t, err)
@@ -1716,6 +1346,7 @@ func TestSCPUploadFiledata(t *testing.T) {
if assert.Error(t, err) {
assert.EqualError(t, err, common.ErrTransferClosed.Error())
}
+ transfer.Connection.RemoveTransfer(transfer)
mockSSHChannel = MockChannel{
Buffer: bytes.NewBuffer(buf),
@@ -1727,9 +1358,12 @@ func TestSCPUploadFiledata(t *testing.T) {
transfer.Connection.AddTransfer(transfer)
err = scpCommand.getUploadFileData(2, transfer)
assert.ErrorContains(t, err, os.ErrClosed.Error())
+ transfer.Connection.RemoveTransfer(transfer)
err = os.Remove(testfile)
assert.NoError(t, err)
+
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func TestUploadError(t *testing.T) {
@@ -1845,9 +1479,9 @@ func TestConfigsFromProvider(t *testing.T) {
SFTPD: &dataprovider.SFTPDConfigs{
HostKeyAlgos: []string{ssh.KeyAlgoRSA},
KexAlgorithms: []string{ssh.InsecureKeyExchangeDHGEXSHA1},
- Ciphers: []string{ssh.InsecureCipherAES128CBC, ssh.InsecureCipherAES192CBC, ssh.InsecureCipherAES256CBC},
+ Ciphers: []string{ssh.InsecureCipherAES128CBC},
MACs: []string{ssh.HMACSHA512ETM},
- PublicKeyAlgos: []string{ssh.InsecureKeyAlgoDSA},
+ PublicKeyAlgos: []string{ssh.InsecureKeyAlgoDSA}, //nolint:staticcheck
},
}
err = dataprovider.UpdateConfigs(&configs, "", "", "")
@@ -1878,7 +1512,7 @@ func TestSupportedSecurityOptions(t *testing.T) {
var defaultKexs []string
for _, k := range supportedKexAlgos {
defaultKexs = append(defaultKexs, k)
- if k == ssh.KeyExchangeCurve25519SHA256 {
+ if k == ssh.KeyExchangeCurve25519 {
defaultKexs = append(defaultKexs, keyExchangeCurve25519SHA256LibSSH)
}
}
@@ -1895,7 +1529,7 @@ func TestSupportedSecurityOptions(t *testing.T) {
c.MACs = []string{
" hmac-sha2-256-etm@openssh.com ", " hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256", "hmac-sha2-512 ",
- " hmac-sha1-96", "hmac-sha1 ",
+ "hmac-sha1 ", " hmac-sha1-96",
}
err = c.configureSecurityOptions(serverConfig)
assert.NoError(t, err)
@@ -1982,40 +1616,6 @@ func TestCertCheckerInitErrors(t *testing.T) {
assert.NoError(t, err)
}
-func TestSFTPSubSystem(t *testing.T) {
- permissions := make(map[string][]string)
- permissions["/"] = []string{dataprovider.PermAny}
- user := &dataprovider.User{
- BaseUser: sdk.BaseUser{
- Permissions: permissions,
- HomeDir: os.TempDir(),
- },
- }
- user.FsConfig.Provider = sdk.AzureBlobFilesystemProvider
- err := ServeSubSystemConnection(user, "connID", nil, nil)
- assert.Error(t, err)
- user.FsConfig.Provider = sdk.LocalFilesystemProvider
-
- buf := make([]byte, 0, 4096)
- stdErrBuf := make([]byte, 0, 4096)
- mockSSHChannel := &MockChannel{
- Buffer: bytes.NewBuffer(buf),
- StdErrBuffer: bytes.NewBuffer(stdErrBuf),
- }
- // this is 327680 and it will result in packet too long error
- _, err = mockSSHChannel.Write([]byte{0x00, 0x05, 0x00, 0x00, 0x00, 0x00})
- assert.NoError(t, err)
- err = ServeSubSystemConnection(user, "id", mockSSHChannel, mockSSHChannel)
- assert.EqualError(t, err, "packet too long")
-
- subsystemChannel := newSubsystemChannel(mockSSHChannel, mockSSHChannel)
- n, err := subsystemChannel.Write([]byte{0x00})
- assert.NoError(t, err)
- assert.Equal(t, n, 1)
- err = subsystemChannel.Close()
- assert.NoError(t, err)
-}
-
func TestRecoverer(t *testing.T) {
c := Configuration{}
c.AcceptInboundConnection(nil, nil)
@@ -2039,6 +1639,7 @@ func TestRecoverer(t *testing.T) {
err = scpCmd.handle()
assert.EqualError(t, err, common.ErrGenericFailure.Error())
assert.Len(t, common.Connections.GetStats(""), 0)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func TestListernerAcceptErrors(t *testing.T) {
@@ -2145,9 +1746,27 @@ func TestMaxUserSessions(t *testing.T) {
c := Configuration{}
c.handleSftpConnection(nil, connection)
+ buf := make([]byte, 65535)
+ stdErrBuf := make([]byte, 65535)
+ mockSSHChannel := MockChannel{
+ Buffer: bytes.NewBuffer(buf),
+ StdErrBuffer: bytes.NewBuffer(stdErrBuf),
+ }
+
+ conn := &Connection{
+ BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolSFTP, "", "", dataprovider.User{
+ BaseUser: sdk.BaseUser{
+ Username: "user_max_sessions",
+ HomeDir: filepath.Clean(os.TempDir()),
+ MaxSessions: 1,
+ },
+ }),
+ channel: &mockSSHChannel,
+ }
+
sshCmd := sshCommand{
command: "cd",
- connection: connection,
+ connection: conn,
}
err = sshCmd.handle()
if assert.Error(t, err) {
@@ -2156,19 +1775,17 @@ func TestMaxUserSessions(t *testing.T) {
scpCmd := scpCommand{
sshCommand: sshCommand{
command: "scp",
- connection: connection,
+ connection: conn,
},
}
err = scpCmd.handle()
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "too many open sessions")
}
- err = ServeSubSystemConnection(&connection.User, connection.ID, nil, nil)
- if assert.Error(t, err) {
- assert.Contains(t, err.Error(), "too many open sessions")
- }
+
common.Connections.Remove(connection.GetID())
assert.Len(t, common.Connections.GetStats(""), 0)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func TestCanReadSymlink(t *testing.T) {
@@ -2203,6 +1820,7 @@ func TestCanReadSymlink(t *testing.T) {
}
func TestAuthenticationErrors(t *testing.T) {
+ sftpAuthError := newAuthenticationError(nil, "", "")
loginMethod := dataprovider.SSHLoginMethodPassword
username := "test user"
err := newAuthenticationError(fmt.Errorf("cannot validate credentials: %w", util.NewRecordNotFoundError("not found")),
@@ -2227,3 +1845,42 @@ func TestAuthenticationErrors(t *testing.T) {
assert.ErrorIs(t, err, sftpAuthError)
assert.NotErrorIs(t, err, util.ErrNotFound)
}
+
+type mockCommandExecutor struct {
+ Output []byte
+ Err error
+}
+
+func (f mockCommandExecutor) CombinedOutput(ctx context.Context, name string, args ...string) ([]byte, error) {
+ return f.Output, f.Err
+}
+
+func TestVerifyWithOPKSSH(t *testing.T) {
+ sshCert := []byte(`ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAg4+hKHVPKv183MU/Q7XD/mzDBFSc2YY3eraltxLMGJo0AAAADAQABAAABAQCe6jMoy1xCQgiZkZJ7gi6NLj4uRqz2OaUGK/OJYZTfBqK+SlS9iymAluHu9K+cc4+0qxx0gn7dRTJWINSgzvca6ayYe995EKgD1hE5krh9BH0bRrXB+hGqyslcZOgLNO+v8jYojClQbRtET2tS+xb4k33GCuL5wgla2790ZgOQgs7huQUjG0S8c1W+EYt6fI4cWE/DeEBnv9sqryS8rOb0PbM6WUd7XBadwySFWYQUX0ei56GNt12Z4gADEGlFQV/OnV0PvnTcAMGUl0rfToPgJ4jgogWKoTVWuZ9wyA/x+2LRLRvgm2a969ig937/AH0i0Wq+FzqfK7EXQ99Yf5K/AAAAAAAAAAAAAAACAAAAFGhvc3QuZXhhbXBsZS5jb20ta2V5AAAAFAAAABBob3N0LmV4YW1wbGUuY29tAAAAAGXEzYAAAAAAd8sP4wAAAAAAAAAAAAAAAAAAARcAAAAHc3NoLXJzYQAAAAMBAAEAAAEBAL4PXUPSERufZWCW/hhEnylk3IeMgaa+2HcNY5Cur77a8fYy6OYZAPF+vhJUT0akwGUpTeXAZumAgHECDrJlw1J+jo9ZVT0AKDo0wU77IzNzYxob7+dpB02NJ7DLAXmPauQ07Zc5pWJFVKtmuh7YH9pjYtNXSMOXye7k06PBGzX+ztIt7nPWvD9fR2mZeTSoljeBCGZHwdlnV2ESQlQbBoEI93RPxqxJh/UCDatQPhpDbyverr2ZvB9Y45rqsx6ZVmu5RXl3MfBU1U21W/4ia2di3PybyD4rSmVoam0efcqxo6cBKSHe26OFoTuS9zgdH0iCWL37vqOFmJ7eH91M3nMAAAEUAAAADHJzYS1zaGEyLTI1NgAAAQA/ByIegNZYJRRl413S/8LxGvTZnbxsPwaluoJ/54niGZV9P28THz7d9jXfSHPjalhH93jNPfTYXvI4opnDC37ua1Nu8KKfk40IWXnnDdZLWraUxEidIzhmfVtz8kGdGoFQ8H0EzubL7zKNOTlfSfOoDlmQVOuxT/+eh2mEp4ri0/+8J1mLfLBr8tREX0/iaNjK+RKdcyTMicKursAYMCDdu8vlaphxea+ocyHM9izSX/l33t44V13ueTqIOh2Zbl2UE2k+jk+0dc1CmV0SEoiWiIyt8TRM4yQry1vPlQLsrf28sYM/QMwnhCVhyZO3vs5F25aQWrB9d51VEzBW9/fd host.example.com`)
+ key, _, _, _, err := ssh.ParseAuthorizedKey(sshCert) //nolint:dogsled
+ require.NoError(t, err)
+ cert, ok := key.(*ssh.Certificate)
+ require.True(t, ok)
+ c := Configuration{}
+ c.executor = mockCommandExecutor{
+ Err: errors.New("test error"),
+ }
+ err = c.verifyWithOPKSSH("user", cert)
+ assert.Error(t, err)
+
+ c.executor = mockCommandExecutor{}
+ err = c.verifyWithOPKSSH("", cert)
+ assert.Error(t, err)
+
+ c.executor = mockCommandExecutor{
+ Output: ssh.MarshalAuthorizedKey(cert),
+ }
+ err = c.verifyWithOPKSSH("", cert)
+ assert.Error(t, err)
+
+ c.executor = mockCommandExecutor{
+ Output: ssh.MarshalAuthorizedKey(cert.SignatureKey),
+ }
+ err = c.verifyWithOPKSSH("", cert)
+ assert.NoError(t, err)
+}
diff --git a/internal/sftpd/internal_unix_test.go b/internal/sftpd/internal_unix_test.go
deleted file mode 100644
index 3bb0207f..00000000
--- a/internal/sftpd/internal_unix_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2019 Nicola Murino
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published
-// by the Free Software Foundation, version 3.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-//go:build !windows
-// +build !windows
-
-package sftpd
-
-import (
- "os/exec"
- "testing"
-
- "github.com/stretchr/testify/assert"
-)
-
-func TestWrapCmd(t *testing.T) {
- cmd := exec.Command("ls")
- cmd = wrapCmd(cmd, 3001, 3002)
- assert.Equal(t, uint32(3001), cmd.SysProcAttr.Credential.Uid)
- assert.Equal(t, uint32(3002), cmd.SysProcAttr.Credential.Gid)
-
- cmd = exec.Command("cd")
- cmd = wrapCmd(cmd, processUID, processGID)
- assert.Nil(t, cmd.SysProcAttr)
-}
diff --git a/internal/sftpd/scp.go b/internal/sftpd/scp.go
index be4079ed..2d91afe3 100644
--- a/internal/sftpd/scp.go
+++ b/internal/sftpd/scp.go
@@ -51,8 +51,9 @@ func (c *scpCommand) handle() (err error) {
}
}()
if err := common.Connections.Add(c.connection); err != nil {
+ defer c.connection.CloseFS() //nolint:errcheck
logger.Info(logSender, "", "unable to add SCP connection: %v", err)
- return err
+ return c.sendErrorResponse(err)
}
defer common.Connections.Remove(c.connection.GetID())
@@ -227,6 +228,12 @@ func (c *scpCommand) getUploadFileData(sizeToRead int64, transfer *transfer) err
}
func (c *scpCommand) handleUploadFile(fs vfs.Fs, resolvedPath, filePath string, sizeToRead int64, isNewFile bool, fileSize int64, requestPath string) error {
+ if err := common.Connections.IsNewTransferAllowed(c.connection.User.Username); err != nil {
+ err := fmt.Errorf("denying file write due to transfer count limits")
+ c.connection.Log(logger.LevelInfo, "denying file write due to transfer count limits")
+ c.sendErrorMessage(nil, err)
+ return err
+ }
diskQuota, transferQuota := c.connection.HasSpace(isNewFile, false, requestPath)
if !diskQuota.HasSpace || !transferQuota.HasUploadSpace() {
err := fmt.Errorf("denying file write due to quota limits")
@@ -258,10 +265,7 @@ func (c *scpCommand) handleUploadFile(fs vfs.Fs, resolvedPath, filePath string,
if vfs.HasTruncateSupport(fs) {
vfolder, err := c.connection.User.GetVirtualFolderForPath(path.Dir(requestPath))
if err == nil {
- dataprovider.UpdateVirtualFolderQuota(&vfolder.BaseVirtualFolder, 0, -fileSize, false) //nolint:errcheck
- if vfolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&c.connection.User, 0, -fileSize, false) //nolint:errcheck
- }
+ dataprovider.UpdateUserFolderQuota(&vfolder, &c.connection.User, 0, -fileSize, false)
} else {
dataprovider.UpdateUserQuota(&c.connection.User, 0, -fileSize, false) //nolint:errcheck
}
@@ -333,7 +337,7 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error
}
if common.Config.IsAtomicUploadEnabled() && fs.IsAtomicUploadSupported() {
- _, _, err = fs.Rename(p, filePath)
+ _, _, err = fs.Rename(p, filePath, 0)
if err != nil {
c.connection.Log(logger.LevelError, "error renaming existing file for atomic upload, source: %q, dest: %q, err: %v",
p, filePath, err)
@@ -504,6 +508,13 @@ func (c *scpCommand) sendDownloadFileData(fs vfs.Fs, filePath string, stat os.Fi
func (c *scpCommand) handleDownload(filePath string) error {
c.connection.UpdateLastActivity()
+
+ if err := common.Connections.IsNewTransferAllowed(c.connection.User.Username); err != nil {
+ err := fmt.Errorf("denying file read due to transfer count limits")
+ c.connection.Log(logger.LevelInfo, "denying file read due to transfer count limits")
+ c.sendErrorMessage(nil, err)
+ return err
+ }
transferQuota := c.connection.GetTransferQuota()
if !transferQuota.HasDownloadSpace() {
c.connection.Log(logger.LevelInfo, "denying file read due to quota limits")
diff --git a/internal/sftpd/server.go b/internal/sftpd/server.go
index 5b4455a3..3bee040d 100644
--- a/internal/sftpd/server.go
+++ b/internal/sftpd/server.go
@@ -16,16 +16,21 @@ package sftpd
import (
"bytes"
+ "context"
+ "crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
+ "maps"
"net"
"os"
+ "os/exec"
"path/filepath"
"runtime/debug"
+ "slices"
"strings"
"sync"
"time"
@@ -50,6 +55,10 @@ const (
defaultPrivateEd25519KeyName = "id_ed25519"
sourceAddressCriticalOption = "source-address"
keyExchangeCurve25519SHA256LibSSH = "curve25519-sha256@libssh.org"
+ extraDataPartialSuccessErrKey = "partialSuccessErr"
+ extraDataUserKey = "user"
+ extraDataKeyIDKey = "keyID"
+ extraDataLoginMethodKey = "login_method"
)
var (
@@ -76,10 +85,20 @@ var (
revokedCertManager = revokedCertificates{
certs: map[string]bool{},
}
-
- sftpAuthError = newAuthenticationError(nil, "", "")
)
+type commandExecutor interface {
+ CombinedOutput(ctx context.Context, name string, args ...string) ([]byte, error)
+}
+
+type defaultExecutor struct{}
+
+func (d defaultExecutor) CombinedOutput(ctx context.Context, name string, args ...string) ([]byte, error) {
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Env = []string{}
+ return cmd.CombinedOutput()
+}
+
// Binding defines the configuration for a network listener
type Binding struct {
// The address to listen on. A blank value means listen on all available network interfaces.
@@ -144,6 +163,10 @@ type Configuration struct {
// Example content:
// ["SHA256:bsBRHC/xgiqBJdSuvSTNpJNLTISP/G356jNMCRYC5Es","SHA256:119+8cL/HH+NLMawRsJx6CzPF1I3xC+jpM60bQHXGE8"]
RevokedUserCertsFile string `json:"revoked_user_certs_file" mapstructure:"revoked_user_certs_file"`
+ // Absolute path to the opkssh binary used for OpenPubkey SSH integration
+ OPKSSHPath string `json:"opkssh_path" mapstructure:"opkssh_path"`
+ // Expected SHA256 checksum of the opkssh binary. It is verified at application startup
+ OPKSSHChecksum string `json:"opkssh_checksum" mapstructure:"opkssh_checksum"`
// LoginBannerFile the contents of the specified file, if any, are sent to
// the remote user before authentication is allowed.
LoginBannerFile string `json:"login_banner_file" mapstructure:"login_banner_file"`
@@ -179,6 +202,7 @@ type Configuration struct {
PasswordAuthentication bool `json:"password_authentication" mapstructure:"password_authentication"`
certChecker *ssh.CertChecker
parsedUserCAKeys []ssh.PublicKey
+ executor commandExecutor
}
type authenticationError struct {
@@ -231,10 +255,6 @@ func (c *Configuration) getServerConfig() *ssh.ServerConfig {
MaxAuthTries: c.MaxAuthTries,
PublicKeyCallback: func(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) {
sp, err := c.validatePublicKeyCredentials(conn, pubKey)
- var partialSuccess *ssh.PartialSuccessError
- if errors.As(err, &partialSuccess) {
- return sp, err
- }
if err != nil {
return nil, newAuthenticationError(fmt.Errorf("could not validate public key credentials: %w", err),
dataprovider.SSHLoginMethodPublicKey, conn.User())
@@ -242,6 +262,35 @@ func (c *Configuration) getServerConfig() *ssh.ServerConfig {
return sp, nil
},
+ VerifiedPublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey, permissions *ssh.Permissions, signatureAlgorithm string) (*ssh.Permissions, error) {
+ if partialErr, ok := permissions.ExtraData[extraDataPartialSuccessErrKey]; ok {
+ logger.Info(logSender, hex.EncodeToString(conn.SessionID()), "user %q authenticated with partial success, signature algorithm %q",
+ conn.User(), signatureAlgorithm)
+ return nil, partialErr.(error)
+ }
+ method := dataprovider.SSHLoginMethodPublicKey
+ user := permissions.ExtraData[extraDataUserKey].(dataprovider.User)
+ keyID := permissions.ExtraData[extraDataKeyIDKey].(string)
+ sshPerm, err := loginUser(&user, method, fmt.Sprintf("%s (%s)", keyID, signatureAlgorithm), conn)
+ if err == nil {
+ // if we have a SSH user cert we need to merge certificate permissions with our ones
+ // we only set Extensions, so CriticalOptions are always the ones from the certificate
+ sshPerm.CriticalOptions = permissions.CriticalOptions
+ if permissions.Extensions != nil {
+ if sshPerm.Extensions == nil {
+ sshPerm.Extensions = make(map[string]string)
+ }
+ maps.Copy(sshPerm.Extensions, permissions.Extensions)
+ }
+ if sshPerm.ExtraData == nil {
+ sshPerm.ExtraData = make(map[any]any)
+ }
+ }
+ user.Username = conn.User()
+ ipAddr := util.GetIPFromRemoteAddress(conn.RemoteAddr().String())
+ updateLoginMetrics(&user, ipAddr, method, err)
+ return sshPerm, err
+ },
ServerVersion: fmt.Sprintf("SSH-2.0-%s", version.GetServerVersion("_", false)),
}
@@ -259,13 +308,13 @@ func (c *Configuration) getServerConfig() *ssh.ServerConfig {
func (c *Configuration) updateSupportedAuthentications() {
serviceStatus.Authentications = util.RemoveDuplicates(serviceStatus.Authentications, false)
- if util.Contains(serviceStatus.Authentications, dataprovider.LoginMethodPassword) &&
- util.Contains(serviceStatus.Authentications, dataprovider.SSHLoginMethodPublicKey) {
+ if slices.Contains(serviceStatus.Authentications, dataprovider.LoginMethodPassword) &&
+ slices.Contains(serviceStatus.Authentications, dataprovider.SSHLoginMethodPublicKey) {
serviceStatus.Authentications = append(serviceStatus.Authentications, dataprovider.SSHLoginMethodKeyAndPassword)
}
- if util.Contains(serviceStatus.Authentications, dataprovider.SSHLoginMethodKeyboardInteractive) &&
- util.Contains(serviceStatus.Authentications, dataprovider.SSHLoginMethodPublicKey) {
+ if slices.Contains(serviceStatus.Authentications, dataprovider.SSHLoginMethodKeyboardInteractive) &&
+ slices.Contains(serviceStatus.Authentications, dataprovider.SSHLoginMethodPublicKey) {
serviceStatus.Authentications = append(serviceStatus.Authentications, dataprovider.SSHLoginMethodKeyAndKeyboardInt)
}
}
@@ -311,6 +360,7 @@ func (c *Configuration) loadFromProvider() error {
// Initialize the SFTP server and add a persistent listener to handle inbound SFTP connections.
func (c *Configuration) Initialize(configDir string) error {
+ c.executor = defaultExecutor{}
if err := c.loadFromProvider(); err != nil {
return fmt.Errorf("unable to load configs from provider: %w", err)
}
@@ -322,7 +372,7 @@ func (c *Configuration) Initialize(configDir string) error {
}
sftp.SetSFTPExtensions(sftpExtensions...) //nolint:errcheck // we configure valid SFTP Extensions so we cannot get an error
- sftp.MaxFilelist = vfs.ListerBatchSize
+ sftp.MaxFilelist = 250
if err := c.configureSecurityOptions(serverConfig); err != nil {
return err
@@ -334,6 +384,9 @@ func (c *Configuration) Initialize(configDir string) error {
if err := c.initializeCertChecker(configDir); err != nil {
return err
}
+ if err := c.initializeOPKSSH(); err != nil {
+ return err
+ }
c.configureKeyboardInteractiveAuth(serverConfig)
c.configureLoginBanner(serverConfig, configDir)
c.checkSSHCommands()
@@ -392,8 +445,8 @@ func (c *Configuration) serve(listener net.Listener, serverConfig *ssh.ServerCon
} else {
tempDelay *= 2
}
- if max := 1 * time.Second; tempDelay > max {
- tempDelay = max
+ if maxDelay := 1 * time.Second; tempDelay > maxDelay {
+ tempDelay = maxDelay
}
logger.Warn(logSender, "", "accept error: %v; retrying in %v", err, tempDelay)
time.Sleep(tempDelay)
@@ -415,7 +468,7 @@ func (c *Configuration) configureKeyAlgos(serverConfig *ssh.ServerConfig) error
c.HostKeyAlgorithms = util.RemoveDuplicates(c.HostKeyAlgorithms, true)
}
for _, hostKeyAlgo := range c.HostKeyAlgorithms {
- if !util.Contains(supportedHostKeyAlgos, hostKeyAlgo) {
+ if !slices.Contains(supportedHostKeyAlgos, hostKeyAlgo) {
return fmt.Errorf("unsupported host key algorithm %q", hostKeyAlgo)
}
}
@@ -423,7 +476,7 @@ func (c *Configuration) configureKeyAlgos(serverConfig *ssh.ServerConfig) error
if len(c.PublicKeyAlgorithms) > 0 {
c.PublicKeyAlgorithms = util.RemoveDuplicates(c.PublicKeyAlgorithms, true)
for _, algo := range c.PublicKeyAlgorithms {
- if !util.Contains(supportedPublicKeyAlgos, algo) {
+ if !slices.Contains(supportedPublicKeyAlgos, algo) {
return fmt.Errorf("unsupported public key authentication algorithm %q", algo)
}
}
@@ -445,9 +498,9 @@ func (c *Configuration) checkKeyExchangeAlgorithms() {
}
kexs = append(kexs, k)
if strings.TrimSpace(k) == keyExchangeCurve25519SHA256LibSSH {
- kexs = append(kexs, ssh.KeyExchangeCurve25519SHA256)
+ kexs = append(kexs, ssh.KeyExchangeCurve25519)
}
- if strings.TrimSpace(k) == ssh.KeyExchangeCurve25519SHA256 {
+ if strings.TrimSpace(k) == ssh.KeyExchangeCurve25519 {
kexs = append(kexs, keyExchangeCurve25519SHA256LibSSH)
}
}
@@ -465,7 +518,7 @@ func (c *Configuration) configureSecurityOptions(serverConfig *ssh.ServerConfig)
if kex == keyExchangeCurve25519SHA256LibSSH {
continue
}
- if !util.Contains(supportedKexAlgos, kex) {
+ if !slices.Contains(supportedKexAlgos, kex) {
return fmt.Errorf("unsupported key-exchange algorithm %q", kex)
}
}
@@ -479,7 +532,10 @@ func (c *Configuration) configureSecurityOptions(serverConfig *ssh.ServerConfig)
if len(c.Ciphers) > 0 {
c.Ciphers = util.RemoveDuplicates(c.Ciphers, true)
for _, cipher := range c.Ciphers {
- if !util.Contains(supportedCiphers, cipher) {
+ if slices.Contains([]string{"aes192-cbc", "aes256-cbc"}, cipher) {
+ continue
+ }
+ if !slices.Contains(supportedCiphers, cipher) {
return fmt.Errorf("unsupported cipher %q", cipher)
}
}
@@ -492,7 +548,7 @@ func (c *Configuration) configureSecurityOptions(serverConfig *ssh.ServerConfig)
if len(c.MACs) > 0 {
c.MACs = util.RemoveDuplicates(c.MACs, true)
for _, mac := range c.MACs {
- if !util.Contains(supportedMACs, mac) {
+ if !slices.Contains(supportedMACs, mac) {
return fmt.Errorf("unsupported MAC algorithm %q", mac)
}
}
@@ -555,7 +611,7 @@ func (c *Configuration) configureKeyboardInteractiveAuth(serverConfig *ssh.Serve
}
// AcceptInboundConnection handles an inbound connection to the server instance and determines if the request should be served or not.
-func (c *Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.ServerConfig) {
+func (c *Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.ServerConfig) { //nolint:gocyclo
defer func() {
if r := recover(); r != nil {
logger.Error(logSender, "", "panic in AcceptInboundConnection: %q stack trace: %v", r, string(debug.Stack()))
@@ -584,14 +640,10 @@ func (c *Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Serve
conn.SetDeadline(time.Time{}) //nolint:errcheck
go ssh.DiscardRequests(reqs)
- defer conn.Close()
+ defer sconn.Close()
- var user dataprovider.User
-
- // Unmarshal cannot fails here and even if it fails we'll have a user with no permissions
- json.Unmarshal(util.StringToBytes(sconn.Permissions.Extensions["sftpgo_user"]), &user) //nolint:errcheck
-
- loginType := sconn.Permissions.Extensions["sftpgo_login_method"]
+ user := sconn.Permissions.ExtraData[extraDataUserKey].(dataprovider.User)
+ loginType := sconn.Permissions.ExtraData[extraDataLoginMethodKey].(string)
connectionID := hex.EncodeToString(sconn.SessionID())
defer user.CloseFs() //nolint:errcheck
@@ -601,13 +653,13 @@ func (c *Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Serve
return
}
- logger.Log(logger.LevelInfo, common.ProtocolSSH, connectionID,
- "User %q logged in with %q, from ip %q, client version %q, negotiated algorithms: %+v",
- user.Username, loginType, ipAddr, util.BytesToString(sconn.ClientVersion()),
- sconn.Conn.(ssh.AlgorithmsConnMetadata).Algorithms())
+ logger.LoginLog(user.Username, ipAddr, loginType, common.ProtocolSSH, connectionID,
+ util.BytesToString(sconn.ClientVersion()), true,
+ fmt.Sprintf("negotiated algorithms: %+v", sconn.Conn.(ssh.AlgorithmsConnMetadata).Algorithms()))
+
dataprovider.UpdateLastLogin(&user)
- sshConnection := common.NewSSHConnection(connectionID, conn)
+ sshConnection := common.NewSSHConnection(connectionID, sconn)
common.Connections.AddSSHConnection(sshConnection)
defer common.Connections.RemoveSSHConnection(connectionID)
@@ -630,7 +682,6 @@ func (c *Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Serve
}
channelCounter++
- sshConnection.UpdateLastActivity()
// Channels have a type that is dependent on the protocol. For SFTP this is "subsystem"
// with a payload that (should) be "sftp". Discard anything else we receive ("pty", "shell", etc)
go func(in <-chan *ssh.Request, counter int64) {
@@ -640,8 +691,9 @@ func (c *Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Serve
switch req.Type {
case "subsystem":
- if util.BytesToString(req.Payload[4:]) == "sftp" {
+ if bytes.Equal(req.Payload[4:], []byte("sftp")) {
ok = true
+ sshConnection.UpdateLastActivity()
connection := &Connection{
BaseConnection: common.NewBaseConnection(connID, common.ProtocolSFTP, conn.LocalAddr().String(),
conn.RemoteAddr().String(), user),
@@ -663,6 +715,9 @@ func (c *Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Serve
channel: channel,
}
ok = processSSHCommand(req.Payload, &connection, c.EnabledSSHCommands)
+ if ok {
+ sshConnection.UpdateLastActivity()
+ }
}
if req.WantReply {
req.Reply(ok, nil) //nolint:errcheck
@@ -679,6 +734,7 @@ func (c *Configuration) handleSftpConnection(channel ssh.Channel, connection *Co
}
}()
if err := common.Connections.Add(connection); err != nil {
+ defer connection.CloseFS() //nolint:errcheck
errClose := connection.Disconnect()
logger.Info(logSender, "", "unable to add connection: %v, close err: %v", err, errClose)
return
@@ -686,7 +742,7 @@ func (c *Configuration) handleSftpConnection(channel ssh.Channel, connection *Co
defer common.Connections.Remove(connection.GetID())
// Create the server instance for the channel using the handler we created above.
- server := sftp.NewRequestServer(channel, c.createHandlers(connection), sftp.WithRSAllocator(),
+ server := sftp.NewRequestServer(channel, c.createHandlers(connection),
sftp.WithStartDirectory(connection.User.Filters.StartDirectory))
defer server.Close()
@@ -778,7 +834,7 @@ func loginUser(user *dataprovider.User, loginMethod, publicKey string, conn ssh.
user.Username, user.HomeDir)
return nil, fmt.Errorf("cannot login user with invalid home dir: %q", user.HomeDir)
}
- if util.Contains(user.Filters.DeniedProtocols, common.ProtocolSSH) {
+ if slices.Contains(user.Filters.DeniedProtocols, common.ProtocolSSH) {
logger.Info(logSender, connectionID, "cannot login user %q, protocol SSH is not allowed", user.Username)
return nil, fmt.Errorf("protocol SSH is not allowed for user %q", user.Username)
}
@@ -807,30 +863,25 @@ func loginUser(user *dataprovider.User, loginMethod, publicKey string, conn ssh.
return nil, fmt.Errorf("login for user %q is not allowed from this address: %v", user.Username, remoteAddr)
}
- json, err := json.Marshal(user)
- if err != nil {
- logger.Warn(logSender, connectionID, "error serializing user info: %v, authentication rejected", err)
- return nil, err
- }
if publicKey != "" {
loginMethod = fmt.Sprintf("%v: %v", loginMethod, publicKey)
}
p := &ssh.Permissions{}
- p.Extensions = make(map[string]string)
- p.Extensions["sftpgo_user"] = util.BytesToString(json)
- p.Extensions["sftpgo_login_method"] = loginMethod
+ p.ExtraData = make(map[any]any)
+ p.ExtraData[extraDataUserKey] = *user
+ p.ExtraData[extraDataLoginMethodKey] = loginMethod
return p, nil
}
func (c *Configuration) checkSSHCommands() {
- if util.Contains(c.EnabledSSHCommands, "*") {
+ if slices.Contains(c.EnabledSSHCommands, "*") {
c.EnabledSSHCommands = GetSupportedSSHCommands()
return
}
sshCommands := []string{}
for _, command := range c.EnabledSSHCommands {
command = strings.TrimSpace(command)
- if util.Contains(supportedSSHCommands, command) {
+ if slices.Contains(supportedSSHCommands, command) {
sshCommands = append(sshCommands, command)
} else {
logger.Warn(logSender, "", "unsupported ssh command: %q ignored", command)
@@ -849,11 +900,12 @@ func (c *Configuration) generateDefaultHostKeys(configDir string) error {
if _, err = os.Stat(autoFile); errors.Is(err, fs.ErrNotExist) {
logger.Info(logSender, "", "No host keys configured and %q does not exist; try to create a new host key", autoFile)
logger.InfoToConsole("No host keys configured and %q does not exist; try to create a new host key", autoFile)
- if k == defaultPrivateRSAKeyName {
+ switch k {
+ case defaultPrivateRSAKeyName:
err = util.GenerateRSAKeys(autoFile)
- } else if k == defaultPrivateECDSAKeyName {
+ case defaultPrivateECDSAKeyName:
err = util.GenerateECDSAKeys(autoFile)
- } else {
+ default:
err = util.GenerateEd25519Keys(autoFile)
}
if err != nil {
@@ -920,7 +972,7 @@ func (c *Configuration) checkHostKeyAutoGeneration(configDir string) error {
func (c *Configuration) getHostKeyAlgorithms(keyFormat string) []string {
var algos []string
for _, algo := range algorithmsForKeyFormat(keyFormat) {
- if util.Contains(c.HostKeyAlgorithms, algo) {
+ if slices.Contains(c.HostKeyAlgorithms, algo) {
algos = append(algos, algo)
}
}
@@ -979,7 +1031,7 @@ func (c *Configuration) checkAndLoadHostKeys(configDir string, serverConfig *ssh
var algos []string
for _, algo := range algorithmsForKeyFormat(signer.PublicKey().Type()) {
if underlyingAlgo, ok := certKeyAlgoNames[algo]; ok {
- if util.Contains(mas.Algorithms(), underlyingAlgo) {
+ if slices.Contains(mas.Algorithms(), underlyingAlgo) {
algos = append(algos, algo)
}
}
@@ -1039,6 +1091,49 @@ func (c *Configuration) loadHostCertificates(configDir string) ([]hostCertificat
return certs, nil
}
+func (c *Configuration) initializeOPKSSH() error {
+ if c.OPKSSHPath != "" {
+ if len(c.parsedUserCAKeys) > 0 {
+ return errors.New("opkssh and certificate authorities are mutually exclusive")
+ }
+ if !util.IsFileInputValid(c.OPKSSHPath) || !filepath.IsAbs(c.OPKSSHPath) {
+ return fmt.Errorf("opkssh path %q is not valid, it must be an absolute path", c.OPKSSHPath)
+ }
+ if c.OPKSSHChecksum == "" {
+ if _, err := os.Stat(c.OPKSSHPath); err != nil {
+ return fmt.Errorf("error validating opkssh path %q: %w", c.OPKSSHPath, err)
+ }
+ } else {
+ if err := util.VerifyFileChecksum(c.OPKSSHPath, sha256.New(), c.OPKSSHChecksum, 100*1024*1024); err != nil {
+ return fmt.Errorf("error validating opkssh checksum: %w", err)
+ }
+ }
+ }
+
+ return nil
+}
+
+func (c *Configuration) verifyWithOPKSSH(username string, cert *ssh.Certificate) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ args := []string{"verify", username, util.BytesToString(ssh.MarshalAuthorizedKey(cert)), cert.Type()}
+ out, err := c.executor.CombinedOutput(ctx, c.OPKSSHPath, args...)
+ if err != nil {
+ logger.Debug(logSender, "", "unable to execute opk verifier: %s", string(out))
+ return fmt.Errorf("unable to execute opk verifier: %w", err)
+ }
+ pubKey, _, _, _, err := ssh.ParseAuthorizedKey(out) //nolint:dogsled
+ if err != nil {
+ logger.Debug(logSender, "", "unable to validate the opk verifier output: %s", string(out))
+ return fmt.Errorf("unable to validate the opk verifier output: %w", err)
+ }
+ if !bytes.Equal(pubKey.Marshal(), cert.SignatureKey.Marshal()) {
+ return errors.New("unable to validate opk result")
+ }
+ return nil
+}
+
func (c *Configuration) initializeCertChecker(configDir string) error {
for _, keyPath := range c.TrustedUserCAKeys {
keyPath = strings.TrimSpace(keyPath)
@@ -1091,12 +1186,12 @@ func (c *Configuration) initializeCertChecker(configDir string) error {
func (c *Configuration) getPartialSuccessError(nextAuthMethods []string) error {
err := &ssh.PartialSuccessError{}
- if c.PasswordAuthentication && util.Contains(nextAuthMethods, dataprovider.LoginMethodPassword) {
+ if c.PasswordAuthentication && slices.Contains(nextAuthMethods, dataprovider.LoginMethodPassword) {
err.Next.PasswordCallback = func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
return c.validatePasswordCredentials(conn, password, dataprovider.SSHLoginMethodKeyAndPassword)
}
}
- if c.KeyboardInteractiveAuthentication && util.Contains(nextAuthMethods, dataprovider.SSHLoginMethodKeyboardInteractive) {
+ if c.KeyboardInteractiveAuthentication && slices.Contains(nextAuthMethods, dataprovider.SSHLoginMethodKeyboardInteractive) {
err.Next.KeyboardInteractiveCallback = func(conn ssh.ConnMetadata, client ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
return c.validateKeyboardInteractiveCredentials(conn, client, dataprovider.SSHLoginMethodKeyAndKeyboardInt, true)
}
@@ -1105,74 +1200,75 @@ func (c *Configuration) getPartialSuccessError(nextAuthMethods []string) error {
}
func (c *Configuration) validatePublicKeyCredentials(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) {
- var err error
var user dataprovider.User
- var keyID string
- var sshPerm *ssh.Permissions
var certPerm *ssh.Permissions
- connectionID := hex.EncodeToString(conn.SessionID())
method := dataprovider.SSHLoginMethodPublicKey
ipAddr := util.GetIPFromRemoteAddress(conn.RemoteAddr().String())
cert, ok := pubKey.(*ssh.Certificate)
var certFingerprint string
if ok {
certFingerprint = ssh.FingerprintSHA256(cert.Key)
- if cert.CertType != ssh.UserCert {
- err = fmt.Errorf("ssh: cert has type %d", cert.CertType)
- user.Username = conn.User()
- updateLoginMetrics(&user, ipAddr, method, err)
- return nil, err
- }
- if !c.certChecker.IsUserAuthority(cert.SignatureKey) {
- err = errors.New("ssh: certificate signed by unrecognized authority")
- user.Username = conn.User()
- updateLoginMetrics(&user, ipAddr, method, err)
- return nil, err
- }
- if len(cert.ValidPrincipals) == 0 {
- err = fmt.Errorf("ssh: certificate %s has no valid principals, user: \"%s\"", certFingerprint, conn.User())
- user.Username = conn.User()
- updateLoginMetrics(&user, ipAddr, method, err)
- return nil, err
- }
- if revokedCertManager.isRevoked(certFingerprint) {
- err = fmt.Errorf("ssh: certificate %s is revoked", certFingerprint)
- user.Username = conn.User()
- updateLoginMetrics(&user, ipAddr, method, err)
- return nil, err
- }
- if err := c.certChecker.CheckCert(conn.User(), cert); err != nil {
- user.Username = conn.User()
- updateLoginMetrics(&user, ipAddr, method, err)
- return nil, err
+ if c.OPKSSHPath != "" {
+ if err := c.verifyWithOPKSSH(conn.User(), cert); err != nil {
+ err := fmt.Errorf("ssh: verification with OPK failed: %v", err)
+ user.Username = conn.User()
+ updateLoginMetrics(&user, ipAddr, method, err)
+ return nil, err
+ }
+ } else {
+ if cert.CertType != ssh.UserCert {
+ err := fmt.Errorf("ssh: cert has type %d", cert.CertType)
+ user.Username = conn.User()
+ updateLoginMetrics(&user, ipAddr, method, err)
+ return nil, err
+ }
+ if !c.certChecker.IsUserAuthority(cert.SignatureKey) {
+ err := errors.New("ssh: certificate signed by unrecognized authority")
+ user.Username = conn.User()
+ updateLoginMetrics(&user, ipAddr, method, err)
+ return nil, err
+ }
+ if len(cert.ValidPrincipals) == 0 {
+ err := fmt.Errorf("ssh: certificate %s has no valid principals, user: \"%s\"", certFingerprint, conn.User())
+ user.Username = conn.User()
+ updateLoginMetrics(&user, ipAddr, method, err)
+ return nil, err
+ }
+ if revokedCertManager.isRevoked(certFingerprint) {
+ err := fmt.Errorf("ssh: certificate %s is revoked", certFingerprint)
+ user.Username = conn.User()
+ updateLoginMetrics(&user, ipAddr, method, err)
+ return nil, err
+ }
+ if err := c.certChecker.CheckCert(conn.User(), cert); err != nil {
+ user.Username = conn.User()
+ updateLoginMetrics(&user, ipAddr, method, err)
+ return nil, err
+ }
}
certPerm = &cert.Permissions
}
- if user, keyID, err = dataprovider.CheckUserAndPubKey(conn.User(), pubKey.Marshal(), ipAddr, common.ProtocolSSH, ok); err == nil {
- if ok {
- keyID = fmt.Sprintf("%s: ID: %s, serial: %v, CA %s %s", certFingerprint,
- cert.KeyId, cert.Serial, cert.Type(), ssh.FingerprintSHA256(cert.SignatureKey))
- }
- if user.IsPartialAuth() {
- logger.Debug(logSender, connectionID, "user %q authenticated with partial success", conn.User())
- return certPerm, c.getPartialSuccessError(user.GetNextAuthMethods())
- }
- sshPerm, err = loginUser(&user, method, keyID, conn)
- if err == nil && certPerm != nil {
- // if we have a SSH user cert we need to merge certificate permissions with our ones
- // we only set Extensions, so CriticalOptions are always the ones from the certificate
- sshPerm.CriticalOptions = certPerm.CriticalOptions
- if certPerm.Extensions != nil {
- for k, v := range certPerm.Extensions {
- sshPerm.Extensions[k] = v
- }
- }
- }
+ user, keyID, err := dataprovider.CheckUserAndPubKey(conn.User(), pubKey.Marshal(), ipAddr, common.ProtocolSSH, ok)
+ if err != nil {
+ user.Username = conn.User()
+ updateLoginMetrics(&user, ipAddr, method, err)
+ return nil, err
}
- user.Username = conn.User()
- updateLoginMetrics(&user, ipAddr, method, err)
- return sshPerm, err
+ if ok {
+ keyID = fmt.Sprintf("%s: ID: %s, serial: %v, CA %s %s", certFingerprint,
+ cert.KeyId, cert.Serial, cert.Type(), ssh.FingerprintSHA256(cert.SignatureKey))
+ }
+ if certPerm == nil {
+ certPerm = &ssh.Permissions{}
+ }
+ certPerm.ExtraData = make(map[any]any)
+ certPerm.ExtraData[extraDataKeyIDKey] = keyID
+ certPerm.ExtraData[extraDataUserKey] = user
+ if user.IsPartialAuth() {
+ certPerm.ExtraData[extraDataPartialSuccessErrKey] = c.getPartialSuccessError(user.GetNextAuthMethods())
+ }
+ return certPerm, nil
}
func (c *Configuration) validatePasswordCredentials(conn ssh.ConnMetadata, pass []byte, method string) (*ssh.Permissions, error) {
@@ -1216,6 +1312,7 @@ func updateLoginMetrics(user *dataprovider.User, ip, method string, err error) {
metric.AddLoginAttempt(method)
if err == nil {
plugin.Handler.NotifyLogEvent(notifier.LogEventTypeLoginOK, common.ProtocolSSH, user.Username, ip, "", err)
+ common.DelayLogin(nil)
} else {
logger.ConnectionFailedLog(user.Username, ip, method, common.ProtocolSSH, err.Error())
if method != dataprovider.SSHLoginMethodPublicKey {
@@ -1230,6 +1327,9 @@ func updateLoginMetrics(user *dataprovider.User, ip, method string, err error) {
}
common.AddDefenderEvent(ip, common.ProtocolSSH, event)
plugin.Handler.NotifyLogEvent(logEv, common.ProtocolSSH, user.Username, ip, "", err)
+ if method != dataprovider.SSHLoginMethodPublicKey {
+ common.DelayLogin(err)
+ }
}
}
metric.AddLoginResult(method, err)
diff --git a/internal/sftpd/sftpd.go b/internal/sftpd/sftpd.go
index 92f30460..3f69cdde 100644
--- a/internal/sftpd/sftpd.go
+++ b/internal/sftpd/sftpd.go
@@ -31,16 +31,15 @@ const (
var (
supportedSSHCommands = []string{"scp", "md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum", "cd", "pwd",
- "git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync", "sftpgo-copy", "sftpgo-remove"}
+ "sftpgo-copy", "sftpgo-remove"}
defaultSSHCommands = []string{"md5sum", "sha1sum", "sha256sum", "cd", "pwd", "scp"}
sshHashCommands = []string{"md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum"}
- systemCommands = []string{"git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"}
serviceStatus ServiceStatus
certKeyAlgoNames = map[string]string{
ssh.CertAlgoRSAv01: ssh.KeyAlgoRSA,
ssh.CertAlgoRSASHA256v01: ssh.KeyAlgoRSASHA256,
ssh.CertAlgoRSASHA512v01: ssh.KeyAlgoRSASHA512,
- ssh.InsecureCertAlgoDSAv01: ssh.InsecureKeyAlgoDSA,
+ ssh.InsecureCertAlgoDSAv01: ssh.InsecureKeyAlgoDSA, //nolint:staticcheck
ssh.CertAlgoECDSA256v01: ssh.KeyAlgoECDSA256,
ssh.CertAlgoECDSA384v01: ssh.KeyAlgoECDSA384,
ssh.CertAlgoECDSA521v01: ssh.KeyAlgoECDSA521,
diff --git a/internal/sftpd/sftpd_test.go b/internal/sftpd/sftpd_test.go
index 59c2fdfc..44de3c31 100644
--- a/internal/sftpd/sftpd_test.go
+++ b/internal/sftpd/sftpd_test.go
@@ -23,6 +23,7 @@ import (
"crypto/sha512"
"encoding/base64"
"encoding/binary"
+ "encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -37,6 +38,7 @@ import (
"path"
"path/filepath"
"runtime"
+ "slices"
"strconv"
"strings"
"sync"
@@ -257,7 +259,7 @@ func TestMain(m *testing.M) {
}
sftpdConf.KexAlgorithms = []string{"curve25519-sha256@libssh.org", ssh.KeyExchangeECDHP256,
ssh.KeyExchangeECDHP384}
- sftpdConf.Ciphers = []string{ssh.CipherChacha20Poly1305, ssh.CipherAES128GCM,
+ sftpdConf.Ciphers = []string{ssh.CipherChaCha20Poly1305, ssh.CipherAES128GCM,
ssh.CipherAES256CTR}
sftpdConf.LoginBannerFile = loginBannerFileName
// we need to test all supported ssh commands
@@ -486,6 +488,17 @@ func TestInitialization(t *testing.T) {
assert.NoError(t, err)
sftpdConf.HostKeys = nil
sftpdConf.HostCertificates = nil
+ sftpdConf.OPKSSHPath = "relative path"
+ err = sftpdConf.Initialize(configDir)
+ assert.Error(t, err)
+ sftpdConf.OPKSSHPath = filepath.Join(os.TempDir(), "missing path")
+ err = sftpdConf.Initialize(configDir)
+ assert.Error(t, err)
+ sftpdConf.OPKSSHChecksum = "invalid checksum"
+ err = sftpdConf.Initialize(configDir)
+ assert.Error(t, err)
+ sftpdConf.OPKSSHPath = ""
+ sftpdConf.OPKSSHChecksum = ""
sftpdConf.RevokedUserCertsFile = "."
err = sftpdConf.Initialize(configDir)
assert.Error(t, err)
@@ -782,6 +795,34 @@ func TestSFTPFsEscapeHomeDir(t *testing.T) {
assert.NoError(t, err)
}
+func TestReadDirLongNames(t *testing.T) {
+ usePubKey := true
+ user, _, err := httpdtest.AddUser(getTestUser(usePubKey), http.StatusCreated)
+ assert.NoError(t, err)
+
+ conn, client, err := getSftpClient(user, usePubKey)
+ if assert.NoError(t, err) {
+ defer conn.Close()
+ defer client.Close()
+
+ numFiles := 1000
+ for i := 0; i < 1000; i++ {
+ fPath := filepath.Join(user.GetHomeDir(), hex.EncodeToString(util.GenerateRandomBytes(127)))
+ err = os.WriteFile(fPath, util.GenerateRandomBytes(30), 0666)
+ assert.NoError(t, err)
+ }
+
+ entries, err := client.ReadDir("/")
+ assert.NoError(t, err)
+ assert.Len(t, entries, numFiles)
+ }
+
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+ err = os.RemoveAll(user.GetHomeDir())
+ assert.NoError(t, err)
+}
+
func TestGroupSettingsOverride(t *testing.T) {
usePubKey := true
g := getTestGroup()
@@ -1172,6 +1213,7 @@ func TestConcurrency(t *testing.T) {
assert.Eventually(t, func() bool {
return len(common.Connections.GetStats("")) == 0
}, 1*time.Second, 50*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
err = os.Remove(testFilePath)
assert.NoError(t, err)
@@ -1196,11 +1238,8 @@ func TestProxyProtocol(t *testing.T) {
defer client.Close()
assert.NoError(t, checkBasicSFTP(client))
}
- conn, client, err = getSftpClientWithAddr(user, usePubKey, "127.0.0.1:2224")
- if !assert.Error(t, err) {
- client.Close()
- conn.Close()
- }
+ _, _, err = getSftpClientWithAddr(user, usePubKey, "127.0.0.1:2224")
+ assert.Error(t, err)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
@@ -2446,7 +2485,7 @@ func TestLoginWithDatabaseCredentials(t *testing.T) {
u := getTestUser(usePubKey)
u.FsConfig.Provider = sdk.GCSFilesystemProvider
u.FsConfig.GCSConfig.Bucket = "testbucket"
- u.FsConfig.GCSConfig.Credentials = kms.NewPlainSecret(`{ "type": "service_account" }`)
+ u.FsConfig.GCSConfig.Credentials = kms.NewPlainSecret(`{ "type": "service_account", "private_key": " ", "client_email": "example@iam.gserviceaccount.com" }`)
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
assert.Equal(t, sdkkms.SecretStatusSecretBox, user.FsConfig.GCSConfig.Credentials.GetStatus())
@@ -2972,6 +3011,15 @@ func TestPreLoginScript(t *testing.T) {
}
usePubKey := true
u := getTestUser(usePubKey)
+ mappedPath := filepath.Join(os.TempDir(), "vdir")
+ folderName := filepath.Base(mappedPath)
+ folderMountPath := "/vpath"
+ u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
+ BaseVirtualFolder: vfs.BaseVirtualFolder{
+ Name: folderName,
+ },
+ VirtualPath: folderMountPath,
+ })
err := dataprovider.Close()
assert.NoError(t, err)
err = config.LoadConfig(configDir, "")
@@ -2983,13 +3031,37 @@ func TestPreLoginScript(t *testing.T) {
err = dataprovider.Initialize(providerConf, configDir, true)
assert.NoError(t, err)
+ f := vfs.BaseVirtualFolder{
+ Name: folderName,
+ MappedPath: mappedPath,
+ FsConfig: vfs.Filesystem{
+ Provider: sdk.CryptedFilesystemProvider,
+ CryptConfig: vfs.CryptFsConfig{
+ Passphrase: kms.NewPlainSecret(defaultPassword),
+ },
+ },
+ }
+ _, _, err = httpdtest.AddFolder(f, http.StatusCreated)
+ assert.NoError(t, err)
+
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
conn, client, err := getSftpClient(u, usePubKey)
if assert.NoError(t, err) {
defer conn.Close()
defer client.Close()
+
assert.NoError(t, checkBasicSFTP(client))
+ testFilePath := filepath.Join(homeBasePath, testFileName)
+ testData := []byte("test data")
+ err = os.WriteFile(testFilePath, testData, 0666)
+ assert.NoError(t, err)
+ err = sftpUploadFile(testFilePath, path.Join(folderMountPath, testFileName), int64(len(testData)), client)
+ assert.NoError(t, err)
+ info, err := os.Stat(filepath.Join(mappedPath, testFileName))
+ if assert.NoError(t, err) {
+ assert.Greater(t, info.Size(), int64(len(testData)))
+ }
}
err = os.WriteFile(preLoginPath, getPreLoginScriptContent(user, true), os.ModePerm)
assert.NoError(t, err)
@@ -3026,6 +3098,10 @@ func TestPreLoginScript(t *testing.T) {
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
+ _, err = httpdtest.RemoveFolder(vfs.BaseVirtualFolder{Name: folderName}, http.StatusOK)
+ assert.NoError(t, err)
+ err = os.RemoveAll(mappedPath)
+ assert.NoError(t, err)
err = dataprovider.Close()
assert.NoError(t, err)
err = config.LoadConfig(configDir, "")
@@ -3043,6 +3119,7 @@ func TestPreLoginUserCreation(t *testing.T) {
}
usePubKey := false
u := getTestUser(usePubKey)
+ u.Permissions["/list"] = []string{"list", "download"}
err := dataprovider.Close()
assert.NoError(t, err)
err = config.LoadConfig(configDir, "")
@@ -3064,6 +3141,23 @@ func TestPreLoginUserCreation(t *testing.T) {
}
user, _, err := httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
assert.NoError(t, err)
+ assert.Len(t, user.Permissions, 2)
+ assert.Empty(t, user.Description)
+ u.Description = "some desc"
+ delete(u.Permissions, "/list")
+ err = os.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), os.ModePerm)
+ assert.NoError(t, err)
+ // The user should be updated and list permission removed
+ conn, client, err = getSftpClient(u, usePubKey)
+ if assert.NoError(t, err) {
+ defer conn.Close()
+ defer client.Close()
+ assert.NoError(t, checkBasicSFTP(client))
+ }
+ user, _, err = httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
+ assert.NoError(t, err)
+ assert.Len(t, user.Permissions, 1)
+ assert.NotEmpty(t, user.Description)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
@@ -4364,6 +4458,78 @@ func TestMaxPerHostConnections(t *testing.T) {
common.Config.MaxPerHostConnections = oldValue
}
+func TestMaxTransfers(t *testing.T) {
+ oldValue := common.Config.MaxPerHostConnections
+ common.Config.MaxPerHostConnections = 2
+
+ assert.Eventually(t, func() bool {
+ return common.Connections.GetClientConnections() == 0
+ }, 1000*time.Millisecond, 50*time.Millisecond)
+
+ usePubKey := true
+ user := getTestUser(usePubKey)
+ err := dataprovider.AddUser(&user, "", "", "")
+ assert.NoError(t, err)
+ user.Password = ""
+ conn, client, err := getSftpClient(user, usePubKey)
+ if assert.NoError(t, err) {
+ assert.NoError(t, checkBasicSFTP(client))
+
+ testFilePath := filepath.Join(homeBasePath, testFileName)
+ testFileSize := int64(65535)
+ err = createTestFile(testFilePath, testFileSize)
+ assert.NoError(t, err)
+ err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
+ assert.NoError(t, err)
+
+ f1, err := client.Create("file1")
+ assert.NoError(t, err)
+ f2, err := client.Create("file2")
+ assert.NoError(t, err)
+ _, err = f1.Write([]byte(" "))
+ assert.NoError(t, err)
+ _, err = f2.Write([]byte(" "))
+ assert.NoError(t, err)
+
+ err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
+ assert.ErrorContains(t, err, sftp.ErrSSHFxPermissionDenied.Error())
+
+ remoteUpPath := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, "/")
+ err = scpUpload(testFilePath, remoteUpPath, false, false)
+ assert.Error(t, err)
+
+ localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
+ err = sftpDownloadFile(testFileName, localDownloadPath, testFileSize, client)
+ assert.ErrorContains(t, err, sftp.ErrSSHFxPermissionDenied.Error())
+
+ remoteDownPath := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, path.Join("/", testFileName))
+ err = scpDownload(localDownloadPath, remoteDownPath, false, false)
+ assert.Error(t, err)
+
+ err = f1.Close()
+ assert.NoError(t, err)
+ err = f2.Close()
+ assert.NoError(t, err)
+ err = os.Remove(testFilePath)
+ assert.NoError(t, err)
+ err = os.Remove(localDownloadPath)
+ assert.NoError(t, err)
+ err = client.Close()
+ assert.NoError(t, err)
+ err = conn.Close()
+ assert.NoError(t, err)
+ }
+ err = dataprovider.DeleteUser(user.Username, "", "", "")
+ assert.NoError(t, err)
+ err = os.RemoveAll(user.GetHomeDir())
+ assert.NoError(t, err)
+ assert.Eventually(t, func() bool {
+ return common.Connections.GetTotalTransfers() == 0
+ }, 1000*time.Millisecond, 50*time.Millisecond)
+
+ common.Config.MaxPerHostConnections = oldValue
+}
+
func TestMaxSessions(t *testing.T) {
usePubKey := false
u := getTestUser(usePubKey)
@@ -4913,6 +5079,7 @@ func TestBandwidthAndConnections(t *testing.T) {
assert.Eventually(t, func() bool {
return len(common.Connections.GetStats("")) == 0
}, 10*time.Second, 200*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
err = os.Remove(testFilePath)
assert.NoError(t, err)
err = os.Remove(localDownloadPath)
@@ -5496,13 +5663,13 @@ func TestNestedVirtualFolders(t *testing.T) {
folderGet, _, err = httpdtest.GetFolderByName(folderName, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, int64(18769), folderGet.UsedQuotaSize)
- assert.Equal(t, 1, folderGet.UsedQuotaFiles)
+ assert.Equal(t, int64(0), folderGet.UsedQuotaSize)
+ assert.Equal(t, 0, folderGet.UsedQuotaFiles)
folderGet, _, err = httpdtest.GetFolderByName(folderNameNested, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, int64(27658), folderGet.UsedQuotaSize)
- assert.Equal(t, 1, folderGet.UsedQuotaFiles)
+ assert.Equal(t, int64(0), folderGet.UsedQuotaSize)
+ assert.Equal(t, 0, folderGet.UsedQuotaFiles)
files, err := client.ReadDir("/")
if assert.NoError(t, err) {
@@ -6169,8 +6336,8 @@ func TestVirtualFoldersQuotaValues(t *testing.T) {
assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize)
f, _, err := httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize, f.UsedQuotaSize)
@@ -6289,8 +6456,8 @@ func TestQuotaRenameInsideSameVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize)
f, _, err := httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 2, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
@@ -6314,8 +6481,8 @@ func TestQuotaRenameInsideSameVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 2, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
// rename a file inside vdir2, it isn't included inside user quota, so we have:
// - vdir1/dir1/testFileName.rename
// - vdir1/dir2/testFileName1
@@ -6333,8 +6500,8 @@ func TestQuotaRenameInsideSameVirtualFolder(t *testing.T) {
assert.Equal(t, 2, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 2, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
// rename a file inside vdir2 overwriting an existing, we now have:
// - vdir1/dir1/testFileName.rename
// - vdir1/dir2/testFileName1
@@ -6351,8 +6518,8 @@ func TestQuotaRenameInsideSameVirtualFolder(t *testing.T) {
assert.Equal(t, 1, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 2, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
// rename a file inside vdir1 overwriting an existing, we now have:
// - vdir1/dir1/testFileName.rename (initial testFileName1)
// - vdir2/dir1/testFileName.rename (initial testFileName1)
@@ -6364,8 +6531,8 @@ func TestQuotaRenameInsideSameVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize1, f.UsedQuotaSize)
@@ -6385,8 +6552,8 @@ func TestQuotaRenameInsideSameVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize1, f.UsedQuotaSize)
@@ -6507,8 +6674,8 @@ func TestQuotaRenameBetweenVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize, user.UsedQuotaSize)
f, _, err := httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize+testFileSize1+testFileSize1, f.UsedQuotaSize)
@@ -6526,8 +6693,8 @@ func TestQuotaRenameBetweenVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize*2, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize*2, f.UsedQuotaSize)
- assert.Equal(t, 2, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize1*2, f.UsedQuotaSize)
@@ -6544,8 +6711,8 @@ func TestQuotaRenameBetweenVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize1+testFileSize, f.UsedQuotaSize)
@@ -6561,8 +6728,8 @@ func TestQuotaRenameBetweenVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize, f.UsedQuotaSize)
@@ -6592,8 +6759,8 @@ func TestQuotaRenameBetweenVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize1*3+testFileSize*2, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize1*3+testFileSize*2, f.UsedQuotaSize)
- assert.Equal(t, 5, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, int64(0), f.UsedQuotaSize)
@@ -6607,8 +6774,8 @@ func TestQuotaRenameBetweenVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize1*2+testFileSize, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize1*2+testFileSize, f.UsedQuotaSize)
- assert.Equal(t, 3, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
@@ -6729,8 +6896,8 @@ func TestQuotaRenameFromVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize)
f, _, err := httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
@@ -6748,8 +6915,8 @@ func TestQuotaRenameFromVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize+testFileSize1+testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize, f.UsedQuotaSize)
@@ -6812,8 +6979,8 @@ func TestQuotaRenameFromVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize*3+testFileSize1*3, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 2, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, int64(0), f.UsedQuotaSize)
@@ -6946,8 +7113,8 @@ func TestQuotaRenameToVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize)
f, _, err := httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
// rename a file from user home dir to vdir2, vdir2 is not included in user quota so we have:
// - /vdir2/dir1/testFileName
// - /vdir1/dir1/testFileName1
@@ -6986,8 +7153,8 @@ func TestQuotaRenameToVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize, f.UsedQuotaSize)
@@ -7003,8 +7170,8 @@ func TestQuotaRenameToVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize1, f.UsedQuotaSize)
@@ -7026,8 +7193,8 @@ func TestQuotaRenameToVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize*2+testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize1, f.UsedQuotaSize)
@@ -7044,8 +7211,8 @@ func TestQuotaRenameToVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize*2+testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize*2+testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 3, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize1, f.UsedQuotaSize)
@@ -7070,8 +7237,8 @@ func TestQuotaRenameToVirtualFolder(t *testing.T) {
assert.Equal(t, testFileSize*2+testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize*2+testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 3, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize1*2+testFileSize, f.UsedQuotaSize)
@@ -7339,8 +7506,8 @@ func TestVFolderQuotaSize(t *testing.T) {
f, _, err := httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, f.UsedQuotaSize)
- assert.Equal(t, 1, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize, f.UsedQuotaSize)
@@ -8417,18 +8584,12 @@ func TestResolvePaths(t *testing.T) {
assert.Equal(t, fs.Join(user.GetHomeDir(), "/test/sub"), resolved)
path = "../test/sub"
resolved, err = fs.ResolvePath(filepath.ToSlash(path))
- if vfs.IsLocalOsFs(fs) {
- assert.Error(t, err, "Unexpected resolved path: %v for: %v, fs: %v", resolved, path, fs.Name())
- } else {
- assert.Equal(t, fs.Join(user.GetHomeDir(), "/test/sub"), resolved)
- }
+ assert.NoError(t, err)
+ assert.Equal(t, fs.Join(user.GetHomeDir(), "/test/sub"), resolved)
path = "../../../test/../sub"
resolved, err = fs.ResolvePath(filepath.ToSlash(path))
- if vfs.IsLocalOsFs(fs) {
- assert.Error(t, err, "Unexpected resolved path: %v for: %v, fs: %v", resolved, path, fs.Name())
- } else {
- assert.Equal(t, fs.Join(user.GetHomeDir(), "/sub"), resolved)
- }
+ assert.NoError(t, err)
+ assert.Equal(t, fs.Join(user.GetHomeDir(), "/sub"), resolved)
}
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
@@ -8610,8 +8771,8 @@ func TestUserAllowedLoginMethods(t *testing.T) {
allowedMethods = user.GetAllowedLoginMethods()
assert.Equal(t, 4, len(allowedMethods))
- assert.True(t, util.Contains(allowedMethods, dataprovider.SSHLoginMethodKeyAndKeyboardInt))
- assert.True(t, util.Contains(allowedMethods, dataprovider.SSHLoginMethodKeyAndPassword))
+ assert.True(t, slices.Contains(allowedMethods, dataprovider.SSHLoginMethodKeyAndKeyboardInt))
+ assert.True(t, slices.Contains(allowedMethods, dataprovider.SSHLoginMethodKeyAndPassword))
}
func TestUserPartialAuth(t *testing.T) {
@@ -9118,8 +9279,8 @@ func TestSSHCopy(t *testing.T) {
assert.Equal(t, 2*testFileSize+2*testFileSize1, user.UsedQuotaSize)
f, _, err := httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 2, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize)
@@ -9197,8 +9358,8 @@ func TestSSHCopy(t *testing.T) {
assert.Equal(t, 5*testFileSize+4*testFileSize1, user.UsedQuotaSize)
f, _, err = httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, 2*testFileSize+2*testFileSize1, f.UsedQuotaSize)
- assert.Equal(t, 4, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
}
// cross folder copy
newDir := "newdir"
@@ -9771,10 +9932,13 @@ func TestSSHRemoveCryptFs(t *testing.T) {
assert.NoError(t, err)
}
-func TestBasicGitCommands(t *testing.T) {
+func TestSSHCommandMaxTransfers(t *testing.T) {
if len(gitPath) == 0 || len(sshPath) == 0 || runtime.GOOS == osWindows {
t.Skip("git and/or ssh command not found or OS is windows, unable to execute this test")
}
+ oldValue := common.Config.MaxPerHostConnections
+ common.Config.MaxPerHostConnections = 2
+
usePubKey := true
u := getTestUser(usePubKey)
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
@@ -9786,224 +9950,41 @@ func TestBasicGitCommands(t *testing.T) {
assert.NoError(t, err)
err = os.RemoveAll(filepath.Join(homeBasePath, repoName))
assert.NoError(t, err)
- out, err := initGitRepo(filepath.Join(user.HomeDir, repoName))
- assert.NoError(t, err, "unexpected error, out: %v", string(out))
- out, err = cloneGitRepo(homeBasePath, "/"+repoName, user.Username)
- assert.NoError(t, err, "unexpected error, out: %v", string(out))
-
- out, err = addFileToGitRepo(clonePath, 128)
- assert.NoError(t, err, "unexpected error, out: %v", string(out))
-
- user.QuotaFiles = 100000
- _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
- assert.NoError(t, err)
-
- out, err = pushToGitRepo(clonePath)
- if !assert.NoError(t, err, "unexpected error, out: %v", string(out)) {
- printLatestLogs(10)
- }
-
- out, err = addFileToGitRepo(clonePath, 131072)
- assert.NoError(t, err, "unexpected error, out: %v", string(out))
-
- user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
- assert.NoError(t, err)
- user.QuotaSize = user.UsedQuotaSize + 1
- _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
- assert.NoError(t, err)
- out, err = pushToGitRepo(clonePath)
- assert.Error(t, err, "git push must fail if quota is exceeded, out: %v", string(out))
-
- aDir := filepath.Join(user.GetHomeDir(), repoName, "adir")
- err = os.MkdirAll(aDir, 0001)
- assert.NoError(t, err)
- _, err = pushToGitRepo(clonePath)
- assert.Error(t, err)
- err = os.Chmod(aDir, os.ModePerm)
- assert.NoError(t, err)
-
- _, err = httpdtest.RemoveUser(user, http.StatusOK)
- assert.NoError(t, err)
-
- err = os.RemoveAll(user.GetHomeDir())
- assert.NoError(t, err)
- err = os.RemoveAll(clonePath)
- assert.NoError(t, err)
-}
-
-func TestGitIncludedVirtualFolders(t *testing.T) {
- if len(gitPath) == 0 || len(sshPath) == 0 || runtime.GOOS == osWindows {
- t.Skip("git and/or ssh command not found or OS is windows, unable to execute this test")
- }
- usePubKey := true
- repoName := "trepo"
- u := getTestUser(usePubKey)
- u.QuotaFiles = 10000
- mappedPath := filepath.Join(os.TempDir(), "repo")
- folderName := filepath.Base(mappedPath)
- u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
- BaseVirtualFolder: vfs.BaseVirtualFolder{
- Name: folderName,
- },
- VirtualPath: "/" + repoName,
- QuotaFiles: -1,
- QuotaSize: -1,
- })
- f := vfs.BaseVirtualFolder{
- Name: folderName,
- MappedPath: mappedPath,
- }
- _, _, err := httpdtest.AddFolder(f, http.StatusCreated)
- assert.NoError(t, err)
- user, _, err := httpdtest.AddUser(u, http.StatusCreated)
- assert.NoError(t, err)
-
- clonePath := filepath.Join(homeBasePath, repoName)
- err = os.RemoveAll(user.GetHomeDir())
- assert.NoError(t, err)
- err = os.RemoveAll(filepath.Join(homeBasePath, repoName))
- assert.NoError(t, err)
- out, err := initGitRepo(mappedPath)
- assert.NoError(t, err, "unexpected error, out: %v", string(out))
-
- out, err = cloneGitRepo(homeBasePath, "/"+repoName, user.Username)
- assert.NoError(t, err, "unexpected error, out: %v", string(out))
-
- out, err = addFileToGitRepo(clonePath, 128)
- assert.NoError(t, err, "unexpected error, out: %v", string(out))
-
- out, err = pushToGitRepo(clonePath)
- assert.NoError(t, err, "unexpected error, out: %v", string(out))
-
- user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
- assert.NoError(t, err)
- if user.UsedQuotaFiles == 0 {
- assert.Eventually(t, func() bool {
- user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
- if err != nil {
- return false
- }
- return user.QuotaFiles > 0
- }, 1*time.Second, 100*time.Millisecond)
- }
- user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
- assert.NoError(t, err)
- assert.Greater(t, user.UsedQuotaFiles, 0)
- assert.Greater(t, user.UsedQuotaSize, int64(0))
-
- folder, _, err := httpdtest.GetFolderByName(folderName, http.StatusOK)
- assert.NoError(t, err)
- assert.Equal(t, user.UsedQuotaFiles, folder.UsedQuotaFiles)
- assert.Equal(t, user.UsedQuotaSize, folder.UsedQuotaSize)
-
- _, err = httpdtest.RemoveUser(user, http.StatusOK)
- assert.NoError(t, err)
- err = os.RemoveAll(user.GetHomeDir())
- assert.NoError(t, err)
- _, err = httpdtest.RemoveFolder(vfs.BaseVirtualFolder{Name: folderName}, http.StatusOK)
- assert.NoError(t, err)
- err = os.RemoveAll(mappedPath)
- assert.NoError(t, err)
- err = os.RemoveAll(clonePath)
- assert.NoError(t, err)
-}
-
-func TestGitQuotaVirtualFolders(t *testing.T) {
- if len(gitPath) == 0 || len(sshPath) == 0 || runtime.GOOS == osWindows {
- t.Skip("git and/or ssh command not found or OS is windows, unable to execute this test")
- }
- usePubKey := true
- repoName := "testrepo"
- u := getTestUser(usePubKey)
- u.QuotaFiles = 1
- u.QuotaSize = 131072
- mappedPath := filepath.Join(os.TempDir(), "repo")
- folderName := filepath.Base(mappedPath)
- u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
- BaseVirtualFolder: vfs.BaseVirtualFolder{
- Name: folderName,
- },
- VirtualPath: "/" + repoName,
- QuotaFiles: 0,
- QuotaSize: 0,
- })
- f := vfs.BaseVirtualFolder{
- Name: folderName,
- MappedPath: mappedPath,
- }
- _, _, err := httpdtest.AddFolder(f, http.StatusCreated)
- assert.NoError(t, err)
- err = os.MkdirAll(mappedPath, os.ModePerm)
- assert.NoError(t, err)
- user, _, err := httpdtest.AddUser(u, http.StatusCreated)
- assert.NoError(t, err)
conn, client, err := getSftpClient(user, usePubKey)
if assert.NoError(t, err) {
- // we upload a file so the user is over quota
- defer conn.Close()
- defer client.Close()
- testFilePath := filepath.Join(homeBasePath, testFileName)
- err = createTestFile(testFilePath, u.QuotaSize)
+ f1, err := client.Create("file1")
assert.NoError(t, err)
- err = sftpUploadFile(testFilePath, testFileName, u.QuotaSize, client)
+ f2, err := client.Create("file2")
assert.NoError(t, err)
- err = os.Remove(testFilePath)
+ _, err = f1.Write([]byte(" "))
+ assert.NoError(t, err)
+ _, err = f2.Write([]byte(" "))
+ assert.NoError(t, err)
+
+ _, err = client.Create("file3")
+ assert.Error(t, err)
+
+ err = f1.Close()
+ assert.NoError(t, err)
+ err = f2.Close()
+ assert.NoError(t, err)
+ err = client.Close()
+ assert.NoError(t, err)
+ err = conn.Close()
assert.NoError(t, err)
}
- clonePath := filepath.Join(homeBasePath, repoName)
- err = os.RemoveAll(user.GetHomeDir())
- assert.NoError(t, err)
- err = os.RemoveAll(filepath.Join(homeBasePath, repoName))
- assert.NoError(t, err)
- out, err := initGitRepo(mappedPath)
- assert.NoError(t, err, "unexpected error, out: %v", string(out))
-
- out, err = cloneGitRepo(homeBasePath, "/"+repoName, user.Username)
- assert.NoError(t, err, "unexpected error, out: %v", string(out))
-
- out, err = addFileToGitRepo(clonePath, 128)
- assert.NoError(t, err, "unexpected error, out: %v", string(out))
-
- out, err = pushToGitRepo(clonePath)
- assert.NoError(t, err, "unexpected error, out: %v", string(out))
-
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
- err = os.RemoveAll(user.GetHomeDir())
- assert.NoError(t, err)
- _, err = httpdtest.RemoveFolder(vfs.BaseVirtualFolder{Name: folderName}, http.StatusOK)
- assert.NoError(t, err)
- err = os.RemoveAll(mappedPath)
- assert.NoError(t, err)
- err = os.RemoveAll(clonePath)
- assert.NoError(t, err)
-}
-func TestGitErrors(t *testing.T) {
- if len(gitPath) == 0 || len(sshPath) == 0 || runtime.GOOS == osWindows {
- t.Skip("git and/or ssh command not found or OS is windows, unable to execute this test")
- }
- usePubKey := true
- u := getTestUser(usePubKey)
- user, _, err := httpdtest.AddUser(u, http.StatusCreated)
- assert.NoError(t, err)
- repoName := "testrepo"
- clonePath := filepath.Join(homeBasePath, repoName)
- err = os.RemoveAll(user.GetHomeDir())
- assert.NoError(t, err)
- err = os.RemoveAll(filepath.Join(homeBasePath, repoName))
- assert.NoError(t, err)
- out, err := cloneGitRepo(homeBasePath, "/"+repoName, user.Username)
- assert.Error(t, err, "cloning a missing repo must fail, out: %v", string(out))
-
- _, err = httpdtest.RemoveUser(user, http.StatusOK)
- assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
err = os.RemoveAll(clonePath)
assert.NoError(t, err)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
+
+ common.Config.MaxPerHostConnections = oldValue
}
// Start SCP tests
@@ -10680,8 +10661,8 @@ func TestSCPVirtualFoldersQuota(t *testing.T) {
assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize)
f, _, err := httpdtest.GetFolderByName(folderName1, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, expectedQuotaSize, f.UsedQuotaSize)
- assert.Equal(t, expectedQuotaFiles, f.UsedQuotaFiles)
+ assert.Equal(t, int64(0), f.UsedQuotaSize)
+ assert.Equal(t, 0, f.UsedQuotaFiles)
f, _, err = httpdtest.GetFolderByName(folderName2, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, expectedQuotaSize, f.UsedQuotaSize)
@@ -11077,6 +11058,7 @@ func TestSCPErrors(t *testing.T) {
err = cmd.Process.Kill()
assert.NoError(t, err)
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 }, 2*time.Second, 100*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
cmd = getScpUploadCommand(testFilePath, remoteUpPath, false, false)
go func() {
err := cmd.Run()
@@ -11089,6 +11071,7 @@ func TestSCPErrors(t *testing.T) {
err = cmd.Process.Kill()
assert.NoError(t, err)
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 }, 2*time.Second, 100*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
err = os.Remove(testFilePath)
assert.NoError(t, err)
os.Remove(localPath)
@@ -11611,64 +11594,6 @@ func checkSystemCommands() {
}
}
-func initGitRepo(path string) ([]byte, error) {
- err := os.MkdirAll(path, os.ModePerm)
- if err != nil {
- return nil, err
- }
- args := []string{"init", "--bare"}
- cmd := exec.Command(gitPath, args...)
- cmd.Dir = path
- return cmd.CombinedOutput()
-}
-
-func pushToGitRepo(repoPath string) ([]byte, error) {
- cmd := exec.Command(gitPath, "push")
- cmd.Dir = repoPath
- cmd.Env = append(os.Environ(),
- fmt.Sprintf("GIT_SSH=%v", gitWrapPath))
- return cmd.CombinedOutput()
-}
-
-func cloneGitRepo(basePath, remotePath, username string) ([]byte, error) {
- remoteURL := fmt.Sprintf("ssh://%v@127.0.0.1:2022%v", username, remotePath)
- args := []string{"clone", remoteURL}
- cmd := exec.Command(gitPath, args...)
- cmd.Dir = basePath
- cmd.Env = append(os.Environ(),
- fmt.Sprintf("GIT_SSH=%v", gitWrapPath))
- return cmd.CombinedOutput()
-}
-
-func addFileToGitRepo(repoPath string, fileSize int64) ([]byte, error) {
- path := filepath.Join(repoPath, "test")
- err := createTestFile(path, fileSize)
- if err != nil {
- return []byte(""), err
- }
- cmd := exec.Command(gitPath, "config", "user.email", "testuser@example.com")
- cmd.Dir = repoPath
- out, err := cmd.CombinedOutput()
- if err != nil {
- return out, err
- }
- cmd = exec.Command(gitPath, "config", "user.name", "testuser")
- cmd.Dir = repoPath
- out, err = cmd.CombinedOutput()
- if err != nil {
- return out, err
- }
- cmd = exec.Command(gitPath, "add", "test")
- cmd.Dir = repoPath
- out, err = cmd.CombinedOutput()
- if err != nil {
- return out, err
- }
- cmd = exec.Command(gitPath, "commit", "-am", "test")
- cmd.Dir = repoPath
- return cmd.CombinedOutput()
-}
-
func getKeyboardInteractiveScriptForBuiltinChecks(addPasscode bool, result int) []byte {
content := []byte("#!/bin/sh\n\n")
echos := []bool{false}
@@ -11788,7 +11713,7 @@ func printLatestLogs(maxNumberOfLines int) {
return
}
for _, line := range lines {
- logger.DebugToConsole(line)
+ logger.DebugToConsole("%s", line)
}
}
diff --git a/internal/sftpd/ssh_cmd.go b/internal/sftpd/ssh_cmd.go
index 8a342e99..c82f3b89 100644
--- a/internal/sftpd/ssh_cmd.go
+++ b/internal/sftpd/ssh_cmd.go
@@ -23,16 +23,12 @@ import (
"fmt"
"hash"
"io"
- "os"
- "os/exec"
- "path"
"runtime/debug"
+ "slices"
"strings"
- "sync"
"time"
"github.com/google/shlex"
- "github.com/sftpgo/sdk"
"golang.org/x/crypto/ssh"
"github.com/drakkan/sftpgo/v2/internal/common"
@@ -48,10 +44,6 @@ const (
sshCommandLogSender = "SSHCommand"
)
-var (
- errUnsupportedConfig = errors.New("command unsupported for this configuration")
-)
-
type sshCommand struct {
command string
args []string
@@ -59,39 +51,13 @@ type sshCommand struct {
startTime time.Time
}
-type systemCommand struct {
- cmd *exec.Cmd
- fsPath string
- quotaCheckPath string
- fs vfs.Fs
-}
-
-func (c *systemCommand) GetSTDs() (io.WriteCloser, io.ReadCloser, io.ReadCloser, error) {
- stdin, err := c.cmd.StdinPipe()
- if err != nil {
- return nil, nil, nil, err
- }
- stdout, err := c.cmd.StdoutPipe()
- if err != nil {
- stdin.Close()
- return nil, nil, nil, err
- }
- stderr, err := c.cmd.StderrPipe()
- if err != nil {
- stdin.Close()
- stdout.Close()
- return nil, nil, nil, err
- }
- return stdin, stdout, stderr, nil
-}
-
func processSSHCommand(payload []byte, connection *Connection, enabledSSHCommands []string) bool {
var msg sshSubsystemExecMsg
if err := ssh.Unmarshal(payload, &msg); err == nil {
name, args, err := parseCommandPayload(msg.Command)
connection.Log(logger.LevelDebug, "new ssh command: %q args: %v num args: %d user: %s, error: %v",
name, args, len(args), connection.User.Username, err)
- if err == nil && util.Contains(enabledSSHCommands, name) {
+ if err == nil && slices.Contains(enabledSSHCommands, name) {
connection.command = msg.Command
if name == scpCmdName && len(args) >= 2 {
connection.SetProtocol(common.ProtocolSCP)
@@ -133,20 +99,15 @@ func (c *sshCommand) handle() (err error) {
}
}()
if err := common.Connections.Add(c.connection); err != nil {
+ defer c.connection.CloseFS() //nolint:errcheck
logger.Info(logSender, "", "unable to add SSH command connection: %v", err)
- return err
+ return c.sendErrorResponse(err)
}
defer common.Connections.Remove(c.connection.GetID())
c.connection.UpdateLastActivity()
- if util.Contains(sshHashCommands, c.command) {
+ if slices.Contains(sshHashCommands, c.command) {
return c.handleHashCommands()
- } else if util.Contains(systemCommands, c.command) {
- command, err := c.getSystemCommand()
- if err != nil {
- return c.sendErrorResponse(err)
- }
- return c.executeSystemCommand(command)
} else if c.command == "cd" {
c.sendExitStatus(nil)
} else if c.command == "pwd" {
@@ -189,29 +150,18 @@ func (c *sshCommand) handleSFTPGoRemove() error {
return nil
}
-func (c *sshCommand) updateQuota(sshDestPath string, filesNum int, filesSize int64) {
- vfolder, err := c.connection.User.GetVirtualFolderForPath(sshDestPath)
- if err == nil {
- dataprovider.UpdateVirtualFolderQuota(&vfolder.BaseVirtualFolder, filesNum, filesSize, false) //nolint:errcheck
- if vfolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&c.connection.User, filesNum, filesSize, false) //nolint:errcheck
- }
- } else {
- dataprovider.UpdateUserQuota(&c.connection.User, filesNum, filesSize, false) //nolint:errcheck
- }
-}
-
func (c *sshCommand) handleHashCommands() error {
var h hash.Hash
- if c.command == "md5sum" {
+ switch c.command {
+ case "md5sum":
h = md5.New()
- } else if c.command == "sha1sum" {
+ case "sha1sum":
h = sha1.New()
- } else if c.command == "sha256sum" {
+ case "sha256sum":
h = sha256.New()
- } else if c.command == "sha384sum" {
+ case "sha384sum":
h = sha512.New384()
- } else {
+ default:
h = sha512.New()
}
var response string
@@ -248,212 +198,6 @@ func (c *sshCommand) handleHashCommands() error {
return nil
}
-func (c *sshCommand) executeSystemCommand(command systemCommand) error {
- sshDestPath := c.getDestPath()
- if !c.isLocalPath(sshDestPath) {
- return c.sendErrorResponse(errUnsupportedConfig)
- }
- diskQuota, transferQuota := c.connection.HasSpace(true, false, command.quotaCheckPath)
- if !diskQuota.HasSpace || !transferQuota.HasUploadSpace() || !transferQuota.HasDownloadSpace() {
- return c.sendErrorResponse(common.ErrQuotaExceeded)
- }
- perms := []string{dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermCreateDirs, dataprovider.PermListItems,
- dataprovider.PermOverwrite, dataprovider.PermDelete}
- if !c.connection.User.HasPerms(perms, sshDestPath) {
- return c.sendErrorResponse(c.connection.GetPermissionDeniedError())
- }
-
- initialFiles, initialSize, err := c.getSizeForPath(command.fs, command.fsPath)
- if err != nil {
- return c.sendErrorResponse(err)
- }
-
- stdin, stdout, stderr, err := command.GetSTDs()
- if err != nil {
- return c.sendErrorResponse(err)
- }
- err = command.cmd.Start()
- if err != nil {
- return c.sendErrorResponse(err)
- }
-
- closeCmdOnError := func() {
- c.connection.Log(logger.LevelDebug, "kill cmd: %q and close ssh channel after read or write error",
- c.connection.command)
- killerr := command.cmd.Process.Kill()
- closerr := c.connection.channel.Close()
- c.connection.Log(logger.LevelDebug, "kill cmd error: %v close channel error: %v", killerr, closerr)
- }
- var once sync.Once
- commandResponse := make(chan bool)
-
- remainingQuotaSize := diskQuota.GetRemainingSize()
-
- go func() {
- defer stdin.Close()
- baseTransfer := common.NewBaseTransfer(nil, c.connection.BaseConnection, nil, command.fsPath, command.fsPath, sshDestPath,
- common.TransferUpload, 0, 0, remainingQuotaSize, 0, false, command.fs, transferQuota)
- transfer := newTransfer(baseTransfer, nil, nil, nil)
-
- w, e := transfer.copyFromReaderToWriter(stdin, c.connection.channel)
- c.connection.Log(logger.LevelDebug, "command: %q, copy from remote command to sdtin ended, written: %v, "+
- "initial remaining quota: %v, err: %v", c.connection.command, w, remainingQuotaSize, e)
- if e != nil {
- once.Do(closeCmdOnError)
- }
- }()
-
- go func() {
- baseTransfer := common.NewBaseTransfer(nil, c.connection.BaseConnection, nil, command.fsPath, command.fsPath, sshDestPath,
- common.TransferDownload, 0, 0, 0, 0, false, command.fs, transferQuota)
- transfer := newTransfer(baseTransfer, nil, nil, nil)
-
- w, e := transfer.copyFromReaderToWriter(c.connection.channel, stdout)
- c.connection.Log(logger.LevelDebug, "command: %q, copy from sdtout to remote command ended, written: %v err: %v",
- c.connection.command, w, e)
- if e != nil {
- once.Do(closeCmdOnError)
- }
- commandResponse <- true
- }()
-
- go func() {
- baseTransfer := common.NewBaseTransfer(nil, c.connection.BaseConnection, nil, command.fsPath, command.fsPath, sshDestPath,
- common.TransferDownload, 0, 0, 0, 0, false, command.fs, transferQuota)
- transfer := newTransfer(baseTransfer, nil, nil, nil)
-
- w, e := transfer.copyFromReaderToWriter(c.connection.channel.(ssh.Channel).Stderr(), stderr)
- c.connection.Log(logger.LevelDebug, "command: %q, copy from sdterr to remote command ended, written: %v err: %v",
- c.connection.command, w, e)
- // os.ErrClosed means that the command is finished so we don't need to do anything
- if (e != nil && !errors.Is(e, os.ErrClosed)) || w > 0 {
- once.Do(closeCmdOnError)
- }
- }()
-
- <-commandResponse
- err = command.cmd.Wait()
- c.sendExitStatus(err)
-
- numFiles, dirSize, errSize := c.getSizeForPath(command.fs, command.fsPath)
- if errSize == nil {
- c.updateQuota(sshDestPath, numFiles-initialFiles, dirSize-initialSize)
- }
- c.connection.Log(logger.LevelDebug, "command %q finished for path %q, initial files %v initial size %v "+
- "current files %v current size %v size err: %v", c.connection.command, command.fsPath, initialFiles, initialSize,
- numFiles, dirSize, errSize)
- return c.connection.GetFsError(command.fs, err)
-}
-
-func (c *sshCommand) isSystemCommandAllowed() error {
- sshDestPath := c.getDestPath()
- if c.connection.User.IsVirtualFolder(sshDestPath) {
- // overlapped virtual path are not allowed
- return nil
- }
- if c.connection.User.HasVirtualFoldersInside(sshDestPath) {
- c.connection.Log(logger.LevelDebug, "command %q is not allowed, path %q has virtual folders inside it, user %q",
- c.command, sshDestPath, c.connection.User.Username)
- return errUnsupportedConfig
- }
- for _, f := range c.connection.User.Filters.FilePatterns {
- if f.Path == sshDestPath {
- c.connection.Log(logger.LevelDebug,
- "command %q is not allowed inside folders with file patterns filters %q user %q",
- c.command, sshDestPath, c.connection.User.Username)
- return errUnsupportedConfig
- }
- if len(sshDestPath) > len(f.Path) {
- if strings.HasPrefix(sshDestPath, f.Path+"/") || f.Path == "/" {
- c.connection.Log(logger.LevelDebug,
- "command %q is not allowed it includes folders with file patterns filters %q user %q",
- c.command, sshDestPath, c.connection.User.Username)
- return errUnsupportedConfig
- }
- }
- if len(sshDestPath) < len(f.Path) {
- if strings.HasPrefix(sshDestPath+"/", f.Path) || sshDestPath == "/" {
- c.connection.Log(logger.LevelDebug,
- "command %q is not allowed inside folder with file patterns filters %q user %q",
- c.command, sshDestPath, c.connection.User.Username)
- return errUnsupportedConfig
- }
- }
- }
- return nil
-}
-
-func (c *sshCommand) getSystemCommand() (systemCommand, error) {
- command := systemCommand{
- cmd: nil,
- fs: nil,
- fsPath: "",
- quotaCheckPath: "",
- }
- if err := common.CheckClosing(); err != nil {
- return command, err
- }
- args := make([]string, len(c.args))
- copy(args, c.args)
- var fsPath, quotaPath string
- sshPath := c.getDestPath()
- fs, err := c.connection.User.GetFilesystemForPath(sshPath, c.connection.ID)
- if err != nil {
- return command, err
- }
- if len(c.args) > 0 {
- var err error
- fsPath, err = fs.ResolvePath(sshPath)
- if err != nil {
- return command, c.connection.GetFsError(fs, err)
- }
- quotaPath = sshPath
- fi, err := fs.Stat(fsPath)
- if err == nil && fi.IsDir() {
- // if the target is an existing dir the command will write inside this dir
- // so we need to check the quota for this directory and not its parent dir
- quotaPath = path.Join(sshPath, "fakecontent")
- }
- if strings.HasSuffix(sshPath, "/") && !strings.HasSuffix(fsPath, string(os.PathSeparator)) {
- fsPath += string(os.PathSeparator)
- c.connection.Log(logger.LevelDebug, "path separator added to fsPath %q", fsPath)
- }
- args = args[:len(args)-1]
- args = append(args, fsPath)
- }
- if err := c.isSystemCommandAllowed(); err != nil {
- return command, errUnsupportedConfig
- }
- if c.command == "rsync" {
- // we cannot avoid that rsync creates symlinks so if the user has the permission
- // to create symlinks we add the option --safe-links to the received rsync command if
- // it is not already set. This should prevent to create symlinks that point outside
- // the home dir.
- // If the user cannot create symlinks we add the option --munge-links, if it is not
- // already set. This should make symlinks unusable (but manually recoverable)
- if c.connection.User.HasPerm(dataprovider.PermCreateSymlinks, c.getDestPath()) {
- if !util.Contains(args, "--safe-links") {
- args = append([]string{"--safe-links"}, args...)
- }
- } else {
- if !util.Contains(args, "--munge-links") {
- args = append([]string{"--munge-links"}, args...)
- }
- }
- }
- c.connection.Log(logger.LevelDebug, "new system command %q, with args: %+v fs path %q quota check path %q",
- c.command, args, fsPath, quotaPath)
- cmd := exec.Command(c.command, args...)
- uid := c.connection.User.GetUID()
- gid := c.connection.User.GetGID()
- cmd = wrapCmd(cmd, uid, gid)
- command.cmd = cmd
- command.fsPath = fsPath
- command.quotaCheckPath = quotaPath
- command.fs = fs
- return command, nil
-}
-
// for the supported commands, the destination path, if any, is the last argument
func (c *sshCommand) getDestPath() string {
if len(c.args) == 0 {
@@ -492,37 +236,6 @@ func (c *sshCommand) getRemovePath() (string, error) {
return sshDestPath, nil
}
-func (c *sshCommand) isLocalPath(virtualPath string) bool {
- folder, err := c.connection.User.GetVirtualFolderForPath(virtualPath)
- if err != nil {
- return c.connection.User.FsConfig.Provider == sdk.LocalFilesystemProvider
- }
- return folder.FsConfig.Provider == sdk.LocalFilesystemProvider
-}
-
-func (c *sshCommand) getSizeForPath(fs vfs.Fs, name string) (int, int64, error) {
- if dataprovider.GetQuotaTracking() > 0 {
- fi, err := fs.Lstat(name)
- if err != nil {
- if fs.IsNotExist(err) {
- return 0, 0, nil
- }
- c.connection.Log(logger.LevelDebug, "unable to stat %q error: %v", name, err)
- return 0, 0, err
- }
- if fi.IsDir() {
- files, size, err := fs.GetDirSize(name)
- if err != nil {
- c.connection.Log(logger.LevelDebug, "unable to get size for dir %q error: %v", name, err)
- }
- return files, size, err
- } else if fi.Mode().IsRegular() {
- return 1, fi.Size(), nil
- }
- }
- return 0, 0, nil
-}
-
func (c *sshCommand) sendErrorResponse(err error) error {
errorString := fmt.Sprintf("%v: %v %v\n", c.command, c.getDestPath(), err)
c.connection.channel.Write([]byte(errorString)) //nolint:errcheck
diff --git a/internal/sftpd/subsystem.go b/internal/sftpd/subsystem.go
deleted file mode 100644
index cda0ecdc..00000000
--- a/internal/sftpd/subsystem.go
+++ /dev/null
@@ -1,87 +0,0 @@
-// Copyright (C) 2019 Nicola Murino
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published
-// by the Free Software Foundation, version 3.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package sftpd
-
-import (
- "io"
- "net"
-
- "github.com/pkg/sftp"
-
- "github.com/drakkan/sftpgo/v2/internal/common"
- "github.com/drakkan/sftpgo/v2/internal/dataprovider"
- "github.com/drakkan/sftpgo/v2/internal/logger"
-)
-
-type subsystemChannel struct {
- reader io.Reader
- writer io.Writer
-}
-
-func (s *subsystemChannel) Read(p []byte) (int, error) {
- return s.reader.Read(p)
-}
-
-func (s *subsystemChannel) Write(p []byte) (int, error) {
- return s.writer.Write(p)
-}
-
-func (s *subsystemChannel) Close() error {
- return nil
-}
-
-func newSubsystemChannel(reader io.Reader, writer io.Writer) *subsystemChannel {
- return &subsystemChannel{
- reader: reader,
- writer: writer,
- }
-}
-
-// ServeSubSystemConnection handles a connection as SSH subsystem
-func ServeSubSystemConnection(user *dataprovider.User, connectionID string, reader io.Reader, writer io.Writer) error {
- err := user.CheckFsRoot(connectionID)
- if err != nil {
- errClose := user.CloseFs()
- logger.Warn(logSender, connectionID, "unable to check fs root: %v close fs error: %v", err, errClose)
- return err
- }
-
- connection := &Connection{
- BaseConnection: common.NewBaseConnection(connectionID, common.ProtocolSFTP, "", "", *user),
- ClientVersion: "",
- RemoteAddr: &net.IPAddr{},
- LocalAddr: &net.IPAddr{},
- channel: newSubsystemChannel(reader, writer),
- }
- err = common.Connections.Add(connection)
- if err != nil {
- errClose := user.CloseFs()
- logger.Warn(logSender, connectionID, "unable to add connection: %v close fs error: %v", err, errClose)
- return err
- }
- defer common.Connections.Remove(connection.GetID())
-
- dataprovider.UpdateLastLogin(user)
- sftp.SetSFTPExtensions(sftpExtensions...) //nolint:errcheck
- server := sftp.NewRequestServer(connection.channel, sftp.Handlers{
- FileGet: connection,
- FilePut: connection,
- FileCmd: connection,
- FileList: connection,
- }, sftp.WithRSAllocator())
-
- defer server.Close()
- return server.Serve()
-}
diff --git a/internal/sftpd/transfer.go b/internal/sftpd/transfer.go
index d3acc202..465ad8c3 100644
--- a/internal/sftpd/transfer.go
+++ b/internal/sftpd/transfer.go
@@ -19,7 +19,6 @@ import (
"io"
"github.com/drakkan/sftpgo/v2/internal/common"
- "github.com/drakkan/sftpgo/v2/internal/metric"
"github.com/drakkan/sftpgo/v2/internal/vfs"
)
@@ -177,7 +176,7 @@ func (t *transfer) closeIO() error {
} else if t.readerAt != nil {
err = t.readerAt.Close()
if metadater, ok := t.readerAt.(vfs.Metadater); ok {
- t.BaseTransfer.SetMetadata(metadater.Metadata())
+ t.SetMetadata(metadater.Metadata())
}
}
return err
@@ -192,63 +191,3 @@ func (t *transfer) setFinished() error {
t.isFinished = true
return nil
}
-
-// used for ssh commands.
-// It reads from src until EOF so it does not treat an EOF from Read as an error to be reported.
-// EOF from Write is reported as error
-func (t *transfer) copyFromReaderToWriter(dst io.Writer, src io.Reader) (int64, error) {
- defer t.Connection.RemoveTransfer(t)
-
- var written int64
- var err error
-
- if t.MaxWriteSize < 0 {
- return 0, common.ErrQuotaExceeded
- }
- isDownload := t.GetType() == common.TransferDownload
- buf := make([]byte, 32768)
- for {
- t.Connection.UpdateLastActivity()
- nr, er := src.Read(buf)
- if nr > 0 {
- nw, ew := dst.Write(buf[0:nr])
- if nw > 0 {
- written += int64(nw)
- if isDownload {
- t.BytesSent.Store(written)
- if errCheck := t.CheckRead(); errCheck != nil {
- err = errCheck
- break
- }
- } else {
- t.BytesReceived.Store(written)
- if errCheck := t.CheckWrite(); errCheck != nil {
- err = errCheck
- break
- }
- }
- }
- if ew != nil {
- err = ew
- break
- }
- if nr != nw {
- err = io.ErrShortWrite
- break
- }
- }
- if er != nil {
- if er != io.EOF {
- err = er
- }
- break
- }
- t.HandleThrottle()
- }
- t.ErrTransfer = err
- if written > 0 || err != nil {
- metric.TransferCompleted(t.BytesSent.Load(), t.BytesReceived.Load(), t.GetType(),
- t.ErrTransfer, vfs.IsSFTPFs(t.Fs))
- }
- return written, err
-}
diff --git a/internal/smtp/oauth2.go b/internal/smtp/oauth2.go
index 5fea89f5..87997947 100644
--- a/internal/smtp/oauth2.go
+++ b/internal/smtp/oauth2.go
@@ -19,6 +19,7 @@ import (
"context"
"errors"
"fmt"
+ "slices"
"sync"
"time"
@@ -27,7 +28,6 @@ import (
"golang.org/x/oauth2/microsoft"
"github.com/drakkan/sftpgo/v2/internal/logger"
- "github.com/drakkan/sftpgo/v2/internal/util"
)
// Supported OAuth2 providers
@@ -56,7 +56,7 @@ type OAuth2Config struct {
// Validate validates and initializes the configuration
func (c *OAuth2Config) Validate() error {
- if !util.Contains(supportedOAuth2Providers, c.Provider) {
+ if !slices.Contains(supportedOAuth2Providers, c.Provider) {
return fmt.Errorf("smtp oauth2: unsupported provider %d", c.Provider)
}
if c.ClientID == "" {
diff --git a/internal/smtp/smtp.go b/internal/smtp/smtp.go
index 308721bb..94984249 100644
--- a/internal/smtp/smtp.go
+++ b/internal/smtp/smtp.go
@@ -279,15 +279,15 @@ func (c *Config) Initialize(configDir string, isService bool) error {
}
func (c *Config) getMailClientOptions() []mail.Option {
- options := []mail.Option{mail.WithoutNoop()}
+ options := []mail.Option{mail.WithPort(c.Port), mail.WithoutNoop()}
switch c.Encryption {
case 1:
- options = append(options, mail.WithSSLPort(false))
+ options = append(options, mail.WithSSL())
case 2:
- options = append(options, mail.WithTLSPortPolicy(mail.TLSMandatory))
+ options = append(options, mail.WithTLSPolicy(mail.TLSMandatory))
default:
- options = append(options, mail.WithTLSPortPolicy(mail.NoTLS))
+ options = append(options, mail.WithTLSPolicy(mail.NoTLS))
}
if c.User != "" {
options = append(options, mail.WithUsername(c.User))
@@ -317,14 +317,13 @@ func (c *Config) getMailClientOptions() []mail.Option {
}),
mail.WithDebugLog())
}
- options = append(options, mail.WithPort(c.Port))
return options
}
func (c *Config) getSMTPClientAndMsg(to, bcc []string, subject, body string, contentType EmailContentType,
attachments ...*mail.File) (*mail.Client, *mail.Msg, error) {
msg := mail.NewMsg()
- msg.SetUserAgent(version.GetServerVersion(" ", true))
+ msg.SetUserAgent(version.GetServerVersion(" ", false))
var from string
if c.From != "" {
@@ -346,7 +345,7 @@ func (c *Config) getSMTPClientAndMsg(to, bcc []string, subject, body string, con
msg.Subject(subject)
msg.SetDate()
msg.SetMessageID()
- msg.SetAttachements(attachments)
+ msg.SetAttachments(attachments)
switch contentType {
case EmailContentTypeTextPlain:
@@ -416,12 +415,6 @@ func SendEmail(to, bcc []string, subject, body string, contentType EmailContentT
return config.sendEmail(to, bcc, subject, body, contentType, attachments...)
}
-// ReloadProviderConf reloads the configuration from the provider
-// and apply it if different from the active one
-func ReloadProviderConf() {
- loadConfigFromProvider() //nolint:errcheck
-}
-
func loadConfigFromProvider() error {
configs, err := dataprovider.GetConfigs()
if err != nil {
diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go
index dad7e869..233636aa 100644
--- a/internal/telemetry/telemetry.go
+++ b/internal/telemetry/telemetry.go
@@ -135,9 +135,9 @@ func (c Conf) Initialize(configDir string) error {
}
logger.Debug(logSender, "", "configured TLS cipher suites: %v", config.CipherSuites)
httpServer.TLSConfig = config
- return util.HTTPListenAndServe(httpServer, c.BindAddress, c.BindPort, true, logSender)
+ return util.HTTPListenAndServe(httpServer, c.BindAddress, c.BindPort, true, nil, logSender)
}
- return util.HTTPListenAndServe(httpServer, c.BindAddress, c.BindPort, false, logSender)
+ return util.HTTPListenAndServe(httpServer, c.BindAddress, c.BindPort, false, nil, logSender)
}
// ReloadCertificateMgr reloads the certificate manager
diff --git a/internal/util/errors.go b/internal/util/errors.go
index c2bdfbce..79e54265 100644
--- a/internal/util/errors.go
+++ b/internal/util/errors.go
@@ -59,9 +59,9 @@ func (e *ValidationError) Is(target error) bool {
}
// NewValidationError returns a validation errors
-func NewValidationError(error string) *ValidationError {
+func NewValidationError(errorString string) *ValidationError {
return &ValidationError{
- err: error,
+ err: errorString,
}
}
@@ -81,9 +81,9 @@ func (e *RecordNotFoundError) Is(target error) bool {
}
// NewRecordNotFoundError returns a not found error
-func NewRecordNotFoundError(error string) *RecordNotFoundError {
+func NewRecordNotFoundError(errorString string) *RecordNotFoundError {
return &RecordNotFoundError{
- err: error,
+ err: errorString,
}
}
@@ -106,9 +106,9 @@ func (e *MethodDisabledError) Is(target error) bool {
}
// NewMethodDisabledError returns a method disabled error
-func NewMethodDisabledError(error string) *MethodDisabledError {
+func NewMethodDisabledError(errorString string) *MethodDisabledError {
return &MethodDisabledError{
- err: error,
+ err: errorString,
}
}
@@ -128,8 +128,8 @@ func (e *GenericError) Is(target error) bool {
}
// NewGenericError returns a generic error
-func NewGenericError(error string) *GenericError {
+func NewGenericError(errorString string) *GenericError {
return &GenericError{
- err: error,
+ err: errorString,
}
}
diff --git a/internal/util/i18n.go b/internal/util/i18n.go
index fd8dba70..2b318310 100644
--- a/internal/util/i18n.go
+++ b/internal/util/i18n.go
@@ -114,6 +114,7 @@ const (
I18nErrorConnectionForbidden = "general.connection_forbidden"
I18nErrorReservedUsername = "user.username_reserved"
I18nErrorInvalidEmail = "general.email_invalid"
+ I18nErrorInvalidInput = "general.invalid_input"
I18nErrorInvalidUser = "user.username_invalid"
I18nErrorInvalidName = "general.name_invalid"
I18nErrorHomeRequired = "user.home_required"
@@ -121,6 +122,7 @@ const (
I18nErrorPubKeyInvalid = "user.pub_key_invalid"
I18nErrorPrivKeyInvalid = "user.priv_key_invalid"
I18nErrorKeySizeInvalid = "user.key_invalid_size"
+ I18nErrorKeyInsecure = "user.key_insecure"
I18nErrorPrimaryGroup = "user.err_primary_group"
I18nErrorDuplicateGroup = "user.err_duplicate_group"
I18nErrorNoPermission = "user.no_permissions"
@@ -240,9 +242,9 @@ const (
I18nErrorRestore = "maintenance.restore_error"
I18nErrorACMEGeneric = "acme.generic_error"
I18nErrorSMTPRequiredFields = "smtp.err_required_fields"
- I18nErrorSMTPClientIDRequired = "smtp.client_id_required"
- I18nErrorSMTPClientSecretRequired = "smtp.client_secret_required"
- I18nErrorSMTPRefreshTokenRequired = "smtp.refresh_token_required"
+ I18nErrorClientIDRequired = "oauth2.client_id_required"
+ I18nErrorClientSecretRequired = "oauth2.client_secret_required"
+ I18nErrorRefreshTokenRequired = "oauth2.refresh_token_required"
I18nErrorURLRequired = "actions.http_url_required"
I18nErrorURLInvalid = "actions.http_url_invalid"
I18nErrorHTTPPartNameRequired = "actions.http_part_name_required"
@@ -274,6 +276,7 @@ const (
I18nActionTypeUserInactivityCheck = "actions.types.user_inactivity_check"
I18nActionTypeIDPCheck = "actions.types.idp_check"
I18nActionTypeCommand = "actions.types.command"
+ I18nActionTypeRotateLogs = "actions.types.rotate_logs"
I18nActionFsTypeRename = "actions.fs_types.rename"
I18nActionFsTypeDelete = "actions.fs_types.delete"
I18nActionFsTypePathExists = "actions.fs_types.path_exists"
@@ -302,6 +305,9 @@ const (
I18nErrorEvSyncUnsupportedFs = "rules.sync_unsupported_fs_event"
I18nErrorRuleFailureActionsOnly = "rules.only_failure_actions"
I18nErrorRuleSyncActionRequired = "rules.sync_action_required"
+ I18nErrorInvalidPNG = "branding.invalid_png"
+ I18nErrorInvalidPNGSize = "branding.invalid_png_size"
+ I18nErrorInvalidDisclaimerURL = "branding.invalid_disclaimer_url"
)
// NewI18nError returns a I18nError wrappring the provided error
diff --git a/internal/util/resources.go b/internal/util/resources.go
index 9d1a360c..8cddd946 100644
--- a/internal/util/resources.go
+++ b/internal/util/resources.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build !bundle
-// +build !bundle
package util
diff --git a/internal/util/resources_embedded.go b/internal/util/resources_embedded.go
index 0512cc70..685eaf72 100644
--- a/internal/util/resources_embedded.go
+++ b/internal/util/resources_embedded.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build bundle
-// +build bundle
package util
diff --git a/internal/util/timeoutlistener.go b/internal/util/timeoutlistener.go
deleted file mode 100644
index 643f7799..00000000
--- a/internal/util/timeoutlistener.go
+++ /dev/null
@@ -1,100 +0,0 @@
-// Copyright (C) 2019 Nicola Murino
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published
-// by the Free Software Foundation, version 3.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package util
-
-import (
- "net"
- "sync/atomic"
- "time"
-)
-
-type listener struct {
- net.Listener
- ReadTimeout time.Duration
- WriteTimeout time.Duration
-}
-
-func (l *listener) Accept() (net.Conn, error) {
- c, err := l.Listener.Accept()
- if err != nil {
- return nil, err
- }
- tc := &Conn{
- Conn: c,
- ReadTimeout: l.ReadTimeout,
- WriteTimeout: l.WriteTimeout,
- ReadThreshold: int32((l.ReadTimeout * 1024) / time.Second),
- WriteThreshold: int32((l.WriteTimeout * 1024) / time.Second),
- }
- tc.BytesReadFromDeadline.Store(0)
- tc.BytesWrittenFromDeadline.Store(0)
- return tc, nil
-}
-
-// Conn wraps a net.Conn, and sets a deadline for every read
-// and write operation.
-type Conn struct {
- net.Conn
- ReadTimeout time.Duration
- WriteTimeout time.Duration
- ReadThreshold int32
- WriteThreshold int32
- BytesReadFromDeadline atomic.Int32
- BytesWrittenFromDeadline atomic.Int32
-}
-
-func (c *Conn) Read(b []byte) (n int, err error) {
- if c.BytesReadFromDeadline.Load() > c.ReadThreshold {
- c.BytesReadFromDeadline.Store(0)
- // we set both read and write deadlines here otherwise after the request
- // is read writing the response fails with an i/o timeout error
- err = c.Conn.SetDeadline(time.Now().Add(c.ReadTimeout))
- if err != nil {
- return 0, err
- }
- }
- n, err = c.Conn.Read(b)
- c.BytesReadFromDeadline.Add(int32(n))
- return
-}
-
-func (c *Conn) Write(b []byte) (n int, err error) {
- if c.BytesWrittenFromDeadline.Load() > c.WriteThreshold {
- c.BytesWrittenFromDeadline.Store(0)
- // we extend the read deadline too, not sure it's necessary,
- // but it doesn't hurt
- err = c.Conn.SetDeadline(time.Now().Add(c.WriteTimeout))
- if err != nil {
- return
- }
- }
- n, err = c.Conn.Write(b)
- c.BytesWrittenFromDeadline.Add(int32(n))
- return
-}
-
-func newListener(network, addr string, readTimeout, writeTimeout time.Duration) (net.Listener, error) {
- l, err := net.Listen(network, addr)
- if err != nil {
- return nil, err
- }
-
- tl := &listener{
- Listener: l,
- ReadTimeout: readTimeout,
- WriteTimeout: writeTimeout,
- }
- return tl, nil
-}
diff --git a/internal/util/util.go b/internal/util/util.go
index 3014db55..82cbbba2 100644
--- a/internal/util/util.go
+++ b/internal/util/util.go
@@ -22,12 +22,16 @@ import (
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
+ "crypto/sha256"
+ "crypto/subtle"
"crypto/tls"
"crypto/x509"
+ "encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
+ "hash"
"io"
"io/fs"
"math"
@@ -40,6 +44,7 @@ import (
"path/filepath"
"regexp"
"runtime"
+ "slices"
"strconv"
"strings"
"time"
@@ -47,8 +52,7 @@ import (
"unsafe"
"github.com/google/uuid"
- "github.com/lithammer/shortuuid/v3"
- "github.com/rs/xid"
+ "github.com/lithammer/shortuuid/v4"
"golang.org/x/crypto/ssh"
"github.com/drakkan/sftpgo/v2/internal/logger"
@@ -128,28 +132,6 @@ var bytesSizeTable = map[string]uint64{
"e": eByte,
}
-// Contains reports whether v is present in elems.
-func Contains[T comparable](elems []T, v T) bool {
- for _, s := range elems {
- if v == s {
- return true
- }
- }
- return false
-}
-
-// Remove removes an element from a string slice and
-// returns the modified slice
-func Remove(elems []string, val string) []string {
- for idx, v := range elems {
- if v == val {
- elems[idx] = elems[len(elems)-1]
- return elems[:len(elems)-1]
- }
- }
- return elems
-}
-
// IsStringPrefixInSlice searches a string prefix in a slice and returns true
// if a matching prefix is found
func IsStringPrefixInSlice(obj string, list []string) bool {
@@ -181,6 +163,48 @@ func RemoveDuplicates(obj []string, trim bool) []string {
return obj[:validIdx]
}
+// IsNameValid validates that a name/username contains only safe characters.
+func IsNameValid(name string) bool {
+ if name == "" {
+ return false
+ }
+ if len(name) > 255 {
+ return false
+ }
+ for _, r := range name {
+ if unicode.IsControl(r) {
+ return false
+ }
+
+ switch r {
+ case '/', '\\':
+ return false
+ case ':', '*', '?', '"', '<', '>', '|':
+ return false
+ }
+ }
+
+ if name == "." || name == ".." {
+ return false
+ }
+
+ upperName := strings.ToUpper(name)
+ baseName := strings.Split(upperName, ".")[0]
+
+ switch baseName {
+ case "CON", "PRN", "AUX", "NUL",
+ "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
+ "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
+ return false
+ }
+
+ if strings.HasSuffix(name, " ") || strings.HasSuffix(name, ".") {
+ return false
+ }
+
+ return true
+}
+
// GetTimeAsMsSinceEpoch returns unix timestamp as milliseconds from a time struct
func GetTimeAsMsSinceEpoch(t time.Time) int64 {
return t.UnixMilli()
@@ -255,7 +279,7 @@ func ParseBytes(s string) (int64, error) {
lastDigit := 0
hasComma := false
for _, r := range s {
- if !(unicode.IsDigit(r) || r == '.' || r == ',') {
+ if !unicode.IsDigit(r) && r != '.' && r != ',' {
break
}
if r == ',' {
@@ -266,7 +290,7 @@ func ParseBytes(s string) (int64, error) {
num := s[:lastDigit]
if hasComma {
- num = strings.Replace(num, ",", "", -1)
+ num = strings.ReplaceAll(num, ",", "")
}
f, err := strconv.ParseFloat(num, 64)
@@ -380,7 +404,7 @@ func GenerateRSAKeys(file string) error {
if err := createDirPathIfMissing(file, 0700); err != nil {
return err
}
- key, err := rsa.GenerateKey(rand.Reader, 4096)
+ key, err := rsa.GenerateKey(rand.Reader, 3072)
if err != nil {
return err
}
@@ -509,10 +533,7 @@ func GetDirsForVirtualPath(virtualPath string) []string {
}
}
dirsForPath := []string{virtualPath}
- for {
- if virtualPath == "/" {
- break
- }
+ for virtualPath != "/" {
virtualPath = path.Dir(virtualPath)
dirsForPath = append(dirsForPath, virtualPath)
}
@@ -527,7 +548,7 @@ func CleanPath(p string) string {
// CleanPathWithBase returns a clean POSIX (/) absolute path to work with.
// The specified base will be used if the provided path is not absolute
func CleanPathWithBase(base, p string) string {
- p = filepath.ToSlash(p)
+ p = strings.ReplaceAll(p, "\\", "/")
if !path.IsAbs(p) {
p = path.Join(base, p)
}
@@ -572,34 +593,37 @@ func createDirPathIfMissing(file string, perm os.FileMode) error {
return nil
}
-// GenerateRandomBytes generates the secret to use for JWT auth
+// GenerateRandomBytes generates random bytes with the specified length
func GenerateRandomBytes(length int) []byte {
b := make([]byte, length)
_, err := io.ReadFull(rand.Reader, b)
- if err == nil {
- return b
+ if err != nil {
+ PanicOnError(fmt.Errorf("failed to read random data (see https://go.dev/issue/66821): %w", err))
}
-
- b = xid.New().Bytes()
- for len(b) < length {
- b = append(b, xid.New().Bytes()...)
- }
-
- return b[:length]
+ return b
}
-// GenerateUniqueID retuens an unique ID
+// GenerateOpaqueString generates a cryptographically secure opaque string
+func GenerateOpaqueString() string {
+ randomBytes := sha256.Sum256(GenerateRandomBytes(32))
+ return hex.EncodeToString(randomBytes[:])
+}
+
+// GenerateUniqueID returns an unique ID
func GenerateUniqueID() string {
u, err := uuid.NewRandom()
if err != nil {
- return xid.New().String()
+ PanicOnError(fmt.Errorf("failed to read random data (see https://go.dev/issue/66821): %w", err))
}
return shortuuid.DefaultEncoder.Encode(u)
}
// HTTPListenAndServe is a wrapper for ListenAndServe that support both tcp
// and Unix-domain sockets
-func HTTPListenAndServe(srv *http.Server, address string, port int, isTLS bool, logSender string) error {
+func HTTPListenAndServe(srv *http.Server, address string, port int, isTLS bool,
+ listenerWrapper func(net.Listener) (net.Listener, error),
+ logSender string,
+) error {
var listener net.Listener
var err error
@@ -613,7 +637,7 @@ func HTTPListenAndServe(srv *http.Server, address string, port int, isTLS bool,
logger.Error(logSender, "", "error creating Unix-domain socket parent dir: %v", err)
}
os.Remove(address)
- listener, err = newListener("unix", address, srv.ReadTimeout, srv.WriteTimeout)
+ listener, err = net.Listen("unix", address)
if err == nil {
// should a chmod err be fatal?
if errChmod := os.Chmod(address, 0770); errChmod != nil {
@@ -622,12 +646,17 @@ func HTTPListenAndServe(srv *http.Server, address string, port int, isTLS bool,
}
} else {
CheckTCP4Port(port)
- listener, err = newListener("tcp", fmt.Sprintf("%s:%d", address, port), srv.ReadTimeout, srv.WriteTimeout)
+ listener, err = net.Listen("tcp", fmt.Sprintf("%s:%d", address, port))
}
if err != nil {
return err
}
-
+ if listenerWrapper != nil {
+ listener, err = listenerWrapper(listener)
+ if err != nil {
+ return err
+ }
+ }
logger.Info(logSender, "", "server listener registered, address: %s TLS enabled: %t", listener.Addr().String(), isTLS)
defer listener.Close()
@@ -648,6 +677,11 @@ func GetTLSCiphersFromNames(cipherNames []string) []uint16 {
ciphers = append(ciphers, c.ID)
}
}
+ for _, c := range tls.InsecureCipherSuites() {
+ if c.Name == strings.TrimSpace(name) {
+ ciphers = append(ciphers, c.ID)
+ }
+ }
}
if len(ciphers) == 0 {
@@ -741,7 +775,7 @@ func GetRealIP(r *http.Request, header string, depth int) string {
var ipAddresses []string
for _, h := range r.Header.Values(header) {
- for _, ipStr := range strings.Split(h, ",") {
+ for ipStr := range strings.SplitSeq(h, ",") {
ipStr = strings.TrimSpace(ipStr)
ipAddresses = append(ipAddresses, ipStr)
}
@@ -809,16 +843,9 @@ func GetRedactedURL(rawurl string) string {
return u.Redacted()
}
-// PrependFileInfo prepends a file info to a slice in an efficient way.
-// We, optimistically, assume that the slice has enough capacity
-func PrependFileInfo(files []os.FileInfo, info os.FileInfo) []os.FileInfo {
- files = append(files, nil)
- copy(files[1:], files)
- files[0] = info
- return files
-}
-
-// GetTLSVersion returns the TLS version for integer:
+// GetTLSVersion returns the TLS version from an integer value:
+// - 10 means TLS 1.0
+// - 11 means TLS 1.1
// - 12 means TLS 1.2
// - 13 means TLS 1.3
// default is TLS 1.2
@@ -826,6 +853,10 @@ func GetTLSVersion(val int) uint16 {
switch val {
case 13:
return tls.VersionTLS13
+ case 11:
+ return tls.VersionTLS11
+ case 10:
+ return tls.VersionTLS10
default:
return tls.VersionTLS12
}
@@ -928,3 +959,55 @@ func ReadConfigFromFile(name, configDir string) (string, error) {
}
return strings.TrimSpace(BytesToString(val)), nil
}
+
+// SlicesEqual checks if the provided slices contain the same elements,
+// also in different order.
+func SlicesEqual(s1, s2 []string) bool {
+ if len(s1) != len(s2) {
+ return false
+ }
+ for _, v := range s1 {
+ if !slices.Contains(s2, v) {
+ return false
+ }
+ }
+
+ return true
+}
+
+// VerifyFileChecksum computes the hash of the given file using the provided
+// hash algorithm and compares it against the expected checksum (in hex format).
+// It returns an error if the checksum does not match or if the operation fails.
+func VerifyFileChecksum(filePath string, h hash.Hash, expectedHex string, maxSize int64) error {
+ expected, err := hex.DecodeString(expectedHex)
+ if err != nil {
+ return fmt.Errorf("invalid checksum %q: %w", expectedHex, err)
+ }
+
+ f, err := os.Open(filePath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ if maxSize > 0 {
+ fi, err := f.Stat()
+ if err != nil {
+ return err
+ }
+ if fi.Size() > maxSize {
+ return fmt.Errorf("file too large: %s", ByteCountIEC(fi.Size()))
+ }
+ }
+
+ if _, err := io.Copy(h, f); err != nil {
+ return err
+ }
+
+ actual := h.Sum(nil)
+ if subtle.ConstantTimeCompare(actual, expected) != 1 {
+ return errors.New("checksum mismatch")
+ }
+
+ return nil
+}
diff --git a/internal/version/version.go b/internal/version/version.go
index d0c46d01..c9e066ea 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -18,7 +18,7 @@ package version
import "strings"
const (
- version = "2.6.0"
+ version = "2.7.99-dev"
appName = "SFTPGo"
)
diff --git a/internal/vfs/azblobfs.go b/internal/vfs/azblobfs.go
index 067d612c..46b7ae78 100644
--- a/internal/vfs/azblobfs.go
+++ b/internal/vfs/azblobfs.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build !noazblob
-// +build !noazblob
package vfs
@@ -39,10 +38,10 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
+ "github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blockblob"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
- "github.com/eikenb/pipeat"
"github.com/google/uuid"
"github.com/pkg/sftp"
@@ -104,10 +103,6 @@ func NewAzBlobFs(connectionID, localTempDir, mountPath string, config AzBlobFsCo
return fs.initFromSASURL()
}
- credential, err := blob.NewSharedKeyCredential(fs.config.AccountName, fs.config.AccountKey.GetPayload())
- if err != nil {
- return fs, fmt.Errorf("invalid credentials: %v", err)
- }
var endpoint string
if fs.config.UseEmulator {
endpoint = fmt.Sprintf("%s/%s", fs.config.Endpoint, fs.config.AccountName)
@@ -115,9 +110,25 @@ func NewAzBlobFs(connectionID, localTempDir, mountPath string, config AzBlobFsCo
endpoint = fmt.Sprintf("https://%s.%s/", fs.config.AccountName, fs.config.Endpoint)
}
containerURL := runtime.JoinPaths(endpoint, fs.config.Container)
- svc, err := container.NewClientWithSharedKeyCredential(containerURL, credential, getAzContainerClientOptions())
+ if fs.config.AccountKey.GetPayload() != "" {
+ credential, err := blob.NewSharedKeyCredential(fs.config.AccountName, fs.config.AccountKey.GetPayload())
+ if err != nil {
+ return fs, fmt.Errorf("invalid credentials: %v", err)
+ }
+ svc, err := container.NewClientWithSharedKeyCredential(containerURL, credential, getAzContainerClientOptions())
+ if err != nil {
+ return fs, fmt.Errorf("unable to create the storage client using shared key credentials: %v", err)
+ }
+ fs.containerClient = svc
+ return fs, err
+ }
+ credential, err := azidentity.NewDefaultAzureCredential(nil)
if err != nil {
- return fs, fmt.Errorf("invalid credentials: %v", err)
+ return fs, fmt.Errorf("invalid default azure credentials: %v", err)
+ }
+ svc, err := container.NewClient(containerURL, credential, getAzContainerClientOptions())
+ if err != nil {
+ return fs, fmt.Errorf("unable to create the storage client using azure credentials: %v", err)
}
fs.containerClient = svc
return fs, err
@@ -186,7 +197,11 @@ func (fs *AzureBlobFs) Stat(name string) (os.FileInfo, error) {
if val := getAzureLastModified(attrs.Metadata); val > 0 {
lastModified = util.GetTimeFromMsecSinceEpoch(val)
}
- return NewFileInfo(name, isDir, util.GetIntFromPointer(attrs.ContentLength), lastModified, false), nil
+ info := NewFileInfo(name, isDir, util.GetIntFromPointer(attrs.ContentLength), lastModified, false)
+ if !isDir {
+ info.setMetadataFromPointerVal(attrs.Metadata)
+ }
+ return info, nil
}
if !fs.IsNotExist(err) {
return nil, err
@@ -209,7 +224,7 @@ func (fs *AzureBlobFs) Lstat(name string) (os.FileInfo, error) {
// Open opens the named file for reading
func (fs *AzureBlobFs) Open(name string, offset int64) (File, PipeReader, func(), error) {
- r, w, err := pipeat.PipeInDir(fs.localTempDir)
+ r, w, err := createPipeFn(fs.localTempDir, fs.config.DownloadPartSize*int64(fs.config.DownloadConcurrency)+1)
if err != nil {
return nil, nil, nil, err
}
@@ -237,7 +252,7 @@ func (fs *AzureBlobFs) Create(name string, flag, checks int) (File, PipeWriter,
return nil, nil, nil, err
}
}
- r, w, err := pipeat.PipeInDir(fs.localTempDir)
+ r, w, err := createPipeFn(fs.localTempDir, fs.config.UploadPartSize+1024*1024)
if err != nil {
return nil, nil, nil, err
}
@@ -302,19 +317,21 @@ func (fs *AzureBlobFs) Create(name string, flag, checks int) (File, PipeWriter,
}
// Rename renames (moves) source to target.
-func (fs *AzureBlobFs) Rename(source, target string) (int, int64, error) {
+func (fs *AzureBlobFs) Rename(source, target string, checks int) (int, int64, error) {
if source == target {
return -1, -1, nil
}
- _, err := fs.Stat(path.Dir(target))
- if err != nil {
- return -1, -1, err
+ if checks&CheckParentDir != 0 {
+ _, err := fs.Stat(path.Dir(target))
+ if err != nil {
+ return -1, -1, err
+ }
}
fi, err := fs.Stat(source)
if err != nil {
return -1, -1, err
}
- return fs.renameInternal(source, target, fi, 0)
+ return fs.renameInternal(source, target, fi, 0, checks&CheckUpdateModTime != 0)
}
// Remove removes the named file or (empty) directory.
@@ -394,7 +411,17 @@ func (fs *AzureBlobFs) Chtimes(name string, _, mtime time.Time, isUploading bool
if metadata == nil {
metadata = make(map[string]*string)
}
- metadata[lastModifiedField] = to.Ptr(strconv.FormatInt(mtime.UnixMilli(), 10))
+ found := false
+ for k := range metadata {
+ if strings.EqualFold(k, lastModifiedField) {
+ metadata[k] = to.Ptr(strconv.FormatInt(mtime.UnixMilli(), 10))
+ found = true
+ break
+ }
+ }
+ if !found {
+ metadata[lastModifiedField] = to.Ptr(strconv.FormatInt(mtime.UnixMilli(), 10))
+ }
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
defer cancelFn()
@@ -533,7 +560,7 @@ func (fs *AzureBlobFs) GetDirSize(dirname string) (int, int64, error) {
metric.AZListObjectsCompleted(err)
return numFiles, size, err
}
- for _, blobItem := range resp.ListBlobsFlatSegmentResponse.Segment.BlobItems {
+ for _, blobItem := range resp.Segment.BlobItems {
if blobItem.Properties != nil {
contentType := util.GetStringFromPointer(blobItem.Properties.ContentType)
isDir := checkDirectoryMarkers(contentType, blobItem.Metadata)
@@ -601,7 +628,7 @@ func (fs *AzureBlobFs) Walk(root string, walkFn filepath.WalkFunc) error {
metric.AZListObjectsCompleted(err)
return err
}
- for _, blobItem := range resp.ListBlobsFlatSegmentResponse.Segment.BlobItems {
+ for _, blobItem := range resp.Segment.BlobItems {
name := util.GetStringFromPointer(blobItem.Name)
if fs.isEqual(name, prefix) {
continue
@@ -642,18 +669,18 @@ func (*AzureBlobFs) HasVirtualFolders() bool {
// ResolvePath returns the matching filesystem path for the specified sftp path
func (fs *AzureBlobFs) ResolvePath(virtualPath string) (string, error) {
if fs.mountPath != "" {
- virtualPath = strings.TrimPrefix(virtualPath, fs.mountPath)
- }
- if !path.IsAbs(virtualPath) {
- virtualPath = path.Clean("/" + virtualPath)
+ if after, found := strings.CutPrefix(virtualPath, fs.mountPath); found {
+ virtualPath = after
+ }
}
+ virtualPath = path.Clean("/" + virtualPath)
return fs.Join(fs.config.KeyPrefix, strings.TrimPrefix(virtualPath, "/")), nil
}
// CopyFile implements the FsFileCopier interface
-func (fs *AzureBlobFs) CopyFile(source, target string, srcSize int64) (int, int64, error) {
+func (fs *AzureBlobFs) CopyFile(source, target string, srcInfo os.FileInfo) (int, int64, error) {
numFiles := 1
- sizeDiff := srcSize
+ sizeDiff := srcInfo.Size()
attrs, err := fs.headObject(target)
if err == nil {
sizeDiff -= util.GetIntFromPointer(attrs.ContentLength)
@@ -663,7 +690,7 @@ func (fs *AzureBlobFs) CopyFile(source, target string, srcSize int64) (int, int6
return 0, 0, err
}
}
- if err := fs.copyFileInternal(source, target); err != nil {
+ if err := fs.copyFileInternal(source, target, srcInfo, true); err != nil {
return 0, 0, err
}
return numFiles, sizeDiff, nil
@@ -746,13 +773,13 @@ func (fs *AzureBlobFs) setConfigDefaults() {
}
}
-func (fs *AzureBlobFs) copyFileInternal(source, target string) error {
+func (fs *AzureBlobFs) copyFileInternal(source, target string, srcInfo os.FileInfo, updateModTime bool) error {
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxLongTimeout))
defer cancelFn()
srcBlob := fs.containerClient.NewBlockBlobClient(source)
dstBlob := fs.containerClient.NewBlockBlobClient(target)
- resp, err := dstBlob.StartCopyFromURL(ctx, srcBlob.URL(), fs.getCopyOptions())
+ resp, err := dstBlob.StartCopyFromURL(ctx, srcBlob.URL(), fs.getCopyOptions(srcInfo, updateModTime))
if err != nil {
metric.AZCopyObjectCompleted(err)
return err
@@ -785,11 +812,13 @@ func (fs *AzureBlobFs) copyFileInternal(source, target string) error {
return nil
}
-func (fs *AzureBlobFs) renameInternal(source, target string, fi os.FileInfo, recursion int) (int, int64, error) {
+func (fs *AzureBlobFs) renameInternal(source, target string, srcInfo os.FileInfo, recursion int,
+ updateModTime bool,
+) (int, int64, error) {
var numFiles int
var filesSize int64
- if fi.IsDir() {
+ if srcInfo.IsDir() {
if renameMode == 0 {
hasContents, err := fs.hasContents(source)
if err != nil {
@@ -803,7 +832,7 @@ func (fs *AzureBlobFs) renameInternal(source, target string, fi os.FileInfo, rec
return numFiles, filesSize, err
}
if renameMode == 1 {
- files, size, err := doRecursiveRename(fs, source, target, fs.renameInternal, recursion)
+ files, size, err := doRecursiveRename(fs, source, target, fs.renameInternal, recursion, updateModTime)
numFiles += files
filesSize += size
if err != nil {
@@ -811,13 +840,13 @@ func (fs *AzureBlobFs) renameInternal(source, target string, fi os.FileInfo, rec
}
}
} else {
- if err := fs.copyFileInternal(source, target); err != nil {
+ if err := fs.copyFileInternal(source, target, srcInfo, updateModTime); err != nil {
return numFiles, filesSize, err
}
numFiles++
- filesSize += fi.Size()
+ filesSize += srcInfo.Size()
}
- err := fs.skipNotExistErr(fs.Remove(source, fi.IsDir()))
+ err := fs.skipNotExistErr(fs.Remove(source, srcInfo.IsDir()))
return numFiles, filesSize, err
}
@@ -856,7 +885,7 @@ func (fs *AzureBlobFs) hasContents(name string) (bool, error) {
return result, err
}
- result = len(resp.ListBlobsFlatSegmentResponse.Segment.BlobItems) > 0
+ result = len(resp.Segment.BlobItems) > 0
}
metric.AZListObjectsCompleted(nil)
@@ -879,15 +908,14 @@ func (fs *AzureBlobFs) downloadPart(ctx context.Context, blockBlob *blockblob.Cl
if err != nil {
return err
}
- defer resp.DownloadResponse.Body.Close()
+ defer resp.Body.Close()
- _, err = io.ReadAtLeast(resp.DownloadResponse.Body, buf, int(count))
+ _, err = io.ReadAtLeast(resp.Body, buf, int(count))
if err != nil {
return err
}
- _, err = fs.writeAtFull(w, buf, writeOffset, int(count))
- return err
+ return writeAtFull(w, buf, writeOffset, int(count))
}
func (fs *AzureBlobFs) handleMultipartDownload(ctx context.Context, blockBlob *blockblob.Client,
@@ -917,6 +945,8 @@ func (fs *AzureBlobFs) handleMultipartDownload(ctx context.Context, blockBlob *b
guard := make(chan struct{}, fs.config.DownloadConcurrency)
blockCtxTimeout := time.Duration(fs.config.DownloadPartSize/(1024*1024)) * time.Minute
pool := newBufferAllocator(int(partSize))
+ defer pool.free()
+
finished := false
var wg sync.WaitGroup
var errOnce sync.Once
@@ -970,7 +1000,6 @@ func (fs *AzureBlobFs) handleMultipartDownload(ctx context.Context, blockBlob *b
wg.Wait()
close(guard)
- pool.free()
return poolError
}
@@ -985,6 +1014,8 @@ func (fs *AzureBlobFs) handleMultipartUpload(ctx context.Context, reader io.Read
// sync.Pool seems to use a lot of memory so prefer our own, very simple, allocator
// we only need to recycle few byte slices
pool := newBufferAllocator(int(partSize))
+ defer pool.free()
+
finished := false
var blocks []string
var wg sync.WaitGroup
@@ -995,10 +1026,17 @@ func (fs *AzureBlobFs) handleMultipartUpload(ctx context.Context, reader io.Read
poolCtx, poolCancel := context.WithCancel(ctx)
defer poolCancel()
+ finalizeFailedUpload := func(err error) {
+ fsLog(fs, logger.LevelDebug, "multipart upload error: %+v", err)
+ hasError.Store(true)
+ poolError = fmt.Errorf("multipart upload error: %w", err)
+ poolCancel()
+ }
+
for part := 0; !finished; part++ {
buf := pool.getBuffer()
- n, err := fs.readFill(reader, buf)
+ n, err := readFill(reader, buf)
if err == io.EOF {
// read finished, if n > 0 we need to process the last data chunck
if n == 0 {
@@ -1008,8 +1046,10 @@ func (fs *AzureBlobFs) handleMultipartUpload(ctx context.Context, reader io.Read
finished = true
} else if err != nil {
pool.releaseBuffer(buf)
- pool.free()
- return err
+ errOnce.Do(func() {
+ finalizeFailedUpload(err)
+ })
+ break
}
// Block IDs are unique values to avoid issue if 2+ clients are uploading blocks
@@ -1017,8 +1057,10 @@ func (fs *AzureBlobFs) handleMultipartUpload(ctx context.Context, reader io.Read
generatedUUID, err := uuid.NewRandom()
if err != nil {
pool.releaseBuffer(buf)
- pool.free()
- return fmt.Errorf("unable to generate block ID: %w", err)
+ errOnce.Do(func() {
+ finalizeFailedUpload(err)
+ })
+ break
}
blockID := base64.StdEncoding.EncodeToString([]byte(generatedUUID.String()))
blocks = append(blocks, blockID)
@@ -1048,9 +1090,7 @@ func (fs *AzureBlobFs) handleMultipartUpload(ctx context.Context, reader io.Read
if err != nil {
errOnce.Do(func() {
fsLog(fs, logger.LevelDebug, "multipart upload error: %+v", err)
- hasError.Store(true)
- poolError = fmt.Errorf("multipart upload error: %w", err)
- poolCancel()
+ finalizeFailedUpload(err)
})
}
}(blockID, buf, n)
@@ -1058,7 +1098,6 @@ func (fs *AzureBlobFs) handleMultipartUpload(ctx context.Context, reader io.Read
wg.Wait()
close(guard)
- pool.free()
if poolError != nil {
return poolError
@@ -1076,33 +1115,27 @@ func (fs *AzureBlobFs) handleMultipartUpload(ctx context.Context, reader io.Read
return err
}
-func (*AzureBlobFs) writeAtFull(w io.WriterAt, buf []byte, offset int64, count int) (int, error) {
- written := 0
- for written < count {
- n, err := w.WriteAt(buf[written:count], offset+int64(written))
- written += n
- if err != nil {
- return written, err
- }
- }
- return written, nil
-}
-
-// copied from rclone
-func (*AzureBlobFs) readFill(r io.Reader, buf []byte) (n int, err error) {
- var nn int
- for n < len(buf) && err == nil {
- nn, err = r.Read(buf[n:])
- n += nn
- }
- return n, err
-}
-
-func (fs *AzureBlobFs) getCopyOptions() *blob.StartCopyFromURLOptions {
+func (fs *AzureBlobFs) getCopyOptions(srcInfo os.FileInfo, updateModTime bool) *blob.StartCopyFromURLOptions {
copyOptions := &blob.StartCopyFromURLOptions{}
if fs.config.AccessTier != "" {
copyOptions.Tier = (*blob.AccessTier)(&fs.config.AccessTier)
}
+ if updateModTime {
+ metadata := make(map[string]*string)
+ for k, v := range getMetadata(srcInfo) {
+ if v != "" {
+ if strings.EqualFold(k, lastModifiedField) {
+ metadata[k] = to.Ptr("0")
+ } else {
+ metadata[k] = to.Ptr(v)
+ }
+ }
+ }
+ if len(metadata) > 0 {
+ copyOptions.Metadata = metadata
+ }
+ }
+
return copyOptions
}
@@ -1125,8 +1158,8 @@ func checkDirectoryMarkers(contentType string, metadata map[string]*string) bool
return true
}
for k, v := range metadata {
- if strings.ToLower(k) == azFolderKey {
- return strings.ToLower(util.GetStringFromPointer(v)) == "true"
+ if strings.EqualFold(k, azFolderKey) {
+ return strings.EqualFold(util.GetStringFromPointer(v), "true")
}
}
return false
@@ -1142,66 +1175,6 @@ func getAzContainerClientOptions() *container.ClientOptions {
}
}
-type bytesReaderWrapper struct {
- *bytes.Reader
-}
-
-func (b *bytesReaderWrapper) Close() error {
- return nil
-}
-
-type bufferAllocator struct {
- sync.Mutex
- available [][]byte
- bufferSize int
- finalized bool
-}
-
-func newBufferAllocator(size int) *bufferAllocator {
- return &bufferAllocator{
- bufferSize: size,
- finalized: false,
- }
-}
-
-func (b *bufferAllocator) getBuffer() []byte {
- b.Lock()
- defer b.Unlock()
-
- if len(b.available) > 0 {
- var result []byte
-
- truncLength := len(b.available) - 1
- result = b.available[truncLength]
-
- b.available[truncLength] = nil
- b.available = b.available[:truncLength]
-
- return result
- }
-
- return make([]byte, b.bufferSize)
-}
-
-func (b *bufferAllocator) releaseBuffer(buf []byte) {
- b.Lock()
- defer b.Unlock()
-
- if b.finalized || len(buf) != b.bufferSize {
- return
- }
-
- b.available = append(b.available, buf)
-}
-
-func (b *bufferAllocator) free() {
- b.Lock()
- defer b.Unlock()
-
- b.available = nil
- b.finalized = true
-}
-
type azureBlobDirLister struct {
baseDirLister
paginator *runtime.Pager[container.ListBlobsHierarchyResponse]
@@ -1234,7 +1207,7 @@ func (l *azureBlobDirLister) Next(limit int) ([]os.FileInfo, error) {
return l.cache, err
}
- for _, blobPrefix := range page.ListBlobsHierarchySegmentResponse.Segment.BlobPrefixes {
+ for _, blobPrefix := range page.Segment.BlobPrefixes {
name := util.GetStringFromPointer(blobPrefix.Name)
// we don't support prefixes == "/" this will be sent if a key starts with "/"
if name == "" || name == "/" {
@@ -1249,11 +1222,12 @@ func (l *azureBlobDirLister) Next(limit int) ([]os.FileInfo, error) {
l.prefixes[strings.TrimSuffix(name, "/")] = true
}
- for _, blobItem := range page.ListBlobsHierarchySegmentResponse.Segment.BlobItems {
+ for _, blobItem := range page.Segment.BlobItems {
name := util.GetStringFromPointer(blobItem.Name)
name = strings.TrimPrefix(name, l.prefix)
size := int64(0)
isDir := false
+ var metadata map[string]*string
modTime := time.Unix(0, 0)
if blobItem.Properties != nil {
size = util.GetIntFromPointer(blobItem.Properties.ContentLength)
@@ -1266,12 +1240,16 @@ func (l *azureBlobDirLister) Next(limit int) ([]os.FileInfo, error) {
continue
}
l.prefixes[name] = true
+ } else {
+ metadata = blobItem.Metadata
}
if val := getAzureLastModified(blobItem.Metadata); val > 0 {
modTime = util.GetTimeFromMsecSinceEpoch(val)
}
}
- l.cache = append(l.cache, NewFileInfo(name, isDir, size, modTime, false))
+ info := NewFileInfo(name, isDir, size, modTime, false)
+ info.setMetadataFromPointerVal(metadata)
+ l.cache = append(l.cache, info)
}
return l.returnFromCache(limit), nil
diff --git a/internal/vfs/azblobfs_disabled.go b/internal/vfs/azblobfs_disabled.go
index 34639d3d..35b83e2b 100644
--- a/internal/vfs/azblobfs_disabled.go
+++ b/internal/vfs/azblobfs_disabled.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build noazblob
-// +build noazblob
package vfs
diff --git a/internal/vfs/cryptfs.go b/internal/vfs/cryptfs.go
index 3f20fb37..8e76da30 100644
--- a/internal/vfs/cryptfs.go
+++ b/internal/vfs/cryptfs.go
@@ -24,7 +24,6 @@ import (
"net/http"
"os"
- "github.com/eikenb/pipeat"
"github.com/minio/sio"
"golang.org/x/crypto/hkdf"
@@ -60,8 +59,8 @@ func NewCryptFs(connectionID, rootDir, mountPath string, config CryptFsConfig) (
connectionID: connectionID,
rootDir: rootDir,
mountPath: getMountPath(mountPath),
- readBufferSize: config.OSFsConfig.ReadBufferSize * 1024 * 1024,
- writeBufferSize: config.OSFsConfig.WriteBufferSize * 1024 * 1024,
+ readBufferSize: config.ReadBufferSize * 1024 * 1024,
+ writeBufferSize: config.WriteBufferSize * 1024 * 1024,
},
masterKey: []byte(config.Passphrase.GetPayload()),
}
@@ -89,7 +88,7 @@ func (fs *CryptFs) Open(name string, offset int64) (File, PipeReader, func(), er
f.Close()
return nil, nil, nil, err
}
- r, w, err := pipeat.PipeInDir(fs.localTempDir)
+ r, w, err := createPipeFn(fs.localTempDir, 0)
if err != nil {
f.Close()
return nil, nil, nil, err
@@ -175,7 +174,7 @@ func (fs *CryptFs) Create(name string, _, _ int) (File, PipeWriter, func(), erro
f.Close()
return nil, nil, nil, err
}
- r, w, err := pipeat.PipeInDir(fs.localTempDir)
+ r, w, err := createPipeFn(fs.localTempDir, 0)
if err != nil {
f.Close()
return nil, nil, nil, err
diff --git a/internal/vfs/fileinfo.go b/internal/vfs/fileinfo.go
index a6079443..c27fbf79 100644
--- a/internal/vfs/fileinfo.go
+++ b/internal/vfs/fileinfo.go
@@ -18,6 +18,8 @@ import (
"os"
"path"
"time"
+
+ "github.com/drakkan/sftpgo/v2/internal/util"
)
// FileInfo implements os.FileInfo for a Cloud Storage file.
@@ -26,6 +28,7 @@ type FileInfo struct {
sizeInBytes int64
modTime time.Time
mode os.FileMode
+ metadata map[string]string
}
// NewFileInfo creates file info.
@@ -79,5 +82,36 @@ func (fi *FileInfo) SetMode(mode os.FileMode) {
// Sys provides the underlying data source (can return nil)
func (fi *FileInfo) Sys() any {
+ return fi.metadata
+}
+
+func (fi *FileInfo) setMetadata(value map[string]string) {
+ fi.metadata = value
+}
+
+func (fi *FileInfo) setMetadataFromPointerVal(value map[string]*string) {
+ if len(value) == 0 {
+ fi.metadata = nil
+ return
+ }
+
+ fi.metadata = map[string]string{}
+ for k, v := range value {
+ val := util.GetStringFromPointer(v)
+ if val != "" {
+ fi.metadata[k] = val
+ }
+ }
+}
+
+func getMetadata(fi os.FileInfo) map[string]string {
+ if fi.Sys() == nil {
+ return nil
+ }
+ if val, ok := fi.Sys().(map[string]string); ok {
+ if len(val) > 0 {
+ return val
+ }
+ }
return nil
}
diff --git a/internal/vfs/filesystem.go b/internal/vfs/filesystem.go
index 95b3df40..59ce47dd 100644
--- a/internal/vfs/filesystem.go
+++ b/internal/vfs/filesystem.go
@@ -20,6 +20,7 @@ import (
"github.com/sftpgo/sdk"
"github.com/drakkan/sftpgo/v2/internal/kms"
+ "github.com/drakkan/sftpgo/v2/internal/util"
)
// Filesystem defines filesystem details
@@ -38,6 +39,7 @@ type Filesystem struct {
// SetEmptySecrets sets the secrets to empty
func (f *Filesystem) SetEmptySecrets() {
f.S3Config.AccessSecret = kms.NewEmptySecret()
+ f.S3Config.SSECustomerKey = kms.NewEmptySecret()
f.GCSConfig.Credentials = kms.NewEmptySecret()
f.AzBlobConfig.AccountKey = kms.NewEmptySecret()
f.AzBlobConfig.SASURL = kms.NewEmptySecret()
@@ -54,6 +56,9 @@ func (f *Filesystem) SetEmptySecretsIfNil() {
if f.S3Config.AccessSecret == nil {
f.S3Config.AccessSecret = kms.NewEmptySecret()
}
+ if f.S3Config.SSECustomerKey == nil {
+ f.S3Config.SSECustomerKey = kms.NewEmptySecret()
+ }
if f.GCSConfig.Credentials == nil {
f.GCSConfig.Credentials = kms.NewEmptySecret()
}
@@ -90,6 +95,9 @@ func (f *Filesystem) SetNilSecretsIfEmpty() {
if f.S3Config.AccessSecret != nil && f.S3Config.AccessSecret.IsEmpty() {
f.S3Config.AccessSecret = nil
}
+ if f.S3Config.SSECustomerKey != nil && f.S3Config.SSECustomerKey.IsEmpty() {
+ f.S3Config.SSECustomerKey = nil
+ }
if f.GCSConfig.Credentials != nil && f.GCSConfig.Credentials.IsEmpty() {
f.GCSConfig.Credentials = nil
}
@@ -232,8 +240,7 @@ func (f *Filesystem) Validate(additionalData string) error {
f.CryptConfig = CryptFsConfig{}
f.SFTPConfig = SFTPFsConfig{}
return nil
- default:
- f.Provider = sdk.LocalFilesystemProvider
+ case sdk.LocalFilesystemProvider:
f.S3Config = S3FsConfig{}
f.GCSConfig = GCSFsConfig{}
f.AzBlobConfig = AzBlobFsConfig{}
@@ -241,6 +248,11 @@ func (f *Filesystem) Validate(additionalData string) error {
f.SFTPConfig = SFTPFsConfig{}
f.HTTPConfig = HTTPFsConfig{}
return validateOSFsConfig(&f.OSConfig)
+ default:
+ return util.NewI18nError(
+ util.NewValidationError("invalid filesystem provider"),
+ util.I18nErrorFsValidation,
+ )
}
}
@@ -249,6 +261,9 @@ func (f *Filesystem) HasRedactedSecret() bool {
// TODO move vfs specific code into each *FsConfig struct
switch f.Provider {
case sdk.S3FilesystemProvider:
+ if f.S3Config.SSECustomerKey.IsRedacted() {
+ return true
+ }
return f.S3Config.AccessSecret.IsRedacted()
case sdk.GCSFilesystemProvider:
return f.GCSConfig.Credentials.IsRedacted()
@@ -323,7 +338,8 @@ func (f *Filesystem) GetACopy() Filesystem {
ForcePathStyle: f.S3Config.ForcePathStyle,
SkipTLSVerify: f.S3Config.SkipTLSVerify,
},
- AccessSecret: f.S3Config.AccessSecret.Clone(),
+ AccessSecret: f.S3Config.AccessSecret.Clone(),
+ SSECustomerKey: f.S3Config.SSECustomerKey.Clone(),
},
GCSConfig: GCSFsConfig{
BaseGCSFsConfig: sdk.BaseGCSFsConfig{
diff --git a/internal/vfs/folder.go b/internal/vfs/folder.go
index b85d82ff..5fcee524 100644
--- a/internal/vfs/folder.go
+++ b/internal/vfs/folder.go
@@ -107,18 +107,24 @@ func (v *BaseVirtualFolder) HasRedactedSecret() bool {
// hasPathPlaceholder returns true if the folder has a path placeholder
func (v *BaseVirtualFolder) hasPathPlaceholder() bool {
- placeholder := "%username%"
+ placeholders := []string{"%username%", "%role%"}
+ var config string
switch v.FsConfig.Provider {
case sdk.S3FilesystemProvider:
- return strings.Contains(v.FsConfig.S3Config.KeyPrefix, placeholder)
+ config = v.FsConfig.S3Config.KeyPrefix
case sdk.GCSFilesystemProvider:
- return strings.Contains(v.FsConfig.GCSConfig.KeyPrefix, placeholder)
+ config = v.FsConfig.GCSConfig.KeyPrefix
case sdk.AzureBlobFilesystemProvider:
- return strings.Contains(v.FsConfig.AzBlobConfig.KeyPrefix, placeholder)
+ config = v.FsConfig.AzBlobConfig.KeyPrefix
case sdk.SFTPFilesystemProvider:
- return strings.Contains(v.FsConfig.SFTPConfig.Prefix, placeholder)
+ config = v.FsConfig.SFTPConfig.Prefix
case sdk.LocalFilesystemProvider, sdk.CryptedFilesystemProvider:
- return strings.Contains(v.MappedPath, placeholder)
+ config = v.MappedPath
+ }
+ for _, placeholder := range placeholders {
+ if strings.Contains(config, placeholder) {
+ return true
+ }
}
return false
}
diff --git a/internal/vfs/gcsfs.go b/internal/vfs/gcsfs.go
index 613e67ff..b3cfa6c2 100644
--- a/internal/vfs/gcsfs.go
+++ b/internal/vfs/gcsfs.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build !nogcs
-// +build !nogcs
package vfs
@@ -32,7 +31,6 @@ import (
"time"
"cloud.google.com/go/storage"
- "github.com/eikenb/pipeat"
"github.com/pkg/sftp"
"github.com/rs/xid"
"google.golang.org/api/googleapi"
@@ -89,13 +87,20 @@ func NewGCSFs(connectionID, localTempDir, mountPath string, config GCSFsConfig)
}
ctx := context.Background()
if fs.config.AutomaticCredentials > 0 {
- fs.svc, err = storage.NewClient(ctx)
+ fs.svc, err = storage.NewClient(ctx,
+ storage.WithJSONReads(),
+ option.WithUserAgent(version.GetVersionHash()),
+ )
} else {
err = fs.config.Credentials.TryDecrypt()
if err != nil {
return fs, err
}
- fs.svc, err = storage.NewClient(ctx, option.WithCredentialsJSON([]byte(fs.config.Credentials.GetPayload())))
+ fs.svc, err = storage.NewClient(ctx,
+ storage.WithJSONReads(),
+ option.WithUserAgent(version.GetVersionHash()),
+ option.WithAuthCredentialsJSON(option.ServiceAccount, []byte(fs.config.Credentials.GetPayload())),
+ )
}
return fs, err
}
@@ -128,7 +133,7 @@ func (fs *GCSFs) Lstat(name string) (os.FileInfo, error) {
// Open opens the named file for reading
func (fs *GCSFs) Open(name string, offset int64) (File, PipeReader, func(), error) {
- r, w, err := pipeat.PipeInDir(fs.localTempDir)
+ r, w, err := createPipeFn(fs.localTempDir, 0)
if err != nil {
return nil, nil, nil, err
}
@@ -176,7 +181,11 @@ func (fs *GCSFs) Create(name string, flag, checks int) (File, PipeWriter, func()
return nil, nil, nil, err
}
}
- r, w, err := pipeat.PipeInDir(fs.localTempDir)
+ chunkSize := googleapi.DefaultUploadChunkSize
+ if fs.config.UploadPartSize > 0 {
+ chunkSize = int(fs.config.UploadPartSize) * 1024 * 1024
+ }
+ r, w, err := createPipeFn(fs.localTempDir, int64(chunkSize+1024*1024))
if err != nil {
return nil, nil, nil, err
}
@@ -220,9 +229,7 @@ func (fs *GCSFs) Create(name string, flag, checks int) (File, PipeWriter, func()
objectWriter = obj.NewWriter(ctx)
}
- if fs.config.UploadPartSize > 0 {
- objectWriter.ChunkSize = int(fs.config.UploadPartSize) * 1024 * 1024
- }
+ objectWriter.ChunkSize = chunkSize
if fs.config.UploadPartMaxTime > 0 {
objectWriter.ChunkRetryDeadline = time.Duration(fs.config.UploadPartMaxTime) * time.Second
}
@@ -255,19 +262,21 @@ func (fs *GCSFs) Create(name string, flag, checks int) (File, PipeWriter, func()
}
// Rename renames (moves) source to target.
-func (fs *GCSFs) Rename(source, target string) (int, int64, error) {
+func (fs *GCSFs) Rename(source, target string, checks int) (int, int64, error) {
if source == target {
return -1, -1, nil
}
- _, err := fs.Stat(path.Dir(target))
- if err != nil {
- return -1, -1, err
+ if checks&CheckParentDir != 0 {
+ _, err := fs.Stat(path.Dir(target))
+ if err != nil {
+ return -1, -1, err
+ }
}
fi, err := fs.getObjectStat(source)
if err != nil {
return -1, -1, err
}
- return fs.renameInternal(source, target, fi, 0)
+ return fs.renameInternal(source, target, fi, 0, checks&CheckUpdateModTime != 0)
}
// Remove removes the named file or (empty) directory.
@@ -419,11 +428,12 @@ func (*GCSFs) IsNotExist(err error) bool {
if err == nil {
return false
}
- if err == storage.ErrObjectNotExist || err == storage.ErrBucketNotExist {
+ if errors.Is(err, storage.ErrObjectNotExist) {
return true
}
- if e, ok := err.(*googleapi.Error); ok {
- if e.Code == http.StatusNotFound {
+ var apiErr *googleapi.Error
+ if errors.As(err, &apiErr) {
+ if apiErr.Code == http.StatusNotFound {
return true
}
}
@@ -436,8 +446,9 @@ func (*GCSFs) IsPermission(err error) bool {
if err == nil {
return false
}
- if e, ok := err.(*googleapi.Error); ok {
- if e.Code == http.StatusForbidden || e.Code == http.StatusUnauthorized {
+ var apiErr *googleapi.Error
+ if errors.As(err, &apiErr) {
+ if apiErr.Code == http.StatusForbidden || apiErr.Code == http.StatusUnauthorized {
return true
}
}
@@ -620,25 +631,25 @@ func (*GCSFs) Join(elem ...string) string {
}
// HasVirtualFolders returns true if folders are emulated
-func (GCSFs) HasVirtualFolders() bool {
+func (*GCSFs) HasVirtualFolders() bool {
return true
}
// ResolvePath returns the matching filesystem path for the specified virtual path
func (fs *GCSFs) ResolvePath(virtualPath string) (string, error) {
if fs.mountPath != "" {
- virtualPath = strings.TrimPrefix(virtualPath, fs.mountPath)
- }
- if !path.IsAbs(virtualPath) {
- virtualPath = path.Clean("/" + virtualPath)
+ if after, found := strings.CutPrefix(virtualPath, fs.mountPath); found {
+ virtualPath = after
+ }
}
+ virtualPath = path.Clean("/" + virtualPath)
return fs.Join(fs.config.KeyPrefix, strings.TrimPrefix(virtualPath, "/")), nil
}
// CopyFile implements the FsFileCopier interface
-func (fs *GCSFs) CopyFile(source, target string, srcSize int64) (int, int64, error) {
+func (fs *GCSFs) CopyFile(source, target string, srcInfo os.FileInfo) (int, int64, error) {
numFiles := 1
- sizeDiff := srcSize
+ sizeDiff := srcInfo.Size()
var conditions *storage.Conditions
attrs, err := fs.headObject(target)
if err == nil {
@@ -651,7 +662,7 @@ func (fs *GCSFs) CopyFile(source, target string, srcSize int64) (int, int64, err
}
conditions = &storage.Conditions{DoesNotExist: true}
}
- if err := fs.copyFileInternal(source, target, conditions); err != nil {
+ if err := fs.copyFileInternal(source, target, conditions, srcInfo, true); err != nil {
return 0, 0, err
}
return numFiles, sizeDiff, nil
@@ -679,7 +690,11 @@ func (fs *GCSFs) getObjectStat(name string) (os.FileInfo, error) {
objectModTime = util.GetTimeFromMsecSinceEpoch(val)
}
isDir := attrs.ContentType == dirMimeType || strings.HasSuffix(attrs.Name, "/")
- return NewFileInfo(name, isDir, objSize, objectModTime, false), nil
+ info := NewFileInfo(name, isDir, objSize, objectModTime, false)
+ if !isDir {
+ info.setMetadata(attrs.Metadata)
+ }
+ return info, nil
}
if !fs.IsNotExist(err) {
return nil, err
@@ -712,10 +727,10 @@ func (fs *GCSFs) setWriterAttrs(objectWriter *storage.Writer, flag int, name str
contentType = mime.TypeByExtension(path.Ext(name))
}
if contentType != "" {
- objectWriter.ObjectAttrs.ContentType = contentType
+ objectWriter.ContentType = contentType
}
if fs.config.StorageClass != "" {
- objectWriter.ObjectAttrs.StorageClass = fs.config.StorageClass
+ objectWriter.StorageClass = fs.config.StorageClass
}
if fs.config.ACL != "" {
objectWriter.PredefinedACL = fs.config.ACL
@@ -749,7 +764,9 @@ func (fs *GCSFs) composeObjects(ctx context.Context, dst, partialObject *storage
return err
}
-func (fs *GCSFs) copyFileInternal(source, target string, conditions *storage.Conditions) error {
+func (fs *GCSFs) copyFileInternal(source, target string, conditions *storage.Conditions,
+ srcInfo os.FileInfo, updateModTime bool,
+) error {
src := fs.svc.Bucket(fs.config.Bucket).Object(source)
dst := fs.svc.Bucket(fs.config.Bucket).Object(target)
if conditions != nil {
@@ -780,16 +797,25 @@ func (fs *GCSFs) copyFileInternal(source, target string, conditions *storage.Con
if contentType != "" {
copier.ContentType = contentType
}
+ metadata := getMetadata(srcInfo)
+ if updateModTime && len(metadata) > 0 {
+ delete(metadata, lastModifiedField)
+ }
+ if len(metadata) > 0 {
+ copier.Metadata = metadata
+ }
_, err := copier.Run(ctx)
metric.GCSCopyObjectCompleted(err)
return err
}
-func (fs *GCSFs) renameInternal(source, target string, fi os.FileInfo, recursion int) (int, int64, error) {
+func (fs *GCSFs) renameInternal(source, target string, srcInfo os.FileInfo, recursion int,
+ updateModTime bool,
+) (int, int64, error) {
var numFiles int
var filesSize int64
- if fi.IsDir() {
+ if srcInfo.IsDir() {
if renameMode == 0 {
hasContents, err := fs.hasContents(source)
if err != nil {
@@ -803,7 +829,7 @@ func (fs *GCSFs) renameInternal(source, target string, fi os.FileInfo, recursion
return numFiles, filesSize, err
}
if renameMode == 1 {
- files, size, err := doRecursiveRename(fs, source, target, fs.renameInternal, recursion)
+ files, size, err := doRecursiveRename(fs, source, target, fs.renameInternal, recursion, updateModTime)
numFiles += files
filesSize += size
if err != nil {
@@ -811,13 +837,13 @@ func (fs *GCSFs) renameInternal(source, target string, fi os.FileInfo, recursion
}
}
} else {
- if err := fs.copyFileInternal(source, target, nil); err != nil {
+ if err := fs.copyFileInternal(source, target, nil, srcInfo, updateModTime); err != nil {
return numFiles, filesSize, err
}
numFiles++
- filesSize += fi.Size()
+ filesSize += srcInfo.Size()
}
- err := fs.Remove(source, fi.IsDir())
+ err := fs.Remove(source, srcInfo.IsDir())
if fs.IsNotExist(err) {
err = nil
}
@@ -1002,7 +1028,9 @@ func (l *gcsDirLister) Next(limit int) ([]os.FileInfo, error) {
if val := getLastModified(attrs.Metadata); val > 0 {
modTime = util.GetTimeFromMsecSinceEpoch(val)
}
- l.cache = append(l.cache, NewFileInfo(name, isDir, attrs.Size, modTime, false))
+ info := NewFileInfo(name, isDir, attrs.Size, modTime, false)
+ info.setMetadata(attrs.Metadata)
+ l.cache = append(l.cache, info)
}
}
diff --git a/internal/vfs/gcsfs_disabled.go b/internal/vfs/gcsfs_disabled.go
index edc0143f..cac24e60 100644
--- a/internal/vfs/gcsfs_disabled.go
+++ b/internal/vfs/gcsfs_disabled.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build nogcs
-// +build nogcs
package vfs
diff --git a/internal/vfs/httpfs.go b/internal/vfs/httpfs.go
index 331f72dc..76aa940c 100644
--- a/internal/vfs/httpfs.go
+++ b/internal/vfs/httpfs.go
@@ -32,7 +32,6 @@ import (
"strings"
"time"
- "github.com/eikenb/pipeat"
"github.com/pkg/sftp"
"github.com/sftpgo/sdk"
@@ -317,7 +316,7 @@ func (fs *HTTPFs) Lstat(name string) (os.FileInfo, error) {
// Open opens the named file for reading
func (fs *HTTPFs) Open(name string, offset int64) (File, PipeReader, func(), error) {
- r, w, err := pipeat.PipeInDir(fs.localTempDir)
+ r, w, err := createPipeFn(fs.localTempDir, 0)
if err != nil {
return nil, nil, nil, err
}
@@ -351,7 +350,7 @@ func (fs *HTTPFs) Open(name string, offset int64) (File, PipeReader, func(), err
// Create creates or opens the named file for writing
func (fs *HTTPFs) Create(name string, flag, checks int) (File, PipeWriter, func(), error) {
- r, w, err := pipeat.PipeInDir(fs.localTempDir)
+ r, w, err := createPipeFn(fs.localTempDir, 0)
if err != nil {
return nil, nil, nil, err
}
@@ -384,7 +383,7 @@ func (fs *HTTPFs) Create(name string, flag, checks int) (File, PipeWriter, func(
}
// Rename renames (moves) source to target.
-func (fs *HTTPFs) Rename(source, target string) (int, int64, error) {
+func (fs *HTTPFs) Rename(source, target string, checks int) (int, int64, error) {
if source == target {
return -1, -1, nil
}
@@ -397,6 +396,9 @@ func (fs *HTTPFs) Rename(source, target string) (int, int64, error) {
return -1, -1, err
}
defer resp.Body.Close()
+ if checks&CheckUpdateModTime != 0 {
+ fs.Chtimes(target, time.Now(), time.Now(), false) //nolint:errcheck
+ }
return -1, -1, nil
}
@@ -636,12 +638,11 @@ func (*HTTPFs) HasVirtualFolders() bool {
// ResolvePath returns the matching filesystem path for the specified virtual path
func (fs *HTTPFs) ResolvePath(virtualPath string) (string, error) {
if fs.mountPath != "" {
- virtualPath = strings.TrimPrefix(virtualPath, fs.mountPath)
+ if after, found := strings.CutPrefix(virtualPath, fs.mountPath); found {
+ virtualPath = after
+ }
}
- if !path.IsAbs(virtualPath) {
- virtualPath = path.Clean("/" + virtualPath)
- }
- return virtualPath, nil
+ return path.Clean("/" + virtualPath), nil
}
// GetMimeType returns the content type
diff --git a/internal/vfs/osfs.go b/internal/vfs/osfs.go
index affc44f4..1f0a502f 100644
--- a/internal/vfs/osfs.go
+++ b/internal/vfs/osfs.go
@@ -24,17 +24,16 @@ import (
"os"
"path"
"path/filepath"
+ "slices"
"strings"
"time"
- "github.com/eikenb/pipeat"
fscopy "github.com/otiai10/copy"
"github.com/pkg/sftp"
"github.com/rs/xid"
"github.com/sftpgo/sdk"
"github.com/drakkan/sftpgo/v2/internal/logger"
- "github.com/drakkan/sftpgo/v2/internal/util"
)
const (
@@ -116,7 +115,7 @@ func (fs *OsFs) Open(name string, offset int64) (File, PipeReader, func(), error
if fs.readBufferSize <= 0 {
return f, nil, nil, err
}
- r, w, err := pipeat.PipeInDir(fs.localTempDir)
+ r, w, err := createPipeFn(fs.localTempDir, 0)
if err != nil {
f.Close()
return nil, nil, nil, err
@@ -149,7 +148,7 @@ func (fs *OsFs) Create(name string, flag, _ int) (File, PipeWriter, func(), erro
if err != nil {
return nil, nil, nil, err
}
- r, w, err := pipeat.PipeInDir(fs.localTempDir)
+ r, w, err := createPipeFn(fs.localTempDir, 0)
if err != nil {
f.Close()
return nil, nil, nil, err
@@ -176,7 +175,7 @@ func (fs *OsFs) Create(name string, flag, _ int) (File, PipeWriter, func(), erro
}
// Rename renames (moves) source to target
-func (fs *OsFs) Rename(source, target string) (int, int64, error) {
+func (fs *OsFs) Rename(source, target string, checks int) (int, int64, error) {
if source == target {
return -1, -1, nil
}
@@ -199,9 +198,15 @@ func (fs *OsFs) Rename(source, target string) (int, int64, error) {
fsLog(fs, logger.LevelError, "cross device copy error: %v", err)
return -1, -1, err
}
+ if checks&CheckUpdateModTime != 0 {
+ fs.Chtimes(target, time.Now(), time.Now(), false) //nolint:errcheck
+ }
err = os.RemoveAll(source)
return -1, -1, err
}
+ if checks&CheckUpdateModTime != 0 && err == nil {
+ fs.Chtimes(target, time.Now(), time.Now(), false) //nolint:errcheck
+ }
return -1, -1, err
}
@@ -352,12 +357,16 @@ func (fs *OsFs) GetRelativePath(name string) string {
}
rel, err := filepath.Rel(fs.rootDir, filepath.Clean(name))
if err != nil {
- return ""
+ return virtualPath
}
- if rel == "." || strings.HasPrefix(rel, "..") {
+ rel = filepath.ToSlash(rel)
+ if rel == ".." || strings.HasPrefix(rel, "../") {
+ return virtualPath
+ }
+ if rel == "." {
rel = ""
}
- return path.Join(virtualPath, filepath.ToSlash(rel))
+ return path.Join(virtualPath, rel)
}
// Walk walks the file tree rooted at root, calling walkFn for each file or
@@ -377,8 +386,11 @@ func (fs *OsFs) ResolvePath(virtualPath string) (string, error) {
return "", fmt.Errorf("invalid root path %q", fs.rootDir)
}
if fs.mountPath != "" {
- virtualPath = strings.TrimPrefix(virtualPath, fs.mountPath)
+ if after, found := strings.CutPrefix(virtualPath, fs.mountPath); found {
+ virtualPath = after
+ }
}
+ virtualPath = path.Clean("/" + virtualPath)
r := filepath.Clean(filepath.Join(fs.rootDir, virtualPath))
p, err := filepath.EvalSymlinks(r)
if isInvalidNameError(err) {
@@ -475,7 +487,7 @@ func (fs *OsFs) findNonexistentDirs(filePath string) ([]string, error) {
for fs.IsNotExist(err) {
results = append(results, parent)
parent = filepath.Dir(parent)
- if util.Contains(results, parent) {
+ if slices.Contains(results, parent) {
break
}
_, err = os.Stat(parent)
diff --git a/internal/vfs/s3fs.go b/internal/vfs/s3fs.go
index b74d2877..f0e9c093 100644
--- a/internal/vfs/s3fs.go
+++ b/internal/vfs/s3fs.go
@@ -13,13 +13,16 @@
// along with this program. If not, see .
//go:build !nos3
-// +build !nos3
package vfs
import (
+ "bytes"
"context"
+ "crypto/md5"
+ "crypto/sha256"
"crypto/tls"
+ "encoding/base64"
"errors"
"fmt"
"io"
@@ -30,6 +33,7 @@ import (
"os"
"path"
"path/filepath"
+ "slices"
"sort"
"strings"
"sync"
@@ -41,11 +45,9 @@ import (
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
- "github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/aws-sdk-go-v2/service/sts"
- "github.com/eikenb/pipeat"
"github.com/pkg/sftp"
"github.com/drakkan/sftpgo/v2/internal/logger"
@@ -71,10 +73,13 @@ type S3Fs struct {
connectionID string
localTempDir string
// if not empty this fs is mouted as virtual folder in the specified path
- mountPath string
- config *S3FsConfig
- svc *s3.Client
- ctxTimeout time.Duration
+ mountPath string
+ config *S3FsConfig
+ svc *s3.Client
+ ctxTimeout time.Duration
+ sseCustomerKey string
+ sseCustomerKeyMD5 string
+ sseCustomerAlgo string
}
func init() {
@@ -120,6 +125,23 @@ func NewS3Fs(connectionID, localTempDir, mountPath string, s3Config S3FsConfig)
fs.config.SessionToken),
)
}
+ if !fs.config.SSECustomerKey.IsEmpty() {
+ if err := fs.config.SSECustomerKey.TryDecrypt(); err != nil {
+ return fs, err
+ }
+ key := fs.config.SSECustomerKey.GetPayload()
+ if len(key) == 32 {
+ md5sumBinary := md5.Sum([]byte(key))
+ fs.sseCustomerKey = base64.StdEncoding.EncodeToString([]byte(key))
+ fs.sseCustomerKeyMD5 = base64.StdEncoding.EncodeToString(md5sumBinary[:])
+ } else {
+ keyHash := sha256.Sum256([]byte(key))
+ md5sumBinary := md5.Sum(keyHash[:])
+ fs.sseCustomerKey = base64.StdEncoding.EncodeToString(keyHash[:])
+ fs.sseCustomerKeyMD5 = base64.StdEncoding.EncodeToString(md5sumBinary[:])
+ }
+ fs.sseCustomerAlgo = "AES256"
+ }
fs.setConfigDefaults()
@@ -131,6 +153,8 @@ func NewS3Fs(connectionID, localTempDir, mountPath string, s3Config S3FsConfig)
fs.svc = s3.NewFromConfig(awsConfig, func(o *s3.Options) {
o.AppID = version.GetVersionHash()
o.UsePathStyle = fs.config.ForcePathStyle
+ o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
+ o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired
if fs.config.Endpoint != "" {
o.BaseEndpoint = aws.String(fs.config.Endpoint)
}
@@ -161,12 +185,13 @@ func (fs *S3Fs) Stat(name string) (os.FileInfo, error) {
if err == nil {
// Some S3 providers (like SeaweedFS) remove the trailing '/' from object keys.
// So we check some common content types to detect if this is a "directory".
- isDir := util.Contains(s3DirMimeTypes, util.GetStringFromPointer(obj.ContentType))
+ isDir := slices.Contains(s3DirMimeTypes, util.GetStringFromPointer(obj.ContentType))
if util.GetIntFromPointer(obj.ContentLength) == 0 && !isDir {
_, err = fs.headObject(name + "/")
isDir = err == nil
}
- return NewFileInfo(name, isDir, util.GetIntFromPointer(obj.ContentLength), util.GetTimeFromPointer(obj.LastModified), false), nil
+ info := NewFileInfo(name, isDir, util.GetIntFromPointer(obj.ContentLength), util.GetTimeFromPointer(obj.LastModified), false)
+ return info, nil
}
if !fs.IsNotExist(err) {
return result, err
@@ -201,50 +226,29 @@ func (fs *S3Fs) Lstat(name string) (os.FileInfo, error) {
// Open opens the named file for reading
func (fs *S3Fs) Open(name string, offset int64) (File, PipeReader, func(), error) {
- r, w, err := pipeat.PipeInDir(fs.localTempDir)
+ attrs, err := fs.headObject(name)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ r, w, err := createPipeFn(fs.localTempDir, fs.config.DownloadPartSize*int64(fs.config.DownloadConcurrency)+1)
if err != nil {
return nil, nil, nil, err
}
p := NewPipeReader(r)
if readMetadata > 0 {
- attrs, err := fs.headObject(name)
- if err != nil {
- r.Close()
- w.Close()
- return nil, nil, nil, err
- }
p.setMetadata(attrs.Metadata)
}
-
ctx, cancelFn := context.WithCancel(context.Background())
- downloader := manager.NewDownloader(fs.svc, func(d *manager.Downloader) {
- d.Concurrency = fs.config.DownloadConcurrency
- d.PartSize = fs.config.DownloadPartSize
- if offset == 0 && fs.config.DownloadPartMaxTime > 0 {
- d.ClientOptions = append(d.ClientOptions, func(o *s3.Options) {
- o.HTTPClient = getAWSHTTPClient(fs.config.DownloadPartMaxTime, 100*time.Millisecond,
- fs.config.SkipTLSVerify)
- })
- }
- })
-
- var streamRange *string
- if offset > 0 {
- streamRange = aws.String(fmt.Sprintf("bytes=%v-", offset))
- }
go func() {
defer cancelFn()
- n, err := downloader.Download(ctx, w, &s3.GetObjectInput{
- Bucket: aws.String(fs.config.Bucket),
- Key: aws.String(name),
- Range: streamRange,
- })
+ err := fs.handleDownload(ctx, name, offset, w, attrs)
w.CloseWithError(err) //nolint:errcheck
- fsLog(fs, logger.LevelDebug, "download completed, path: %q size: %v, err: %+v", name, n, err)
- metric.S3TransferCompleted(n, 1, err)
+ fsLog(fs, logger.LevelDebug, "download completed, path: %q size: %d, err: %+v", name, w.GetWrittenBytes(), err)
+ metric.S3TransferCompleted(w.GetWrittenBytes(), 1, err)
}()
+
return nil, p, cancelFn, nil
}
@@ -256,7 +260,7 @@ func (fs *S3Fs) Create(name string, flag, checks int) (File, PipeWriter, func(),
return nil, nil, nil, err
}
}
- r, w, err := pipeat.PipeInDir(fs.localTempDir)
+ r, w, err := createPipeFn(fs.localTempDir, fs.config.UploadPartSize+1024*1024)
if err != nil {
return nil, nil, nil, err
}
@@ -267,16 +271,6 @@ func (fs *S3Fs) Create(name string, flag, checks int) (File, PipeWriter, func(),
p = NewPipeWriter(w)
}
ctx, cancelFn := context.WithCancel(context.Background())
- uploader := manager.NewUploader(fs.svc, func(u *manager.Uploader) {
- u.Concurrency = fs.config.UploadConcurrency
- u.PartSize = fs.config.UploadPartSize
- if fs.config.UploadPartMaxTime > 0 {
- u.ClientOptions = append(u.ClientOptions, func(o *s3.Options) {
- o.HTTPClient = getAWSHTTPClient(fs.config.UploadPartMaxTime, 100*time.Millisecond,
- fs.config.SkipTLSVerify)
- })
- }
- })
go func() {
defer cancelFn()
@@ -287,14 +281,7 @@ func (fs *S3Fs) Create(name string, flag, checks int) (File, PipeWriter, func(),
} else {
contentType = mime.TypeByExtension(path.Ext(name))
}
- _, err := uploader.Upload(ctx, &s3.PutObjectInput{
- Bucket: aws.String(fs.config.Bucket),
- Key: aws.String(name),
- Body: r,
- ACL: types.ObjectCannedACL(fs.config.ACL),
- StorageClass: types.StorageClass(fs.config.StorageClass),
- ContentType: util.NilIfEmpty(contentType),
- })
+ err := fs.handleUpload(ctx, r, name, contentType)
r.CloseWithError(err) //nolint:errcheck
p.Done(err)
fsLog(fs, logger.LevelDebug, "upload completed, path: %q, acl: %q, readed bytes: %d, err: %+v",
@@ -329,19 +316,21 @@ func (fs *S3Fs) Create(name string, flag, checks int) (File, PipeWriter, func(),
}
// Rename renames (moves) source to target.
-func (fs *S3Fs) Rename(source, target string) (int, int64, error) {
+func (fs *S3Fs) Rename(source, target string, checks int) (int, int64, error) {
if source == target {
return -1, -1, nil
}
- _, err := fs.Stat(path.Dir(target))
- if err != nil {
- return -1, -1, err
+ if checks&CheckParentDir != 0 {
+ _, err := fs.Stat(path.Dir(target))
+ if err != nil {
+ return -1, -1, err
+ }
}
fi, err := fs.Stat(source)
if err != nil {
return -1, -1, err
}
- return fs.renameInternal(source, target, fi, 0)
+ return fs.renameInternal(source, target, fi, 0, checks&CheckUpdateModTime != 0)
}
// Remove removes the named file or (empty) directory.
@@ -622,18 +611,18 @@ func (*S3Fs) HasVirtualFolders() bool {
// ResolvePath returns the matching filesystem path for the specified virtual path
func (fs *S3Fs) ResolvePath(virtualPath string) (string, error) {
if fs.mountPath != "" {
- virtualPath = strings.TrimPrefix(virtualPath, fs.mountPath)
- }
- if !path.IsAbs(virtualPath) {
- virtualPath = path.Clean("/" + virtualPath)
+ if after, found := strings.CutPrefix(virtualPath, fs.mountPath); found {
+ virtualPath = after
+ }
}
+ virtualPath = path.Clean("/" + virtualPath)
return fs.Join(fs.config.KeyPrefix, strings.TrimPrefix(virtualPath, "/")), nil
}
// CopyFile implements the FsFileCopier interface
-func (fs *S3Fs) CopyFile(source, target string, srcSize int64) (int, int64, error) {
+func (fs *S3Fs) CopyFile(source, target string, srcInfo os.FileInfo) (int, int64, error) {
numFiles := 1
- sizeDiff := srcSize
+ sizeDiff := srcInfo.Size()
attrs, err := fs.headObject(target)
if err == nil {
sizeDiff -= util.GetIntFromPointer(attrs.ContentLength)
@@ -643,7 +632,7 @@ func (fs *S3Fs) CopyFile(source, target string, srcSize int64) (int, int64, erro
return 0, 0, err
}
}
- if err := fs.copyFileInternal(source, target, srcSize); err != nil {
+ if err := fs.copyFileInternal(source, target, srcInfo); err != nil {
return 0, 0, err
}
return numFiles, sizeDiff, nil
@@ -659,60 +648,73 @@ func (fs *S3Fs) resolve(name *string, prefix string) (string, bool) {
}
func (fs *S3Fs) setConfigDefaults() {
+ const defaultPartSize = 1024 * 1024 * 5
+ const defaultConcurrency = 5
+
if fs.config.UploadPartSize == 0 {
- fs.config.UploadPartSize = manager.DefaultUploadPartSize
+ fs.config.UploadPartSize = defaultPartSize
} else {
if fs.config.UploadPartSize < 1024*1024 {
fs.config.UploadPartSize *= 1024 * 1024
}
}
if fs.config.UploadConcurrency == 0 {
- fs.config.UploadConcurrency = manager.DefaultUploadConcurrency
+ fs.config.UploadConcurrency = defaultConcurrency
}
if fs.config.DownloadPartSize == 0 {
- fs.config.DownloadPartSize = manager.DefaultDownloadPartSize
+ fs.config.DownloadPartSize = defaultPartSize
} else {
if fs.config.DownloadPartSize < 1024*1024 {
fs.config.DownloadPartSize *= 1024 * 1024
}
}
if fs.config.DownloadConcurrency == 0 {
- fs.config.DownloadConcurrency = manager.DefaultDownloadConcurrency
+ fs.config.DownloadConcurrency = defaultConcurrency
}
}
-func (fs *S3Fs) copyFileInternal(source, target string, fileSize int64) error {
+func (fs *S3Fs) copyFileInternal(source, target string, srcInfo os.FileInfo) error {
contentType := mime.TypeByExtension(path.Ext(source))
copySource := pathEscape(fs.Join(fs.config.Bucket, source))
- if fileSize > s3CopyObjectThreshold {
+ if srcInfo.Size() > s3CopyObjectThreshold {
fsLog(fs, logger.LevelDebug, "renaming file %q with size %d using multipart copy",
- source, fileSize)
- err := fs.doMultipartCopy(copySource, target, contentType, fileSize)
+ source, srcInfo.Size())
+ err := fs.doMultipartCopy(copySource, target, contentType, srcInfo.Size())
metric.S3CopyObjectCompleted(err)
return err
}
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
defer cancelFn()
- _, err := fs.svc.CopyObject(ctx, &s3.CopyObjectInput{
- Bucket: aws.String(fs.config.Bucket),
- CopySource: aws.String(copySource),
- Key: aws.String(target),
- StorageClass: types.StorageClass(fs.config.StorageClass),
- ACL: types.ObjectCannedACL(fs.config.ACL),
- ContentType: util.NilIfEmpty(contentType),
- })
+ copyObject := &s3.CopyObjectInput{
+ Bucket: aws.String(fs.config.Bucket),
+ CopySource: aws.String(copySource),
+ Key: aws.String(target),
+ StorageClass: types.StorageClass(fs.config.StorageClass),
+ ACL: types.ObjectCannedACL(fs.config.ACL),
+ ContentType: util.NilIfEmpty(contentType),
+ CopySourceSSECustomerKey: util.NilIfEmpty(fs.sseCustomerKey),
+ CopySourceSSECustomerAlgorithm: util.NilIfEmpty(fs.sseCustomerAlgo),
+ CopySourceSSECustomerKeyMD5: util.NilIfEmpty(fs.sseCustomerKeyMD5),
+ SSECustomerKey: util.NilIfEmpty(fs.sseCustomerKey),
+ SSECustomerAlgorithm: util.NilIfEmpty(fs.sseCustomerAlgo),
+ SSECustomerKeyMD5: util.NilIfEmpty(fs.sseCustomerKeyMD5),
+ }
+
+ _, err := fs.svc.CopyObject(ctx, copyObject)
metric.S3CopyObjectCompleted(err)
return err
}
-func (fs *S3Fs) renameInternal(source, target string, fi os.FileInfo, recursion int) (int, int64, error) {
+func (fs *S3Fs) renameInternal(source, target string, srcInfo os.FileInfo, recursion int,
+ updateModTime bool,
+) (int, int64, error) {
var numFiles int
var filesSize int64
- if fi.IsDir() {
+ if srcInfo.IsDir() {
if renameMode == 0 {
hasContents, err := fs.hasContents(source)
if err != nil {
@@ -726,7 +728,7 @@ func (fs *S3Fs) renameInternal(source, target string, fi os.FileInfo, recursion
return numFiles, filesSize, err
}
if renameMode == 1 {
- files, size, err := doRecursiveRename(fs, source, target, fs.renameInternal, recursion)
+ files, size, err := doRecursiveRename(fs, source, target, fs.renameInternal, recursion, updateModTime)
numFiles += files
filesSize += size
if err != nil {
@@ -734,13 +736,13 @@ func (fs *S3Fs) renameInternal(source, target string, fi os.FileInfo, recursion
}
}
} else {
- if err := fs.copyFileInternal(source, target, fi.Size()); err != nil {
+ if err := fs.copyFileInternal(source, target, srcInfo); err != nil {
return numFiles, filesSize, err
}
numFiles++
- filesSize += fi.Size()
+ filesSize += srcInfo.Size()
}
- err := fs.Remove(source, fi.IsDir())
+ err := fs.Remove(source, srcInfo.IsDir())
if fs.IsNotExist(err) {
err = nil
}
@@ -791,16 +793,340 @@ func (fs *S3Fs) hasContents(name string) (bool, error) {
return false, nil
}
+func (fs *S3Fs) downloadPart(ctx context.Context, name string, buf []byte, w io.WriterAt, start, count, writeOffset int64) error {
+ if count == 0 {
+ return nil
+ }
+ rangeHeader := fmt.Sprintf("bytes=%d-%d", start, start+count-1)
+
+ resp, err := fs.svc.GetObject(ctx, &s3.GetObjectInput{
+ Bucket: aws.String(fs.config.Bucket),
+ Key: aws.String(name),
+ Range: &rangeHeader,
+ SSECustomerKey: util.NilIfEmpty(fs.sseCustomerKey),
+ SSECustomerAlgorithm: util.NilIfEmpty(fs.sseCustomerAlgo),
+ SSECustomerKeyMD5: util.NilIfEmpty(fs.sseCustomerKeyMD5),
+ })
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ _, err = io.ReadAtLeast(resp.Body, buf, int(count))
+ if err != nil {
+ return err
+ }
+
+ return writeAtFull(w, buf, writeOffset, int(count))
+}
+
+func (fs *S3Fs) handleDownload(ctx context.Context, name string, offset int64, writer io.WriterAt, attrs *s3.HeadObjectOutput) error {
+ contentLength := util.GetIntFromPointer(attrs.ContentLength)
+ sizeToDownload := contentLength - offset
+ if sizeToDownload < 0 {
+ fsLog(fs, logger.LevelError, "invalid multipart download size or offset, size: %d, offset: %d, size to download: %d",
+ contentLength, offset, sizeToDownload)
+ return errors.New("the requested offset exceeds the file size")
+ }
+ if sizeToDownload == 0 {
+ fsLog(fs, logger.LevelDebug, "nothing to download, offset %d, content length %d", offset, contentLength)
+ return nil
+ }
+ partSize := fs.config.DownloadPartSize
+ guard := make(chan struct{}, fs.config.DownloadConcurrency)
+ var blockCtxTimeout time.Duration
+ if fs.config.DownloadPartMaxTime > 0 {
+ blockCtxTimeout = time.Duration(fs.config.DownloadPartMaxTime) * time.Second
+ } else {
+ blockCtxTimeout = time.Duration(fs.config.DownloadPartSize/(1024*1024)) * time.Minute
+ }
+ pool := newBufferAllocator(int(partSize))
+ defer pool.free()
+
+ finished := false
+ var wg sync.WaitGroup
+ var errOnce sync.Once
+ var hasError atomic.Bool
+ var poolError error
+
+ poolCtx, poolCancel := context.WithCancel(ctx)
+ defer poolCancel()
+
+ for part := 0; !finished; part++ {
+ start := offset
+ end := offset + partSize
+ if end >= contentLength {
+ end = contentLength
+ finished = true
+ }
+ writeOffset := int64(part) * partSize
+ offset = end
+
+ guard <- struct{}{}
+ if hasError.Load() {
+ fsLog(fs, logger.LevelDebug, "pool error, download for part %d not started", part)
+ break
+ }
+
+ buf := pool.getBuffer()
+ wg.Add(1)
+ go func(start, end, writeOffset int64, buf []byte) {
+ defer func() {
+ pool.releaseBuffer(buf)
+ <-guard
+ wg.Done()
+ }()
+
+ innerCtx, cancelFn := context.WithDeadline(poolCtx, time.Now().Add(blockCtxTimeout))
+ defer cancelFn()
+
+ err := fs.downloadPart(innerCtx, name, buf, writer, start, end-start, writeOffset)
+ if err != nil {
+ errOnce.Do(func() {
+ fsLog(fs, logger.LevelError, "multipart download error: %+v", err)
+ hasError.Store(true)
+ poolError = fmt.Errorf("multipart download error: %w", err)
+ poolCancel()
+ })
+ }
+ }(start, end, writeOffset, buf)
+ }
+
+ wg.Wait()
+ close(guard)
+
+ return poolError
+}
+
+func (fs *S3Fs) initiateMultipartUpload(ctx context.Context, name, contentType string) (string, error) {
+ ctx, cancelFn := context.WithDeadline(ctx, time.Now().Add(fs.ctxTimeout))
+ defer cancelFn()
+
+ res, err := fs.svc.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
+ Bucket: aws.String(fs.config.Bucket),
+ Key: aws.String(name),
+ StorageClass: types.StorageClass(fs.config.StorageClass),
+ ACL: types.ObjectCannedACL(fs.config.ACL),
+ ContentType: util.NilIfEmpty(contentType),
+ SSECustomerKey: util.NilIfEmpty(fs.sseCustomerKey),
+ SSECustomerAlgorithm: util.NilIfEmpty(fs.sseCustomerAlgo),
+ SSECustomerKeyMD5: util.NilIfEmpty(fs.sseCustomerKeyMD5),
+ })
+ if err != nil {
+ return "", fmt.Errorf("unable to create multipart upload request: %w", err)
+ }
+ uploadID := util.GetStringFromPointer(res.UploadId)
+ if uploadID == "" {
+ return "", errors.New("unable to get multipart upload ID")
+ }
+ return uploadID, nil
+}
+
+func (fs *S3Fs) uploadPart(ctx context.Context, name, uploadID string, partNumber int32, data []byte) (*string, error) {
+ timeout := time.Duration(fs.config.UploadPartSize/(1024*1024)) * time.Minute
+ if fs.config.UploadPartMaxTime > 0 {
+ timeout = time.Duration(fs.config.UploadPartMaxTime) * time.Second
+ }
+ ctx, cancelFn := context.WithDeadline(ctx, time.Now().Add(timeout))
+ defer cancelFn()
+
+ resp, err := fs.svc.UploadPart(ctx, &s3.UploadPartInput{
+ Bucket: aws.String(fs.config.Bucket),
+ Key: aws.String(name),
+ PartNumber: &partNumber,
+ UploadId: aws.String(uploadID),
+ Body: bytes.NewReader(data),
+ SSECustomerKey: util.NilIfEmpty(fs.sseCustomerKey),
+ SSECustomerAlgorithm: util.NilIfEmpty(fs.sseCustomerAlgo),
+ SSECustomerKeyMD5: util.NilIfEmpty(fs.sseCustomerKeyMD5),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("unable to upload part number %d: %w", partNumber, err)
+ }
+ return resp.ETag, nil
+}
+
+func (fs *S3Fs) completeMultipartUpload(ctx context.Context, name, uploadID string, completedParts []types.CompletedPart) error {
+ ctx, cancelFn := context.WithDeadline(ctx, time.Now().Add(fs.ctxTimeout))
+ defer cancelFn()
+
+ _, err := fs.svc.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
+ Bucket: aws.String(fs.config.Bucket),
+ Key: aws.String(name),
+ UploadId: aws.String(uploadID),
+ MultipartUpload: &types.CompletedMultipartUpload{
+ Parts: completedParts,
+ },
+ })
+ return err
+}
+
+func (fs *S3Fs) abortMultipartUpload(name, uploadID string) error {
+ ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
+ defer cancelFn()
+
+ _, err := fs.svc.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{
+ Bucket: aws.String(fs.config.Bucket),
+ Key: aws.String(name),
+ UploadId: aws.String(uploadID),
+ })
+ return err
+}
+
+func (fs *S3Fs) singlePartUpload(ctx context.Context, name, contentType string, data []byte) error {
+ timeout := time.Duration(fs.config.UploadPartSize/(1024*1024)) * time.Minute
+ if fs.config.UploadPartMaxTime > 0 {
+ timeout = time.Duration(fs.config.UploadPartMaxTime) * time.Second
+ }
+ ctx, cancelFn := context.WithDeadline(ctx, time.Now().Add(timeout))
+ defer cancelFn()
+
+ contentLength := int64(len(data))
+ _, err := fs.svc.PutObject(ctx, &s3.PutObjectInput{
+ Bucket: aws.String(fs.config.Bucket),
+ Key: aws.String(name),
+ ACL: types.ObjectCannedACL(fs.config.ACL),
+ Body: bytes.NewReader(data),
+ ContentType: util.NilIfEmpty(contentType),
+ ContentLength: &contentLength,
+ SSECustomerKey: util.NilIfEmpty(fs.sseCustomerKey),
+ SSECustomerAlgorithm: util.NilIfEmpty(fs.sseCustomerAlgo),
+ SSECustomerKeyMD5: util.NilIfEmpty(fs.sseCustomerKeyMD5),
+ StorageClass: types.StorageClass(fs.config.StorageClass),
+ })
+ return err
+}
+
+func (fs *S3Fs) handleUpload(ctx context.Context, reader io.Reader, name, contentType string) error {
+ pool := newBufferAllocator(int(fs.config.UploadPartSize))
+ defer pool.free()
+
+ firstBuf := pool.getBuffer()
+ firstReadSize, err := readFill(reader, firstBuf)
+ if err == io.EOF {
+ return fs.singlePartUpload(ctx, name, contentType, firstBuf[:firstReadSize])
+ }
+ if err != nil {
+ return err
+ }
+
+ uploadID, err := fs.initiateMultipartUpload(ctx, name, contentType)
+ if err != nil {
+ return err
+ }
+ guard := make(chan struct{}, fs.config.UploadConcurrency)
+ finished := false
+ var partMutex sync.Mutex
+ var completedParts []types.CompletedPart
+ var wg sync.WaitGroup
+ var hasError atomic.Bool
+ var poolErr error
+ var errOnce sync.Once
+ var partNumber int32
+
+ poolCtx, poolCancel := context.WithCancel(ctx)
+ defer poolCancel()
+
+ finalizeFailedUpload := func(err error) {
+ fsLog(fs, logger.LevelError, "finalize failed multipart upload after error: %v", err)
+ hasError.Store(true)
+ poolErr = err
+ poolCancel()
+ if abortErr := fs.abortMultipartUpload(name, uploadID); abortErr != nil {
+ fsLog(fs, logger.LevelError, "unable to abort multipart upload: %+v", abortErr)
+ }
+ }
+
+ uploadPart := func(partNum int32, buf []byte, bytesRead int) {
+ defer func() {
+ pool.releaseBuffer(buf)
+ <-guard
+ wg.Done()
+ }()
+
+ etag, err := fs.uploadPart(poolCtx, name, uploadID, partNum, buf[:bytesRead])
+ if err != nil {
+ errOnce.Do(func() {
+ finalizeFailedUpload(err)
+ })
+ return
+ }
+ partMutex.Lock()
+ completedParts = append(completedParts, types.CompletedPart{
+ PartNumber: &partNum,
+ ETag: etag,
+ })
+ partMutex.Unlock()
+ }
+
+ partNumber = 1
+ guard <- struct{}{}
+
+ wg.Add(1)
+ go uploadPart(partNumber, firstBuf, firstReadSize)
+
+ for partNumber = 2; !finished; partNumber++ {
+ buf := pool.getBuffer()
+
+ n, err := readFill(reader, buf)
+ if err == io.EOF {
+ if n == 0 {
+ pool.releaseBuffer(buf)
+ break
+ }
+ finished = true
+ } else if err != nil {
+ pool.releaseBuffer(buf)
+ errOnce.Do(func() {
+ finalizeFailedUpload(err)
+ })
+ break
+ }
+ guard <- struct{}{}
+ if hasError.Load() {
+ fsLog(fs, logger.LevelError, "pool error, upload for part %d not started", partNumber)
+ pool.releaseBuffer(buf)
+ break
+ }
+
+ wg.Add(1)
+ go uploadPart(partNumber, buf, n)
+ }
+
+ wg.Wait()
+ close(guard)
+
+ if poolErr != nil {
+ return poolErr
+ }
+
+ sort.Slice(completedParts, func(i, j int) bool {
+ getPartNumber := func(number *int32) int32 {
+ if number == nil {
+ return 0
+ }
+ return *number
+ }
+
+ return getPartNumber(completedParts[i].PartNumber) < getPartNumber(completedParts[j].PartNumber)
+ })
+
+ return fs.completeMultipartUpload(ctx, name, uploadID, completedParts)
+}
+
func (fs *S3Fs) doMultipartCopy(source, target, contentType string, fileSize int64) error {
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
defer cancelFn()
res, err := fs.svc.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
- Bucket: aws.String(fs.config.Bucket),
- Key: aws.String(target),
- StorageClass: types.StorageClass(fs.config.StorageClass),
- ACL: types.ObjectCannedACL(fs.config.ACL),
- ContentType: util.NilIfEmpty(contentType),
+ Bucket: aws.String(fs.config.Bucket),
+ Key: aws.String(target),
+ StorageClass: types.StorageClass(fs.config.StorageClass),
+ ACL: types.ObjectCannedACL(fs.config.ACL),
+ ContentType: util.NilIfEmpty(contentType),
+ SSECustomerKey: util.NilIfEmpty(fs.sseCustomerKey),
+ SSECustomerAlgorithm: util.NilIfEmpty(fs.sseCustomerAlgo),
+ SSECustomerKeyMD5: util.NilIfEmpty(fs.sseCustomerKeyMD5),
})
if err != nil {
return fmt.Errorf("unable to create multipart copy request: %w", err)
@@ -855,12 +1181,18 @@ func (fs *S3Fs) doMultipartCopy(source, target, contentType string, fileSize int
defer innerCancelFn()
partResp, err := fs.svc.UploadPartCopy(innerCtx, &s3.UploadPartCopyInput{
- Bucket: aws.String(fs.config.Bucket),
- CopySource: aws.String(source),
- Key: aws.String(target),
- PartNumber: &partNum,
- UploadId: aws.String(uploadID),
- CopySourceRange: aws.String(fmt.Sprintf("bytes=%d-%d", partStart, partEnd-1)),
+ Bucket: aws.String(fs.config.Bucket),
+ CopySource: aws.String(source),
+ Key: aws.String(target),
+ PartNumber: &partNum,
+ UploadId: aws.String(uploadID),
+ CopySourceRange: aws.String(fmt.Sprintf("bytes=%d-%d", partStart, partEnd-1)),
+ CopySourceSSECustomerKey: util.NilIfEmpty(fs.sseCustomerKey),
+ CopySourceSSECustomerAlgorithm: util.NilIfEmpty(fs.sseCustomerAlgo),
+ CopySourceSSECustomerKeyMD5: util.NilIfEmpty(fs.sseCustomerKeyMD5),
+ SSECustomerKey: util.NilIfEmpty(fs.sseCustomerKey),
+ SSECustomerAlgorithm: util.NilIfEmpty(fs.sseCustomerAlgo),
+ SSECustomerKeyMD5: util.NilIfEmpty(fs.sseCustomerKeyMD5),
})
if err != nil {
errOnce.Do(func() {
@@ -869,15 +1201,7 @@ func (fs *S3Fs) doMultipartCopy(source, target, contentType string, fileSize int
copyError = fmt.Errorf("error copying part number %d: %w", partNum, err)
opCancel()
- abortCtx, abortCancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
- defer abortCancelFn()
-
- _, errAbort := fs.svc.AbortMultipartUpload(abortCtx, &s3.AbortMultipartUploadInput{
- Bucket: aws.String(fs.config.Bucket),
- Key: aws.String(target),
- UploadId: aws.String(uploadID),
- })
- if errAbort != nil {
+ if errAbort := fs.abortMultipartUpload(target, uploadID); errAbort != nil {
fsLog(fs, logger.LevelError, "unable to abort multipart copy: %+v", errAbort)
}
})
@@ -943,8 +1267,11 @@ func (fs *S3Fs) headObject(name string) (*s3.HeadObjectOutput, error) {
defer cancelFn()
obj, err := fs.svc.HeadObject(ctx, &s3.HeadObjectInput{
- Bucket: aws.String(fs.config.Bucket),
- Key: aws.String(name),
+ Bucket: aws.String(fs.config.Bucket),
+ Key: aws.String(name),
+ SSECustomerKey: util.NilIfEmpty(fs.sseCustomerKey),
+ SSECustomerAlgorithm: util.NilIfEmpty(fs.sseCustomerAlgo),
+ SSECustomerKeyMD5: util.NilIfEmpty(fs.sseCustomerKeyMD5),
})
metric.S3HeadObjectCompleted(err)
return obj, err
@@ -971,28 +1298,18 @@ func (*S3Fs) GetAvailableDiskSize(_ string) (*sftp.StatVFS, error) {
func (fs *S3Fs) downloadToWriter(name string, w PipeWriter) (int64, error) {
fsLog(fs, logger.LevelDebug, "starting download before resuming upload, path %q", name)
+ attrs, err := fs.headObject(name)
+ if err != nil {
+ return 0, err
+ }
ctx, cancelFn := context.WithTimeout(context.Background(), preResumeTimeout)
defer cancelFn()
- downloader := manager.NewDownloader(fs.svc, func(d *manager.Downloader) {
- d.Concurrency = fs.config.DownloadConcurrency
- d.PartSize = fs.config.DownloadPartSize
- if fs.config.DownloadPartMaxTime > 0 {
- d.ClientOptions = append(d.ClientOptions, func(o *s3.Options) {
- o.HTTPClient = getAWSHTTPClient(fs.config.DownloadPartMaxTime, 100*time.Millisecond,
- fs.config.SkipTLSVerify)
- })
- }
- })
-
- n, err := downloader.Download(ctx, w, &s3.GetObjectInput{
- Bucket: aws.String(fs.config.Bucket),
- Key: aws.String(name),
- })
+ err = fs.handleDownload(ctx, name, 0, w, attrs)
fsLog(fs, logger.LevelDebug, "download before resuming upload completed, path %q size: %d, err: %+v",
- name, n, err)
- metric.S3TransferCompleted(n, 1, err)
- return n, err
+ name, w.GetWrittenBytes(), err)
+ metric.S3TransferCompleted(w.GetWrittenBytes(), 1, err)
+ return w.GetWrittenBytes(), err
}
type s3DirLister struct {
diff --git a/internal/vfs/s3fs_disabled.go b/internal/vfs/s3fs_disabled.go
index 8f71384a..5c1f1b53 100644
--- a/internal/vfs/s3fs_disabled.go
+++ b/internal/vfs/s3fs_disabled.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build nos3
-// +build nos3
package vfs
diff --git a/internal/vfs/sftpfs.go b/internal/vfs/sftpfs.go
index 869c315e..2f263dd3 100644
--- a/internal/vfs/sftpfs.go
+++ b/internal/vfs/sftpfs.go
@@ -18,9 +18,10 @@ import (
"bufio"
"bytes"
"crypto/rsa"
+ "crypto/sha256"
+ "encoding/hex"
"errors"
"fmt"
- "hash/fnv"
"io"
"io/fs"
"net"
@@ -28,13 +29,13 @@ import (
"os"
"path"
"path/filepath"
+ "slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
- "github.com/eikenb/pipeat"
"github.com/pkg/sftp"
"github.com/robfig/cron/v3"
"github.com/rs/xid"
@@ -69,6 +70,17 @@ type SFTPFsConfig struct {
forbiddenSelfUsernames []string `json:"-"`
}
+func (c *SFTPFsConfig) getKeySigner() (ssh.Signer, error) {
+ privPayload := c.PrivateKey.GetPayload()
+ if privPayload == "" {
+ return nil, nil
+ }
+ if key := c.KeyPassphrase.GetPayload(); key != "" {
+ return ssh.ParsePrivateKeyWithPassphrase([]byte(privPayload), []byte(key))
+ }
+ return ssh.ParsePrivateKey([]byte(privPayload))
+}
+
// HideConfidentialData hides confidential data
func (c *SFTPFsConfig) HideConfidentialData() {
if c.Password != nil {
@@ -114,7 +126,7 @@ func (c *SFTPFsConfig) isEqual(other SFTPFsConfig) bool {
return false
}
for _, fp := range c.Fingerprints {
- if !util.Contains(other.Fingerprints, fp) {
+ if !slices.Contains(other.Fingerprints, fp) {
return false
}
}
@@ -185,25 +197,20 @@ func (c *SFTPFsConfig) validate() error {
func (c *SFTPFsConfig) validatePrivateKey() error {
if c.PrivateKey.IsPlain() {
- var signer ssh.Signer
- var err error
- if c.KeyPassphrase.IsPlain() {
- signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(c.PrivateKey.GetPayload()),
- []byte(c.KeyPassphrase.GetPayload()))
- } else {
- signer, err = ssh.ParsePrivateKey([]byte(c.PrivateKey.GetPayload()))
- }
+ signer, err := c.getKeySigner()
if err != nil {
return util.NewI18nError(fmt.Errorf("invalid private key: %w", err), util.I18nErrorPrivKeyInvalid)
}
- if key, ok := signer.PublicKey().(ssh.CryptoPublicKey); ok {
- cryptoKey := key.CryptoPublicKey()
- if rsaKey, ok := cryptoKey.(*rsa.PublicKey); ok {
- if size := rsaKey.N.BitLen(); size < 2048 {
- return util.NewI18nError(
- fmt.Errorf("rsa key with size %d not accepted, minimum 2048", size),
- util.I18nErrorKeySizeInvalid,
- )
+ if signer != nil {
+ if key, ok := signer.PublicKey().(ssh.CryptoPublicKey); ok {
+ cryptoKey := key.CryptoPublicKey()
+ if rsaKey, ok := cryptoKey.(*rsa.PublicKey); ok {
+ if size := rsaKey.N.BitLen(); size < 2048 {
+ return util.NewI18nError(
+ fmt.Errorf("rsa key with size %d not accepted, minimum 2048", size),
+ util.I18nErrorKeySizeInvalid,
+ )
+ }
}
}
}
@@ -277,8 +284,8 @@ func (c *SFTPFsConfig) ValidateAndEncryptCredentials(additionalData string) erro
}
// getUniqueID returns an hash of the settings used to connect to the SFTP server
-func (c *SFTPFsConfig) getUniqueID(partition int) uint64 {
- h := fnv.New64a()
+func (c *SFTPFsConfig) getUniqueID(partition int) string {
+ h := sha256.New()
var b bytes.Buffer
b.WriteString(c.Endpoint)
@@ -295,7 +302,7 @@ func (c *SFTPFsConfig) getUniqueID(partition int) uint64 {
b.WriteString(strconv.Itoa(partition))
h.Write(b.Bytes())
- return h.Sum64()
+ return hex.EncodeToString(h.Sum(nil))
}
// SFTPFs is a Fs implementation for SFTP backends
@@ -331,15 +338,19 @@ func NewSFTPFs(connectionID, mountPath, localTempDir string, forbiddenSelfUserna
return nil, err
}
}
+ conn, err := sftpConnsCache.Get(&config, connectionID)
+ if err != nil {
+ return nil, err
+ }
config.forbiddenSelfUsernames = forbiddenSelfUsernames
sftpFs := &SFTPFs{
connectionID: connectionID,
mountPath: getMountPath(mountPath),
localTempDir: localTempDir,
config: &config,
- conn: sftpConnsCache.Get(&config, connectionID),
+ conn: conn,
}
- err := sftpFs.createConnection()
+ err = sftpFs.createConnection()
if err != nil {
sftpFs.Close() //nolint:errcheck
}
@@ -394,7 +405,7 @@ func (fs *SFTPFs) Open(name string, offset int64) (File, PipeReader, func(), err
if fs.config.BufferSize == 0 {
return f, nil, nil, nil
}
- r, w, err := pipeat.PipeInDir(fs.localTempDir)
+ r, w, err := createPipeFn(fs.localTempDir, 0)
if err != nil {
f.Close()
return nil, nil, nil, err
@@ -434,7 +445,7 @@ func (fs *SFTPFs) Create(name string, flag, _ int) (File, PipeWriter, func(), er
if err != nil {
return nil, nil, nil, err
}
- r, w, err := pipeat.PipeInDir(fs.localTempDir)
+ r, w, err := createPipeFn(fs.localTempDir, 0)
if err != nil {
f.Close()
return nil, nil, nil, err
@@ -468,7 +479,7 @@ func (fs *SFTPFs) Create(name string, flag, _ int) (File, PipeWriter, func(), er
}
// Rename renames (moves) source to target.
-func (fs *SFTPFs) Rename(source, target string) (int, int64, error) {
+func (fs *SFTPFs) Rename(source, target string, checks int) (int, int64, error) {
if source == target {
return -1, -1, nil
}
@@ -478,9 +489,15 @@ func (fs *SFTPFs) Rename(source, target string) (int, int64, error) {
}
if _, ok := client.HasExtension("posix-rename@openssh.com"); ok {
err := client.PosixRename(source, target)
+ if checks&CheckUpdateModTime != 0 && err == nil {
+ fs.Chtimes(target, time.Now(), time.Now(), false) //nolint:errcheck
+ }
return -1, -1, err
}
err = client.Rename(source, target)
+ if checks&CheckUpdateModTime != 0 && err == nil {
+ fs.Chtimes(target, time.Now(), time.Now(), false) //nolint:errcheck
+ }
return -1, -1, err
}
@@ -524,7 +541,7 @@ func (fs *SFTPFs) Readlink(name string) (string, error) {
if err != nil {
return resolved, err
}
- resolved = path.Clean(resolved)
+ resolved = path.Clean(strings.ReplaceAll(resolved, "\\", "/"))
if !path.IsAbs(resolved) {
// we assume that multiple links are not followed
resolved = path.Join(path.Dir(name), resolved)
@@ -666,13 +683,23 @@ func (fs *SFTPFs) GetRelativePath(name string) string {
rel = ""
}
if !path.IsAbs(rel) {
- return "/" + rel
- }
- if fs.config.Prefix != "/" {
- if !strings.HasPrefix(rel, fs.config.Prefix) {
+ // If we have a relative path we assume it is already relative to the virtual root
+ rel = "/" + rel
+ } else if fs.config.Prefix != "/" {
+ prefixDir := fs.config.Prefix
+ if !strings.HasSuffix(prefixDir, "/") {
+ prefixDir += "/"
+ }
+
+ if rel == fs.config.Prefix {
+ rel = "/"
+ } else if after, found := strings.CutPrefix(rel, prefixDir); found {
+ rel = path.Clean("/" + after)
+ } else {
+ // Absolute path outside of the configured prefix
+ fsLog(fs, logger.LevelWarn, "path %q is an absolute path outside %q", name, fs.config.Prefix)
rel = "/"
}
- rel = path.Clean("/" + strings.TrimPrefix(rel, fs.config.Prefix))
}
if fs.mountPath != "" {
rel = path.Join(fs.mountPath, rel)
@@ -714,11 +741,11 @@ func (*SFTPFs) HasVirtualFolders() bool {
// ResolvePath returns the matching filesystem path for the specified virtual path
func (fs *SFTPFs) ResolvePath(virtualPath string) (string, error) {
if fs.mountPath != "" {
- virtualPath = strings.TrimPrefix(virtualPath, fs.mountPath)
- }
- if !path.IsAbs(virtualPath) {
- virtualPath = path.Clean("/" + virtualPath)
+ if after, found := strings.CutPrefix(virtualPath, fs.mountPath); found {
+ virtualPath = after
+ }
}
+ virtualPath = path.Clean("/" + virtualPath)
fsPath := fs.Join(fs.config.Prefix, virtualPath)
if fs.config.Prefix != "/" && fsPath != "/" {
// we need to check if this path is a symlink outside the given prefix
@@ -765,6 +792,7 @@ func (fs *SFTPFs) RealPath(p string) (string, error) {
if err != nil {
return "", err
}
+ resolved = path.Clean(strings.ReplaceAll(resolved, "\\", "/"))
if fs.config.Prefix != "/" {
if err := fs.isSubDir(resolved); err != nil {
fsLog(fs, logger.LevelError, "Invalid real path resolution, original path %q resolved %q err: %v",
@@ -794,6 +822,7 @@ func (fs *SFTPFs) getRealPath(name string) (string, error) {
if err != nil {
return name, fmt.Errorf("unable to resolve link to %q: %w", name, err)
}
+ resolvedLink = strings.ReplaceAll(resolvedLink, "\\", "/")
resolvedLink = path.Clean(resolvedLink)
if path.IsAbs(resolvedLink) {
name = resolvedLink
@@ -910,6 +939,7 @@ type sftpConnection struct {
isConnected bool
sessions map[string]bool
lastActivity time.Time
+ signer ssh.Signer
}
func newSFTPConnection(config *SFTPFsConfig, sessionID string) *sftpConnection {
@@ -919,6 +949,7 @@ func newSFTPConnection(config *SFTPFsConfig, sessionID string) *sftpConnection {
isConnected: false,
sessions: map[string]bool{},
lastActivity: time.Now().UTC(),
+ signer: nil,
}
c.sessions[sessionID] = true
return c
@@ -931,17 +962,6 @@ func (c *sftpConnection) OpenConnection() error {
return c.openConnNoLock()
}
-func (c *sftpConnection) getKeySigner() (ssh.Signer, error) {
- privPayload := c.config.PrivateKey.GetPayload()
- if privPayload == "" {
- return nil, nil
- }
- if key := c.config.KeyPassphrase.GetPayload(); key != "" {
- return ssh.ParsePrivateKeyWithPassphrase([]byte(privPayload), []byte(key))
- }
- return ssh.ParsePrivateKey([]byte(privPayload))
-}
-
func (c *sftpConnection) openConnNoLock() error {
if c.isConnected {
logger.Debug(c.logSender, "", "reusing connection")
@@ -953,12 +973,12 @@ func (c *sftpConnection) openConnNoLock() error {
User: c.config.Username,
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
fp := ssh.FingerprintSHA256(key)
- if util.Contains(sftpFingerprints, fp) {
+ if slices.Contains(sftpFingerprints, fp) {
if allowSelfConnections == 0 {
logger.Log(logger.LevelError, c.logSender, "", "SFTP self connections not allowed")
return ErrSFTPLoop
}
- if util.Contains(c.config.forbiddenSelfUsernames, c.config.Username) {
+ if slices.Contains(c.config.forbiddenSelfUsernames, c.config.Username) {
logger.Log(logger.LevelError, c.logSender, "",
"SFTP loop or nested local SFTP folders detected, username %q, forbidden usernames: %+v",
c.config.Username, c.config.forbiddenSelfUsernames)
@@ -979,12 +999,8 @@ func (c *sftpConnection) openConnNoLock() error {
Timeout: 15 * time.Second,
ClientVersion: fmt.Sprintf("SSH-2.0-%s", version.GetServerVersion("_", false)),
}
- signer, err := c.getKeySigner()
- if err != nil {
- return fmt.Errorf("sftpfs: unable to parse the private key: %w", err)
- }
- if signer != nil {
- clientConfig.Auth = append(clientConfig.Auth, ssh.PublicKeys(signer))
+ if c.signer != nil {
+ clientConfig.Auth = append(clientConfig.Auth, ssh.PublicKeys(c.signer))
}
if pwd := c.config.Password.GetPayload(); pwd != "" {
clientConfig.Auth = append(clientConfig.Auth, ssh.Password(pwd))
@@ -992,8 +1008,7 @@ func (c *sftpConnection) openConnNoLock() error {
supportedAlgos := ssh.SupportedAlgorithms()
insecureAlgos := ssh.InsecureAlgorithms()
// add all available ciphers, KEXs and MACs, they are negotiated according to the order
- clientConfig.Ciphers = append(supportedAlgos.Ciphers, ssh.InsecureCipherAES128CBC,
- ssh.InsecureCipherAES192CBC, ssh.InsecureCipherAES256CBC)
+ clientConfig.Ciphers = append(supportedAlgos.Ciphers, ssh.InsecureCipherAES128CBC)
clientConfig.KeyExchanges = append(supportedAlgos.KeyExchanges, insecureAlgos.KeyExchanges...)
clientConfig.MACs = append(supportedAlgos.MACs, insecureAlgos.MACs...)
sshClient, err := ssh.Dial("tcp", c.config.Endpoint, clientConfig)
@@ -1141,14 +1156,14 @@ func (c *sftpConnection) GetLastActivity() time.Time {
type sftpConnectionsCache struct {
scheduler *cron.Cron
- sync.RWMutex
- items map[uint64]*sftpConnection
+ sync.Mutex
+ items map[string]*sftpConnection
}
func newSFTPConnectionCache() *sftpConnectionsCache {
c := &sftpConnectionsCache{
scheduler: cron.New(cron.WithLocation(time.UTC), cron.WithLogger(cron.DiscardLogger)),
- items: make(map[uint64]*sftpConnection),
+ items: make(map[string]*sftpConnection),
}
_, err := c.scheduler.AddFunc("@every 1m", c.Cleanup)
util.PanicOnError(err)
@@ -1156,65 +1171,62 @@ func newSFTPConnectionCache() *sftpConnectionsCache {
return c
}
-func (c *sftpConnectionsCache) Get(config *SFTPFsConfig, sessionID string) *sftpConnection {
+func (c *sftpConnectionsCache) Get(config *SFTPFsConfig, sessionID string) (*sftpConnection, error) {
partition := 0
key := config.getUniqueID(partition)
c.Lock()
defer c.Unlock()
- var oldKey uint64
for {
if val, ok := c.items[key]; ok {
activeSessions := val.ActiveSessions()
- if activeSessions < maxSessionsPerConnection || key == oldKey {
+ if activeSessions < maxSessionsPerConnection {
logger.Debug(logSenderSFTPCache, "",
- "reusing connection for session ID %q, key: %d, active sessions %d, active connections: %d",
+ "reusing connection for session ID %q, key %s, active sessions %d, active connections: %d",
sessionID, key, activeSessions+1, len(c.items))
val.AddSession(sessionID)
- return val
+ return val, nil
}
partition++
- oldKey = key
key = config.getUniqueID(partition)
logger.Debug(logSenderSFTPCache, "",
- "connection full, generated new key for partition: %d, active sessions: %d, key: %d, old key: %d",
- partition, activeSessions, oldKey, key)
+ "connection full, generated new key for partition: %d, active sessions: %d, key: %s",
+ partition, activeSessions, key)
} else {
conn := newSFTPConnection(config, sessionID)
+ signer, err := config.getKeySigner()
+ if err != nil {
+ return nil, fmt.Errorf("sftpfs: unable to parse the private key: %w", err)
+ }
+ conn.signer = signer
c.items[key] = conn
logger.Debug(logSenderSFTPCache, "",
- "adding new connection for session ID %q, partition: %d, key: %d, active connections: %d",
+ "adding new connection for session ID %q, partition: %d, key: %s, active connections: %d",
sessionID, partition, key, len(c.items))
- return conn
+ return conn, nil
}
}
}
-func (c *sftpConnectionsCache) Remove(key uint64) {
- c.Lock()
- defer c.Unlock()
-
- if conn, ok := c.items[key]; ok {
- delete(c.items, key)
- logger.Debug(logSenderSFTPCache, "", "removed connection with key %d, active connections: %d", key, len(c.items))
-
- defer conn.Close()
- }
-}
-
func (c *sftpConnectionsCache) Cleanup() {
- c.RLock()
+ c.Lock()
+
+ var connectionsToClose []*sftpConnection
for k, conn := range c.items {
if val := conn.GetLastActivity(); val.Before(time.Now().Add(-30 * time.Second)) {
- logger.Debug(conn.logSender, "", "removing inactive connection, last activity %s", val)
-
- defer func(key uint64) {
- c.Remove(key)
- }(k)
+ delete(c.items, k)
+ logger.Debug(logSenderSFTPCache, "", "removed connection with key %s, last activity %s, active connections: %d",
+ k, val, len(c.items))
+ connectionsToClose = append(connectionsToClose, conn)
}
}
- c.RUnlock()
+ c.Unlock()
+
+ for _, conn := range connectionsToClose {
+ err := conn.Close()
+ logger.Debug(logSenderSFTPCache, "", "connection closed, err: %v", err)
+ }
}
diff --git a/internal/vfs/statvfs_fallback.go b/internal/vfs/statvfs_fallback.go
index f857ffbe..19d8e2c3 100644
--- a/internal/vfs/statvfs_fallback.go
+++ b/internal/vfs/statvfs_fallback.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build !darwin && !linux && !freebsd
-// +build !darwin,!linux,!freebsd
package vfs
diff --git a/internal/vfs/statvfs_linux.go b/internal/vfs/statvfs_linux.go
index 03b71459..772265fc 100644
--- a/internal/vfs/statvfs_linux.go
+++ b/internal/vfs/statvfs_linux.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build linux
-// +build linux
package vfs
diff --git a/internal/vfs/statvfs_unix.go b/internal/vfs/statvfs_unix.go
index 26482b1a..53f43202 100644
--- a/internal/vfs/statvfs_unix.go
+++ b/internal/vfs/statvfs_unix.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build freebsd || darwin
-// +build freebsd darwin
package vfs
diff --git a/internal/vfs/sys_unix.go b/internal/vfs/sys_unix.go
index e2e01e13..427792f8 100644
--- a/internal/vfs/sys_unix.go
+++ b/internal/vfs/sys_unix.go
@@ -13,7 +13,6 @@
// along with this program. If not, see .
//go:build !windows
-// +build !windows
package vfs
diff --git a/internal/vfs/vfs.go b/internal/vfs/vfs.go
index b8edcb34..6f883e6a 100644
--- a/internal/vfs/vfs.go
+++ b/internal/vfs/vfs.go
@@ -16,6 +16,7 @@
package vfs
import (
+ "bytes"
"errors"
"fmt"
"io"
@@ -24,6 +25,7 @@ import (
"path"
"path/filepath"
"runtime"
+ "slices"
"strconv"
"strings"
"sync"
@@ -51,8 +53,9 @@ const (
// Additional checks for files
const (
- CheckParentDir = 1
- CheckResume = 2
+ CheckParentDir = 1
+ CheckResume = 2
+ CheckUpdateModTime = 4
)
var (
@@ -71,6 +74,12 @@ var (
uploadMode int
)
+var (
+ createPipeFn = func(dirPath string, _ int64) (pipeReaderAt, pipeWriterAt, error) {
+ return pipeat.PipeInDir(dirPath)
+ }
+)
+
// SetAllowSelfConnections sets the desired behaviour for self connections
func SetAllowSelfConnections(value int) {
allowSelfConnections = value
@@ -120,7 +129,7 @@ type Fs interface {
Lstat(name string) (os.FileInfo, error)
Open(name string, offset int64) (File, PipeReader, func(), error)
Create(name string, flag, checks int) (File, PipeWriter, func(), error)
- Rename(source, target string) (int, int64, error)
+ Rename(source, target string, checks int) (int, int64, error)
Remove(name string, isDir bool) error
Mkdir(name string) error
Symlink(source, target string) error
@@ -159,7 +168,7 @@ type FsRealPather interface {
// FsFileCopier is a Fs that implements the CopyFile method.
type FsFileCopier interface {
Fs
- CopyFile(source, target string, srcSize int64) (int, int64, error)
+ CopyFile(source, target string, srcInfo os.FileInfo) (int, int64, error)
}
// File defines an interface representing a SFTPGo file
@@ -194,6 +203,22 @@ type PipeReader interface {
Metadata() map[string]string
}
+type pipeReaderAt interface {
+ Read(p []byte) (int, error)
+ ReadAt(p []byte, offset int64) (int, error)
+ GetReadedBytes() int64
+ Close() error
+ CloseWithError(err error) error
+}
+
+type pipeWriterAt interface {
+ Write(p []byte) (int, error)
+ WriteAt(p []byte, offset int64) (int, error)
+ GetWrittenBytes() int64
+ Close() error
+ CloseWithError(err error) error
+}
+
// DirLister defines an interface for a directory lister
type DirLister interface {
Next(limit int) ([]os.FileInfo, error)
@@ -265,7 +290,8 @@ func (q *QuotaCheckResult) GetRemainingFiles() int {
// S3FsConfig defines the configuration for S3 based filesystem
type S3FsConfig struct {
sdk.BaseS3FsConfig
- AccessSecret *kms.Secret `json:"access_secret,omitempty"`
+ AccessSecret *kms.Secret `json:"access_secret,omitempty"`
+ SSECustomerKey *kms.Secret `json:"sse_customer_key,omitempty"`
}
// HideConfidentialData hides confidential data
@@ -273,6 +299,9 @@ func (c *S3FsConfig) HideConfidentialData() {
if c.AccessSecret != nil {
c.AccessSecret.Hide()
}
+ if c.SSECustomerKey != nil {
+ c.SSECustomerKey.Hide()
+ }
}
func (c *S3FsConfig) isEqual(other S3FsConfig) bool {
@@ -335,6 +364,15 @@ func (c *S3FsConfig) areMultipartFieldsEqual(other S3FsConfig) bool {
}
func (c *S3FsConfig) isSecretEqual(other S3FsConfig) bool {
+ if c.SSECustomerKey == nil {
+ c.SSECustomerKey = kms.NewEmptySecret()
+ }
+ if other.SSECustomerKey == nil {
+ other.SSECustomerKey = kms.NewEmptySecret()
+ }
+ if !c.SSECustomerKey.IsEqual(other.SSECustomerKey) {
+ return false
+ }
if c.AccessSecret == nil {
c.AccessSecret = kms.NewEmptySecret()
}
@@ -363,6 +401,12 @@ func (c *S3FsConfig) checkCredentials() error {
if !c.AccessSecret.IsEmpty() && !c.AccessSecret.IsValidInput() {
return errors.New("invalid access_secret")
}
+ if c.SSECustomerKey.IsEncrypted() && !c.SSECustomerKey.IsValid() {
+ return errors.New("invalid encrypted sse_customer_key")
+ }
+ if !c.SSECustomerKey.IsEmpty() && !c.SSECustomerKey.IsValidInput() {
+ return errors.New("invalid sse_customer_key")
+ }
return nil
}
@@ -386,13 +430,23 @@ func (c *S3FsConfig) ValidateAndEncryptCredentials(additionalData string) error
)
}
}
+ if c.SSECustomerKey.IsPlain() {
+ c.SSECustomerKey.SetAdditionalData(additionalData)
+ err := c.SSECustomerKey.Encrypt()
+ if err != nil {
+ return util.NewI18nError(
+ util.NewValidationError(fmt.Sprintf("could not encrypt s3 SSE customer key: %v", err)),
+ util.I18nErrorFsValidation,
+ )
+ }
+ }
return nil
}
func (c *S3FsConfig) checkPartSizeAndConcurrency() error {
- if c.UploadPartSize != 0 && (c.UploadPartSize < 5 || c.UploadPartSize > 5000) {
+ if c.UploadPartSize != 0 && (c.UploadPartSize < 5 || c.UploadPartSize > 2000) {
return util.NewI18nError(
- errors.New("upload_part_size cannot be != 0, lower than 5 (MB) or greater than 5000 (MB)"),
+ errors.New("upload_part_size cannot be != 0, lower than 5 (MB) or greater than 2000 (MB)"),
util.I18nErrorULPartSizeInvalid,
)
}
@@ -402,9 +456,9 @@ func (c *S3FsConfig) checkPartSizeAndConcurrency() error {
util.I18nErrorULConcurrencyInvalid,
)
}
- if c.DownloadPartSize != 0 && (c.DownloadPartSize < 5 || c.DownloadPartSize > 5000) {
+ if c.DownloadPartSize != 0 && (c.DownloadPartSize < 5 || c.DownloadPartSize > 2000) {
return util.NewI18nError(
- errors.New("download_part_size cannot be != 0, lower than 5 (MB) or greater than 5000 (MB)"),
+ errors.New("download_part_size cannot be != 0, lower than 5 (MB) or greater than 2000 (MB)"),
util.I18nErrorDLPartSizeInvalid,
)
}
@@ -432,6 +486,9 @@ func (c *S3FsConfig) validate() error {
if c.AccessSecret == nil {
c.AccessSecret = kms.NewEmptySecret()
}
+ if c.SSECustomerKey == nil {
+ c.SSECustomerKey = kms.NewEmptySecret()
+ }
if c.Bucket == "" {
return util.NewI18nError(errors.New("bucket cannot be empty"), util.I18nErrorBucketRequired)
}
@@ -529,7 +586,7 @@ func (c *GCSFsConfig) isSameResource(other GCSFsConfig) bool {
}
// validate returns an error if the configuration is not valid
-func (c *GCSFsConfig) validate() error {
+func (c *GCSFsConfig) validate() error { //nolint:gocyclo
if c.Credentials == nil || c.AutomaticCredentials == 1 {
c.Credentials = kms.NewEmptySecret()
}
@@ -553,7 +610,7 @@ func (c *GCSFsConfig) validate() error {
}
c.StorageClass = strings.TrimSpace(c.StorageClass)
c.ACL = strings.TrimSpace(c.ACL)
- if c.UploadPartSize < 0 {
+ if c.UploadPartSize < 0 || c.UploadPartSize > 2000 {
c.UploadPartSize = 0
}
if c.UploadPartMaxTime < 0 {
@@ -680,8 +737,8 @@ func (c *AzBlobFsConfig) checkCredentials() error {
if !c.SASURL.IsEmpty() {
return nil
}
- if c.AccountName == "" || !c.AccountKey.IsValidInput() {
- return util.NewI18nError(errors.New("credentials cannot be empty or invalid"), util.I18nErrorAccountNameRequired)
+ if c.AccountName == "" {
+ return util.NewI18nError(errors.New("account name is required"), util.I18nErrorAccountNameRequired)
}
if c.AccountKey.IsEncrypted() && !c.AccountKey.IsValid() {
return errors.New("invalid encrypted account_key")
@@ -690,7 +747,7 @@ func (c *AzBlobFsConfig) checkCredentials() error {
}
func (c *AzBlobFsConfig) checkPartSizeAndConcurrency() error {
- if c.UploadPartSize < 0 || c.UploadPartSize > 100 {
+ if c.UploadPartSize < 0 || c.UploadPartSize > 2000 {
return util.NewI18nError(
fmt.Errorf("invalid upload part size: %v", c.UploadPartSize),
util.I18nErrorULPartSizeInvalid,
@@ -702,7 +759,7 @@ func (c *AzBlobFsConfig) checkPartSizeAndConcurrency() error {
util.I18nErrorULConcurrencyInvalid,
)
}
- if c.DownloadPartSize < 0 || c.DownloadPartSize > 100 {
+ if c.DownloadPartSize < 0 || c.DownloadPartSize > 2000 {
return util.NewI18nError(
fmt.Errorf("invalid download part size: %v", c.DownloadPartSize),
util.I18nErrorDLPartSizeInvalid,
@@ -734,6 +791,12 @@ func (c *AzBlobFsConfig) isSameResource(other AzBlobFsConfig) bool {
if c.Endpoint != other.Endpoint {
return false
}
+ if c.SASURL == nil {
+ c.SASURL = kms.NewEmptySecret()
+ }
+ if other.SASURL == nil {
+ other.SASURL = kms.NewEmptySecret()
+ }
return c.SASURL.GetPayload() == other.SASURL.GetPayload()
}
@@ -764,7 +827,7 @@ func (c *AzBlobFsConfig) validate() error {
if err := c.checkPartSizeAndConcurrency(); err != nil {
return err
}
- if !util.Contains(validAzAccessTier, c.AccessTier) {
+ if !slices.Contains(validAzAccessTier, c.AccessTier) {
return fmt.Errorf("invalid access tier %q, valid values: \"''%v\"", c.AccessTier, strings.Join(validAzAccessTier, ", "))
}
return nil
@@ -833,25 +896,25 @@ func (c *CryptFsConfig) validate() error {
return nil
}
-// pipeWriter defines a wrapper for pipeat.PipeWriterAt.
+// pipeWriter defines a wrapper for a pipeWriterAt.
type pipeWriter struct {
- *pipeat.PipeWriterAt
+ pipeWriterAt
err error
done chan bool
}
// NewPipeWriter initializes a new PipeWriter
-func NewPipeWriter(w *pipeat.PipeWriterAt) PipeWriter {
+func NewPipeWriter(w pipeWriterAt) PipeWriter {
return &pipeWriter{
- PipeWriterAt: w,
+ pipeWriterAt: w,
err: nil,
done: make(chan bool),
}
}
-// Close waits for the upload to end, closes the pipeat.PipeWriterAt and returns an error if any.
+// Close waits for the upload to end, closes the pipeWriterAt and returns an error if any.
func (p *pipeWriter) Close() error {
- p.PipeWriterAt.Close() //nolint:errcheck // the returned error is always null
+ p.pipeWriterAt.Close() //nolint:errcheck // the returned error is always null
<-p.done
return p.err
}
@@ -863,10 +926,10 @@ func (p *pipeWriter) Done(err error) {
p.done <- true
}
-func newPipeWriterAtOffset(w *pipeat.PipeWriterAt, offset int64) PipeWriter {
+func newPipeWriterAtOffset(w pipeWriterAt, offset int64) PipeWriter {
return &pipeWriterAtOffset{
pipeWriter: &pipeWriter{
- PipeWriterAt: w,
+ pipeWriterAt: w,
err: nil,
done: make(chan bool),
},
@@ -895,15 +958,15 @@ func (p *pipeWriterAtOffset) Write(buf []byte) (int, error) {
}
// NewPipeReader initializes a new PipeReader
-func NewPipeReader(r *pipeat.PipeReaderAt) PipeReader {
+func NewPipeReader(r pipeReaderAt) PipeReader {
return &pipeReader{
- PipeReaderAt: r,
+ pipeReaderAt: r,
}
}
// pipeReader defines a wrapper for pipeat.PipeReaderAt.
type pipeReader struct {
- *pipeat.PipeReaderAt
+ pipeReaderAt
mu sync.RWMutex
metadata map[string]string
}
@@ -1083,7 +1146,7 @@ func IsUploadResumeSupported(fs Fs, size int64) bool {
}
func getLastModified(metadata map[string]string) int64 {
- if val, ok := metadata[lastModifiedField]; ok {
+ if val, ok := metadata[lastModifiedField]; ok && val != "" {
lastModified, err := strconv.ParseInt(val, 10, 64)
if err == nil {
return lastModified
@@ -1094,7 +1157,7 @@ func getLastModified(metadata map[string]string) int64 {
func getAzureLastModified(metadata map[string]*string) int64 {
for k, v := range metadata {
- if strings.ToLower(k) == lastModifiedField {
+ if strings.EqualFold(k, lastModifiedField) {
if val := util.GetStringFromPointer(v); val != "" {
lastModified, err := strconv.ParseInt(val, 10, 64)
if err == nil {
@@ -1166,8 +1229,8 @@ func getLocalTempDir() string {
}
func doRecursiveRename(fs Fs, source, target string,
- renameFn func(string, string, os.FileInfo, int) (int, int64, error),
- recursion int,
+ renameFn func(string, string, os.FileInfo, int, bool) (int, int64, error),
+ recursion int, updateModTime bool,
) (int, int64, error) {
var numFiles int
var filesSize int64
@@ -1192,7 +1255,7 @@ func doRecursiveRename(fs Fs, source, target string,
for _, info := range entries {
sourceEntry := fs.Join(source, info.Name())
targetEntry := fs.Join(target, info.Name())
- files, size, err := renameFn(sourceEntry, targetEntry, info, recursion)
+ files, size, err := renameFn(sourceEntry, targetEntry, info, recursion, updateModTime)
if err != nil {
if fs.IsNotExist(err) {
fsLog(fs, logger.LevelInfo, "skipping rename for %q: %v", sourceEntry, err)
@@ -1209,6 +1272,88 @@ func doRecursiveRename(fs Fs, source, target string,
}
}
+// copied from rclone
+func readFill(r io.Reader, buf []byte) (n int, err error) {
+ var nn int
+ for n < len(buf) && err == nil {
+ nn, err = r.Read(buf[n:])
+ n += nn
+ }
+ return n, err
+}
+
+func writeAtFull(w io.WriterAt, buf []byte, offset int64, count int) error {
+ written := 0
+ for written < count {
+ n, err := w.WriteAt(buf[written:count], offset+int64(written))
+ written += n
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+type bytesReaderWrapper struct {
+ *bytes.Reader
+}
+
+func (b *bytesReaderWrapper) Close() error {
+ return nil
+}
+
+type bufferAllocator struct {
+ sync.Mutex
+ available [][]byte
+ bufferSize int
+ finalized bool
+}
+
+func newBufferAllocator(size int) *bufferAllocator {
+ return &bufferAllocator{
+ bufferSize: size,
+ finalized: false,
+ }
+}
+
+func (b *bufferAllocator) getBuffer() []byte {
+ b.Lock()
+ defer b.Unlock()
+
+ if len(b.available) > 0 {
+ var result []byte
+
+ truncLength := len(b.available) - 1
+ result = b.available[truncLength]
+
+ b.available[truncLength] = nil
+ b.available = b.available[:truncLength]
+
+ return result
+ }
+
+ return make([]byte, b.bufferSize)
+}
+
+func (b *bufferAllocator) releaseBuffer(buf []byte) {
+ b.Lock()
+ defer b.Unlock()
+
+ if b.finalized || len(buf) != b.bufferSize {
+ return
+ }
+
+ b.available = append(b.available, buf)
+}
+
+func (b *bufferAllocator) free() {
+ b.Lock()
+ defer b.Unlock()
+
+ b.available = nil
+ b.finalized = true
+}
+
func fsLog(fs Fs, level logger.LogLevel, format string, v ...any) {
logger.Log(level, fs.Name(), fs.ConnectionID(), format, v...)
}
diff --git a/internal/webdavd/file.go b/internal/webdavd/file.go
index 345c2714..88cc6bde 100644
--- a/internal/webdavd/file.go
+++ b/internal/webdavd/file.go
@@ -23,11 +23,11 @@ import (
"net/http"
"os"
"path"
+ "slices"
"sync/atomic"
"time"
"github.com/drakkan/webdav"
- "github.com/eikenb/pipeat"
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
@@ -51,7 +51,7 @@ type webDavFile struct {
readTried atomic.Bool
}
-func newWebDavFile(baseTransfer *common.BaseTransfer, pipeWriter vfs.PipeWriter, pipeReader *pipeat.PipeReaderAt) *webDavFile {
+func newWebDavFile(baseTransfer *common.BaseTransfer, pipeWriter vfs.PipeWriter, pipeReader vfs.PipeReader) *webDavFile {
var writer io.WriteCloser
var reader io.ReadCloser
if baseTransfer.File != nil {
@@ -165,7 +165,7 @@ func (f *webDavFile) checkFirstRead() error {
if !f.Connection.User.HasPerm(dataprovider.PermDownload, path.Dir(f.GetVirtualPath())) {
return f.Connection.GetPermissionDeniedError()
}
- transferQuota := f.BaseTransfer.GetTransferQuota()
+ transferQuota := f.GetTransferQuota()
if !transferQuota.HasDownloadSpace() {
f.Connection.Log(logger.LevelInfo, "denying file read due to quota limits")
return f.Connection.GetReadQuotaExceededError()
@@ -212,7 +212,7 @@ func (f *webDavFile) Read(p []byte) (n int, err error) {
} else if r != nil {
f.reader = r
}
- f.BaseTransfer.SetCancelFn(cancelFn)
+ f.SetCancelFn(cancelFn)
}
f.ErrTransfer = e
f.startOffset = 0
@@ -322,9 +322,10 @@ func (f *webDavFile) Seek(offset int64, whence int) (int64, error) {
if f.GetType() == common.TransferDownload {
readOffset := f.startOffset + f.BytesSent.Load()
if offset == 0 && readOffset == 0 {
- if whence == io.SeekStart {
+ switch whence {
+ case io.SeekStart:
return 0, nil
- } else if whence == io.SeekEnd {
+ case io.SeekEnd:
if err := f.updateStatInfo(); err != nil {
return 0, err
}
@@ -363,7 +364,7 @@ func (f *webDavFile) Seek(offset int64, whence int) (int64, error) {
f.reader = r
}
f.ErrTransfer = err
- f.BaseTransfer.SetCancelFn(cancelFn)
+ f.SetCancelFn(cancelFn)
f.Unlock()
return startByte, err
@@ -403,7 +404,7 @@ func (f *webDavFile) closeIO() error {
} else if f.reader != nil {
err = f.reader.Close()
if metadater, ok := f.reader.(vfs.Metadater); ok {
- f.BaseTransfer.SetMetadata(metadater.Metadata())
+ f.SetMetadata(metadater.Metadata())
}
}
return err
@@ -447,11 +448,11 @@ func (f *webDavFile) Patch(patches []webdav.Proppatch) ([]webdav.Propstat, error
pstat := webdav.Propstat{}
for _, p := range patch.Props {
if status == http.StatusForbidden && !hasError {
- if !patch.Remove && util.Contains(lastModifiedProps, p.XMLName.Local) {
+ if !patch.Remove && slices.Contains(lastModifiedProps, p.XMLName.Local) {
parsed, err := parseTime(util.BytesToString(p.InnerXML))
if err != nil {
f.Connection.Log(logger.LevelWarn, "unsupported last modification time: %q, err: %v",
- util.BytesToString(p.InnerXML), err)
+ p.InnerXML, err)
hasError = true
continue
}
diff --git a/internal/webdavd/handler.go b/internal/webdavd/handler.go
index 674b5b72..2fc3ca1b 100644
--- a/internal/webdavd/handler.go
+++ b/internal/webdavd/handler.go
@@ -36,6 +36,17 @@ import (
type Connection struct {
*common.BaseConnection
request *http.Request
+ rc *http.ResponseController
+}
+
+func newConnection(conn *common.BaseConnection, w http.ResponseWriter, r *http.Request) *Connection {
+ rc := http.NewResponseController(w)
+ responseControllerDeadlines(rc, time.Time{}, time.Time{})
+ return &Connection{
+ BaseConnection: conn,
+ request: r,
+ rc: rc,
+ }
}
func (c *Connection) getModificationTime() time.Time {
@@ -73,6 +84,9 @@ func (c *Connection) GetRemoteAddress() string {
// Disconnect closes the active transfer
func (c *Connection) Disconnect() error {
+ if c.rc != nil {
+ responseControllerDeadlines(c.rc, time.Now().Add(5*time.Second), time.Now().Add(5*time.Second))
+ }
return c.SignalTransfersAbort()
}
@@ -145,6 +159,11 @@ func (c *Connection) RemoveAll(_ context.Context, name string) error {
func (c *Connection) OpenFile(_ context.Context, name string, flag int, _ os.FileMode) (webdav.File, error) {
c.UpdateLastActivity()
+ if err := common.Connections.IsNewTransferAllowed(c.User.Username); err != nil {
+ c.Log(logger.LevelInfo, "denying transfer due to count limits")
+ return nil, c.GetPermissionDeniedError()
+ }
+
name = util.CleanPath(name)
fs, p, err := c.GetFsAndResolvedPath(name)
if err != nil {
@@ -254,7 +273,7 @@ func (c *Connection) handleUploadToExistingFile(fs vfs.Fs, resolvedPath, filePat
maxWriteSize, _ := c.GetMaxWriteSize(diskQuota, false, fileSize, fs.IsUploadResumeSupported())
if common.Config.IsAtomicUploadEnabled() && fs.IsAtomicUploadSupported() {
- _, _, err = fs.Rename(resolvedPath, filePath)
+ _, _, err = fs.Rename(resolvedPath, filePath, 0)
if err != nil {
c.Log(logger.LevelError, "error renaming existing file for atomic upload, source: %q, dest: %q, err: %+v",
resolvedPath, filePath, err)
@@ -272,10 +291,7 @@ func (c *Connection) handleUploadToExistingFile(fs vfs.Fs, resolvedPath, filePat
if vfs.HasTruncateSupport(fs) {
vfolder, err := c.User.GetVirtualFolderForPath(path.Dir(requestPath))
if err == nil {
- dataprovider.UpdateVirtualFolderQuota(&vfolder.BaseVirtualFolder, 0, -fileSize, false) //nolint:errcheck
- if vfolder.IsIncludedInUserQuota() {
- dataprovider.UpdateUserQuota(&c.User, 0, -fileSize, false) //nolint:errcheck
- }
+ dataprovider.UpdateUserFolderQuota(&vfolder, &c.User, 0, -fileSize, false)
} else {
dataprovider.UpdateUserQuota(&c.User, 0, -fileSize, false) //nolint:errcheck
}
diff --git a/internal/webdavd/internal_test.go b/internal/webdavd/internal_test.go
index 0366c7d2..35f71035 100644
--- a/internal/webdavd/internal_test.go
+++ b/internal/webdavd/internal_test.go
@@ -270,6 +270,7 @@ Ap157PUHTriSnxyMF2Sb3EhX/rQkmbnbCqqygHC14iBy8MrKzLG00X6BelZV5n0D
RKjnkiEZeG4+G91Xu7+HmcBLwV86k5I+tXK9O1Okomr6Zry8oqVcxU5TB6VRS+rA
ubwF00Drdvk2+kDZfxIM137nBiy7wgCJi2Ksm5ihN3dUF6Q0oNPl
-----END RSA PRIVATE KEY-----`
+ osWindows = "windows"
)
// MockOsFs mockable OsFs
@@ -312,7 +313,7 @@ func (fs *MockOsFs) Remove(name string, _ bool) error {
}
// Rename renames (moves) source to target
-func (fs *MockOsFs) Rename(source, target string) (int, int64, error) {
+func (fs *MockOsFs) Rename(source, target string, _ int) (int, int64, error) {
err := os.Rename(source, target)
return -1, -1, err
}
@@ -378,6 +379,15 @@ func TestAllowedProxyUnixDomainSocket(t *testing.T) {
}
}
+func TestProxyListenerWrapper(t *testing.T) {
+ b := Binding{
+ ProxyMode: 0,
+ }
+ require.Nil(t, b.listenerWrapper())
+ b.ProxyMode = 1
+ require.NotNil(t, b.listenerWrapper())
+}
+
func TestRemoteAddress(t *testing.T) {
remoteAddr1 := "100.100.100.100"
remoteAddr2 := "172.172.172.172"
@@ -514,7 +524,7 @@ func TestResolvePathErrors(t *testing.T) {
assert.EqualError(t, err, common.ErrGenericFailure.Error())
}
- if runtime.GOOS != "windows" {
+ if runtime.GOOS != osWindows {
user.HomeDir = filepath.Clean(os.TempDir())
connection.User = user
fs := vfs.NewOsFs("connID", connection.User.HomeDir, "", nil)
@@ -751,6 +761,8 @@ func TestContentType(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "application/sftpgo", ctype)
}
+ err = davFile.Close()
+ assert.NoError(t, err)
baseTransfer = common.NewBaseTransfer(nil, connection.BaseConnection, nil, testFilePath, testFilePath, testFile+".unknown2",
common.TransferDownload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{})
@@ -797,7 +809,7 @@ func TestTransferReadWriteErrors(t *testing.T) {
r, w, err := pipeat.Pipe()
assert.NoError(t, err)
- davFile = newWebDavFile(baseTransfer, nil, r)
+ davFile = newWebDavFile(baseTransfer, nil, vfs.NewPipeReader(r))
davFile.Connection.RemoveTransfer(davFile.BaseTransfer)
davFile = newWebDavFile(baseTransfer, vfs.NewPipeWriter(w), nil)
davFile.Connection.RemoveTransfer(davFile.BaseTransfer)
@@ -805,6 +817,8 @@ func TestTransferReadWriteErrors(t *testing.T) {
assert.NoError(t, err)
err = w.Close()
assert.NoError(t, err)
+ err = davFile.BaseTransfer.Close()
+ assert.Error(t, err)
baseTransfer = common.NewBaseTransfer(nil, connection.BaseConnection, nil, testFilePath, testFilePath, testFile,
common.TransferDownload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{})
@@ -813,6 +827,8 @@ func TestTransferReadWriteErrors(t *testing.T) {
assert.True(t, fs.IsNotExist(err))
_, err = davFile.Stat()
assert.True(t, fs.IsNotExist(err))
+ err = davFile.Close()
+ assert.Error(t, err)
baseTransfer = common.NewBaseTransfer(nil, connection.BaseConnection, nil, testFilePath, testFilePath, testFile,
common.TransferDownload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{})
@@ -835,6 +851,8 @@ func TestTransferReadWriteErrors(t *testing.T) {
if assert.NoError(t, err) {
assert.Equal(t, int64(0), info.Size())
}
+ err = davFile.Close()
+ assert.Error(t, err)
r, w, err = pipeat.Pipe()
assert.NoError(t, err)
@@ -978,8 +996,11 @@ func TestTransferSeek(t *testing.T) {
res, err = davFile.Seek(2, io.SeekEnd)
assert.True(t, fs.IsNotExist(err))
assert.Equal(t, int64(0), res)
+ err = davFile.Close()
+ assert.NoError(t, err)
assert.Len(t, common.Connections.GetStats(""), 0)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
err = os.Remove(testFilePath)
assert.NoError(t, err)
@@ -1476,8 +1497,11 @@ func TestUserCacheIsolation(t *testing.T) {
LockSystem: webdav.NewMemLS(),
}
cachedUser.User.FsConfig.S3Config.AccessSecret = kms.NewPlainSecret("test secret")
+ cachedUser.User.FsConfig.S3Config.SSECustomerKey = kms.NewPlainSecret("test key")
err = cachedUser.User.FsConfig.S3Config.AccessSecret.Encrypt()
assert.NoError(t, err)
+ err = cachedUser.User.FsConfig.S3Config.SSECustomerKey.Encrypt()
+ assert.NoError(t, err)
dataprovider.CacheWebDAVUser(cachedUser)
cachedUser, ok := dataprovider.GetCachedWebDAVUser(username)
@@ -1491,6 +1515,9 @@ func TestUserCacheIsolation(t *testing.T) {
assert.True(t, cachedUser.User.FsConfig.S3Config.AccessSecret.IsEncrypted())
err = cachedUser.User.FsConfig.S3Config.AccessSecret.Decrypt()
assert.NoError(t, err)
+ assert.True(t, cachedUser.User.FsConfig.S3Config.SSECustomerKey.IsEncrypted())
+ err = cachedUser.User.FsConfig.S3Config.SSECustomerKey.Decrypt()
+ assert.NoError(t, err)
cachedUser.User.FsConfig.Provider = sdk.S3FilesystemProvider
_, err = cachedUser.User.GetFilesystem("")
assert.Error(t, err, "we don't have to get the previously cached filesystem!")
@@ -1499,6 +1526,7 @@ func TestUserCacheIsolation(t *testing.T) {
if assert.True(t, ok) {
assert.Equal(t, sdk.LocalFilesystemProvider, cachedUser.User.FsConfig.Provider)
assert.False(t, cachedUser.User.FsConfig.S3Config.AccessSecret.IsEncrypted())
+ assert.False(t, cachedUser.User.FsConfig.S3Config.SSECustomerKey.IsEncrypted())
}
err = dataprovider.DeleteUser(username, "", "", "")
@@ -1713,3 +1741,68 @@ func TestGetCacheExpirationTime(t *testing.T) {
c.ExpirationTime = 1
assert.False(t, c.getExpirationTime().IsZero())
}
+
+func TestBindingGetAddress(t *testing.T) {
+ tests := []struct {
+ name string
+ binding Binding
+ want string
+ }{
+ {
+ name: "IP address with port",
+ binding: Binding{Address: "127.0.0.1", Port: 8080},
+ want: "127.0.0.1:8080",
+ },
+ {
+ name: "Unix socket path (no port)",
+ binding: Binding{Address: "/tmp/app.sock", Port: 0},
+ want: "/tmp/app.sock",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := tt.binding.GetAddress(); got != tt.want {
+ t.Errorf("GetAddress() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestBindingIsValid(t *testing.T) {
+ tests := []struct {
+ name string
+ binding Binding
+ want bool
+ }{
+ {
+ name: "Valid: Positive port",
+ binding: Binding{Address: "127.0.0.1", Port: 10080},
+ want: true,
+ },
+ {
+ name: "Valid: Absolute path on Unix (non-Windows)",
+ binding: Binding{Address: "/var/run/app.sock", Port: 0},
+ // This test outcome is dynamic based on the OS
+ want: runtime.GOOS != osWindows,
+ },
+ {
+ name: "Invalid: Port 0 and relative path",
+ binding: Binding{Address: "relative/path", Port: 0},
+ want: false,
+ },
+ {
+ name: "Invalid: Empty address and port 0",
+ binding: Binding{Address: "", Port: 0},
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := tt.binding.IsValid(); got != tt.want {
+ t.Errorf("IsValid() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/webdavd/server.go b/internal/webdavd/server.go
index 88ba6a08..06533320 100644
--- a/internal/webdavd/server.go
+++ b/internal/webdavd/server.go
@@ -26,6 +26,7 @@ import (
"path"
"path/filepath"
"runtime/debug"
+ "slices"
"strings"
"time"
@@ -33,6 +34,7 @@ import (
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/cors"
"github.com/rs/xid"
+ "github.com/rs/zerolog"
"github.com/sftpgo/sdk/plugin/notifier"
"github.com/drakkan/sftpgo/v2/internal/common"
@@ -53,8 +55,6 @@ func (s *webDavServer) listenAndServe(compressor *middleware.Compressor) error {
handler := compressor.Handler(s)
httpServer := &http.Server{
ReadHeaderTimeout: 30 * time.Second,
- ReadTimeout: 60 * time.Second,
- WriteTimeout: 60 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16, // 64KB
ErrorLog: log.New(&logger.StdLoggerWrapper{Sender: logSender}, "", 0),
@@ -98,11 +98,13 @@ func (s *webDavServer) listenAndServe(compressor *middleware.Compressor) error {
httpServer.TLSConfig.ClientAuth = tls.VerifyClientCertIfGiven
}
}
- return util.HTTPListenAndServe(httpServer, s.binding.Address, s.binding.Port, true, logSender)
+ return util.HTTPListenAndServe(httpServer, s.binding.Address, s.binding.Port, true,
+ s.binding.listenerWrapper(), logSender)
}
s.binding.EnableHTTPS = false
serviceStatus.Bindings = append(serviceStatus.Bindings, s.binding)
- return util.HTTPListenAndServe(httpServer, s.binding.Address, s.binding.Port, false, logSender)
+ return util.HTTPListenAndServe(httpServer, s.binding.Address, s.binding.Port, false,
+ s.binding.listenerWrapper(), logSender)
}
func (s *webDavServer) verifyTLSConnection(state tls.ConnectionState) error {
@@ -166,6 +168,11 @@ func (s *webDavServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}()
+ responseControllerDeadlines(
+ http.NewResponseController(w),
+ time.Now().Add(60*time.Second),
+ time.Now().Add(60*time.Second),
+ )
w.Header().Set("Server", version.GetServerVersion("/", false))
ipAddr := s.checkRemoteAddress(r)
@@ -206,7 +213,7 @@ func (s *webDavServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err != nil {
// remove the cached user, we have not yet validated its filesystem
dataprovider.RemoveCachedWebDAVUser(user.Username)
- updateLoginMetrics(&user, ipAddr, loginMethod, err)
+ updateLoginMetrics(&user, ipAddr, loginMethod, err, r)
http.Error(w, err.Error(), http.StatusForbidden)
return
}
@@ -219,26 +226,24 @@ func (s *webDavServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err != nil {
errClose := user.CloseFs()
logger.Warn(logSender, connectionID, "unable to check fs root: %v close fs error: %v", err, errClose)
- updateLoginMetrics(&user, ipAddr, loginMethod, common.ErrInternalFailure)
+ updateLoginMetrics(&user, ipAddr, loginMethod, common.ErrInternalFailure, r)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- connection := &Connection{
- BaseConnection: common.NewBaseConnection(connectionID, common.ProtocolWebDAV, util.GetHTTPLocalAddress(r),
- r.RemoteAddr, user),
- request: r,
- }
+ baseConn := common.NewBaseConnection(connectionID, common.ProtocolWebDAV, util.GetHTTPLocalAddress(r),
+ r.RemoteAddr, user)
+ connection := newConnection(baseConn, w, r)
if err = common.Connections.Add(connection); err != nil {
errClose := user.CloseFs()
logger.Warn(logSender, connectionID, "unable add connection: %v close fs error: %v", err, errClose)
- updateLoginMetrics(&user, ipAddr, loginMethod, err)
+ updateLoginMetrics(&user, ipAddr, loginMethod, err, r)
http.Error(w, err.Error(), http.StatusTooManyRequests)
return
}
defer common.Connections.Remove(connection.GetID())
- updateLoginMetrics(&user, ipAddr, loginMethod, err)
+ updateLoginMetrics(&user, ipAddr, loginMethod, err, r)
ctx := context.WithValue(r.Context(), requestIDKey, connectionID)
ctx = context.WithValue(ctx, requestStartKey, time.Now())
@@ -315,7 +320,7 @@ func (s *webDavServer) authenticate(r *http.Request, ip string) (dataprovider.Us
dataprovider.CacheWebDAVUser(cachedUser)
return cachedUser.User, false, cachedUser.LockSystem, loginMethod, nil
}
- updateLoginMetrics(&cachedUser.User, ip, loginMethod, dataprovider.ErrInvalidCredentials)
+ updateLoginMetrics(&cachedUser.User, ip, loginMethod, dataprovider.ErrInvalidCredentials, r)
return user, false, nil, loginMethod, dataprovider.ErrInvalidCredentials
}
}
@@ -323,7 +328,7 @@ func (s *webDavServer) authenticate(r *http.Request, ip string) (dataprovider.Us
common.ProtocolWebDAV, tlsCert)
if err != nil {
user.Username = username
- updateLoginMetrics(&user, ip, loginMethod, err)
+ updateLoginMetrics(&user, ip, loginMethod, err, r)
return user, false, nil, loginMethod, dataprovider.ErrInvalidCredentials
}
lockSystem := webdav.NewMemLS()
@@ -346,7 +351,7 @@ func (s *webDavServer) validateUser(user *dataprovider.User, r *http.Request, lo
user.Username, user.HomeDir)
return connID, fmt.Errorf("cannot login user with invalid home dir: %q", user.HomeDir)
}
- if util.Contains(user.Filters.DeniedProtocols, common.ProtocolWebDAV) {
+ if slices.Contains(user.Filters.DeniedProtocols, common.ProtocolWebDAV) {
logger.Info(logSender, connectionID, "cannot login user %q, protocol DAV is not allowed", user.Username)
return connID, fmt.Errorf("protocol DAV is not allowed for user %q", user.Username)
}
@@ -385,17 +390,30 @@ func (s *webDavServer) checkRemoteAddress(r *http.Request) string {
return ipAddr
}
+func responseControllerDeadlines(rc *http.ResponseController, read, write time.Time) {
+ if err := rc.SetReadDeadline(read); err != nil {
+ logger.Error(logSender, "", "unable to set read timeout to %s: %v", read, err)
+ }
+ if err := rc.SetWriteDeadline(write); err != nil {
+ logger.Error(logSender, "", "unable to set write timeout to %s: %v", write, err)
+ }
+}
+
func writeLog(r *http.Request, status int, err error) {
scheme := "http"
+ cipherSuite := ""
if r.TLS != nil {
scheme = "https"
+ cipherSuite = tls.CipherSuiteName(r.TLS.CipherSuite)
}
fields := map[string]any{
- "remote_addr": r.RemoteAddr,
- "proto": r.Proto,
- "method": r.Method,
- "user_agent": r.UserAgent(),
- "uri": fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI)}
+ "remote_addr": r.RemoteAddr,
+ "proto": r.Proto,
+ "method": r.Method,
+ "user_agent": r.UserAgent(),
+ "uri": fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI),
+ "cipher_suite": cipherSuite,
+ }
if reqID, ok := r.Context().Value(requestIDKey).(string); ok {
fields["request_id"] = reqID
}
@@ -414,7 +432,15 @@ func writeLog(r *http.Request, status int, err error) {
if status != 0 {
fields["resp_status"] = status
}
- logger.GetLogger().Info().
+ var ev *zerolog.Event
+ if status >= http.StatusInternalServerError {
+ ev = logger.GetLogger().Error()
+ } else if status >= http.StatusBadRequest {
+ ev = logger.GetLogger().Warn()
+ } else {
+ ev = logger.GetLogger().Debug()
+ }
+ ev.
Timestamp().
Str("sender", logSender).
Fields(fields).
@@ -422,10 +448,12 @@ func writeLog(r *http.Request, status int, err error) {
Send()
}
-func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err error) {
+func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err error, r *http.Request) {
metric.AddLoginAttempt(loginMethod)
if err == nil {
+ logger.LoginLog(user.Username, ip, loginMethod, common.ProtocolWebDAV, "", r.UserAgent(), r.TLS != nil, "")
plugin.Handler.NotifyLogEvent(notifier.LogEventTypeLoginOK, common.ProtocolWebDAV, user.Username, ip, "", nil)
+ common.DelayLogin(nil)
} else if err != common.ErrInternalFailure && err != common.ErrNoCredentials {
logger.ConnectionFailedLog(user.Username, ip, loginMethod, common.ProtocolWebDAV, err.Error())
event := common.HostEventLoginFailed
@@ -436,6 +464,9 @@ func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err err
}
common.AddDefenderEvent(ip, common.ProtocolWebDAV, event)
plugin.Handler.NotifyLogEvent(logEv, common.ProtocolWebDAV, user.Username, ip, "", err)
+ if loginMethod != dataprovider.LoginMethodTLSCertificate {
+ common.DelayLogin(err)
+ }
}
metric.AddLoginResult(loginMethod, err)
dataprovider.ExecutePostLoginHook(user, loginMethod, ip, common.ProtocolWebDAV, err)
diff --git a/internal/webdavd/webdavd.go b/internal/webdavd/webdavd.go
index 62d37568..36b3b33c 100644
--- a/internal/webdavd/webdavd.go
+++ b/internal/webdavd/webdavd.go
@@ -21,6 +21,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "runtime"
"time"
"github.com/go-chi/chi/v5/middleware"
@@ -138,6 +139,9 @@ type Binding struct {
// Prefix for WebDAV resources, if empty WebDAV resources will be available at the
// root ("/") URI. If defined it must be an absolute URI.
Prefix string `json:"prefix" mapstructure:"prefix"`
+ // Defines whether to use the common proxy protocol configuration or the
+ // binding-specific proxy header configuration.
+ ProxyMode int `json:"proxy_mode" mapstructure:"proxy_mode"`
// List of IP addresses and IP ranges allowed to set client IP proxy headers
ProxyAllowed []string `json:"proxy_allowed" mapstructure:"proxy_allowed"`
// Allowed client IP proxy header such as "X-Forwarded-For", "X-Real-IP"
@@ -173,12 +177,28 @@ func (b *Binding) isMutualTLSEnabled() bool {
// GetAddress returns the binding address
func (b *Binding) GetAddress() string {
- return fmt.Sprintf("%s:%d", b.Address, b.Port)
+ if b.Port > 0 {
+ return fmt.Sprintf("%s:%d", b.Address, b.Port)
+ }
+ return b.Address
}
-// IsValid returns true if the binding port is > 0
+// IsValid returns true if the binding is valid
func (b *Binding) IsValid() bool {
- return b.Port > 0
+ if b.Port > 0 {
+ return true
+ }
+ if filepath.IsAbs(b.Address) && runtime.GOOS != "windows" {
+ return true
+ }
+ return false
+}
+
+func (b *Binding) listenerWrapper() func(net.Listener) (net.Listener, error) {
+ if b.ProxyMode == 1 {
+ return common.Config.GetProxyListener
+ }
+ return nil
}
// Configuration defines the configuration for the WevDAV server
diff --git a/internal/webdavd/webdavd_test.go b/internal/webdavd/webdavd_test.go
index b6b2bbdb..f7985c34 100644
--- a/internal/webdavd/webdavd_test.go
+++ b/internal/webdavd/webdavd_test.go
@@ -38,11 +38,13 @@ import (
"time"
"github.com/minio/sio"
+ "github.com/pkg/sftp"
"github.com/rs/zerolog"
"github.com/sftpgo/sdk"
sdkkms "github.com/sftpgo/sdk/kms"
"github.com/stretchr/testify/assert"
"github.com/studio-b12/gowebdav"
+ "golang.org/x/crypto/ssh"
"github.com/drakkan/sftpgo/v2/internal/common"
"github.com/drakkan/sftpgo/v2/internal/config"
@@ -637,6 +639,7 @@ func TestBasicHandling(t *testing.T) {
assert.NoError(t, err)
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 },
1*time.Second, 100*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
status := webdavd.GetStatus()
assert.True(t, status.IsActive)
}
@@ -667,6 +670,8 @@ func TestBasicHandlingCryptFs(t *testing.T) {
localDownloadPath := filepath.Join(homeBasePath, testDLFileName)
err = downloadFile(testFileName, localDownloadPath, testFileSize, client)
assert.NoError(t, err)
+ assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 },
+ 1*time.Second, 100*time.Millisecond)
user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
assert.NoError(t, err)
assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles)
@@ -719,6 +724,7 @@ func TestBasicHandlingCryptFs(t *testing.T) {
assert.NoError(t, err)
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 },
1*time.Second, 100*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func TestBufferedUser(t *testing.T) {
@@ -1008,6 +1014,8 @@ func TestRenameWithLock(t *testing.T) {
err = resp.Body.Close()
assert.NoError(t, err)
+ err = os.Remove(testFilePath)
+ assert.NoError(t, err)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
@@ -1075,6 +1083,7 @@ func TestPropPatch(t *testing.T) {
assert.NoError(t, err)
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 },
1*time.Second, 100*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func TestLoginInvalidPwd(t *testing.T) {
@@ -1518,6 +1527,7 @@ func TestPreDownloadHook(t *testing.T) {
assert.NoError(t, err)
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 },
1*time.Second, 100*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
common.Config.Actions.ExecuteOn = []string{common.OperationPreDownload}
common.Config.Actions.Hook = preDownloadPath
@@ -1568,6 +1578,7 @@ func TestPreUploadHook(t *testing.T) {
assert.NoError(t, err)
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 },
1*time.Second, 100*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
common.Config.Actions.ExecuteOn = oldExecuteOn
common.Config.Actions.Hook = oldHook
@@ -1631,6 +1642,7 @@ func TestMaxConnections(t *testing.T) {
assert.NoError(t, err)
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 },
1*time.Second, 100*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
common.Config.MaxTotalConnections = oldValue
}
@@ -1663,6 +1675,61 @@ func TestMaxPerHostConnections(t *testing.T) {
assert.NoError(t, err)
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 },
1*time.Second, 100*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
+
+ common.Config.MaxPerHostConnections = oldValue
+}
+
+func TestMaxTransfers(t *testing.T) {
+ oldValue := common.Config.MaxPerHostConnections
+ common.Config.MaxPerHostConnections = 2
+
+ assert.Eventually(t, func() bool {
+ return common.Connections.GetClientConnections() == 0
+ }, 1000*time.Millisecond, 50*time.Millisecond)
+
+ user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+ assert.NoError(t, err)
+ client := getWebDavClient(user, true, nil)
+ assert.NoError(t, checkBasicFunc(client))
+
+ conn, sftpClient, err := getSftpClient(user)
+ assert.NoError(t, err)
+ defer conn.Close()
+ defer sftpClient.Close()
+
+ f1, err := sftpClient.Create("file1")
+ assert.NoError(t, err)
+ f2, err := sftpClient.Create("file2")
+ assert.NoError(t, err)
+ _, err = f1.Write([]byte(" "))
+ assert.NoError(t, err)
+ _, err = f2.Write([]byte(" "))
+ assert.NoError(t, err)
+
+ testFilePath := filepath.Join(homeBasePath, testFileName)
+ testFileSize := int64(65535)
+ err = createTestFile(testFilePath, testFileSize)
+ assert.NoError(t, err)
+ err = uploadFileWithRawClient(testFilePath, testFileName, user.Username, defaultPassword,
+ false, testFileSize, client)
+ assert.Error(t, err)
+
+ err = os.Remove(testFilePath)
+ assert.NoError(t, err)
+
+ err = f1.Close()
+ assert.NoError(t, err)
+ err = f2.Close()
+ assert.NoError(t, err)
+
+ _, err = httpdtest.RemoveUser(user, http.StatusOK)
+ assert.NoError(t, err)
+ err = os.RemoveAll(user.GetHomeDir())
+ assert.NoError(t, err)
+ assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 },
+ 1*time.Second, 100*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
common.Config.MaxPerHostConnections = oldValue
}
@@ -1710,6 +1777,7 @@ func TestMaxSessions(t *testing.T) {
assert.NoError(t, err)
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 },
1*time.Second, 100*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func TestLoginWithIPilters(t *testing.T) {
@@ -2169,6 +2237,7 @@ func TestClientClose(t *testing.T) {
wg.Wait()
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 },
1*time.Second, 100*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
err = os.Remove(localDownloadPath)
assert.NoError(t, err)
@@ -2186,7 +2255,7 @@ func TestLoginWithDatabaseCredentials(t *testing.T) {
u := getTestUser()
u.FsConfig.Provider = sdk.GCSFilesystemProvider
u.FsConfig.GCSConfig.Bucket = "test"
- u.FsConfig.GCSConfig.Credentials = kms.NewPlainSecret(`{ "type": "service_account" }`)
+ u.FsConfig.GCSConfig.Credentials = kms.NewPlainSecret(`{ "type": "service_account", "private_key": " ", "client_email": "example@iam.gserviceaccount.com" }`)
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
assert.Equal(t, sdkkms.SecretStatusSecretBox, user.FsConfig.GCSConfig.Credentials.GetStatus())
@@ -2537,6 +2606,7 @@ func TestStat(t *testing.T) {
func TestUploadOverwriteVfolder(t *testing.T) {
u := getTestUser()
+ u.QuotaFiles = 1000
vdir := "/vdir"
mappedPath := filepath.Join(os.TempDir(), "mappedDir")
folderName := filepath.Base(mappedPath)
@@ -2583,15 +2653,25 @@ func TestUploadOverwriteVfolder(t *testing.T) {
assert.NoError(t, err)
folder, _, err := httpdtest.GetFolderByName(folderName, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, folder.UsedQuotaSize)
- assert.Equal(t, 1, folder.UsedQuotaFiles)
+ assert.Equal(t, int64(0), folder.UsedQuotaSize)
+ assert.Equal(t, 0, folder.UsedQuotaFiles)
+ user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+ assert.NoError(t, err)
+ assert.Equal(t, testFileSize, user.UsedQuotaSize)
+ assert.Equal(t, 1, user.UsedQuotaFiles)
+
err = uploadFileWithRawClient(testFilePath, path.Join(vdir, testFileName), user.Username,
defaultPassword, true, testFileSize, client)
assert.NoError(t, err)
folder, _, err = httpdtest.GetFolderByName(folderName, http.StatusOK)
assert.NoError(t, err)
- assert.Equal(t, testFileSize, folder.UsedQuotaSize)
- assert.Equal(t, 1, folder.UsedQuotaFiles)
+ assert.Equal(t, int64(0), folder.UsedQuotaSize)
+ assert.Equal(t, 0, folder.UsedQuotaFiles)
+ user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+ assert.NoError(t, err)
+ assert.Equal(t, testFileSize, user.UsedQuotaSize)
+ assert.Equal(t, 1, user.UsedQuotaFiles)
+
err = os.Remove(testFilePath)
assert.NoError(t, err)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
@@ -3263,6 +3343,7 @@ func TestNestedVirtualFolders(t *testing.T) {
assert.NoError(t, err)
assert.Eventually(t, func() bool { return len(common.Connections.GetStats("")) == 0 },
1*time.Second, 100*time.Millisecond)
+ assert.Equal(t, int32(0), common.Connections.GetTotalTransfers())
}
func checkBasicFunc(client *gowebdav.Client) error {
@@ -3459,6 +3540,30 @@ func getTestUserWithCryptFs() dataprovider.User {
return user
}
+func getSftpClient(user dataprovider.User) (*ssh.Client, *sftp.Client, error) {
+ var sftpClient *sftp.Client
+ config := &ssh.ClientConfig{
+ User: user.Username,
+ HostKeyCallback: ssh.InsecureIgnoreHostKey(),
+ Timeout: 5 * time.Second,
+ }
+ if user.Password != "" {
+ config.Auth = []ssh.AuthMethod{ssh.Password(user.Password)}
+ } else {
+ config.Auth = []ssh.AuthMethod{ssh.Password(defaultPassword)}
+ }
+
+ conn, err := ssh.Dial("tcp", sftpServerAddr, config)
+ if err != nil {
+ return conn, sftpClient, err
+ }
+ sftpClient, err = sftp.NewClient(conn)
+ if err != nil {
+ conn.Close()
+ }
+ return conn, sftpClient, err
+}
+
func getEncryptedFileSize(size int64) (int64, error) {
encSize, err := sio.EncryptedSize(uint64(size))
return int64(encSize) + 33, err
@@ -3545,6 +3650,6 @@ func printLatestLogs(maxNumberOfLines int) {
return
}
for _, line := range lines {
- logger.DebugToConsole(line)
+ logger.DebugToConsole("%s", line)
}
}
diff --git a/main.go b/main.go
index f147eb47..1d9cf48e 100644
--- a/main.go
+++ b/main.go
@@ -20,17 +20,9 @@
package main // import "github.com/drakkan/sftpgo"
import (
- "fmt"
-
- "go.uber.org/automaxprocs/maxprocs"
-
"github.com/drakkan/sftpgo/v2/internal/cmd"
)
func main() {
- if undo, err := maxprocs.Set(); err != nil {
- fmt.Printf("error setting max procs: %v\n", err)
- undo()
- }
cmd.Execute()
}
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index 94c1fbb6..32dc3dc7 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -29,7 +29,7 @@ info:
SFTPGo supports groups to simplify the administration of multiple accounts by letting you assign settings once to a group, instead of multiple times to each individual user.
The SFTPGo WebClient allows end users to change their credentials, browse and manage their files in the browser and setup two-factor authentication which works with Authy, Google Authenticator and other compatible apps.
From the WebClient each authorized user can also create HTTP/S links to externally share files and folders securely, by setting limits to the number of downloads/uploads, protecting the share with a password, limiting access by source IP address, setting an automatic expiration date.
- version: 2.6.0
+ version: v2.7.0
contact:
name: API support
url: 'https://github.com/drakkan/sftpgo'
@@ -1088,60 +1088,6 @@ paths:
$ref: '#/components/responses/InternalServerError'
default:
$ref: '#/components/responses/DefaultResponse'
- /retention/users/{username}/check:
- parameters:
- - name: username
- in: path
- description: the username
- required: true
- schema:
- type: string
- - name: notifications
- in: query
- description: 'specify how to notify results'
- explode: false
- schema:
- type: array
- items:
- $ref: '#/components/schemas/RetentionCheckNotification'
- post:
- tags:
- - data retention
- summary: Start a retention check
- description: 'Starts a new retention check for the given user. If a retention check for this user is already active a 409 status code is returned'
- operationId: start_user_retention_check
- requestBody:
- required: true
- description: 'Defines virtual paths to check and their retention time in hours'
- content:
- application/json:
- schema:
- type: array
- items:
- $ref: '#/components/schemas/FolderRetention'
- responses:
- '202':
- description: successful operation
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiResponse'
- example:
- message: Check started
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '403':
- $ref: '#/components/responses/Forbidden'
- '404':
- $ref: '#/components/responses/NotFound'
- '409':
- $ref: '#/components/responses/Conflict'
- '500':
- $ref: '#/components/responses/InternalServerError'
- default:
- $ref: '#/components/responses/DefaultResponse'
/quotas/users/scans:
get:
tags:
@@ -1517,7 +1463,7 @@ paths:
name: confidential_data
schema:
type: integer
- description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the manage_system permission is not granted.'
+ description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the * permission is not granted.'
requestBody:
required: true
content:
@@ -1565,7 +1511,7 @@ paths:
name: confidential_data
schema:
type: integer
- description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the manage_system permission is not granted.'
+ description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the * permission is not granted.'
responses:
'200':
description: successful operation
@@ -1709,7 +1655,7 @@ paths:
name: confidential_data
schema:
type: integer
- description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the manage_system permission is not granted.'
+ description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the * permission is not granted.'
requestBody:
required: true
content:
@@ -1757,7 +1703,7 @@ paths:
name: confidential_data
schema:
type: integer
- description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the manage_system permission is not granted.'
+ description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the * permission is not granted.'
responses:
'200':
description: successful operation
@@ -2081,7 +2027,7 @@ paths:
name: confidential_data
schema:
type: integer
- description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the manage_system permission is not granted.'
+ description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the * permission is not granted.'
requestBody:
required: true
content:
@@ -2129,7 +2075,7 @@ paths:
name: confidential_data
schema:
type: integer
- description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the manage_system permission is not granted.'
+ description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the * permission is not granted.'
responses:
'200':
description: successful operation
@@ -2273,7 +2219,7 @@ paths:
name: confidential_data
schema:
type: integer
- description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the manage_system permission is not granted.'
+ description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the * permission is not granted.'
requestBody:
required: true
content:
@@ -2321,7 +2267,7 @@ paths:
name: confidential_data
schema:
type: integer
- description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the manage_system permission is not granted.'
+ description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the * permission is not granted.'
responses:
'200':
description: successful operation
@@ -2732,7 +2678,7 @@ paths:
$ref: '#/components/responses/InternalServerError'
default:
$ref: '#/components/responses/DefaultResponse'
- /events/log:
+ /events/logs:
get:
tags:
- events
@@ -3416,7 +3362,7 @@ paths:
name: confidential_data
schema:
type: integer
- description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the hash of the password and the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the manage_system permission is not granted.'
+ description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the hash of the password and the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the * permission is not granted.'
requestBody:
required: true
content:
@@ -3464,7 +3410,7 @@ paths:
name: confidential_data
schema:
type: integer
- description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the hash of the password and the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the manage_system permission is not granted.'
+ description: 'If set to 1 confidential data will not be hidden. This means that the response will contain the hash of the password and the key and additional data for secrets. If a master key is not set or an external KMS is used, the data returned are enough to get the secrets in cleartext. Ignored if the * permission is not granted.'
responses:
'200':
description: successful operation
@@ -4935,23 +4881,16 @@ components:
- view_conns
- close_conns
- view_status
- - manage_admins
- manage_folders
- manage_groups
- - manage_apikeys
- quota_scans
- - manage_system
- manage_defender
- view_defender
- - retention_checks
- view_events
- - manage_event_rules
- - manage_roles
- - manage_ip_lists
- disable_mfa
description: |
Admin permissions:
- * `*` - all permissions are granted
+ * `*` - super admin permissions are granted
* `add_users` - add new users is allowed
* `edit_users` - change existing users is allowed
* `del_users` - remove users is allowed
@@ -4959,19 +4898,12 @@ components:
* `view_conns` - list active connections is allowed
* `close_conns` - close active connections is allowed
* `view_status` - view the server status is allowed
- * `manage_admins` - manage other admins is allowed
* `manage_folders` - manage folders is allowed
* `manage_groups` - manage groups is allowed
- * `manage_apikeys` - manage API keys is allowed
* `quota_scans` - view and start quota scans is allowed
- * `manage_system` - backups and restores are allowed
* `manage_defender` - remove ip from the dynamic blocklist is allowed
* `view_defender` - list the dynamic blocklist is allowed
- * `retention_checks` - view and start retention checks is allowed
* `view_events` - view and search filesystem and provider events is allowed
- * `manage_event_rules` - manage event actions and rules is allowed
- * `manage_roles` - manage roles is allowed
- * `manage_ip_lists` - manage global and ratelimter allow lists and defender block and safe lists is allowed
* `disable_mfa` - allow to disable two-factor authentication for users and admins
FsProviders:
type: integer
@@ -5008,6 +4940,7 @@ components:
- 12
- 13
- 14
+ - 15
description: |
Supported event action types:
* `1` - HTTP
@@ -5023,6 +4956,7 @@ components:
* `12` - User expiration check
* `13` - Identity Provider account check
* `14` - User inactivity check
+ * `15` - Rotate log file
FilesystemActionTypes:
type: integer
enum:
@@ -5154,15 +5088,6 @@ components:
* `shares-disabled` - sharing files and directories with external users is not allowed
* `password-reset-disabled` - resetting the password is not allowed
* `shares-without-password-disabled` - creating shares without password protection is not allowed
- RetentionCheckNotification:
- type: string
- enum:
- - Hook
- - Email
- description: |
- Options:
- * `Hook` - notify result using the defined hook. A "data_retention_hook" must be defined in your configuration file for this to work
- * `Email` - notify results by email. The admin starting the retention check must have an associated email address and the SMTP server must be configured for this to work
APIKeyScope:
type: integer
enum:
@@ -5460,14 +5385,10 @@ components:
max_upload_file_size:
type: integer
format: int64
- description: 'maximum allowed size, as bytes, for a single file upload. The upload will be aborted if/when the size of the file being sent exceeds this limit. 0 means unlimited. This restriction does not apply for SSH system commands such as `git` and `rsync`'
+ description: 'maximum allowed size, as bytes, for a single file upload. The upload will be aborted if/when the size of the file being sent exceeds this limit. 0 means unlimited'
tls_username:
type: string
description: 'defines the TLS certificate field to use as username. For FTP clients it must match the name provided using the "USER" command. For WebDAV, if no username is provided, the CN will be used as username. For WebDAV clients it must match the implicit or provided username. Ignored if mutual TLS is disabled. Currently the only supported value is `CommonName`'
- tls_certs:
- type: array
- items:
- type: string
hooks:
$ref: '#/components/schemas/HooksFilter'
disable_fs_checks:
@@ -5517,6 +5438,9 @@ components:
password_expiration:
type: integer
description: 'The password expires after the defined number of days. 0 means no expiration'
+ password_strength:
+ type: integer
+ description: 'Defines the minimum password strength. 0 means disabled, any password will be accepted. Values in the 50-70 range are suggested for common use cases'
access_time:
type: array
items:
@@ -5536,6 +5460,15 @@ components:
type: array
items:
$ref: '#/components/schemas/RecoveryCode'
+ tls_certs:
+ type: array
+ items:
+ type: string
+ additional_emails:
+ type: array
+ items:
+ type: string
+ format: email
Secret:
type: object
properties:
@@ -5574,6 +5507,8 @@ components:
type: string
access_secret:
$ref: '#/components/schemas/Secret'
+ sse_customer_key:
+ $ref: '#/components/schemas/Secret'
role_arn:
type: string
description: 'Optional IAM Role ARN to assume'
@@ -6106,7 +6041,7 @@ components:
description: Last user login as unix timestamp in milliseconds. It is saved at most once every 10 minutes
role:
type: string
- description: 'If set the admin can only administer users with the same role. Role admins cannot have the following permissions: "manage_admins", "manage_apikeys", "manage_system", "manage_event_rules", "manage_roles", "manage_ip_lists"'
+ description: 'If set the admin can only administer users with the same role. Role admins cannot have the "*" permission'
AdminProfile:
type: object
properties:
@@ -6273,9 +6208,6 @@ components:
delete_empty_dirs:
type: boolean
description: if enabled, empty directories will be deleted
- ignore_user_permissions:
- type: boolean
- description: 'if enabled, files will be deleted even if the user does not have the delete permission. The default is "false" which means that files will be skipped if the user does not have permission to delete them. File patterns filters will always be silently ignored'
RetentionCheck:
type: object
properties:
@@ -6290,14 +6222,6 @@ components:
type: integer
format: int64
description: check start time as unix timestamp in milliseconds
- notifications:
- type: array
- items:
- $ref: '#/components/schemas/RetentionCheckNotification'
- email:
- type: string
- format: email
- description: 'if the notification method is set to "Email", this is the e-mail address that receives the retention check report. This field is automatically set to the email address associated with the administrator starting the check'
QuotaScan:
type: object
properties:
@@ -6971,6 +6895,14 @@ components:
type: string
value:
type: string
+ RenameConfig:
+ allOf:
+ - $ref: '#/components/schemas/KeyValue'
+ - type: object
+ properties:
+ update_modtime:
+ type: boolean
+ description: 'Update modification time. This setting is not recursive and only applies to storage providers that support changing modification times'
HTTPPart:
type: object
properties:
@@ -7103,7 +7035,7 @@ components:
renames:
type: array
items:
- $ref: '#/components/schemas/KeyValue'
+ $ref: '#/components/schemas/RenameConfig'
mkdirs:
type: array
items:
@@ -7282,6 +7214,19 @@ components:
max_size:
type: integer
format: int64
+ event_statuses:
+ type: array
+ items:
+ type: integer
+ enum:
+ - 1
+ - 2
+ - 3
+ description: |
+ Event status:
+ - `1` OK
+ - `2` Failed
+ - `3` Quota exceeded
concurrent_execution:
type: boolean
description: allow concurrent execution from multiple nodes
diff --git a/openapi/swagger-ui/swagger-ui-bundle.js b/openapi/swagger-ui/swagger-ui-bundle.js
index 7c6f45b8..64a04935 100644
--- a/openapi/swagger-ui/swagger-ui-bundle.js
+++ b/openapi/swagger-ui/swagger-ui-bundle.js
@@ -1,2 +1,2 @@
/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */
-!function webpackUniversalModuleDefinition(s,i){"object"==typeof exports&&"object"==typeof module?module.exports=i():"function"==typeof define&&define.amd?define([],i):"object"==typeof exports?exports.SwaggerUIBundle=i():s.SwaggerUIBundle=i()}(this,(()=>(()=>{var s,i,u={69119:(s,i)=>{"use strict";Object.defineProperty(i,"__esModule",{value:!0}),i.BLANK_URL=i.relativeFirstCharacters=i.urlSchemeRegex=i.ctrlCharactersRegex=i.htmlCtrlEntityRegex=i.htmlEntitiesRegex=i.invalidProtocolRegex=void 0,i.invalidProtocolRegex=/^([^\w]*)(javascript|data|vbscript)/im,i.htmlEntitiesRegex=/(\w+)(^\w|;)?/g,i.htmlCtrlEntityRegex=/&(newline|tab);/gi,i.ctrlCharactersRegex=/[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim,i.urlSchemeRegex=/^.+(:|:)/gim,i.relativeFirstCharacters=[".","/"],i.BLANK_URL="about:blank"},16750:(s,i,u)=>{"use strict";i.J=void 0;var _=u(69119);i.J=function sanitizeUrl(s){if(!s)return _.BLANK_URL;var i,u,w=s;do{i=(w=(u=w,u.replace(_.ctrlCharactersRegex,"").replace(_.htmlEntitiesRegex,(function(s,i){return String.fromCharCode(i)}))).replace(_.htmlCtrlEntityRegex,"").replace(_.ctrlCharactersRegex,"").trim()).match(_.ctrlCharactersRegex)||w.match(_.htmlEntitiesRegex)||w.match(_.htmlCtrlEntityRegex)}while(i&&i.length>0);var x=w;if(!x)return _.BLANK_URL;if(function isRelativeUrlWithoutProtocol(s){return _.relativeFirstCharacters.indexOf(s[0])>-1}(x))return x;var j=x.match(_.urlSchemeRegex);if(!j)return x;var B=j[0];return _.invalidProtocolRegex.test(B)?_.BLANK_URL:x}},67526:(s,i)=>{"use strict";i.byteLength=function byteLength(s){var i=getLens(s),u=i[0],_=i[1];return 3*(u+_)/4-_},i.toByteArray=function toByteArray(s){var i,u,x=getLens(s),j=x[0],B=x[1],L=new w(function _byteLength(s,i,u){return 3*(i+u)/4-u}(0,j,B)),$=0,U=B>0?j-4:j;for(u=0;u>16&255,L[$++]=i>>8&255,L[$++]=255&i;2===B&&(i=_[s.charCodeAt(u)]<<2|_[s.charCodeAt(u+1)]>>4,L[$++]=255&i);1===B&&(i=_[s.charCodeAt(u)]<<10|_[s.charCodeAt(u+1)]<<4|_[s.charCodeAt(u+2)]>>2,L[$++]=i>>8&255,L[$++]=255&i);return L},i.fromByteArray=function fromByteArray(s){for(var i,_=s.length,w=_%3,x=[],j=16383,B=0,L=_-w;BL?L:B+j));1===w?(i=s[_-1],x.push(u[i>>2]+u[i<<4&63]+"==")):2===w&&(i=(s[_-2]<<8)+s[_-1],x.push(u[i>>10]+u[i>>4&63]+u[i<<2&63]+"="));return x.join("")};for(var u=[],_=[],w="undefined"!=typeof Uint8Array?Uint8Array:Array,x="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",j=0;j<64;++j)u[j]=x[j],_[x.charCodeAt(j)]=j;function getLens(s){var i=s.length;if(i%4>0)throw new Error("Invalid string. Length must be a multiple of 4");var u=s.indexOf("=");return-1===u&&(u=i),[u,u===i?0:4-u%4]}function encodeChunk(s,i,_){for(var w,x,j=[],B=i;B<_;B+=3)w=(s[B]<<16&16711680)+(s[B+1]<<8&65280)+(255&s[B+2]),j.push(u[(x=w)>>18&63]+u[x>>12&63]+u[x>>6&63]+u[63&x]);return j.join("")}_["-".charCodeAt(0)]=62,_["_".charCodeAt(0)]=63},48287:(s,i,u)=>{"use strict";const _=u(67526),w=u(251),x="function"==typeof Symbol&&"function"==typeof Symbol.for?Symbol.for("nodejs.util.inspect.custom"):null;i.Buffer=Buffer,i.SlowBuffer=function SlowBuffer(s){+s!=s&&(s=0);return Buffer.alloc(+s)},i.INSPECT_MAX_BYTES=50;const j=2147483647;function createBuffer(s){if(s>j)throw new RangeError('The value "'+s+'" is invalid for option "size"');const i=new Uint8Array(s);return Object.setPrototypeOf(i,Buffer.prototype),i}function Buffer(s,i,u){if("number"==typeof s){if("string"==typeof i)throw new TypeError('The "string" argument must be of type string. Received type number');return allocUnsafe(s)}return from(s,i,u)}function from(s,i,u){if("string"==typeof s)return function fromString(s,i){"string"==typeof i&&""!==i||(i="utf8");if(!Buffer.isEncoding(i))throw new TypeError("Unknown encoding: "+i);const u=0|byteLength(s,i);let _=createBuffer(u);const w=_.write(s,i);w!==u&&(_=_.slice(0,w));return _}(s,i);if(ArrayBuffer.isView(s))return function fromArrayView(s){if(isInstance(s,Uint8Array)){const i=new Uint8Array(s);return fromArrayBuffer(i.buffer,i.byteOffset,i.byteLength)}return fromArrayLike(s)}(s);if(null==s)throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof s);if(isInstance(s,ArrayBuffer)||s&&isInstance(s.buffer,ArrayBuffer))return fromArrayBuffer(s,i,u);if("undefined"!=typeof SharedArrayBuffer&&(isInstance(s,SharedArrayBuffer)||s&&isInstance(s.buffer,SharedArrayBuffer)))return fromArrayBuffer(s,i,u);if("number"==typeof s)throw new TypeError('The "value" argument must not be of type number. Received type number');const _=s.valueOf&&s.valueOf();if(null!=_&&_!==s)return Buffer.from(_,i,u);const w=function fromObject(s){if(Buffer.isBuffer(s)){const i=0|checked(s.length),u=createBuffer(i);return 0===u.length||s.copy(u,0,0,i),u}if(void 0!==s.length)return"number"!=typeof s.length||numberIsNaN(s.length)?createBuffer(0):fromArrayLike(s);if("Buffer"===s.type&&Array.isArray(s.data))return fromArrayLike(s.data)}(s);if(w)return w;if("undefined"!=typeof Symbol&&null!=Symbol.toPrimitive&&"function"==typeof s[Symbol.toPrimitive])return Buffer.from(s[Symbol.toPrimitive]("string"),i,u);throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof s)}function assertSize(s){if("number"!=typeof s)throw new TypeError('"size" argument must be of type number');if(s<0)throw new RangeError('The value "'+s+'" is invalid for option "size"')}function allocUnsafe(s){return assertSize(s),createBuffer(s<0?0:0|checked(s))}function fromArrayLike(s){const i=s.length<0?0:0|checked(s.length),u=createBuffer(i);for(let _=0;_=j)throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+j.toString(16)+" bytes");return 0|s}function byteLength(s,i){if(Buffer.isBuffer(s))return s.length;if(ArrayBuffer.isView(s)||isInstance(s,ArrayBuffer))return s.byteLength;if("string"!=typeof s)throw new TypeError('The "string" argument must be one of type string, Buffer, or ArrayBuffer. Received type '+typeof s);const u=s.length,_=arguments.length>2&&!0===arguments[2];if(!_&&0===u)return 0;let w=!1;for(;;)switch(i){case"ascii":case"latin1":case"binary":return u;case"utf8":case"utf-8":return utf8ToBytes(s).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*u;case"hex":return u>>>1;case"base64":return base64ToBytes(s).length;default:if(w)return _?-1:utf8ToBytes(s).length;i=(""+i).toLowerCase(),w=!0}}function slowToString(s,i,u){let _=!1;if((void 0===i||i<0)&&(i=0),i>this.length)return"";if((void 0===u||u>this.length)&&(u=this.length),u<=0)return"";if((u>>>=0)<=(i>>>=0))return"";for(s||(s="utf8");;)switch(s){case"hex":return hexSlice(this,i,u);case"utf8":case"utf-8":return utf8Slice(this,i,u);case"ascii":return asciiSlice(this,i,u);case"latin1":case"binary":return latin1Slice(this,i,u);case"base64":return base64Slice(this,i,u);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return utf16leSlice(this,i,u);default:if(_)throw new TypeError("Unknown encoding: "+s);s=(s+"").toLowerCase(),_=!0}}function swap(s,i,u){const _=s[i];s[i]=s[u],s[u]=_}function bidirectionalIndexOf(s,i,u,_,w){if(0===s.length)return-1;if("string"==typeof u?(_=u,u=0):u>2147483647?u=2147483647:u<-2147483648&&(u=-2147483648),numberIsNaN(u=+u)&&(u=w?0:s.length-1),u<0&&(u=s.length+u),u>=s.length){if(w)return-1;u=s.length-1}else if(u<0){if(!w)return-1;u=0}if("string"==typeof i&&(i=Buffer.from(i,_)),Buffer.isBuffer(i))return 0===i.length?-1:arrayIndexOf(s,i,u,_,w);if("number"==typeof i)return i&=255,"function"==typeof Uint8Array.prototype.indexOf?w?Uint8Array.prototype.indexOf.call(s,i,u):Uint8Array.prototype.lastIndexOf.call(s,i,u):arrayIndexOf(s,[i],u,_,w);throw new TypeError("val must be string, number or Buffer")}function arrayIndexOf(s,i,u,_,w){let x,j=1,B=s.length,L=i.length;if(void 0!==_&&("ucs2"===(_=String(_).toLowerCase())||"ucs-2"===_||"utf16le"===_||"utf-16le"===_)){if(s.length<2||i.length<2)return-1;j=2,B/=2,L/=2,u/=2}function read(s,i){return 1===j?s[i]:s.readUInt16BE(i*j)}if(w){let _=-1;for(x=u;xB&&(u=B-L),x=u;x>=0;x--){let u=!0;for(let _=0;_w&&(_=w):_=w;const x=i.length;let j;for(_>x/2&&(_=x/2),j=0;j<_;++j){const _=parseInt(i.substr(2*j,2),16);if(numberIsNaN(_))return j;s[u+j]=_}return j}function utf8Write(s,i,u,_){return blitBuffer(utf8ToBytes(i,s.length-u),s,u,_)}function asciiWrite(s,i,u,_){return blitBuffer(function asciiToBytes(s){const i=[];for(let u=0;u>8,w=u%256,x.push(w),x.push(_);return x}(i,s.length-u),s,u,_)}function base64Slice(s,i,u){return 0===i&&u===s.length?_.fromByteArray(s):_.fromByteArray(s.slice(i,u))}function utf8Slice(s,i,u){u=Math.min(s.length,u);const _=[];let w=i;for(;w239?4:i>223?3:i>191?2:1;if(w+j<=u){let u,_,B,L;switch(j){case 1:i<128&&(x=i);break;case 2:u=s[w+1],128==(192&u)&&(L=(31&i)<<6|63&u,L>127&&(x=L));break;case 3:u=s[w+1],_=s[w+2],128==(192&u)&&128==(192&_)&&(L=(15&i)<<12|(63&u)<<6|63&_,L>2047&&(L<55296||L>57343)&&(x=L));break;case 4:u=s[w+1],_=s[w+2],B=s[w+3],128==(192&u)&&128==(192&_)&&128==(192&B)&&(L=(15&i)<<18|(63&u)<<12|(63&_)<<6|63&B,L>65535&&L<1114112&&(x=L))}}null===x?(x=65533,j=1):x>65535&&(x-=65536,_.push(x>>>10&1023|55296),x=56320|1023&x),_.push(x),w+=j}return function decodeCodePointsArray(s){const i=s.length;if(i<=B)return String.fromCharCode.apply(String,s);let u="",_=0;for(;__.length?(Buffer.isBuffer(i)||(i=Buffer.from(i)),i.copy(_,w)):Uint8Array.prototype.set.call(_,i,w);else{if(!Buffer.isBuffer(i))throw new TypeError('"list" argument must be an Array of Buffers');i.copy(_,w)}w+=i.length}return _},Buffer.byteLength=byteLength,Buffer.prototype._isBuffer=!0,Buffer.prototype.swap16=function swap16(){const s=this.length;if(s%2!=0)throw new RangeError("Buffer size must be a multiple of 16-bits");for(let i=0;iu&&(s+=" ... "),""},x&&(Buffer.prototype[x]=Buffer.prototype.inspect),Buffer.prototype.compare=function compare(s,i,u,_,w){if(isInstance(s,Uint8Array)&&(s=Buffer.from(s,s.offset,s.byteLength)),!Buffer.isBuffer(s))throw new TypeError('The "target" argument must be one of type Buffer or Uint8Array. Received type '+typeof s);if(void 0===i&&(i=0),void 0===u&&(u=s?s.length:0),void 0===_&&(_=0),void 0===w&&(w=this.length),i<0||u>s.length||_<0||w>this.length)throw new RangeError("out of range index");if(_>=w&&i>=u)return 0;if(_>=w)return-1;if(i>=u)return 1;if(this===s)return 0;let x=(w>>>=0)-(_>>>=0),j=(u>>>=0)-(i>>>=0);const B=Math.min(x,j),L=this.slice(_,w),$=s.slice(i,u);for(let s=0;s>>=0,isFinite(u)?(u>>>=0,void 0===_&&(_="utf8")):(_=u,u=void 0)}const w=this.length-i;if((void 0===u||u>w)&&(u=w),s.length>0&&(u<0||i<0)||i>this.length)throw new RangeError("Attempt to write outside buffer bounds");_||(_="utf8");let x=!1;for(;;)switch(_){case"hex":return hexWrite(this,s,i,u);case"utf8":case"utf-8":return utf8Write(this,s,i,u);case"ascii":case"latin1":case"binary":return asciiWrite(this,s,i,u);case"base64":return base64Write(this,s,i,u);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return ucs2Write(this,s,i,u);default:if(x)throw new TypeError("Unknown encoding: "+_);_=(""+_).toLowerCase(),x=!0}},Buffer.prototype.toJSON=function toJSON(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};const B=4096;function asciiSlice(s,i,u){let _="";u=Math.min(s.length,u);for(let w=i;w_)&&(u=_);let w="";for(let _=i;_u)throw new RangeError("Trying to access beyond buffer length")}function checkInt(s,i,u,_,w,x){if(!Buffer.isBuffer(s))throw new TypeError('"buffer" argument must be a Buffer instance');if(i>w||is.length)throw new RangeError("Index out of range")}function wrtBigUInt64LE(s,i,u,_,w){checkIntBI(i,_,w,s,u,7);let x=Number(i&BigInt(4294967295));s[u++]=x,x>>=8,s[u++]=x,x>>=8,s[u++]=x,x>>=8,s[u++]=x;let j=Number(i>>BigInt(32)&BigInt(4294967295));return s[u++]=j,j>>=8,s[u++]=j,j>>=8,s[u++]=j,j>>=8,s[u++]=j,u}function wrtBigUInt64BE(s,i,u,_,w){checkIntBI(i,_,w,s,u,7);let x=Number(i&BigInt(4294967295));s[u+7]=x,x>>=8,s[u+6]=x,x>>=8,s[u+5]=x,x>>=8,s[u+4]=x;let j=Number(i>>BigInt(32)&BigInt(4294967295));return s[u+3]=j,j>>=8,s[u+2]=j,j>>=8,s[u+1]=j,j>>=8,s[u]=j,u+8}function checkIEEE754(s,i,u,_,w,x){if(u+_>s.length)throw new RangeError("Index out of range");if(u<0)throw new RangeError("Index out of range")}function writeFloat(s,i,u,_,x){return i=+i,u>>>=0,x||checkIEEE754(s,0,u,4),w.write(s,i,u,_,23,4),u+4}function writeDouble(s,i,u,_,x){return i=+i,u>>>=0,x||checkIEEE754(s,0,u,8),w.write(s,i,u,_,52,8),u+8}Buffer.prototype.slice=function slice(s,i){const u=this.length;(s=~~s)<0?(s+=u)<0&&(s=0):s>u&&(s=u),(i=void 0===i?u:~~i)<0?(i+=u)<0&&(i=0):i>u&&(i=u),i>>=0,i>>>=0,u||checkOffset(s,i,this.length);let _=this[s],w=1,x=0;for(;++x>>=0,i>>>=0,u||checkOffset(s,i,this.length);let _=this[s+--i],w=1;for(;i>0&&(w*=256);)_+=this[s+--i]*w;return _},Buffer.prototype.readUint8=Buffer.prototype.readUInt8=function readUInt8(s,i){return s>>>=0,i||checkOffset(s,1,this.length),this[s]},Buffer.prototype.readUint16LE=Buffer.prototype.readUInt16LE=function readUInt16LE(s,i){return s>>>=0,i||checkOffset(s,2,this.length),this[s]|this[s+1]<<8},Buffer.prototype.readUint16BE=Buffer.prototype.readUInt16BE=function readUInt16BE(s,i){return s>>>=0,i||checkOffset(s,2,this.length),this[s]<<8|this[s+1]},Buffer.prototype.readUint32LE=Buffer.prototype.readUInt32LE=function readUInt32LE(s,i){return s>>>=0,i||checkOffset(s,4,this.length),(this[s]|this[s+1]<<8|this[s+2]<<16)+16777216*this[s+3]},Buffer.prototype.readUint32BE=Buffer.prototype.readUInt32BE=function readUInt32BE(s,i){return s>>>=0,i||checkOffset(s,4,this.length),16777216*this[s]+(this[s+1]<<16|this[s+2]<<8|this[s+3])},Buffer.prototype.readBigUInt64LE=defineBigIntMethod((function readBigUInt64LE(s){validateNumber(s>>>=0,"offset");const i=this[s],u=this[s+7];void 0!==i&&void 0!==u||boundsError(s,this.length-8);const _=i+256*this[++s]+65536*this[++s]+this[++s]*2**24,w=this[++s]+256*this[++s]+65536*this[++s]+u*2**24;return BigInt(_)+(BigInt(w)<>>=0,"offset");const i=this[s],u=this[s+7];void 0!==i&&void 0!==u||boundsError(s,this.length-8);const _=i*2**24+65536*this[++s]+256*this[++s]+this[++s],w=this[++s]*2**24+65536*this[++s]+256*this[++s]+u;return(BigInt(_)<>>=0,i>>>=0,u||checkOffset(s,i,this.length);let _=this[s],w=1,x=0;for(;++x=w&&(_-=Math.pow(2,8*i)),_},Buffer.prototype.readIntBE=function readIntBE(s,i,u){s>>>=0,i>>>=0,u||checkOffset(s,i,this.length);let _=i,w=1,x=this[s+--_];for(;_>0&&(w*=256);)x+=this[s+--_]*w;return w*=128,x>=w&&(x-=Math.pow(2,8*i)),x},Buffer.prototype.readInt8=function readInt8(s,i){return s>>>=0,i||checkOffset(s,1,this.length),128&this[s]?-1*(255-this[s]+1):this[s]},Buffer.prototype.readInt16LE=function readInt16LE(s,i){s>>>=0,i||checkOffset(s,2,this.length);const u=this[s]|this[s+1]<<8;return 32768&u?4294901760|u:u},Buffer.prototype.readInt16BE=function readInt16BE(s,i){s>>>=0,i||checkOffset(s,2,this.length);const u=this[s+1]|this[s]<<8;return 32768&u?4294901760|u:u},Buffer.prototype.readInt32LE=function readInt32LE(s,i){return s>>>=0,i||checkOffset(s,4,this.length),this[s]|this[s+1]<<8|this[s+2]<<16|this[s+3]<<24},Buffer.prototype.readInt32BE=function readInt32BE(s,i){return s>>>=0,i||checkOffset(s,4,this.length),this[s]<<24|this[s+1]<<16|this[s+2]<<8|this[s+3]},Buffer.prototype.readBigInt64LE=defineBigIntMethod((function readBigInt64LE(s){validateNumber(s>>>=0,"offset");const i=this[s],u=this[s+7];void 0!==i&&void 0!==u||boundsError(s,this.length-8);const _=this[s+4]+256*this[s+5]+65536*this[s+6]+(u<<24);return(BigInt(_)<>>=0,"offset");const i=this[s],u=this[s+7];void 0!==i&&void 0!==u||boundsError(s,this.length-8);const _=(i<<24)+65536*this[++s]+256*this[++s]+this[++s];return(BigInt(_)<>>=0,i||checkOffset(s,4,this.length),w.read(this,s,!0,23,4)},Buffer.prototype.readFloatBE=function readFloatBE(s,i){return s>>>=0,i||checkOffset(s,4,this.length),w.read(this,s,!1,23,4)},Buffer.prototype.readDoubleLE=function readDoubleLE(s,i){return s>>>=0,i||checkOffset(s,8,this.length),w.read(this,s,!0,52,8)},Buffer.prototype.readDoubleBE=function readDoubleBE(s,i){return s>>>=0,i||checkOffset(s,8,this.length),w.read(this,s,!1,52,8)},Buffer.prototype.writeUintLE=Buffer.prototype.writeUIntLE=function writeUIntLE(s,i,u,_){if(s=+s,i>>>=0,u>>>=0,!_){checkInt(this,s,i,u,Math.pow(2,8*u)-1,0)}let w=1,x=0;for(this[i]=255&s;++x>>=0,u>>>=0,!_){checkInt(this,s,i,u,Math.pow(2,8*u)-1,0)}let w=u-1,x=1;for(this[i+w]=255&s;--w>=0&&(x*=256);)this[i+w]=s/x&255;return i+u},Buffer.prototype.writeUint8=Buffer.prototype.writeUInt8=function writeUInt8(s,i,u){return s=+s,i>>>=0,u||checkInt(this,s,i,1,255,0),this[i]=255&s,i+1},Buffer.prototype.writeUint16LE=Buffer.prototype.writeUInt16LE=function writeUInt16LE(s,i,u){return s=+s,i>>>=0,u||checkInt(this,s,i,2,65535,0),this[i]=255&s,this[i+1]=s>>>8,i+2},Buffer.prototype.writeUint16BE=Buffer.prototype.writeUInt16BE=function writeUInt16BE(s,i,u){return s=+s,i>>>=0,u||checkInt(this,s,i,2,65535,0),this[i]=s>>>8,this[i+1]=255&s,i+2},Buffer.prototype.writeUint32LE=Buffer.prototype.writeUInt32LE=function writeUInt32LE(s,i,u){return s=+s,i>>>=0,u||checkInt(this,s,i,4,4294967295,0),this[i+3]=s>>>24,this[i+2]=s>>>16,this[i+1]=s>>>8,this[i]=255&s,i+4},Buffer.prototype.writeUint32BE=Buffer.prototype.writeUInt32BE=function writeUInt32BE(s,i,u){return s=+s,i>>>=0,u||checkInt(this,s,i,4,4294967295,0),this[i]=s>>>24,this[i+1]=s>>>16,this[i+2]=s>>>8,this[i+3]=255&s,i+4},Buffer.prototype.writeBigUInt64LE=defineBigIntMethod((function writeBigUInt64LE(s,i=0){return wrtBigUInt64LE(this,s,i,BigInt(0),BigInt("0xffffffffffffffff"))})),Buffer.prototype.writeBigUInt64BE=defineBigIntMethod((function writeBigUInt64BE(s,i=0){return wrtBigUInt64BE(this,s,i,BigInt(0),BigInt("0xffffffffffffffff"))})),Buffer.prototype.writeIntLE=function writeIntLE(s,i,u,_){if(s=+s,i>>>=0,!_){const _=Math.pow(2,8*u-1);checkInt(this,s,i,u,_-1,-_)}let w=0,x=1,j=0;for(this[i]=255&s;++w>0)-j&255;return i+u},Buffer.prototype.writeIntBE=function writeIntBE(s,i,u,_){if(s=+s,i>>>=0,!_){const _=Math.pow(2,8*u-1);checkInt(this,s,i,u,_-1,-_)}let w=u-1,x=1,j=0;for(this[i+w]=255&s;--w>=0&&(x*=256);)s<0&&0===j&&0!==this[i+w+1]&&(j=1),this[i+w]=(s/x>>0)-j&255;return i+u},Buffer.prototype.writeInt8=function writeInt8(s,i,u){return s=+s,i>>>=0,u||checkInt(this,s,i,1,127,-128),s<0&&(s=255+s+1),this[i]=255&s,i+1},Buffer.prototype.writeInt16LE=function writeInt16LE(s,i,u){return s=+s,i>>>=0,u||checkInt(this,s,i,2,32767,-32768),this[i]=255&s,this[i+1]=s>>>8,i+2},Buffer.prototype.writeInt16BE=function writeInt16BE(s,i,u){return s=+s,i>>>=0,u||checkInt(this,s,i,2,32767,-32768),this[i]=s>>>8,this[i+1]=255&s,i+2},Buffer.prototype.writeInt32LE=function writeInt32LE(s,i,u){return s=+s,i>>>=0,u||checkInt(this,s,i,4,2147483647,-2147483648),this[i]=255&s,this[i+1]=s>>>8,this[i+2]=s>>>16,this[i+3]=s>>>24,i+4},Buffer.prototype.writeInt32BE=function writeInt32BE(s,i,u){return s=+s,i>>>=0,u||checkInt(this,s,i,4,2147483647,-2147483648),s<0&&(s=4294967295+s+1),this[i]=s>>>24,this[i+1]=s>>>16,this[i+2]=s>>>8,this[i+3]=255&s,i+4},Buffer.prototype.writeBigInt64LE=defineBigIntMethod((function writeBigInt64LE(s,i=0){return wrtBigUInt64LE(this,s,i,-BigInt("0x8000000000000000"),BigInt("0x7fffffffffffffff"))})),Buffer.prototype.writeBigInt64BE=defineBigIntMethod((function writeBigInt64BE(s,i=0){return wrtBigUInt64BE(this,s,i,-BigInt("0x8000000000000000"),BigInt("0x7fffffffffffffff"))})),Buffer.prototype.writeFloatLE=function writeFloatLE(s,i,u){return writeFloat(this,s,i,!0,u)},Buffer.prototype.writeFloatBE=function writeFloatBE(s,i,u){return writeFloat(this,s,i,!1,u)},Buffer.prototype.writeDoubleLE=function writeDoubleLE(s,i,u){return writeDouble(this,s,i,!0,u)},Buffer.prototype.writeDoubleBE=function writeDoubleBE(s,i,u){return writeDouble(this,s,i,!1,u)},Buffer.prototype.copy=function copy(s,i,u,_){if(!Buffer.isBuffer(s))throw new TypeError("argument should be a Buffer");if(u||(u=0),_||0===_||(_=this.length),i>=s.length&&(i=s.length),i||(i=0),_>0&&_=this.length)throw new RangeError("Index out of range");if(_<0)throw new RangeError("sourceEnd out of bounds");_>this.length&&(_=this.length),s.length-i<_-u&&(_=s.length-i+u);const w=_-u;return this===s&&"function"==typeof Uint8Array.prototype.copyWithin?this.copyWithin(i,u,_):Uint8Array.prototype.set.call(s,this.subarray(u,_),i),w},Buffer.prototype.fill=function fill(s,i,u,_){if("string"==typeof s){if("string"==typeof i?(_=i,i=0,u=this.length):"string"==typeof u&&(_=u,u=this.length),void 0!==_&&"string"!=typeof _)throw new TypeError("encoding must be a string");if("string"==typeof _&&!Buffer.isEncoding(_))throw new TypeError("Unknown encoding: "+_);if(1===s.length){const i=s.charCodeAt(0);("utf8"===_&&i<128||"latin1"===_)&&(s=i)}}else"number"==typeof s?s&=255:"boolean"==typeof s&&(s=Number(s));if(i<0||this.length>>=0,u=void 0===u?this.length:u>>>0,s||(s=0),"number"==typeof s)for(w=i;w=_+4;u-=3)i=`_${s.slice(u-3,u)}${i}`;return`${s.slice(0,u)}${i}`}function checkIntBI(s,i,u,_,w,x){if(s>u||s3?0===i||i===BigInt(0)?`>= 0${_} and < 2${_} ** ${8*(x+1)}${_}`:`>= -(2${_} ** ${8*(x+1)-1}${_}) and < 2 ** ${8*(x+1)-1}${_}`:`>= ${i}${_} and <= ${u}${_}`,new L.ERR_OUT_OF_RANGE("value",w,s)}!function checkBounds(s,i,u){validateNumber(i,"offset"),void 0!==s[i]&&void 0!==s[i+u]||boundsError(i,s.length-(u+1))}(_,w,x)}function validateNumber(s,i){if("number"!=typeof s)throw new L.ERR_INVALID_ARG_TYPE(i,"number",s)}function boundsError(s,i,u){if(Math.floor(s)!==s)throw validateNumber(s,u),new L.ERR_OUT_OF_RANGE(u||"offset","an integer",s);if(i<0)throw new L.ERR_BUFFER_OUT_OF_BOUNDS;throw new L.ERR_OUT_OF_RANGE(u||"offset",`>= ${u?1:0} and <= ${i}`,s)}E("ERR_BUFFER_OUT_OF_BOUNDS",(function(s){return s?`${s} is outside of buffer bounds`:"Attempt to access memory outside buffer bounds"}),RangeError),E("ERR_INVALID_ARG_TYPE",(function(s,i){return`The "${s}" argument must be of type number. Received type ${typeof i}`}),TypeError),E("ERR_OUT_OF_RANGE",(function(s,i,u){let _=`The value of "${s}" is out of range.`,w=u;return Number.isInteger(u)&&Math.abs(u)>2**32?w=addNumericalSeparator(String(u)):"bigint"==typeof u&&(w=String(u),(u>BigInt(2)**BigInt(32)||u<-(BigInt(2)**BigInt(32)))&&(w=addNumericalSeparator(w)),w+="n"),_+=` It must be ${i}. Received ${w}`,_}),RangeError);const $=/[^+/0-9A-Za-z-_]/g;function utf8ToBytes(s,i){let u;i=i||1/0;const _=s.length;let w=null;const x=[];for(let j=0;j<_;++j){if(u=s.charCodeAt(j),u>55295&&u<57344){if(!w){if(u>56319){(i-=3)>-1&&x.push(239,191,189);continue}if(j+1===_){(i-=3)>-1&&x.push(239,191,189);continue}w=u;continue}if(u<56320){(i-=3)>-1&&x.push(239,191,189),w=u;continue}u=65536+(w-55296<<10|u-56320)}else w&&(i-=3)>-1&&x.push(239,191,189);if(w=null,u<128){if((i-=1)<0)break;x.push(u)}else if(u<2048){if((i-=2)<0)break;x.push(u>>6|192,63&u|128)}else if(u<65536){if((i-=3)<0)break;x.push(u>>12|224,u>>6&63|128,63&u|128)}else{if(!(u<1114112))throw new Error("Invalid code point");if((i-=4)<0)break;x.push(u>>18|240,u>>12&63|128,u>>6&63|128,63&u|128)}}return x}function base64ToBytes(s){return _.toByteArray(function base64clean(s){if((s=(s=s.split("=")[0]).trim().replace($,"")).length<2)return"";for(;s.length%4!=0;)s+="=";return s}(s))}function blitBuffer(s,i,u,_){let w;for(w=0;w<_&&!(w+u>=i.length||w>=s.length);++w)i[w+u]=s[w];return w}function isInstance(s,i){return s instanceof i||null!=s&&null!=s.constructor&&null!=s.constructor.name&&s.constructor.name===i.name}function numberIsNaN(s){return s!=s}const U=function(){const s="0123456789abcdef",i=new Array(256);for(let u=0;u<16;++u){const _=16*u;for(let w=0;w<16;++w)i[_+w]=s[u]+s[w]}return i}();function defineBigIntMethod(s){return"undefined"==typeof BigInt?BufferBigIntNotDefined:s}function BufferBigIntNotDefined(){throw new Error("BigInt not supported")}},38075:(s,i,u)=>{"use strict";var _=u(70453),w=u(10487),x=w(_("String.prototype.indexOf"));s.exports=function callBoundIntrinsic(s,i){var u=_(s,!!i);return"function"==typeof u&&x(s,".prototype.")>-1?w(u):u}},10487:(s,i,u)=>{"use strict";var _=u(66743),w=u(70453),x=u(96897),j=u(69675),B=w("%Function.prototype.apply%"),L=w("%Function.prototype.call%"),$=w("%Reflect.apply%",!0)||_.call(L,B),U=u(30655),Y=w("%Math.max%");s.exports=function callBind(s){if("function"!=typeof s)throw new j("a function is required");var i=$(_,L,arguments);return x(i,1+Y(0,s.length-(arguments.length-1)),!0)};var Z=function applyBind(){return $(_,B,arguments)};U?U(s.exports,"apply",{value:Z}):s.exports.apply=Z},57427:(s,i)=>{"use strict";i.parse=function parse(s,i){if("string"!=typeof s)throw new TypeError("argument str must be a string");var u={},_=(i||{}).decode||decode,w=0;for(;w{"use strict";var _=u(16426),w={"text/plain":"Text","text/html":"Url",default:"Text"};s.exports=function copy(s,i){var u,x,j,B,L,$,U=!1;i||(i={}),u=i.debug||!1;try{if(j=_(),B=document.createRange(),L=document.getSelection(),($=document.createElement("span")).textContent=s,$.ariaHidden="true",$.style.all="unset",$.style.position="fixed",$.style.top=0,$.style.clip="rect(0, 0, 0, 0)",$.style.whiteSpace="pre",$.style.webkitUserSelect="text",$.style.MozUserSelect="text",$.style.msUserSelect="text",$.style.userSelect="text",$.addEventListener("copy",(function(_){if(_.stopPropagation(),i.format)if(_.preventDefault(),void 0===_.clipboardData){u&&console.warn("unable to use e.clipboardData"),u&&console.warn("trying IE specific stuff"),window.clipboardData.clearData();var x=w[i.format]||w.default;window.clipboardData.setData(x,s)}else _.clipboardData.clearData(),_.clipboardData.setData(i.format,s);i.onCopy&&(_.preventDefault(),i.onCopy(_.clipboardData))})),document.body.appendChild($),B.selectNodeContents($),L.addRange(B),!document.execCommand("copy"))throw new Error("copy command was unsuccessful");U=!0}catch(_){u&&console.error("unable to copy using execCommand: ",_),u&&console.warn("trying IE specific stuff");try{window.clipboardData.setData(i.format||"text",s),i.onCopy&&i.onCopy(window.clipboardData),U=!0}catch(_){u&&console.error("unable to copy using clipboardData: ",_),u&&console.error("falling back to prompt"),x=function format(s){var i=(/mac os x/i.test(navigator.userAgent)?"⌘":"Ctrl")+"+C";return s.replace(/#{\s*key\s*}/g,i)}("message"in i?i.message:"Copy to clipboard: #{key}, Enter"),window.prompt(x,s)}}finally{L&&("function"==typeof L.removeRange?L.removeRange(B):L.removeAllRanges()),$&&document.body.removeChild($),j()}return U}},2205:function(s,i,u){var _;_=void 0!==u.g?u.g:this,s.exports=function(s){if(s.CSS&&s.CSS.escape)return s.CSS.escape;var cssEscape=function(s){if(0==arguments.length)throw new TypeError("`CSS.escape` requires an argument.");for(var i,u=String(s),_=u.length,w=-1,x="",j=u.charCodeAt(0);++w<_;)0!=(i=u.charCodeAt(w))?x+=i>=1&&i<=31||127==i||0==w&&i>=48&&i<=57||1==w&&i>=48&&i<=57&&45==j?"\\"+i.toString(16)+" ":0==w&&1==_&&45==i||!(i>=128||45==i||95==i||i>=48&&i<=57||i>=65&&i<=90||i>=97&&i<=122)?"\\"+u.charAt(w):u.charAt(w):x+="�";return x};return s.CSS||(s.CSS={}),s.CSS.escape=cssEscape,cssEscape}(_)},81919:(s,i,u)=>{"use strict";var _=u(48287).Buffer;function isSpecificValue(s){return s instanceof _||s instanceof Date||s instanceof RegExp}function cloneSpecificValue(s){if(s instanceof _){var i=_.alloc?_.alloc(s.length):new _(s.length);return s.copy(i),i}if(s instanceof Date)return new Date(s.getTime());if(s instanceof RegExp)return new RegExp(s);throw new Error("Unexpected situation")}function deepCloneArray(s){var i=[];return s.forEach((function(s,u){"object"==typeof s&&null!==s?Array.isArray(s)?i[u]=deepCloneArray(s):isSpecificValue(s)?i[u]=cloneSpecificValue(s):i[u]=w({},s):i[u]=s})),i}function safeGetProperty(s,i){return"__proto__"===i?void 0:s[i]}var w=s.exports=function(){if(arguments.length<1||"object"!=typeof arguments[0])return!1;if(arguments.length<2)return arguments[0];var s,i,u=arguments[0];return Array.prototype.slice.call(arguments,1).forEach((function(_){"object"!=typeof _||null===_||Array.isArray(_)||Object.keys(_).forEach((function(x){return i=safeGetProperty(u,x),(s=safeGetProperty(_,x))===u?void 0:"object"!=typeof s||null===s?void(u[x]=s):Array.isArray(s)?void(u[x]=deepCloneArray(s)):isSpecificValue(s)?void(u[x]=cloneSpecificValue(s)):"object"!=typeof i||null===i||Array.isArray(i)?void(u[x]=w({},s)):void(u[x]=w(i,s))}))})),u}},14744:s=>{"use strict";var i=function isMergeableObject(s){return function isNonNullObject(s){return!!s&&"object"==typeof s}(s)&&!function isSpecial(s){var i=Object.prototype.toString.call(s);return"[object RegExp]"===i||"[object Date]"===i||function isReactElement(s){return s.$$typeof===u}(s)}(s)};var u="function"==typeof Symbol&&Symbol.for?Symbol.for("react.element"):60103;function cloneUnlessOtherwiseSpecified(s,i){return!1!==i.clone&&i.isMergeableObject(s)?deepmerge(function emptyTarget(s){return Array.isArray(s)?[]:{}}(s),s,i):s}function defaultArrayMerge(s,i,u){return s.concat(i).map((function(s){return cloneUnlessOtherwiseSpecified(s,u)}))}function getKeys(s){return Object.keys(s).concat(function getEnumerableOwnPropertySymbols(s){return Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(s).filter((function(i){return Object.propertyIsEnumerable.call(s,i)})):[]}(s))}function propertyIsOnObject(s,i){try{return i in s}catch(s){return!1}}function mergeObject(s,i,u){var _={};return u.isMergeableObject(s)&&getKeys(s).forEach((function(i){_[i]=cloneUnlessOtherwiseSpecified(s[i],u)})),getKeys(i).forEach((function(w){(function propertyIsUnsafe(s,i){return propertyIsOnObject(s,i)&&!(Object.hasOwnProperty.call(s,i)&&Object.propertyIsEnumerable.call(s,i))})(s,w)||(propertyIsOnObject(s,w)&&u.isMergeableObject(i[w])?_[w]=function getMergeFunction(s,i){if(!i.customMerge)return deepmerge;var u=i.customMerge(s);return"function"==typeof u?u:deepmerge}(w,u)(s[w],i[w],u):_[w]=cloneUnlessOtherwiseSpecified(i[w],u))})),_}function deepmerge(s,u,_){(_=_||{}).arrayMerge=_.arrayMerge||defaultArrayMerge,_.isMergeableObject=_.isMergeableObject||i,_.cloneUnlessOtherwiseSpecified=cloneUnlessOtherwiseSpecified;var w=Array.isArray(u);return w===Array.isArray(s)?w?_.arrayMerge(s,u,_):mergeObject(s,u,_):cloneUnlessOtherwiseSpecified(u,_)}deepmerge.all=function deepmergeAll(s,i){if(!Array.isArray(s))throw new Error("first argument should be an array");return s.reduce((function(s,u){return deepmerge(s,u,i)}),{})};var _=deepmerge;s.exports=_},30041:(s,i,u)=>{"use strict";var _=u(30655),w=u(58068),x=u(69675),j=u(75795);s.exports=function defineDataProperty(s,i,u){if(!s||"object"!=typeof s&&"function"!=typeof s)throw new x("`obj` must be an object or a function`");if("string"!=typeof i&&"symbol"!=typeof i)throw new x("`property` must be a string or a symbol`");if(arguments.length>3&&"boolean"!=typeof arguments[3]&&null!==arguments[3])throw new x("`nonEnumerable`, if provided, must be a boolean or null");if(arguments.length>4&&"boolean"!=typeof arguments[4]&&null!==arguments[4])throw new x("`nonWritable`, if provided, must be a boolean or null");if(arguments.length>5&&"boolean"!=typeof arguments[5]&&null!==arguments[5])throw new x("`nonConfigurable`, if provided, must be a boolean or null");if(arguments.length>6&&"boolean"!=typeof arguments[6])throw new x("`loose`, if provided, must be a boolean");var B=arguments.length>3?arguments[3]:null,L=arguments.length>4?arguments[4]:null,$=arguments.length>5?arguments[5]:null,U=arguments.length>6&&arguments[6],Y=!!j&&j(s,i);if(_)_(s,i,{configurable:null===$&&Y?Y.configurable:!$,enumerable:null===B&&Y?Y.enumerable:!B,value:u,writable:null===L&&Y?Y.writable:!L});else{if(!U&&(B||L||$))throw new w("This environment does not support defining a property as non-configurable, non-writable, or non-enumerable.");s[i]=u}}},42838:function(s){s.exports=function(){"use strict";const{entries:s,setPrototypeOf:i,isFrozen:u,getPrototypeOf:_,getOwnPropertyDescriptor:w}=Object;let{freeze:x,seal:j,create:B}=Object,{apply:L,construct:$}="undefined"!=typeof Reflect&&Reflect;x||(x=function freeze(s){return s}),j||(j=function seal(s){return s}),L||(L=function apply(s,i,u){return s.apply(i,u)}),$||($=function construct(s,i){return new s(...i)});const U=unapply(Array.prototype.forEach),Y=unapply(Array.prototype.pop),Z=unapply(Array.prototype.push),ee=unapply(String.prototype.toLowerCase),ie=unapply(String.prototype.toString),ae=unapply(String.prototype.match),le=unapply(String.prototype.replace),ce=unapply(String.prototype.indexOf),pe=unapply(String.prototype.trim),de=unapply(Object.prototype.hasOwnProperty),fe=unapply(RegExp.prototype.test),ye=unconstruct(TypeError);function unapply(s){return function(i){for(var u=arguments.length,_=new Array(u>1?u-1:0),w=1;w2&&void 0!==arguments[2]?arguments[2]:ee;i&&i(s,null);let x=_.length;for(;x--;){let i=_[x];if("string"==typeof i){const s=w(i);s!==i&&(u(_)||(_[x]=s),i=s)}s[i]=!0}return s}function cleanArray(s){for(let i=0;i/gm),Xe=j(/\${[\w\W]*}/gm),Ye=j(/^data-[\-\w.\u00B7-\uFFFF]/),Qe=j(/^aria-[\-\w]+$/),et=j(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),tt=j(/^(?:\w+script|data):/i),rt=j(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),nt=j(/^html$/i),ot=j(/^[a-z][.\w]*(-[.\w]+)+$/i);var st=Object.freeze({__proto__:null,MUSTACHE_EXPR:We,ERB_EXPR:He,TMPLIT_EXPR:Xe,DATA_ATTR:Ye,ARIA_ATTR:Qe,IS_ALLOWED_URI:et,IS_SCRIPT_OR_DATA:tt,ATTR_WHITESPACE:rt,DOCTYPE_NAME:nt,CUSTOM_ELEMENT:ot});const it=function getGlobal(){return"undefined"==typeof window?null:window},at=function _createTrustedTypesPolicy(s,i){if("object"!=typeof s||"function"!=typeof s.createPolicy)return null;let u=null;const _="data-tt-policy-suffix";i&&i.hasAttribute(_)&&(u=i.getAttribute(_));const w="dompurify"+(u?"#"+u:"");try{return s.createPolicy(w,{createHTML:s=>s,createScriptURL:s=>s})}catch(s){return console.warn("TrustedTypes policy "+w+" could not be created."),null}};function createDOMPurify(){let i=arguments.length>0&&void 0!==arguments[0]?arguments[0]:it();const DOMPurify=s=>createDOMPurify(s);if(DOMPurify.version="3.1.1",DOMPurify.removed=[],!i||!i.document||9!==i.document.nodeType)return DOMPurify.isSupported=!1,DOMPurify;let{document:u}=i;const _=u,w=_.currentScript,{DocumentFragment:j,HTMLTemplateElement:L,Node:$,Element:We,NodeFilter:He,NamedNodeMap:Xe=i.NamedNodeMap||i.MozNamedAttrMap,HTMLFormElement:Ye,DOMParser:Qe,trustedTypes:tt}=i,rt=We.prototype,ot=lookupGetter(rt,"cloneNode"),lt=lookupGetter(rt,"nextSibling"),ct=lookupGetter(rt,"childNodes"),ut=lookupGetter(rt,"parentNode");if("function"==typeof L){const s=u.createElement("template");s.content&&s.content.ownerDocument&&(u=s.content.ownerDocument)}let pt,ht="";const{implementation:dt,createNodeIterator:mt,createDocumentFragment:gt,getElementsByTagName:yt}=u,{importNode:vt}=_;let bt={};DOMPurify.isSupported="function"==typeof s&&"function"==typeof ut&&dt&&void 0!==dt.createHTMLDocument;const{MUSTACHE_EXPR:_t,ERB_EXPR:Et,TMPLIT_EXPR:wt,DATA_ATTR:St,ARIA_ATTR:xt,IS_SCRIPT_OR_DATA:kt,ATTR_WHITESPACE:Ot,CUSTOM_ELEMENT:Ct}=st;let{IS_ALLOWED_URI:At}=st,jt=null;const Pt=addToSet({},[...be,..._e,...we,...xe,...Te]);let It=null;const Nt=addToSet({},[...Re,...qe,...$e,...ze]);let Mt=Object.seal(B(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),Tt=null,Rt=null,Dt=!0,Bt=!0,Lt=!1,Ft=!0,qt=!1,$t=!0,Ut=!1,zt=!1,Vt=!1,Wt=!1,Kt=!1,Ht=!1,Jt=!0,Gt=!1;const Xt="user-content-";let Yt=!0,Qt=!1,Zt={},er=null;const tr=addToSet({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let rr=null;const nr=addToSet({},["audio","video","img","source","image","track"]);let sr=null;const ir=addToSet({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),ar="http://www.w3.org/1998/Math/MathML",lr="http://www.w3.org/2000/svg",cr="http://www.w3.org/1999/xhtml";let ur=cr,pr=!1,dr=null;const fr=addToSet({},[ar,lr,cr],ie);let mr=null;const gr=["application/xhtml+xml","text/html"],yr="text/html";let vr=null,br=null;const _r=255,Er=u.createElement("form"),wr=function isRegexOrFunction(s){return s instanceof RegExp||s instanceof Function},Sr=function _parseConfig(){let s=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!br||br!==s){if(s&&"object"==typeof s||(s={}),s=clone(s),mr=-1===gr.indexOf(s.PARSER_MEDIA_TYPE)?yr:s.PARSER_MEDIA_TYPE,vr="application/xhtml+xml"===mr?ie:ee,jt=de(s,"ALLOWED_TAGS")?addToSet({},s.ALLOWED_TAGS,vr):Pt,It=de(s,"ALLOWED_ATTR")?addToSet({},s.ALLOWED_ATTR,vr):Nt,dr=de(s,"ALLOWED_NAMESPACES")?addToSet({},s.ALLOWED_NAMESPACES,ie):fr,sr=de(s,"ADD_URI_SAFE_ATTR")?addToSet(clone(ir),s.ADD_URI_SAFE_ATTR,vr):ir,rr=de(s,"ADD_DATA_URI_TAGS")?addToSet(clone(nr),s.ADD_DATA_URI_TAGS,vr):nr,er=de(s,"FORBID_CONTENTS")?addToSet({},s.FORBID_CONTENTS,vr):tr,Tt=de(s,"FORBID_TAGS")?addToSet({},s.FORBID_TAGS,vr):{},Rt=de(s,"FORBID_ATTR")?addToSet({},s.FORBID_ATTR,vr):{},Zt=!!de(s,"USE_PROFILES")&&s.USE_PROFILES,Dt=!1!==s.ALLOW_ARIA_ATTR,Bt=!1!==s.ALLOW_DATA_ATTR,Lt=s.ALLOW_UNKNOWN_PROTOCOLS||!1,Ft=!1!==s.ALLOW_SELF_CLOSE_IN_ATTR,qt=s.SAFE_FOR_TEMPLATES||!1,$t=!1!==s.SAFE_FOR_XML,Ut=s.WHOLE_DOCUMENT||!1,Wt=s.RETURN_DOM||!1,Kt=s.RETURN_DOM_FRAGMENT||!1,Ht=s.RETURN_TRUSTED_TYPE||!1,Vt=s.FORCE_BODY||!1,Jt=!1!==s.SANITIZE_DOM,Gt=s.SANITIZE_NAMED_PROPS||!1,Yt=!1!==s.KEEP_CONTENT,Qt=s.IN_PLACE||!1,At=s.ALLOWED_URI_REGEXP||et,ur=s.NAMESPACE||cr,Mt=s.CUSTOM_ELEMENT_HANDLING||{},s.CUSTOM_ELEMENT_HANDLING&&wr(s.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(Mt.tagNameCheck=s.CUSTOM_ELEMENT_HANDLING.tagNameCheck),s.CUSTOM_ELEMENT_HANDLING&&wr(s.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(Mt.attributeNameCheck=s.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),s.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof s.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(Mt.allowCustomizedBuiltInElements=s.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),qt&&(Bt=!1),Kt&&(Wt=!0),Zt&&(jt=addToSet({},Te),It=[],!0===Zt.html&&(addToSet(jt,be),addToSet(It,Re)),!0===Zt.svg&&(addToSet(jt,_e),addToSet(It,qe),addToSet(It,ze)),!0===Zt.svgFilters&&(addToSet(jt,we),addToSet(It,qe),addToSet(It,ze)),!0===Zt.mathMl&&(addToSet(jt,xe),addToSet(It,$e),addToSet(It,ze))),s.ADD_TAGS&&(jt===Pt&&(jt=clone(jt)),addToSet(jt,s.ADD_TAGS,vr)),s.ADD_ATTR&&(It===Nt&&(It=clone(It)),addToSet(It,s.ADD_ATTR,vr)),s.ADD_URI_SAFE_ATTR&&addToSet(sr,s.ADD_URI_SAFE_ATTR,vr),s.FORBID_CONTENTS&&(er===tr&&(er=clone(er)),addToSet(er,s.FORBID_CONTENTS,vr)),Yt&&(jt["#text"]=!0),Ut&&addToSet(jt,["html","head","body"]),jt.table&&(addToSet(jt,["tbody"]),delete Tt.tbody),s.TRUSTED_TYPES_POLICY){if("function"!=typeof s.TRUSTED_TYPES_POLICY.createHTML)throw ye('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof s.TRUSTED_TYPES_POLICY.createScriptURL)throw ye('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');pt=s.TRUSTED_TYPES_POLICY,ht=pt.createHTML("")}else void 0===pt&&(pt=at(tt,w)),null!==pt&&"string"==typeof ht&&(ht=pt.createHTML(""));x&&x(s),br=s}},xr=addToSet({},["mi","mo","mn","ms","mtext"]),kr=addToSet({},["foreignobject","desc","title","annotation-xml"]),Or=addToSet({},["title","style","font","a","script"]),Cr=addToSet({},[..._e,...we,...Se]),Ar=addToSet({},[...xe,...Pe]),jr=function _checkValidNamespace(s){let i=ut(s);i&&i.tagName||(i={namespaceURI:ur,tagName:"template"});const u=ee(s.tagName),_=ee(i.tagName);return!!dr[s.namespaceURI]&&(s.namespaceURI===lr?i.namespaceURI===cr?"svg"===u:i.namespaceURI===ar?"svg"===u&&("annotation-xml"===_||xr[_]):Boolean(Cr[u]):s.namespaceURI===ar?i.namespaceURI===cr?"math"===u:i.namespaceURI===lr?"math"===u&&kr[_]:Boolean(Ar[u]):s.namespaceURI===cr?!(i.namespaceURI===lr&&!kr[_])&&!(i.namespaceURI===ar&&!xr[_])&&!Ar[u]&&(Or[u]||!Cr[u]):!("application/xhtml+xml"!==mr||!dr[s.namespaceURI]))},Pr=function _forceRemove(s){Z(DOMPurify.removed,{element:s});try{s.parentNode.removeChild(s)}catch(i){s.remove()}},Ir=function _removeAttribute(s,i){try{Z(DOMPurify.removed,{attribute:i.getAttributeNode(s),from:i})}catch(s){Z(DOMPurify.removed,{attribute:null,from:i})}if(i.removeAttribute(s),"is"===s&&!It[s])if(Wt||Kt)try{Pr(i)}catch(s){}else try{i.setAttribute(s,"")}catch(s){}},Nr=function _initDocument(s){let i=null,_=null;if(Vt)s=""+s;else{const i=ae(s,/^[\r\n\t ]+/);_=i&&i[0]}"application/xhtml+xml"===mr&&ur===cr&&(s=''+s+"");const w=pt?pt.createHTML(s):s;if(ur===cr)try{i=(new Qe).parseFromString(w,mr)}catch(s){}if(!i||!i.documentElement){i=dt.createDocument(ur,"template",null);try{i.documentElement.innerHTML=pr?ht:w}catch(s){}}const x=i.body||i.documentElement;return s&&_&&x.insertBefore(u.createTextNode(_),x.childNodes[0]||null),ur===cr?yt.call(i,Ut?"html":"body")[0]:Ut?i.documentElement:x},Mr=function _createNodeIterator(s){return mt.call(s.ownerDocument||s,s,He.SHOW_ELEMENT|He.SHOW_COMMENT|He.SHOW_TEXT|He.SHOW_PROCESSING_INSTRUCTION|He.SHOW_CDATA_SECTION,null)},Tr=function _isClobbered(s){return s instanceof Ye&&(void 0!==s.__depth&&"number"!=typeof s.__depth||void 0!==s.__removalCount&&"number"!=typeof s.__removalCount||"string"!=typeof s.nodeName||"string"!=typeof s.textContent||"function"!=typeof s.removeChild||!(s.attributes instanceof Xe)||"function"!=typeof s.removeAttribute||"function"!=typeof s.setAttribute||"string"!=typeof s.namespaceURI||"function"!=typeof s.insertBefore||"function"!=typeof s.hasChildNodes)},Rr=function _isNode(s){return"function"==typeof $&&s instanceof $},Dr=function _executeHook(s,i,u){bt[s]&&U(bt[s],(s=>{s.call(DOMPurify,i,u,br)}))},Br=function _sanitizeElements(s){let i=null;if(Dr("beforeSanitizeElements",s,null),Tr(s))return Pr(s),!0;const u=vr(s.nodeName);if(Dr("uponSanitizeElement",s,{tagName:u,allowedTags:jt}),s.hasChildNodes()&&!Rr(s.firstElementChild)&&fe(/<[/\w]/g,s.innerHTML)&&fe(/<[/\w]/g,s.textContent))return Pr(s),!0;if(7===s.nodeType)return Pr(s),!0;if($t&&8===s.nodeType&&fe(/<[/\w]/g,s.data))return Pr(s),!0;if(!jt[u]||Tt[u]){if(!Tt[u]&&Fr(u)){if(Mt.tagNameCheck instanceof RegExp&&fe(Mt.tagNameCheck,u))return!1;if(Mt.tagNameCheck instanceof Function&&Mt.tagNameCheck(u))return!1}if(Yt&&!er[u]){const i=ut(s)||s.parentNode,u=ct(s)||s.childNodes;if(u&&i)for(let _=u.length-1;_>=0;--_){const w=ot(u[_],!0);w.__removalCount=(s.__removalCount||0)+1,i.insertBefore(w,lt(s))}}return Pr(s),!0}return s instanceof We&&!jr(s)?(Pr(s),!0):"noscript"!==u&&"noembed"!==u&&"noframes"!==u||!fe(/<\/no(script|embed|frames)/i,s.innerHTML)?(qt&&3===s.nodeType&&(i=s.textContent,U([_t,Et,wt],(s=>{i=le(i,s," ")})),s.textContent!==i&&(Z(DOMPurify.removed,{element:s.cloneNode()}),s.textContent=i)),Dr("afterSanitizeElements",s,null),!1):(Pr(s),!0)},Lr=function _isValidAttribute(s,i,_){if(Jt&&("id"===i||"name"===i)&&(_ in u||_ in Er))return!1;if(Bt&&!Rt[i]&&fe(St,i));else if(Dt&&fe(xt,i));else if(!It[i]||Rt[i]){if(!(Fr(s)&&(Mt.tagNameCheck instanceof RegExp&&fe(Mt.tagNameCheck,s)||Mt.tagNameCheck instanceof Function&&Mt.tagNameCheck(s))&&(Mt.attributeNameCheck instanceof RegExp&&fe(Mt.attributeNameCheck,i)||Mt.attributeNameCheck instanceof Function&&Mt.attributeNameCheck(i))||"is"===i&&Mt.allowCustomizedBuiltInElements&&(Mt.tagNameCheck instanceof RegExp&&fe(Mt.tagNameCheck,_)||Mt.tagNameCheck instanceof Function&&Mt.tagNameCheck(_))))return!1}else if(sr[i]);else if(fe(At,le(_,Ot,"")));else if("src"!==i&&"xlink:href"!==i&&"href"!==i||"script"===s||0!==ce(_,"data:")||!rr[s])if(Lt&&!fe(kt,le(_,Ot,"")));else if(_)return!1;return!0},Fr=function _isBasicCustomElement(s){return"annotation-xml"!==s&&ae(s,Ct)},qr=function _sanitizeAttributes(s){Dr("beforeSanitizeAttributes",s,null);const{attributes:i}=s;if(!i)return;const u={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:It};let _=i.length;for(;_--;){const w=i[_],{name:x,namespaceURI:j,value:B}=w,L=vr(x);let $="value"===x?B:pe(B);if(u.attrName=L,u.attrValue=$,u.keepAttr=!0,u.forceKeepAttr=void 0,Dr("uponSanitizeAttribute",s,u),$=u.attrValue,u.forceKeepAttr)continue;if(Ir(x,s),!u.keepAttr)continue;if(!Ft&&fe(/\/>/i,$)){Ir(x,s);continue}qt&&U([_t,Et,wt],(s=>{$=le($,s," ")}));const Z=vr(s.nodeName);if(Lr(Z,L,$)){if(!Gt||"id"!==L&&"name"!==L||(Ir(x,s),$=Xt+$),pt&&"object"==typeof tt&&"function"==typeof tt.getAttributeType)if(j);else switch(tt.getAttributeType(Z,L)){case"TrustedHTML":$=pt.createHTML($);break;case"TrustedScriptURL":$=pt.createScriptURL($)}try{j?s.setAttributeNS(j,x,$):s.setAttribute(x,$),Y(DOMPurify.removed)}catch(s){}}}Dr("afterSanitizeAttributes",s,null)},$r=function _sanitizeShadowDOM(s){let i=null;const u=Mr(s);for(Dr("beforeSanitizeShadowDOM",s,null);i=u.nextNode();)Dr("uponSanitizeShadowNode",i,null),Br(i)||(1===i.nodeType&&(i.parentNode&&i.parentNode.__depth?i.__depth=(i.__removalCount||0)+i.parentNode.__depth+1:i.__depth=1),i.__depth>=_r&&Pr(i),i.content instanceof j&&(i.content.__depth=i.__depth,_sanitizeShadowDOM(i.content)),qr(i));Dr("afterSanitizeShadowDOM",s,null)};return DOMPurify.sanitize=function(s){let i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},u=null,w=null,x=null,B=null;if(pr=!s,pr&&(s="\x3c!--\x3e"),"string"!=typeof s&&!Rr(s)){if("function"!=typeof s.toString)throw ye("toString is not a function");if("string"!=typeof(s=s.toString()))throw ye("dirty is not a string, aborting")}if(!DOMPurify.isSupported)return s;if(zt||Sr(i),DOMPurify.removed=[],"string"==typeof s&&(Qt=!1),Qt){if(s.nodeName){const i=vr(s.nodeName);if(!jt[i]||Tt[i])throw ye("root node is forbidden and cannot be sanitized in-place")}}else if(s instanceof $)u=Nr("\x3c!----\x3e"),w=u.ownerDocument.importNode(s,!0),1===w.nodeType&&"BODY"===w.nodeName||"HTML"===w.nodeName?u=w:u.appendChild(w);else{if(!Wt&&!qt&&!Ut&&-1===s.indexOf("<"))return pt&&Ht?pt.createHTML(s):s;if(u=Nr(s),!u)return Wt?null:Ht?ht:""}u&&Vt&&Pr(u.firstChild);const L=Mr(Qt?s:u);for(;x=L.nextNode();)Br(x)||(1===x.nodeType&&(x.parentNode&&x.parentNode.__depth?x.__depth=(x.__removalCount||0)+x.parentNode.__depth+1:x.__depth=1),x.__depth>=_r&&Pr(x),x.content instanceof j&&(x.content.__depth=x.__depth,$r(x.content)),qr(x));if(Qt)return s;if(Wt){if(Kt)for(B=gt.call(u.ownerDocument);u.firstChild;)B.appendChild(u.firstChild);else B=u;return(It.shadowroot||It.shadowrootmode)&&(B=vt.call(_,B,!0)),B}let Y=Ut?u.outerHTML:u.innerHTML;return Ut&&jt["!doctype"]&&u.ownerDocument&&u.ownerDocument.doctype&&u.ownerDocument.doctype.name&&fe(nt,u.ownerDocument.doctype.name)&&(Y="\n"+Y),qt&&U([_t,Et,wt],(s=>{Y=le(Y,s," ")})),pt&&Ht?pt.createHTML(Y):Y},DOMPurify.setConfig=function(){Sr(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),zt=!0},DOMPurify.clearConfig=function(){br=null,zt=!1},DOMPurify.isValidAttribute=function(s,i,u){br||Sr({});const _=vr(s),w=vr(i);return Lr(_,w,u)},DOMPurify.addHook=function(s,i){"function"==typeof i&&(bt[s]=bt[s]||[],Z(bt[s],i))},DOMPurify.removeHook=function(s){if(bt[s])return Y(bt[s])},DOMPurify.removeHooks=function(s){bt[s]&&(bt[s]=[])},DOMPurify.removeAllHooks=function(){bt={}},DOMPurify}return createDOMPurify()}()},78004:s=>{"use strict";class SubRange{constructor(s,i){this.low=s,this.high=i,this.length=1+i-s}overlaps(s){return!(this.highs.high)}touches(s){return!(this.high+1s.high)}add(s){return new SubRange(Math.min(this.low,s.low),Math.max(this.high,s.high))}subtract(s){return s.low<=this.low&&s.high>=this.high?[]:s.low>this.low&&s.highs+i.length),0)}add(s,i){var _add=s=>{for(var i=0;i{for(var i=0;i{for(var i=0;i{for(var u=i.low;u<=i.high;)s.push(u),u++;return s}),[])}subranges(){return this.ranges.map((s=>({low:s.low,high:s.high,length:1+s.high-s.low})))}}s.exports=DRange},30655:(s,i,u)=>{"use strict";var _=u(70453)("%Object.defineProperty%",!0)||!1;if(_)try{_({},"a",{value:1})}catch(s){_=!1}s.exports=_},41237:s=>{"use strict";s.exports=EvalError},69383:s=>{"use strict";s.exports=Error},79290:s=>{"use strict";s.exports=RangeError},79538:s=>{"use strict";s.exports=ReferenceError},58068:s=>{"use strict";s.exports=SyntaxError},69675:s=>{"use strict";s.exports=TypeError},35345:s=>{"use strict";s.exports=URIError},37007:s=>{"use strict";var i,u="object"==typeof Reflect?Reflect:null,_=u&&"function"==typeof u.apply?u.apply:function ReflectApply(s,i,u){return Function.prototype.apply.call(s,i,u)};i=u&&"function"==typeof u.ownKeys?u.ownKeys:Object.getOwnPropertySymbols?function ReflectOwnKeys(s){return Object.getOwnPropertyNames(s).concat(Object.getOwnPropertySymbols(s))}:function ReflectOwnKeys(s){return Object.getOwnPropertyNames(s)};var w=Number.isNaN||function NumberIsNaN(s){return s!=s};function EventEmitter(){EventEmitter.init.call(this)}s.exports=EventEmitter,s.exports.once=function once(s,i){return new Promise((function(u,_){function errorListener(u){s.removeListener(i,resolver),_(u)}function resolver(){"function"==typeof s.removeListener&&s.removeListener("error",errorListener),u([].slice.call(arguments))}eventTargetAgnosticAddListener(s,i,resolver,{once:!0}),"error"!==i&&function addErrorHandlerIfEventEmitter(s,i,u){"function"==typeof s.on&&eventTargetAgnosticAddListener(s,"error",i,u)}(s,errorListener,{once:!0})}))},EventEmitter.EventEmitter=EventEmitter,EventEmitter.prototype._events=void 0,EventEmitter.prototype._eventsCount=0,EventEmitter.prototype._maxListeners=void 0;var x=10;function checkListener(s){if("function"!=typeof s)throw new TypeError('The "listener" argument must be of type Function. Received type '+typeof s)}function _getMaxListeners(s){return void 0===s._maxListeners?EventEmitter.defaultMaxListeners:s._maxListeners}function _addListener(s,i,u,_){var w,x,j;if(checkListener(u),void 0===(x=s._events)?(x=s._events=Object.create(null),s._eventsCount=0):(void 0!==x.newListener&&(s.emit("newListener",i,u.listener?u.listener:u),x=s._events),j=x[i]),void 0===j)j=x[i]=u,++s._eventsCount;else if("function"==typeof j?j=x[i]=_?[u,j]:[j,u]:_?j.unshift(u):j.push(u),(w=_getMaxListeners(s))>0&&j.length>w&&!j.warned){j.warned=!0;var B=new Error("Possible EventEmitter memory leak detected. "+j.length+" "+String(i)+" listeners added. Use emitter.setMaxListeners() to increase limit");B.name="MaxListenersExceededWarning",B.emitter=s,B.type=i,B.count=j.length,function ProcessEmitWarning(s){console&&console.warn&&console.warn(s)}(B)}return s}function onceWrapper(){if(!this.fired)return this.target.removeListener(this.type,this.wrapFn),this.fired=!0,0===arguments.length?this.listener.call(this.target):this.listener.apply(this.target,arguments)}function _onceWrap(s,i,u){var _={fired:!1,wrapFn:void 0,target:s,type:i,listener:u},w=onceWrapper.bind(_);return w.listener=u,_.wrapFn=w,w}function _listeners(s,i,u){var _=s._events;if(void 0===_)return[];var w=_[i];return void 0===w?[]:"function"==typeof w?u?[w.listener||w]:[w]:u?function unwrapListeners(s){for(var i=new Array(s.length),u=0;u0&&(j=i[0]),j instanceof Error)throw j;var B=new Error("Unhandled error."+(j?" ("+j.message+")":""));throw B.context=j,B}var L=x[s];if(void 0===L)return!1;if("function"==typeof L)_(L,this,i);else{var $=L.length,U=arrayClone(L,$);for(u=0;u<$;++u)_(U[u],this,i)}return!0},EventEmitter.prototype.addListener=function addListener(s,i){return _addListener(this,s,i,!1)},EventEmitter.prototype.on=EventEmitter.prototype.addListener,EventEmitter.prototype.prependListener=function prependListener(s,i){return _addListener(this,s,i,!0)},EventEmitter.prototype.once=function once(s,i){return checkListener(i),this.on(s,_onceWrap(this,s,i)),this},EventEmitter.prototype.prependOnceListener=function prependOnceListener(s,i){return checkListener(i),this.prependListener(s,_onceWrap(this,s,i)),this},EventEmitter.prototype.removeListener=function removeListener(s,i){var u,_,w,x,j;if(checkListener(i),void 0===(_=this._events))return this;if(void 0===(u=_[s]))return this;if(u===i||u.listener===i)0==--this._eventsCount?this._events=Object.create(null):(delete _[s],_.removeListener&&this.emit("removeListener",s,u.listener||i));else if("function"!=typeof u){for(w=-1,x=u.length-1;x>=0;x--)if(u[x]===i||u[x].listener===i){j=u[x].listener,w=x;break}if(w<0)return this;0===w?u.shift():function spliceOne(s,i){for(;i+1=0;_--)this.removeListener(s,i[_]);return this},EventEmitter.prototype.listeners=function listeners(s){return _listeners(this,s,!0)},EventEmitter.prototype.rawListeners=function rawListeners(s){return _listeners(this,s,!1)},EventEmitter.listenerCount=function(s,i){return"function"==typeof s.listenerCount?s.listenerCount(i):listenerCount.call(s,i)},EventEmitter.prototype.listenerCount=listenerCount,EventEmitter.prototype.eventNames=function eventNames(){return this._eventsCount>0?i(this._events):[]}},85587:(s,i,u)=>{"use strict";var _=u(26311),w=create(Error);function create(s){return FormattedError.displayName=s.displayName||s.name,FormattedError;function FormattedError(i){return i&&(i=_.apply(null,arguments)),new s(i)}}s.exports=w,w.eval=create(EvalError),w.range=create(RangeError),w.reference=create(ReferenceError),w.syntax=create(SyntaxError),w.type=create(TypeError),w.uri=create(URIError),w.create=create},26311:s=>{!function(){var i;function format(s){for(var i,u,_,w,x=1,j=[].slice.call(arguments),B=0,L=s.length,$="",U=!1,Y=!1,nextArg=function(){return j[x++]},slurpNumber=function(){for(var u="";/\d/.test(s[B]);)u+=s[B++],i=s[B];return u.length>0?parseInt(u):null};B{"use strict";var i=Object.prototype.toString,u=Math.max,_=function concatty(s,i){for(var u=[],_=0;_{"use strict";var _=u(89353);s.exports=Function.prototype.bind||_},70453:(s,i,u)=>{"use strict";var _,w=u(69383),x=u(41237),j=u(79290),B=u(79538),L=u(58068),$=u(69675),U=u(35345),Y=Function,getEvalledConstructor=function(s){try{return Y('"use strict"; return ('+s+").constructor;")()}catch(s){}},Z=Object.getOwnPropertyDescriptor;if(Z)try{Z({},"")}catch(s){Z=null}var throwTypeError=function(){throw new $},ee=Z?function(){try{return throwTypeError}catch(s){try{return Z(arguments,"callee").get}catch(s){return throwTypeError}}}():throwTypeError,ie=u(64039)(),ae=u(80024)(),le=Object.getPrototypeOf||(ae?function(s){return s.__proto__}:null),ce={},pe="undefined"!=typeof Uint8Array&&le?le(Uint8Array):_,de={__proto__:null,"%AggregateError%":"undefined"==typeof AggregateError?_:AggregateError,"%Array%":Array,"%ArrayBuffer%":"undefined"==typeof ArrayBuffer?_:ArrayBuffer,"%ArrayIteratorPrototype%":ie&&le?le([][Symbol.iterator]()):_,"%AsyncFromSyncIteratorPrototype%":_,"%AsyncFunction%":ce,"%AsyncGenerator%":ce,"%AsyncGeneratorFunction%":ce,"%AsyncIteratorPrototype%":ce,"%Atomics%":"undefined"==typeof Atomics?_:Atomics,"%BigInt%":"undefined"==typeof BigInt?_:BigInt,"%BigInt64Array%":"undefined"==typeof BigInt64Array?_:BigInt64Array,"%BigUint64Array%":"undefined"==typeof BigUint64Array?_:BigUint64Array,"%Boolean%":Boolean,"%DataView%":"undefined"==typeof DataView?_:DataView,"%Date%":Date,"%decodeURI%":decodeURI,"%decodeURIComponent%":decodeURIComponent,"%encodeURI%":encodeURI,"%encodeURIComponent%":encodeURIComponent,"%Error%":w,"%eval%":eval,"%EvalError%":x,"%Float32Array%":"undefined"==typeof Float32Array?_:Float32Array,"%Float64Array%":"undefined"==typeof Float64Array?_:Float64Array,"%FinalizationRegistry%":"undefined"==typeof FinalizationRegistry?_:FinalizationRegistry,"%Function%":Y,"%GeneratorFunction%":ce,"%Int8Array%":"undefined"==typeof Int8Array?_:Int8Array,"%Int16Array%":"undefined"==typeof Int16Array?_:Int16Array,"%Int32Array%":"undefined"==typeof Int32Array?_:Int32Array,"%isFinite%":isFinite,"%isNaN%":isNaN,"%IteratorPrototype%":ie&&le?le(le([][Symbol.iterator]())):_,"%JSON%":"object"==typeof JSON?JSON:_,"%Map%":"undefined"==typeof Map?_:Map,"%MapIteratorPrototype%":"undefined"!=typeof Map&&ie&&le?le((new Map)[Symbol.iterator]()):_,"%Math%":Math,"%Number%":Number,"%Object%":Object,"%parseFloat%":parseFloat,"%parseInt%":parseInt,"%Promise%":"undefined"==typeof Promise?_:Promise,"%Proxy%":"undefined"==typeof Proxy?_:Proxy,"%RangeError%":j,"%ReferenceError%":B,"%Reflect%":"undefined"==typeof Reflect?_:Reflect,"%RegExp%":RegExp,"%Set%":"undefined"==typeof Set?_:Set,"%SetIteratorPrototype%":"undefined"!=typeof Set&&ie&&le?le((new Set)[Symbol.iterator]()):_,"%SharedArrayBuffer%":"undefined"==typeof SharedArrayBuffer?_:SharedArrayBuffer,"%String%":String,"%StringIteratorPrototype%":ie&&le?le(""[Symbol.iterator]()):_,"%Symbol%":ie?Symbol:_,"%SyntaxError%":L,"%ThrowTypeError%":ee,"%TypedArray%":pe,"%TypeError%":$,"%Uint8Array%":"undefined"==typeof Uint8Array?_:Uint8Array,"%Uint8ClampedArray%":"undefined"==typeof Uint8ClampedArray?_:Uint8ClampedArray,"%Uint16Array%":"undefined"==typeof Uint16Array?_:Uint16Array,"%Uint32Array%":"undefined"==typeof Uint32Array?_:Uint32Array,"%URIError%":U,"%WeakMap%":"undefined"==typeof WeakMap?_:WeakMap,"%WeakRef%":"undefined"==typeof WeakRef?_:WeakRef,"%WeakSet%":"undefined"==typeof WeakSet?_:WeakSet};if(le)try{null.error}catch(s){var fe=le(le(s));de["%Error.prototype%"]=fe}var ye=function doEval(s){var i;if("%AsyncFunction%"===s)i=getEvalledConstructor("async function () {}");else if("%GeneratorFunction%"===s)i=getEvalledConstructor("function* () {}");else if("%AsyncGeneratorFunction%"===s)i=getEvalledConstructor("async function* () {}");else if("%AsyncGenerator%"===s){var u=doEval("%AsyncGeneratorFunction%");u&&(i=u.prototype)}else if("%AsyncIteratorPrototype%"===s){var _=doEval("%AsyncGenerator%");_&&le&&(i=le(_.prototype))}return de[s]=i,i},be={__proto__:null,"%ArrayBufferPrototype%":["ArrayBuffer","prototype"],"%ArrayPrototype%":["Array","prototype"],"%ArrayProto_entries%":["Array","prototype","entries"],"%ArrayProto_forEach%":["Array","prototype","forEach"],"%ArrayProto_keys%":["Array","prototype","keys"],"%ArrayProto_values%":["Array","prototype","values"],"%AsyncFunctionPrototype%":["AsyncFunction","prototype"],"%AsyncGenerator%":["AsyncGeneratorFunction","prototype"],"%AsyncGeneratorPrototype%":["AsyncGeneratorFunction","prototype","prototype"],"%BooleanPrototype%":["Boolean","prototype"],"%DataViewPrototype%":["DataView","prototype"],"%DatePrototype%":["Date","prototype"],"%ErrorPrototype%":["Error","prototype"],"%EvalErrorPrototype%":["EvalError","prototype"],"%Float32ArrayPrototype%":["Float32Array","prototype"],"%Float64ArrayPrototype%":["Float64Array","prototype"],"%FunctionPrototype%":["Function","prototype"],"%Generator%":["GeneratorFunction","prototype"],"%GeneratorPrototype%":["GeneratorFunction","prototype","prototype"],"%Int8ArrayPrototype%":["Int8Array","prototype"],"%Int16ArrayPrototype%":["Int16Array","prototype"],"%Int32ArrayPrototype%":["Int32Array","prototype"],"%JSONParse%":["JSON","parse"],"%JSONStringify%":["JSON","stringify"],"%MapPrototype%":["Map","prototype"],"%NumberPrototype%":["Number","prototype"],"%ObjectPrototype%":["Object","prototype"],"%ObjProto_toString%":["Object","prototype","toString"],"%ObjProto_valueOf%":["Object","prototype","valueOf"],"%PromisePrototype%":["Promise","prototype"],"%PromiseProto_then%":["Promise","prototype","then"],"%Promise_all%":["Promise","all"],"%Promise_reject%":["Promise","reject"],"%Promise_resolve%":["Promise","resolve"],"%RangeErrorPrototype%":["RangeError","prototype"],"%ReferenceErrorPrototype%":["ReferenceError","prototype"],"%RegExpPrototype%":["RegExp","prototype"],"%SetPrototype%":["Set","prototype"],"%SharedArrayBufferPrototype%":["SharedArrayBuffer","prototype"],"%StringPrototype%":["String","prototype"],"%SymbolPrototype%":["Symbol","prototype"],"%SyntaxErrorPrototype%":["SyntaxError","prototype"],"%TypedArrayPrototype%":["TypedArray","prototype"],"%TypeErrorPrototype%":["TypeError","prototype"],"%Uint8ArrayPrototype%":["Uint8Array","prototype"],"%Uint8ClampedArrayPrototype%":["Uint8ClampedArray","prototype"],"%Uint16ArrayPrototype%":["Uint16Array","prototype"],"%Uint32ArrayPrototype%":["Uint32Array","prototype"],"%URIErrorPrototype%":["URIError","prototype"],"%WeakMapPrototype%":["WeakMap","prototype"],"%WeakSetPrototype%":["WeakSet","prototype"]},_e=u(66743),we=u(9957),Se=_e.call(Function.call,Array.prototype.concat),xe=_e.call(Function.apply,Array.prototype.splice),Pe=_e.call(Function.call,String.prototype.replace),Te=_e.call(Function.call,String.prototype.slice),Re=_e.call(Function.call,RegExp.prototype.exec),qe=/[^%.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|%$))/g,$e=/\\(\\)?/g,ze=function getBaseIntrinsic(s,i){var u,_=s;if(we(be,_)&&(_="%"+(u=be[_])[0]+"%"),we(de,_)){var w=de[_];if(w===ce&&(w=ye(_)),void 0===w&&!i)throw new $("intrinsic "+s+" exists, but is not available. Please file an issue!");return{alias:u,name:_,value:w}}throw new L("intrinsic "+s+" does not exist!")};s.exports=function GetIntrinsic(s,i){if("string"!=typeof s||0===s.length)throw new $("intrinsic name must be a non-empty string");if(arguments.length>1&&"boolean"!=typeof i)throw new $('"allowMissing" argument must be a boolean');if(null===Re(/^%?[^%]*%?$/,s))throw new L("`%` may not be present anywhere but at the beginning and end of the intrinsic name");var u=function stringToPath(s){var i=Te(s,0,1),u=Te(s,-1);if("%"===i&&"%"!==u)throw new L("invalid intrinsic syntax, expected closing `%`");if("%"===u&&"%"!==i)throw new L("invalid intrinsic syntax, expected opening `%`");var _=[];return Pe(s,qe,(function(s,i,u,w){_[_.length]=u?Pe(w,$e,"$1"):i||s})),_}(s),_=u.length>0?u[0]:"",w=ze("%"+_+"%",i),x=w.name,j=w.value,B=!1,U=w.alias;U&&(_=U[0],xe(u,Se([0,1],U)));for(var Y=1,ee=!0;Y=u.length){var ce=Z(j,ie);j=(ee=!!ce)&&"get"in ce&&!("originalValue"in ce.get)?ce.get:j[ie]}else ee=we(j,ie),j=j[ie];ee&&!B&&(de[x]=j)}}return j}},75795:(s,i,u)=>{"use strict";var _=u(70453)("%Object.getOwnPropertyDescriptor%",!0);if(_)try{_([],"length")}catch(s){_=null}s.exports=_},30592:(s,i,u)=>{"use strict";var _=u(30655),w=function hasPropertyDescriptors(){return!!_};w.hasArrayLengthDefineBug=function hasArrayLengthDefineBug(){if(!_)return null;try{return 1!==_([],"length",{value:1}).length}catch(s){return!0}},s.exports=w},80024:s=>{"use strict";var i={__proto__:null,foo:{}},u=Object;s.exports=function hasProto(){return{__proto__:i}.foo===i.foo&&!(i instanceof u)}},64039:(s,i,u)=>{"use strict";var _="undefined"!=typeof Symbol&&Symbol,w=u(41333);s.exports=function hasNativeSymbols(){return"function"==typeof _&&("function"==typeof Symbol&&("symbol"==typeof _("foo")&&("symbol"==typeof Symbol("bar")&&w())))}},41333:s=>{"use strict";s.exports=function hasSymbols(){if("function"!=typeof Symbol||"function"!=typeof Object.getOwnPropertySymbols)return!1;if("symbol"==typeof Symbol.iterator)return!0;var s={},i=Symbol("test"),u=Object(i);if("string"==typeof i)return!1;if("[object Symbol]"!==Object.prototype.toString.call(i))return!1;if("[object Symbol]"!==Object.prototype.toString.call(u))return!1;for(i in s[i]=42,s)return!1;if("function"==typeof Object.keys&&0!==Object.keys(s).length)return!1;if("function"==typeof Object.getOwnPropertyNames&&0!==Object.getOwnPropertyNames(s).length)return!1;var _=Object.getOwnPropertySymbols(s);if(1!==_.length||_[0]!==i)return!1;if(!Object.prototype.propertyIsEnumerable.call(s,i))return!1;if("function"==typeof Object.getOwnPropertyDescriptor){var w=Object.getOwnPropertyDescriptor(s,i);if(42!==w.value||!0!==w.enumerable)return!1}return!0}},9957:(s,i,u)=>{"use strict";var _=Function.prototype.call,w=Object.prototype.hasOwnProperty,x=u(66743);s.exports=x.call(_,w)},45981:s=>{function deepFreeze(s){return s instanceof Map?s.clear=s.delete=s.set=function(){throw new Error("map is read-only")}:s instanceof Set&&(s.add=s.clear=s.delete=function(){throw new Error("set is read-only")}),Object.freeze(s),Object.getOwnPropertyNames(s).forEach((function(i){var u=s[i];"object"!=typeof u||Object.isFrozen(u)||deepFreeze(u)})),s}var i=deepFreeze,u=deepFreeze;i.default=u;class Response{constructor(s){void 0===s.data&&(s.data={}),this.data=s.data,this.isMatchIgnored=!1}ignoreMatch(){this.isMatchIgnored=!0}}function escapeHTML(s){return s.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function inherit(s,...i){const u=Object.create(null);for(const i in s)u[i]=s[i];return i.forEach((function(s){for(const i in s)u[i]=s[i]})),u}const emitsWrappingTags=s=>!!s.kind;class HTMLRenderer{constructor(s,i){this.buffer="",this.classPrefix=i.classPrefix,s.walk(this)}addText(s){this.buffer+=escapeHTML(s)}openNode(s){if(!emitsWrappingTags(s))return;let i=s.kind;s.sublanguage||(i=`${this.classPrefix}${i}`),this.span(i)}closeNode(s){emitsWrappingTags(s)&&(this.buffer+="")}value(){return this.buffer}span(s){this.buffer+=``}}class TokenTree{constructor(){this.rootNode={children:[]},this.stack=[this.rootNode]}get top(){return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(s){this.top.children.push(s)}openNode(s){const i={kind:s,children:[]};this.add(i),this.stack.push(i)}closeNode(){if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}walk(s){return this.constructor._walk(s,this.rootNode)}static _walk(s,i){return"string"==typeof i?s.addText(i):i.children&&(s.openNode(i),i.children.forEach((i=>this._walk(s,i))),s.closeNode(i)),s}static _collapse(s){"string"!=typeof s&&s.children&&(s.children.every((s=>"string"==typeof s))?s.children=[s.children.join("")]:s.children.forEach((s=>{TokenTree._collapse(s)})))}}class TokenTreeEmitter extends TokenTree{constructor(s){super(),this.options=s}addKeyword(s,i){""!==s&&(this.openNode(i),this.addText(s),this.closeNode())}addText(s){""!==s&&this.add(s)}addSublanguage(s,i){const u=s.root;u.kind=i,u.sublanguage=!0,this.add(u)}toHTML(){return new HTMLRenderer(this,this.options).value()}finalize(){return!0}}function source(s){return s?"string"==typeof s?s:s.source:null}const _=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./;const w="[a-zA-Z]\\w*",x="[a-zA-Z_]\\w*",j="\\b\\d+(\\.\\d+)?",B="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",L="\\b(0b[01]+)",$={begin:"\\\\[\\s\\S]",relevance:0},U={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[$]},Y={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[$]},Z={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},COMMENT=function(s,i,u={}){const _=inherit({className:"comment",begin:s,end:i,contains:[]},u);return _.contains.push(Z),_.contains.push({className:"doctag",begin:"(?:TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):",relevance:0}),_},ee=COMMENT("//","$"),ie=COMMENT("/\\*","\\*/"),ae=COMMENT("#","$"),le={className:"number",begin:j,relevance:0},ce={className:"number",begin:B,relevance:0},pe={className:"number",begin:L,relevance:0},de={className:"number",begin:j+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0},fe={begin:/(?=\/[^/\n]*\/)/,contains:[{className:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[$,{begin:/\[/,end:/\]/,relevance:0,contains:[$]}]}]},ye={className:"title",begin:w,relevance:0},be={className:"title",begin:x,relevance:0},_e={begin:"\\.\\s*"+x,relevance:0};var we=Object.freeze({__proto__:null,MATCH_NOTHING_RE:/\b\B/,IDENT_RE:w,UNDERSCORE_IDENT_RE:x,NUMBER_RE:j,C_NUMBER_RE:B,BINARY_NUMBER_RE:L,RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",SHEBANG:(s={})=>{const i=/^#![ ]*\//;return s.binary&&(s.begin=function concat(...s){return s.map((s=>source(s))).join("")}(i,/.*\b/,s.binary,/\b.*/)),inherit({className:"meta",begin:i,end:/$/,relevance:0,"on:begin":(s,i)=>{0!==s.index&&i.ignoreMatch()}},s)},BACKSLASH_ESCAPE:$,APOS_STRING_MODE:U,QUOTE_STRING_MODE:Y,PHRASAL_WORDS_MODE:Z,COMMENT,C_LINE_COMMENT_MODE:ee,C_BLOCK_COMMENT_MODE:ie,HASH_COMMENT_MODE:ae,NUMBER_MODE:le,C_NUMBER_MODE:ce,BINARY_NUMBER_MODE:pe,CSS_NUMBER_MODE:de,REGEXP_MODE:fe,TITLE_MODE:ye,UNDERSCORE_TITLE_MODE:be,METHOD_GUARD:_e,END_SAME_AS_BEGIN:function(s){return Object.assign(s,{"on:begin":(s,i)=>{i.data._beginMatch=s[1]},"on:end":(s,i)=>{i.data._beginMatch!==s[1]&&i.ignoreMatch()}})}});function skipIfhasPrecedingDot(s,i){"."===s.input[s.index-1]&&i.ignoreMatch()}function beginKeywords(s,i){i&&s.beginKeywords&&(s.begin="\\b("+s.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",s.__beforeBegin=skipIfhasPrecedingDot,s.keywords=s.keywords||s.beginKeywords,delete s.beginKeywords,void 0===s.relevance&&(s.relevance=0))}function compileIllegal(s,i){Array.isArray(s.illegal)&&(s.illegal=function either(...s){return"("+s.map((s=>source(s))).join("|")+")"}(...s.illegal))}function compileMatch(s,i){if(s.match){if(s.begin||s.end)throw new Error("begin & end are not supported with match");s.begin=s.match,delete s.match}}function compileRelevance(s,i){void 0===s.relevance&&(s.relevance=1)}const Se=["of","and","for","in","not","or","if","then","parent","list","value"],xe="keyword";function compileKeywords(s,i,u=xe){const _={};return"string"==typeof s?compileList(u,s.split(" ")):Array.isArray(s)?compileList(u,s):Object.keys(s).forEach((function(u){Object.assign(_,compileKeywords(s[u],i,u))})),_;function compileList(s,u){i&&(u=u.map((s=>s.toLowerCase()))),u.forEach((function(i){const u=i.split("|");_[u[0]]=[s,scoreForKeyword(u[0],u[1])]}))}}function scoreForKeyword(s,i){return i?Number(i):function commonKeyword(s){return Se.includes(s.toLowerCase())}(s)?0:1}function compileLanguage(s,{plugins:i}){function langRe(i,u){return new RegExp(source(i),"m"+(s.case_insensitive?"i":"")+(u?"g":""))}class MultiRegex{constructor(){this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}addRule(s,i){i.position=this.position++,this.matchIndexes[this.matchAt]=i,this.regexes.push([i,s]),this.matchAt+=function countMatchGroups(s){return new RegExp(s.toString()+"|").exec("").length-1}(s)+1}compile(){0===this.regexes.length&&(this.exec=()=>null);const s=this.regexes.map((s=>s[1]));this.matcherRe=langRe(function join(s,i="|"){let u=0;return s.map((s=>{u+=1;const i=u;let w=source(s),x="";for(;w.length>0;){const s=_.exec(w);if(!s){x+=w;break}x+=w.substring(0,s.index),w=w.substring(s.index+s[0].length),"\\"===s[0][0]&&s[1]?x+="\\"+String(Number(s[1])+i):(x+=s[0],"("===s[0]&&u++)}return x})).map((s=>`(${s})`)).join(i)}(s),!0),this.lastIndex=0}exec(s){this.matcherRe.lastIndex=this.lastIndex;const i=this.matcherRe.exec(s);if(!i)return null;const u=i.findIndex(((s,i)=>i>0&&void 0!==s)),_=this.matchIndexes[u];return i.splice(0,u),Object.assign(i,_)}}class ResumableMultiRegex{constructor(){this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(s){if(this.multiRegexes[s])return this.multiRegexes[s];const i=new MultiRegex;return this.rules.slice(s).forEach((([s,u])=>i.addRule(s,u))),i.compile(),this.multiRegexes[s]=i,i}resumingScanAtSamePosition(){return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(s,i){this.rules.push([s,i]),"begin"===i.type&&this.count++}exec(s){const i=this.getMatcher(this.regexIndex);i.lastIndex=this.lastIndex;let u=i.exec(s);if(this.resumingScanAtSamePosition())if(u&&u.index===this.lastIndex);else{const i=this.getMatcher(0);i.lastIndex=this.lastIndex+1,u=i.exec(s)}return u&&(this.regexIndex+=u.position+1,this.regexIndex===this.count&&this.considerAll()),u}}if(s.compilerExtensions||(s.compilerExtensions=[]),s.contains&&s.contains.includes("self"))throw new Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.");return s.classNameAliases=inherit(s.classNameAliases||{}),function compileMode(i,u){const _=i;if(i.isCompiled)return _;[compileMatch].forEach((s=>s(i,u))),s.compilerExtensions.forEach((s=>s(i,u))),i.__beforeBegin=null,[beginKeywords,compileIllegal,compileRelevance].forEach((s=>s(i,u))),i.isCompiled=!0;let w=null;if("object"==typeof i.keywords&&(w=i.keywords.$pattern,delete i.keywords.$pattern),i.keywords&&(i.keywords=compileKeywords(i.keywords,s.case_insensitive)),i.lexemes&&w)throw new Error("ERR: Prefer `keywords.$pattern` to `mode.lexemes`, BOTH are not allowed. (see mode reference) ");return w=w||i.lexemes||/\w+/,_.keywordPatternRe=langRe(w,!0),u&&(i.begin||(i.begin=/\B|\b/),_.beginRe=langRe(i.begin),i.endSameAsBegin&&(i.end=i.begin),i.end||i.endsWithParent||(i.end=/\B|\b/),i.end&&(_.endRe=langRe(i.end)),_.terminatorEnd=source(i.end)||"",i.endsWithParent&&u.terminatorEnd&&(_.terminatorEnd+=(i.end?"|":"")+u.terminatorEnd)),i.illegal&&(_.illegalRe=langRe(i.illegal)),i.contains||(i.contains=[]),i.contains=[].concat(...i.contains.map((function(s){return function expandOrCloneMode(s){s.variants&&!s.cachedVariants&&(s.cachedVariants=s.variants.map((function(i){return inherit(s,{variants:null},i)})));if(s.cachedVariants)return s.cachedVariants;if(dependencyOnParent(s))return inherit(s,{starts:s.starts?inherit(s.starts):null});if(Object.isFrozen(s))return inherit(s);return s}("self"===s?i:s)}))),i.contains.forEach((function(s){compileMode(s,_)})),i.starts&&compileMode(i.starts,u),_.matcher=function buildModeRegex(s){const i=new ResumableMultiRegex;return s.contains.forEach((s=>i.addRule(s.begin,{rule:s,type:"begin"}))),s.terminatorEnd&&i.addRule(s.terminatorEnd,{type:"end"}),s.illegal&&i.addRule(s.illegal,{type:"illegal"}),i}(_),_}(s)}function dependencyOnParent(s){return!!s&&(s.endsWithParent||dependencyOnParent(s.starts))}function BuildVuePlugin(s){const i={props:["language","code","autodetect"],data:function(){return{detectedLanguage:"",unknownLanguage:!1}},computed:{className(){return this.unknownLanguage?"":"hljs "+this.detectedLanguage},highlighted(){if(!this.autoDetect&&!s.getLanguage(this.language))return console.warn(`The language "${this.language}" you specified could not be found.`),this.unknownLanguage=!0,escapeHTML(this.code);let i={};return this.autoDetect?(i=s.highlightAuto(this.code),this.detectedLanguage=i.language):(i=s.highlight(this.language,this.code,this.ignoreIllegals),this.detectedLanguage=this.language),i.value},autoDetect(){return!this.language||function hasValueOrEmptyAttribute(s){return Boolean(s||""===s)}(this.autodetect)},ignoreIllegals:()=>!0},render(s){return s("pre",{},[s("code",{class:this.className,domProps:{innerHTML:this.highlighted}})])}};return{Component:i,VuePlugin:{install(s){s.component("highlightjs",i)}}}}const Pe={"after:highlightElement":({el:s,result:i,text:u})=>{const _=nodeStream(s);if(!_.length)return;const w=document.createElement("div");w.innerHTML=i.value,i.value=function mergeStreams(s,i,u){let _=0,w="";const x=[];function selectStream(){return s.length&&i.length?s[0].offset!==i[0].offset?s[0].offset"}function close(s){w+=""+tag(s)+">"}function render(s){("start"===s.event?open:close)(s.node)}for(;s.length||i.length;){let i=selectStream();if(w+=escapeHTML(u.substring(_,i[0].offset)),_=i[0].offset,i===s){x.reverse().forEach(close);do{render(i.splice(0,1)[0]),i=selectStream()}while(i===s&&i.length&&i[0].offset===_);x.reverse().forEach(open)}else"start"===i[0].event?x.push(i[0].node):x.pop(),render(i.splice(0,1)[0])}return w+escapeHTML(u.substr(_))}(_,nodeStream(w),u)}};function tag(s){return s.nodeName.toLowerCase()}function nodeStream(s){const i=[];return function _nodeStream(s,u){for(let _=s.firstChild;_;_=_.nextSibling)3===_.nodeType?u+=_.nodeValue.length:1===_.nodeType&&(i.push({event:"start",offset:u,node:_}),u=_nodeStream(_,u),tag(_).match(/br|hr|img|input/)||i.push({event:"stop",offset:u,node:_}));return u}(s,0),i}const Te={},error=s=>{console.error(s)},warn=(s,...i)=>{console.log(`WARN: ${s}`,...i)},deprecated=(s,i)=>{Te[`${s}/${i}`]||(console.log(`Deprecated as of ${s}. ${i}`),Te[`${s}/${i}`]=!0)},Re=escapeHTML,qe=inherit,$e=Symbol("nomatch");var ze=function(s){const u=Object.create(null),_=Object.create(null),w=[];let x=!0;const j=/(^(<[^>]+>|\t|)+|\n)/gm,B="Could not find the language '{}', did you forget to load/include a language module?",L={disableAutodetect:!0,name:"Plain text",contains:[]};let $={noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:null,__emitter:TokenTreeEmitter};function shouldNotHighlight(s){return $.noHighlightRe.test(s)}function highlight(s,i,u,_){let w="",x="";"object"==typeof i?(w=s,u=i.ignoreIllegals,x=i.language,_=void 0):(deprecated("10.7.0","highlight(lang, code, ...args) has been deprecated."),deprecated("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"),x=s,w=i);const j={code:w,language:x};fire("before:highlight",j);const B=j.result?j.result:_highlight(j.language,j.code,u,_);return B.code=j.code,fire("after:highlight",B),B}function _highlight(s,i,_,j){function keywordData(s,i){const u=U.case_insensitive?i[0].toLowerCase():i[0];return Object.prototype.hasOwnProperty.call(s.keywords,u)&&s.keywords[u]}function processBuffer(){null!=ee.subLanguage?function processSubLanguage(){if(""===le)return;let s=null;if("string"==typeof ee.subLanguage){if(!u[ee.subLanguage])return void ae.addText(le);s=_highlight(ee.subLanguage,le,!0,ie[ee.subLanguage]),ie[ee.subLanguage]=s.top}else s=highlightAuto(le,ee.subLanguage.length?ee.subLanguage:null);ee.relevance>0&&(ce+=s.relevance),ae.addSublanguage(s.emitter,s.language)}():function processKeywords(){if(!ee.keywords)return void ae.addText(le);let s=0;ee.keywordPatternRe.lastIndex=0;let i=ee.keywordPatternRe.exec(le),u="";for(;i;){u+=le.substring(s,i.index);const _=keywordData(ee,i);if(_){const[s,w]=_;if(ae.addText(u),u="",ce+=w,s.startsWith("_"))u+=i[0];else{const u=U.classNameAliases[s]||s;ae.addKeyword(i[0],u)}}else u+=i[0];s=ee.keywordPatternRe.lastIndex,i=ee.keywordPatternRe.exec(le)}u+=le.substr(s),ae.addText(u)}(),le=""}function startNewMode(s){return s.className&&ae.openNode(U.classNameAliases[s.className]||s.className),ee=Object.create(s,{parent:{value:ee}}),ee}function endOfMode(s,i,u){let _=function startsWith(s,i){const u=s&&s.exec(i);return u&&0===u.index}(s.endRe,u);if(_){if(s["on:end"]){const u=new Response(s);s["on:end"](i,u),u.isMatchIgnored&&(_=!1)}if(_){for(;s.endsParent&&s.parent;)s=s.parent;return s}}if(s.endsWithParent)return endOfMode(s.parent,i,u)}function doIgnore(s){return 0===ee.matcher.regexIndex?(le+=s[0],1):(fe=!0,0)}function doBeginMatch(s){const i=s[0],u=s.rule,_=new Response(u),w=[u.__beforeBegin,u["on:begin"]];for(const u of w)if(u&&(u(s,_),_.isMatchIgnored))return doIgnore(i);return u&&u.endSameAsBegin&&(u.endRe=function escape(s){return new RegExp(s.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"),"m")}(i)),u.skip?le+=i:(u.excludeBegin&&(le+=i),processBuffer(),u.returnBegin||u.excludeBegin||(le=i)),startNewMode(u),u.returnBegin?0:i.length}function doEndMatch(s){const u=s[0],_=i.substr(s.index),w=endOfMode(ee,s,_);if(!w)return $e;const x=ee;x.skip?le+=u:(x.returnEnd||x.excludeEnd||(le+=u),processBuffer(),x.excludeEnd&&(le=u));do{ee.className&&ae.closeNode(),ee.skip||ee.subLanguage||(ce+=ee.relevance),ee=ee.parent}while(ee!==w.parent);return w.starts&&(w.endSameAsBegin&&(w.starts.endRe=w.endRe),startNewMode(w.starts)),x.returnEnd?0:u.length}let L={};function processLexeme(u,w){const j=w&&w[0];if(le+=u,null==j)return processBuffer(),0;if("begin"===L.type&&"end"===w.type&&L.index===w.index&&""===j){if(le+=i.slice(w.index,w.index+1),!x){const i=new Error("0 width match regex");throw i.languageName=s,i.badRule=L.rule,i}return 1}if(L=w,"begin"===w.type)return doBeginMatch(w);if("illegal"===w.type&&!_){const s=new Error('Illegal lexeme "'+j+'" for mode "'+(ee.className||"")+'"');throw s.mode=ee,s}if("end"===w.type){const s=doEndMatch(w);if(s!==$e)return s}if("illegal"===w.type&&""===j)return 1;if(de>1e5&&de>3*w.index){throw new Error("potential infinite loop, way more iterations than matches")}return le+=j,j.length}const U=getLanguage(s);if(!U)throw error(B.replace("{}",s)),new Error('Unknown language: "'+s+'"');const Y=compileLanguage(U,{plugins:w});let Z="",ee=j||Y;const ie={},ae=new $.__emitter($);!function processContinuations(){const s=[];for(let i=ee;i!==U;i=i.parent)i.className&&s.unshift(i.className);s.forEach((s=>ae.openNode(s)))}();let le="",ce=0,pe=0,de=0,fe=!1;try{for(ee.matcher.considerAll();;){de++,fe?fe=!1:ee.matcher.considerAll(),ee.matcher.lastIndex=pe;const s=ee.matcher.exec(i);if(!s)break;const u=processLexeme(i.substring(pe,s.index),s);pe=s.index+u}return processLexeme(i.substr(pe)),ae.closeAllNodes(),ae.finalize(),Z=ae.toHTML(),{relevance:Math.floor(ce),value:Z,language:s,illegal:!1,emitter:ae,top:ee}}catch(u){if(u.message&&u.message.includes("Illegal"))return{illegal:!0,illegalBy:{msg:u.message,context:i.slice(pe-100,pe+100),mode:u.mode},sofar:Z,relevance:0,value:Re(i),emitter:ae};if(x)return{illegal:!1,relevance:0,value:Re(i),emitter:ae,language:s,top:ee,errorRaised:u};throw u}}function highlightAuto(s,i){i=i||$.languages||Object.keys(u);const _=function justTextHighlightResult(s){const i={relevance:0,emitter:new $.__emitter($),value:Re(s),illegal:!1,top:L};return i.emitter.addText(s),i}(s),w=i.filter(getLanguage).filter(autoDetection).map((i=>_highlight(i,s,!1)));w.unshift(_);const x=w.sort(((s,i)=>{if(s.relevance!==i.relevance)return i.relevance-s.relevance;if(s.language&&i.language){if(getLanguage(s.language).supersetOf===i.language)return 1;if(getLanguage(i.language).supersetOf===s.language)return-1}return 0})),[j,B]=x,U=j;return U.second_best=B,U}const U={"before:highlightElement":({el:s})=>{$.useBR&&(s.innerHTML=s.innerHTML.replace(/\n/g,"").replace(/
/g,"\n"))},"after:highlightElement":({result:s})=>{$.useBR&&(s.value=s.value.replace(/\n/g,"
"))}},Y=/^(<[^>]+>|\t)+/gm,Z={"after:highlightElement":({result:s})=>{$.tabReplace&&(s.value=s.value.replace(Y,(s=>s.replace(/\t/g,$.tabReplace))))}};function highlightElement(s){let i=null;const u=function blockLanguage(s){let i=s.className+" ";i+=s.parentNode?s.parentNode.className:"";const u=$.languageDetectRe.exec(i);if(u){const i=getLanguage(u[1]);return i||(warn(B.replace("{}",u[1])),warn("Falling back to no-highlight mode for this block.",s)),i?u[1]:"no-highlight"}return i.split(/\s+/).find((s=>shouldNotHighlight(s)||getLanguage(s)))}(s);if(shouldNotHighlight(u))return;fire("before:highlightElement",{el:s,language:u}),i=s;const w=i.textContent,x=u?highlight(w,{language:u,ignoreIllegals:!0}):highlightAuto(w);fire("after:highlightElement",{el:s,result:x,text:w}),s.innerHTML=x.value,function updateClassName(s,i,u){const w=i?_[i]:u;s.classList.add("hljs"),w&&s.classList.add(w)}(s,u,x.language),s.result={language:x.language,re:x.relevance,relavance:x.relevance},x.second_best&&(s.second_best={language:x.second_best.language,re:x.second_best.relevance,relavance:x.second_best.relevance})}const initHighlighting=()=>{if(initHighlighting.called)return;initHighlighting.called=!0,deprecated("10.6.0","initHighlighting() is deprecated. Use highlightAll() instead.");document.querySelectorAll("pre code").forEach(highlightElement)};let ee=!1;function highlightAll(){if("loading"===document.readyState)return void(ee=!0);document.querySelectorAll("pre code").forEach(highlightElement)}function getLanguage(s){return s=(s||"").toLowerCase(),u[s]||u[_[s]]}function registerAliases(s,{languageName:i}){"string"==typeof s&&(s=[s]),s.forEach((s=>{_[s.toLowerCase()]=i}))}function autoDetection(s){const i=getLanguage(s);return i&&!i.disableAutodetect}function fire(s,i){const u=s;w.forEach((function(s){s[u]&&s[u](i)}))}"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(function boot(){ee&&highlightAll()}),!1),Object.assign(s,{highlight,highlightAuto,highlightAll,fixMarkup:function deprecateFixMarkup(s){return deprecated("10.2.0","fixMarkup will be removed entirely in v11.0"),deprecated("10.2.0","Please see https://github.com/highlightjs/highlight.js/issues/2534"),function fixMarkup(s){return $.tabReplace||$.useBR?s.replace(j,(s=>"\n"===s?$.useBR?"
":s:$.tabReplace?s.replace(/\t/g,$.tabReplace):s)):s}(s)},highlightElement,highlightBlock:function deprecateHighlightBlock(s){return deprecated("10.7.0","highlightBlock will be removed entirely in v12.0"),deprecated("10.7.0","Please use highlightElement now."),highlightElement(s)},configure:function configure(s){s.useBR&&(deprecated("10.3.0","'useBR' will be removed entirely in v11.0"),deprecated("10.3.0","Please see https://github.com/highlightjs/highlight.js/issues/2559")),$=qe($,s)},initHighlighting,initHighlightingOnLoad:function initHighlightingOnLoad(){deprecated("10.6.0","initHighlightingOnLoad() is deprecated. Use highlightAll() instead."),ee=!0},registerLanguage:function registerLanguage(i,_){let w=null;try{w=_(s)}catch(s){if(error("Language definition for '{}' could not be registered.".replace("{}",i)),!x)throw s;error(s),w=L}w.name||(w.name=i),u[i]=w,w.rawDefinition=_.bind(null,s),w.aliases&®isterAliases(w.aliases,{languageName:i})},unregisterLanguage:function unregisterLanguage(s){delete u[s];for(const i of Object.keys(_))_[i]===s&&delete _[i]},listLanguages:function listLanguages(){return Object.keys(u)},getLanguage,registerAliases,requireLanguage:function requireLanguage(s){deprecated("10.4.0","requireLanguage will be removed entirely in v11."),deprecated("10.4.0","Please see https://github.com/highlightjs/highlight.js/pull/2844");const i=getLanguage(s);if(i)return i;throw new Error("The '{}' language is required, but not loaded.".replace("{}",s))},autoDetection,inherit:qe,addPlugin:function addPlugin(s){!function upgradePluginAPI(s){s["before:highlightBlock"]&&!s["before:highlightElement"]&&(s["before:highlightElement"]=i=>{s["before:highlightBlock"](Object.assign({block:i.el},i))}),s["after:highlightBlock"]&&!s["after:highlightElement"]&&(s["after:highlightElement"]=i=>{s["after:highlightBlock"](Object.assign({block:i.el},i))})}(s),w.push(s)},vuePlugin:BuildVuePlugin(s).VuePlugin}),s.debugMode=function(){x=!1},s.safeMode=function(){x=!0},s.versionString="10.7.3";for(const s in we)"object"==typeof we[s]&&i(we[s]);return Object.assign(s,we),s.addPlugin(U),s.addPlugin(Pe),s.addPlugin(Z),s}({});s.exports=ze},35344:s=>{function concat(...s){return s.map((s=>function source(s){return s?"string"==typeof s?s:s.source:null}(s))).join("")}s.exports=function bash(s){const i={},u={begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[i]}]};Object.assign(i,{className:"variable",variants:[{begin:concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},u]});const _={className:"subst",begin:/\$\(/,end:/\)/,contains:[s.BACKSLASH_ESCAPE]},w={begin:/<<-?\s*(?=\w+)/,starts:{contains:[s.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,className:"string"})]}},x={className:"string",begin:/"/,end:/"/,contains:[s.BACKSLASH_ESCAPE,i,_]};_.contains.push(x);const j={begin:/\$\(\(/,end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},s.NUMBER_MODE,i]},B=s.SHEBANG({binary:`(${["fish","bash","zsh","sh","csh","ksh","tcsh","dash","scsh"].join("|")})`,relevance:10}),L={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[s.inherit(s.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z._-]+\b/,keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp"},contains:[B,s.SHEBANG(),L,j,s.HASH_COMMENT_MODE,w,x,{className:"",begin:/\\"/},{className:"string",begin:/'/,end:/'/},i]}}},73402:s=>{function concat(...s){return s.map((s=>function source(s){return s?"string"==typeof s?s:s.source:null}(s))).join("")}s.exports=function http(s){const i="HTTP/(2|1\\.[01])",u={className:"attribute",begin:concat("^",/[A-Za-z][A-Za-z0-9-]*/,"(?=\\:\\s)"),starts:{contains:[{className:"punctuation",begin:/: /,relevance:0,starts:{end:"$",relevance:0}}]}},_=[u,{begin:"\\n\\n",starts:{subLanguage:[],endsWithParent:!0}}];return{name:"HTTP",aliases:["https"],illegal:/\S/,contains:[{begin:"^(?="+i+" \\d{3})",end:/$/,contains:[{className:"meta",begin:i},{className:"number",begin:"\\b\\d{3}\\b"}],starts:{end:/\b\B/,illegal:/\S/,contains:_}},{begin:"(?=^[A-Z]+ (.*?) "+i+"$)",end:/$/,contains:[{className:"string",begin:" ",end:" ",excludeBegin:!0,excludeEnd:!0},{className:"meta",begin:i},{className:"keyword",begin:"[A-Z]+"}],starts:{end:/\b\B/,illegal:/\S/,contains:_}},s.inherit(u,{relevance:0})]}}},95089:s=>{const i="[A-Za-z$_][0-9A-Za-z$_]*",u=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],_=["true","false","null","undefined","NaN","Infinity"],w=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer","BigInt64Array","BigUint64Array","BigInt"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"]);function lookahead(s){return concat("(?=",s,")")}function concat(...s){return s.map((s=>function source(s){return s?"string"==typeof s?s:s.source:null}(s))).join("")}s.exports=function javascript(s){const x=i,j="<>",B=">",L={begin:/<[A-Za-z0-9\\._:-]+/,end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(s,i)=>{const u=s[0].length+s.index,_=s.input[u];"<"!==_?">"===_&&(((s,{after:i})=>{const u=""+s[0].slice(1);return-1!==s.input.indexOf(u,i)})(s,{after:u})||i.ignoreMatch()):i.ignoreMatch()}},$={$pattern:i,keyword:u,literal:_,built_in:w},U="[0-9](_?[0-9])*",Y=`\\.(${U})`,Z="0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*",ee={className:"number",variants:[{begin:`(\\b(${Z})((${Y})|\\.)?|(${Y}))[eE][+-]?(${U})\\b`},{begin:`\\b(${Z})\\b((${Y})\\b|\\.)?|(${Y})\\b`},{begin:"\\b(0|[1-9](_?[0-9])*)n\\b"},{begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*n?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*n?\\b"},{begin:"\\b0[0-7]+n?\\b"}],relevance:0},ie={className:"subst",begin:"\\$\\{",end:"\\}",keywords:$,contains:[]},ae={begin:"html`",end:"",starts:{end:"`",returnEnd:!1,contains:[s.BACKSLASH_ESCAPE,ie],subLanguage:"xml"}},le={begin:"css`",end:"",starts:{end:"`",returnEnd:!1,contains:[s.BACKSLASH_ESCAPE,ie],subLanguage:"css"}},ce={className:"string",begin:"`",end:"`",contains:[s.BACKSLASH_ESCAPE,ie]},pe={className:"comment",variants:[s.COMMENT(/\/\*\*(?!\/)/,"\\*/",{relevance:0,contains:[{className:"doctag",begin:"@[A-Za-z]+",contains:[{className:"type",begin:"\\{",end:"\\}",relevance:0},{className:"variable",begin:x+"(?=\\s*(-)|$)",endsParent:!0,relevance:0},{begin:/(?=[^\n])\s/,relevance:0}]}]}),s.C_BLOCK_COMMENT_MODE,s.C_LINE_COMMENT_MODE]},de=[s.APOS_STRING_MODE,s.QUOTE_STRING_MODE,ae,le,ce,ee,s.REGEXP_MODE];ie.contains=de.concat({begin:/\{/,end:/\}/,keywords:$,contains:["self"].concat(de)});const fe=[].concat(pe,ie.contains),ye=fe.concat([{begin:/\(/,end:/\)/,keywords:$,contains:["self"].concat(fe)}]),be={className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:$,contains:ye};return{name:"Javascript",aliases:["js","jsx","mjs","cjs"],keywords:$,exports:{PARAMS_CONTAINS:ye},illegal:/#(?![$_A-z])/,contains:[s.SHEBANG({label:"shebang",binary:"node",relevance:5}),{label:"use_strict",className:"meta",relevance:10,begin:/^\s*['"]use (strict|asm)['"]/},s.APOS_STRING_MODE,s.QUOTE_STRING_MODE,ae,le,ce,pe,ee,{begin:concat(/[{,\n]\s*/,lookahead(concat(/(((\/\/.*$)|(\/\*(\*[^/]|[^*])*\*\/))\s*)*/,x+"\\s*:"))),relevance:0,contains:[{className:"attr",begin:x+lookahead("\\s*:"),relevance:0}]},{begin:"("+s.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",contains:[pe,s.REGEXP_MODE,{className:"function",begin:"(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+s.UNDERSCORE_IDENT_RE+")\\s*=>",returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:s.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:$,contains:ye}]}]},{begin:/,/,relevance:0},{className:"",begin:/\s/,end:/\s*/,skip:!0},{variants:[{begin:j,end:B},{begin:L.begin,"on:begin":L.isTrulyOpeningTag,end:L.end}],subLanguage:"xml",contains:[{begin:L.begin,end:L.end,skip:!0,contains:["self"]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/[{;]/,excludeEnd:!0,keywords:$,contains:["self",s.inherit(s.TITLE_MODE,{begin:x}),be],illegal:/%/},{beginKeywords:"while if switch catch for"},{className:"function",begin:s.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",returnBegin:!0,contains:[be,s.inherit(s.TITLE_MODE,{begin:x})]},{variants:[{begin:"\\."+x},{begin:"\\$"+x}],relevance:0},{className:"class",beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"[\]]/,contains:[{beginKeywords:"extends"},s.UNDERSCORE_TITLE_MODE]},{begin:/\b(?=constructor)/,end:/[{;]/,excludeEnd:!0,contains:[s.inherit(s.TITLE_MODE,{begin:x}),"self",be]},{begin:"(get|set)\\s+(?="+x+"\\()",end:/\{/,keywords:"get set",contains:[s.inherit(s.TITLE_MODE,{begin:x}),{begin:/\(\)/},be]},{begin:/\$[(.]/}]}}},65772:s=>{s.exports=function json(s){const i={literal:"true false null"},u=[s.C_LINE_COMMENT_MODE,s.C_BLOCK_COMMENT_MODE],_=[s.QUOTE_STRING_MODE,s.C_NUMBER_MODE],w={end:",",endsWithParent:!0,excludeEnd:!0,contains:_,keywords:i},x={begin:/\{/,end:/\}/,contains:[{className:"attr",begin:/"/,end:/"/,contains:[s.BACKSLASH_ESCAPE],illegal:"\\n"},s.inherit(w,{begin:/:/})].concat(u),illegal:"\\S"},j={begin:"\\[",end:"\\]",contains:[s.inherit(w)],illegal:"\\S"};return _.push(x,j),u.forEach((function(s){_.push(s)})),{name:"JSON",contains:_,keywords:i,illegal:"\\S"}}},26571:s=>{s.exports=function powershell(s){const i={$pattern:/-?[A-z\.\-]+\b/,keyword:"if else foreach return do while until elseif begin for trap data dynamicparam end break throw param continue finally in switch exit filter try process catch hidden static parameter",built_in:"ac asnp cat cd CFS chdir clc clear clhy cli clp cls clv cnsn compare copy cp cpi cpp curl cvpa dbp del diff dir dnsn ebp echo|0 epal epcsv epsn erase etsn exsn fc fhx fl ft fw gal gbp gc gcb gci gcm gcs gdr gerr ghy gi gin gjb gl gm gmo gp gps gpv group gsn gsnp gsv gtz gu gv gwmi h history icm iex ihy ii ipal ipcsv ipmo ipsn irm ise iwmi iwr kill lp ls man md measure mi mount move mp mv nal ndr ni nmo npssc nsn nv ogv oh popd ps pushd pwd r rbp rcjb rcsn rd rdr ren ri rjb rm rmdir rmo rni rnp rp rsn rsnp rujb rv rvpa rwmi sajb sal saps sasv sbp sc scb select set shcm si sl sleep sls sort sp spjb spps spsv start stz sujb sv swmi tee trcm type wget where wjb write"},u={begin:"`[\\s\\S]",relevance:0},_={className:"variable",variants:[{begin:/\$\B/},{className:"keyword",begin:/\$this/},{begin:/\$[\w\d][\w\d_:]*/}]},w={className:"string",variants:[{begin:/"/,end:/"/},{begin:/@"/,end:/^"@/}],contains:[u,_,{className:"variable",begin:/\$[A-z]/,end:/[^A-z]/}]},x={className:"string",variants:[{begin:/'/,end:/'/},{begin:/@'/,end:/^'@/}]},j=s.inherit(s.COMMENT(null,null),{variants:[{begin:/#/,end:/$/},{begin:/<#/,end:/#>/}],contains:[{className:"doctag",variants:[{begin:/\.(synopsis|description|example|inputs|outputs|notes|link|component|role|functionality)/},{begin:/\.(parameter|forwardhelptargetname|forwardhelpcategory|remotehelprunspace|externalhelp)\s+\S+/}]}]}),B={className:"built_in",variants:[{begin:"(".concat("Add|Clear|Close|Copy|Enter|Exit|Find|Format|Get|Hide|Join|Lock|Move|New|Open|Optimize|Pop|Push|Redo|Remove|Rename|Reset|Resize|Search|Select|Set|Show|Skip|Split|Step|Switch|Undo|Unlock|Watch|Backup|Checkpoint|Compare|Compress|Convert|ConvertFrom|ConvertTo|Dismount|Edit|Expand|Export|Group|Import|Initialize|Limit|Merge|Mount|Out|Publish|Restore|Save|Sync|Unpublish|Update|Approve|Assert|Build|Complete|Confirm|Deny|Deploy|Disable|Enable|Install|Invoke|Register|Request|Restart|Resume|Start|Stop|Submit|Suspend|Uninstall|Unregister|Wait|Debug|Measure|Ping|Repair|Resolve|Test|Trace|Connect|Disconnect|Read|Receive|Send|Write|Block|Grant|Protect|Revoke|Unblock|Unprotect|Use|ForEach|Sort|Tee|Where",")+(-)[\\w\\d]+")}]},L={className:"class",beginKeywords:"class enum",end:/\s*[{]/,excludeEnd:!0,relevance:0,contains:[s.TITLE_MODE]},$={className:"function",begin:/function\s+/,end:/\s*\{|$/,excludeEnd:!0,returnBegin:!0,relevance:0,contains:[{begin:"function",relevance:0,className:"keyword"},{className:"title",begin:/\w[\w\d]*((-)[\w\d]+)*/,relevance:0},{begin:/\(/,end:/\)/,className:"params",relevance:0,contains:[_]}]},U={begin:/using\s/,end:/$/,returnBegin:!0,contains:[w,x,{className:"keyword",begin:/(using|assembly|command|module|namespace|type)/}]},Y={variants:[{className:"operator",begin:"(".concat("-and|-as|-band|-bnot|-bor|-bxor|-casesensitive|-ccontains|-ceq|-cge|-cgt|-cle|-clike|-clt|-cmatch|-cne|-cnotcontains|-cnotlike|-cnotmatch|-contains|-creplace|-csplit|-eq|-exact|-f|-file|-ge|-gt|-icontains|-ieq|-ige|-igt|-ile|-ilike|-ilt|-imatch|-in|-ine|-inotcontains|-inotlike|-inotmatch|-ireplace|-is|-isnot|-isplit|-join|-le|-like|-lt|-match|-ne|-not|-notcontains|-notin|-notlike|-notmatch|-or|-regex|-replace|-shl|-shr|-split|-wildcard|-xor",")\\b")},{className:"literal",begin:/(-)[\w\d]+/,relevance:0}]},Z={className:"function",begin:/\[.*\]\s*[\w]+[ ]??\(/,end:/$/,returnBegin:!0,relevance:0,contains:[{className:"keyword",begin:"(".concat(i.keyword.toString().replace(/\s/g,"|"),")\\b"),endsParent:!0,relevance:0},s.inherit(s.TITLE_MODE,{endsParent:!0})]},ee=[Z,j,u,s.NUMBER_MODE,w,x,B,_,{className:"literal",begin:/\$(null|true|false)\b/},{className:"selector-tag",begin:/@\B/,relevance:0}],ie={begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[].concat("self",ee,{begin:"("+["string","char","byte","int","long","bool","decimal","single","double","DateTime","xml","array","hashtable","void"].join("|")+")",className:"built_in",relevance:0},{className:"type",begin:/[\.\w\d]+/,relevance:0})};return Z.contains.unshift(ie),{name:"PowerShell",aliases:["ps","ps1"],case_insensitive:!0,keywords:i,contains:ee.concat(L,$,U,Y,ie)}}},17285:s=>{function source(s){return s?"string"==typeof s?s:s.source:null}function lookahead(s){return concat("(?=",s,")")}function concat(...s){return s.map((s=>source(s))).join("")}function either(...s){return"("+s.map((s=>source(s))).join("|")+")"}s.exports=function xml(s){const i=concat(/[A-Z_]/,function optional(s){return concat("(",s,")?")}(/[A-Z0-9_.-]*:/),/[A-Z0-9_.-]*/),u={className:"symbol",begin:/&[a-z]+;|[0-9]+;|[a-f0-9]+;/},_={begin:/\s/,contains:[{className:"meta-keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}]},w=s.inherit(_,{begin:/\(/,end:/\)/}),x=s.inherit(s.APOS_STRING_MODE,{className:"meta-string"}),j=s.inherit(s.QUOTE_STRING_MODE,{className:"meta-string"}),B={endsWithParent:!0,illegal:/,relevance:0,contains:[{className:"attr",begin:/[A-Za-z0-9._:-]+/,relevance:0},{begin:/=\s*/,relevance:0,contains:[{className:"string",endsParent:!0,variants:[{begin:/"/,end:/"/,contains:[u]},{begin:/'/,end:/'/,contains:[u]},{begin:/[^\s"'=<>`]+/}]}]}]};return{name:"HTML, XML",aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"],case_insensitive:!0,contains:[{className:"meta",begin://,relevance:10,contains:[_,j,x,w,{begin:/\[/,end:/\]/,contains:[{className:"meta",begin://,contains:[_,w,j,x]}]}]},s.COMMENT(//,{relevance:10}),{begin://,relevance:10},u,{className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag",begin:/