web admin: add backup/restore

This commit is contained in:
Nicola Murino 2021-01-22 19:42:18 +01:00
parent 820169c5c6
commit 80f5ccd357
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
17 changed files with 1231 additions and 780 deletions

View file

@ -1,6 +1,6 @@
# Defender
The experimental built-in `defender` allows you to configure an auto-blocking policy for SFTPGo and thus helps to prevent DoS (Denial of Service) and brute force password guessing.
The built-in `defender` allows you to configure an auto-blocking policy for SFTPGo and thus helps to prevent DoS (Denial of Service) and brute force password guessing.
If enabled it will protect SFTP, FTP and WebDAV services and it will automatically block hosts (IP addresses) that continually fail to log in or attempt to connect.

View file

@ -13,7 +13,7 @@ We use [Go CDK](https://gocloud.dev/howto/secrets/) to access several key manage
### Local provider
If the `url` is empty SFTPGo uses local encryption for keeping secrets. Internally, it uses the [NaCl secret box](https://godoc.org/golang.org/x/crypto/nacl/secretbox) algorithm to perform encryption and authentication.
If the `url` is empty SFTPGo uses local encryption for keeping secrets. Internally, it uses the [NaCl secret box](https://pkg.go.dev/golang.org/x/crypto/nacl/secretbox) algorithm to perform encryption and authentication.
We first generate a random key, then the per-object encryption key is derived from this random key in the following way:

42
go.sum
View file

@ -57,32 +57,25 @@ github.com/Azure/azure-storage-blob-go v0.12.0 h1:7bFXA1QB+lOK2/ASWHhp6/vnxjaeeZ
github.com/Azure/azure-storage-blob-go v0.12.0/go.mod h1:A0u4VjtpgZJ7Y7um/+ix2DHBuEKFC6sEIlj0xc13a4Q=
github.com/Azure/go-amqp v0.13.0/go.mod h1:qj+o8xPCz9tMSbQ83Vp8boHahuRDl5mkNHyt1xlxUTs=
github.com/Azure/go-amqp v0.13.1/go.mod h1:qj+o8xPCz9tMSbQ83Vp8boHahuRDl5mkNHyt1xlxUTs=
github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
github.com/Azure/go-autorest/autorest v0.11.3/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw=
github.com/Azure/go-autorest/autorest v0.11.7/go.mod h1:V6p3pKZx1KKkJubbxnDWrzNhEIfOy/pTGasLqzHIPHs=
github.com/Azure/go-autorest/autorest v0.11.9/go.mod h1:eipySxLmqSyC5s5k1CLupqet0PSENBEDP93LQ9a8QYw=
github.com/Azure/go-autorest/autorest v0.11.12 h1:gI8ytXbxMfI+IVbI9mP2JGCTXIuhHLgRlvQ9X4PsnHE=
github.com/Azure/go-autorest/autorest v0.11.12/go.mod h1:eipySxLmqSyC5s5k1CLupqet0PSENBEDP93LQ9a8QYw=
github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg=
github.com/Azure/go-autorest/autorest/adal v0.9.2/go.mod h1:/3SMAM86bP6wC9Ev35peQDUeqFZBMH07vvUOmg4z/fE=
github.com/Azure/go-autorest/autorest/adal v0.9.4/go.mod h1:/3SMAM86bP6wC9Ev35peQDUeqFZBMH07vvUOmg4z/fE=
github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A=
github.com/Azure/go-autorest/autorest/adal v0.9.6 h1:d3pSDwvBWBLqdA91u+keH1zs1cCEzrQdHKY6iqbQNkE=
github.com/Azure/go-autorest/autorest/adal v0.9.6/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A=
github.com/Azure/go-autorest/autorest/azure/auth v0.5.3/go.mod h1:4bJZhUhcq8LB20TruwHbAQsmUs2Xh+QR7utuJpLXX3A=
github.com/Azure/go-autorest/autorest/azure/cli v0.4.2/go.mod h1:7qkJkT+j6b+hIpzMOwPChJhTqS8VbsqqgULzMNRugoM=
github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw=
github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74=
github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
github.com/Azure/go-autorest/autorest/mocks v0.4.1 h1:K0laFcLE6VLTOwNgSxaGbUcLPuGXlNkbVvq4cW4nIHk=
github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcPR4o9jEImooCeWJcYV/zLE=
github.com/Azure/go-autorest/autorest/validation v0.3.0/go.mod h1:yhLgjC0Wda5DYXl6JAsWyUe4KVNffhoDhG0zVzUMo3E=
github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo=
github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
@ -92,7 +85,6 @@ github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d h1:G0m3OIz70MZUWq3EgK3CesDbo8upS2Vm9/P3FtgI+Jk=
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
@ -191,13 +183,11 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fclairamb/ftpserverlib v0.12.0 h1:vud3Q4v/rLZU5CfIDFaXq7ST2+V9BF5cKjzNWPN18c4=
github.com/fclairamb/ftpserverlib v0.12.0/go.mod h1:X6sAMSYtN0YDPu+nHfyE9dsKPUOrEZ8O5EMgt1xvPwk=
github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk=
github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
github.com/frankban/quicktest v1.10.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y=
github.com/frankban/quicktest v1.11.2 h1:mjwHjStlXWibxOohM7HYieIViKyh56mmt3+6viyhDDI=
github.com/frankban/quicktest v1.11.2/go.mod h1:K+q6oSqb0W0Ininfk863uOk1lMy69l/P6txr3mVT54s=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
@ -218,15 +208,12 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2
github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.10.0 h1:dXFJfIHVvUcpSgDOV+Ne6t7jXri8Tfv2uOLHUZ2XNuo=
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM=
github.com/go-ole/go-ole v1.2.5 h1:t4MGB5xEDZvXI+0rMjjsfBsD7yAgp/s9ZDkL1JndXwY=
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
@ -237,7 +224,6 @@ github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GO
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
@ -293,16 +279,12 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-replayers/grpcreplay v1.0.0 h1:B5kVOzJ1hBgnevTgIWhSTatQ3608yu/2NnU0Ta1d0kY=
github.com/google/go-replayers/grpcreplay v1.0.0/go.mod h1:8Ig2Idjpr6gifRd6pNVggX6TC1Zw6Jx74AKp7QNH2QE=
github.com/google/go-replayers/httpreplay v0.1.2 h1:HCfx+dQzwN9XbGTHF8qJ+67WN8glL9FTWV5rraCJ/jU=
github.com/google/go-replayers/httpreplay v0.1.2/go.mod h1:YKZViNhiGgqdBlUbI2MwGpq4pXxNmhJLPHQ7cv2b5no=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible h1:xmapqc1AyLoB+ddYT6r04bD9lIjlOqGaREovi0SzFaE=
github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0 h1:wCKgOCHuUEVfsaQLpPSJb7VdYCdTVZQAuOdYm1yc/60=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
@ -330,7 +312,6 @@ github.com/googleapis/gax-go v2.0.2+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
@ -357,7 +338,6 @@ github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVo
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI=
github.com/hashicorp/go-hclog v0.8.0/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
@ -399,14 +379,12 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
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/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
@ -417,9 +395,7 @@ github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
@ -434,10 +410,8 @@ github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
@ -452,9 +426,7 @@ github.com/lestrrat-go/jwx v1.0.8 h1:Mj/2Ey9rkGx4w5IMQ2Q+9KLZn4cZoMgKrnMxi9eXE3k
github.com/lestrrat-go/jwx v1.0.8/go.mod h1:6XJ5sxHF5U116AxYxeHfTnfsZRMgmeKY214zwZDdvho=
github.com/lestrrat-go/option v0.0.0-20210103042652-6f1ecfceda35 h1:lea8Wt+1ePkVrI2/WD+NgQT5r/XsLAzxeqtyFLcEs10=
github.com/lestrrat-go/option v0.0.0-20210103042652-6f1ecfceda35/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/pdebug v0.0.0-20200204225717-4d6bd78da58d h1:aEZT3f1GGg5RIlHMAy4/4fe4ciOi3SCwYoaURphcB4k=
github.com/lestrrat-go/pdebug v0.0.0-20200204225717-4d6bd78da58d/go.mod h1:B06CSso/AWxiPejj+fheUINGeBKeeEZNt8w+EoU7+L8=
github.com/lestrrat-go/pdebug/v3 v3.0.0-20210111091911-ec4f5c88c087 h1:T5Wh8C/p5nWoGuEUBQj+daEXkj1CScB9GshvvsBJhpg=
github.com/lestrrat-go/pdebug/v3 v3.0.0-20210111091911-ec4f5c88c087/go.mod h1:za+m+Ve24yCxTEhR59N7UlnJomWwCiIqbJRmKeiADU4=
github.com/lib/pq v1.9.0 h1:L8nSXQQzAYByakOFMTwpjRoHsMJklur4Gi59b6VivR8=
github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
@ -487,7 +459,6 @@ github.com/minio/sio v0.2.1/go.mod h1:8b0yPp2avGThviy/+OCJBI6OMpvxoUuiLvE6F1lebh
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
@ -514,7 +485,6 @@ github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzE
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
@ -535,10 +505,8 @@ github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnh
github.com/otiai10/copy v1.4.2 h1:RTiz2sol3eoXPLF4o+YWqEybwfUa/Q2Nkc4ZIUs3fwI=
github.com/otiai10/copy v1.4.2/go.mod h1:XWfuS3CrI0R6IE0FbgHsEazaXO8G0LpMp9o8tos0x4E=
github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
github.com/otiai10/curr v1.0.0 h1:TJIWdbX0B+kpNagQrjgq8bCMrbhiuX73M2XwgtDMoOI=
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.3.2 h1:VYWnrP5fXmz1MXvjuUvcBrXSjGE6xjON+axB/UrpO3E=
github.com/otiai10/mint v1.3.2/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
@ -613,7 +581,6 @@ github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkB
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
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/shirou/gopsutil/v3 v3.20.12 h1:abpcjSQRHdb3thCge/UyJty9CnvvmUHljTSrjtFU+Og=
github.com/shirou/gopsutil/v3 v3.20.12/go.mod h1:igHnfak0qnw1biGeI2qKQvu0ZkwvEkUcCLlYhZzdr/4=
@ -621,9 +588,7 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
@ -728,7 +693,6 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHl
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
@ -739,7 +703,6 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1 h1:Kvvh58BN8Y9/lBi7hTekvtMpm07eUZ0ck5pRHpsMWrY=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -760,7 +723,6 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 h1:SQFwaSi55rU7vdNs9Yr0Z324VNlrF+0wMqRXT4St8ck=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -819,7 +781,6 @@ golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78 h1:nVuTkr9L6Bq62qpUqKo/RnZCFfzDBL0bYo6w9OJUqZY=
golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -898,7 +859,6 @@ golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20201202200335-bef1c476418a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201203202102-a1a1cbeaa516/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210115202250-e0d201561e39 h1:BTs2GMGSMWpgtCpv1CE7vkJTv7XcHdcLLnAMu7UbgTY=
golang.org/x/tools v0.0.0-20210115202250-e0d201561e39/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -937,7 +897,6 @@ google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww
google.golang.org/appengine v1.6.2/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@ -1022,7 +981,6 @@ gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUy
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=

View file

@ -45,8 +45,8 @@ func getFolders(w http.ResponseWriter, r *http.Request) {
return
}
}
if _, ok := r.URL.Query()["folder_path"]; ok {
folderPath = r.URL.Query().Get("folder_path")
if _, ok := r.URL.Query()["folder-path"]; ok {
folderPath = r.URL.Query().Get("folder-path")
}
folders, err := dataprovider.GetFolders(limit, offset, order, folderPath)
if err == nil {
@ -84,8 +84,8 @@ func renderFolder(w http.ResponseWriter, r *http.Request, mappedPath string) {
func deleteFolderByPath(w http.ResponseWriter, r *http.Request) {
var folderPath string
if _, ok := r.URL.Query()["folder_path"]; ok {
folderPath = r.URL.Query().Get("folder_path")
if _, ok := r.URL.Query()["folder-path"]; ok {
folderPath = r.URL.Query().Get("folder-path")
}
if folderPath == "" {
err := errors.New("a non-empty folder path is required")

View file

@ -11,40 +11,56 @@ import (
"strconv"
"strings"
"github.com/go-chi/render"
"github.com/drakkan/sftpgo/common"
"github.com/drakkan/sftpgo/dataprovider"
"github.com/drakkan/sftpgo/logger"
"github.com/drakkan/sftpgo/vfs"
)
func validateBackupFile(outputFile string) (string, error) {
if outputFile == "" {
return "", errors.New("Invalid or missing output-file")
}
if filepath.IsAbs(outputFile) {
return "", fmt.Errorf("Invalid output-file %#v: it must be a relative path", outputFile)
}
if strings.Contains(outputFile, "..") {
return "", fmt.Errorf("Invalid output-file %#v", outputFile)
}
outputFile = filepath.Join(backupsPath, outputFile)
return outputFile, nil
}
func dumpData(w http.ResponseWriter, r *http.Request) {
var outputFile, indent string
if _, ok := r.URL.Query()["output_file"]; ok {
outputFile = strings.TrimSpace(r.URL.Query().Get("output_file"))
var outputFile, outputData, indent string
if _, ok := r.URL.Query()["output-file"]; ok {
outputFile = strings.TrimSpace(r.URL.Query().Get("output-file"))
}
if _, ok := r.URL.Query()["output-data"]; ok {
outputData = strings.TrimSpace(r.URL.Query().Get("output-data"))
}
if _, ok := r.URL.Query()["indent"]; ok {
indent = strings.TrimSpace(r.URL.Query().Get("indent"))
}
if outputFile == "" {
sendAPIResponse(w, r, errors.New("Invalid or missing output_file"), "", http.StatusBadRequest)
return
if outputData != "1" {
var err error
outputFile, err = validateBackupFile(outputFile)
if err != nil {
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
return
}
err = os.MkdirAll(filepath.Dir(outputFile), 0700)
if err != nil {
logger.Warn(logSender, "", "dumping data error: %v, output file: %#v", err, outputFile)
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
}
logger.Debug(logSender, "", "dumping data to: %#v", outputFile)
}
if filepath.IsAbs(outputFile) {
sendAPIResponse(w, r, fmt.Errorf("Invalid output_file %#v: it must be a relative path", outputFile), "", http.StatusBadRequest)
return
}
if strings.Contains(outputFile, "..") {
sendAPIResponse(w, r, fmt.Errorf("Invalid output_file %#v", outputFile), "", http.StatusBadRequest)
return
}
outputFile = filepath.Join(backupsPath, outputFile)
err := os.MkdirAll(filepath.Dir(outputFile), 0700)
if err != nil {
logger.Warn(logSender, "", "dumping data error: %v, output file: %#v", err, outputFile)
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
}
logger.Debug(logSender, "", "dumping data to: %#v", outputFile)
backup, err := dataprovider.DumpData()
if err != nil {
@ -52,6 +68,13 @@ func dumpData(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
}
if outputData == "1" {
w.Header().Set("Content-Disposition", "attachment; filename=\"sftpgo-backup.json\"")
render.JSON(w, r, backup)
return
}
var dump []byte
if indent == "1" {
dump, err = json.MarshalIndent(backup, "", " ")
@ -70,6 +93,28 @@ func dumpData(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, err, "Data saved", http.StatusOK)
}
func loadDataFromRequest(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, MaxRestoreSize)
_, scanQuota, mode, err := getLoaddataOptions(r)
if err != nil {
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
return
}
content, err := ioutil.ReadAll(r.Body)
if err != nil || len(content) == 0 {
if len(content) == 0 {
err = dataprovider.NewValidationError("request body is required")
}
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
}
if err := restoreBackup(content, "", scanQuota, mode); err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
}
sendAPIResponse(w, r, err, "Data restored", http.StatusOK)
}
func loadData(w http.ResponseWriter, r *http.Request) {
inputFile, scanQuota, mode, err := getLoaddataOptions(r)
if err != nil {
@ -96,30 +141,34 @@ func loadData(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
}
if err := restoreBackup(content, inputFile, scanQuota, mode); err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
}
sendAPIResponse(w, r, err, "Data restored", http.StatusOK)
}
func restoreBackup(content []byte, inputFile string, scanQuota, mode int) error {
dump, err := dataprovider.ParseDumpData(content)
if err != nil {
sendAPIResponse(w, r, err, fmt.Sprintf("Unable to parse input file: %#v", inputFile), http.StatusBadRequest)
return
return dataprovider.NewValidationError(fmt.Sprintf("Unable to parse backup content: %v", err))
}
if err = RestoreFolders(dump.Folders, inputFile, scanQuota); err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
return err
}
if err = RestoreUsers(dump.Users, inputFile, mode, scanQuota); err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
return err
}
if err = RestoreAdmins(dump.Admins, inputFile, mode); err != nil {
sendAPIResponse(w, r, err, "", getRespStatus(err))
return
return err
}
logger.Debug(logSender, "", "backup restored, users: %v, folders: %v, admins: %vs",
len(dump.Users), len(dump.Folders), len(dump.Admins))
sendAPIResponse(w, r, err, "Data restored", http.StatusOK)
return nil
}
func getLoaddataOptions(r *http.Request) (string, int, int, error) {
@ -127,19 +176,21 @@ func getLoaddataOptions(r *http.Request) (string, int, int, error) {
var err error
scanQuota := 0
restoreMode := 0
if _, ok := r.URL.Query()["input_file"]; ok {
inputFile = strings.TrimSpace(r.URL.Query().Get("input_file"))
if _, ok := r.URL.Query()["input-file"]; ok {
inputFile = strings.TrimSpace(r.URL.Query().Get("input-file"))
}
if _, ok := r.URL.Query()["scan_quota"]; ok {
scanQuota, err = strconv.Atoi(r.URL.Query().Get("scan_quota"))
if _, ok := r.URL.Query()["scan-quota"]; ok {
scanQuota, err = strconv.Atoi(r.URL.Query().Get("scan-quota"))
if err != nil {
err = fmt.Errorf("invalid scan_quota: %v", err)
return inputFile, scanQuota, restoreMode, err
}
}
if _, ok := r.URL.Query()["mode"]; ok {
restoreMode, err = strconv.Atoi(r.URL.Query().Get("mode"))
if err != nil {
err = fmt.Errorf("invalid mode: %v", err)
return inputFile, scanQuota, restoreMode, err
}
}
return inputFile, scanQuota, restoreMode, err

View file

@ -54,6 +54,9 @@ const (
webStatusPath = "/web/status"
webAdminsPath = "/web/admins"
webAdminPath = "/web/admin"
webMaintenancePath = "/web/maintenance"
webBackupPath = "/web/backup"
webRestorePath = "/web/restore"
webScanVFolderPath = "/web/folder-quota-scans"
webQuotaScanPath = "/web/quota-scans"
webChangeAdminPwdPath = "/web/changepwd/admin"

View file

@ -73,6 +73,8 @@ const (
webStatusPath = "/web/status"
webAdminsPath = "/web/admins"
webAdminPath = "/web/admin"
webMaintenancePath = "/web/maintenance"
webRestorePath = "/web/restore"
webChangeAdminPwdPath = "/web/changepwd/admin"
httpBaseURL = "http://127.0.0.1:8081"
configDir = ".."
@ -2179,7 +2181,7 @@ func TestProviderErrors(t *testing.T) {
if assert.NoError(t, err) {
assert.False(t, status.DataProvider.IsActive)
}
_, _, err = httpdtest.Dumpdata("backup.json", "", http.StatusInternalServerError)
_, _, err = httpdtest.Dumpdata("backup.json", "", "", http.StatusInternalServerError)
assert.NoError(t, err)
_, _, err = httpdtest.GetFolders(0, 0, "", http.StatusInternalServerError)
assert.NoError(t, err)
@ -2296,25 +2298,35 @@ func TestDumpdata(t *testing.T) {
providerConf := config.GetProviderConf()
err = dataprovider.Initialize(providerConf, configDir, true)
assert.NoError(t, err)
_, _, err = httpdtest.Dumpdata("", "", http.StatusBadRequest)
_, _, err = httpdtest.Dumpdata("", "", "", http.StatusBadRequest)
assert.NoError(t, err)
_, _, err = httpdtest.Dumpdata(filepath.Join(backupsPath, "backup.json"), "", http.StatusBadRequest)
_, _, err = httpdtest.Dumpdata(filepath.Join(backupsPath, "backup.json"), "", "", http.StatusBadRequest)
assert.NoError(t, err)
_, _, err = httpdtest.Dumpdata("../backup.json", "", http.StatusBadRequest)
_, _, err = httpdtest.Dumpdata("../backup.json", "", "", http.StatusBadRequest)
assert.NoError(t, err)
_, _, err = httpdtest.Dumpdata("backup.json", "0", http.StatusOK)
_, _, err = httpdtest.Dumpdata("backup.json", "", "0", http.StatusOK)
assert.NoError(t, err)
_, _, err = httpdtest.Dumpdata("backup.json", "1", http.StatusOK)
response, _, err := httpdtest.Dumpdata("", "1", "0", http.StatusOK)
assert.NoError(t, err)
_, ok := response["admins"]
assert.True(t, ok)
_, ok = response["users"]
assert.True(t, ok)
_, ok = response["folders"]
assert.True(t, ok)
_, ok = response["version"]
assert.True(t, ok)
_, _, err = httpdtest.Dumpdata("backup.json", "", "1", http.StatusOK)
assert.NoError(t, err)
err = os.Remove(filepath.Join(backupsPath, "backup.json"))
assert.NoError(t, err)
if runtime.GOOS != "windows" {
err = os.Chmod(backupsPath, 0001)
assert.NoError(t, err)
_, _, err = httpdtest.Dumpdata("bck.json", "", http.StatusInternalServerError)
_, _, err = httpdtest.Dumpdata("bck.json", "", "", http.StatusInternalServerError)
assert.NoError(t, err)
// subdir cannot be created
_, _, err = httpdtest.Dumpdata(filepath.Join("subdir", "bck.json"), "", http.StatusInternalServerError)
_, _, err = httpdtest.Dumpdata(filepath.Join("subdir", "bck.json"), "", "", http.StatusInternalServerError)
assert.NoError(t, err)
err = os.Chmod(backupsPath, 0755)
assert.NoError(t, err)
@ -2396,6 +2408,63 @@ func TestDefenderAPIErrors(t *testing.T) {
require.NoError(t, err)
}
func TestLoaddataFromPostBody(t *testing.T) {
mappedPath := filepath.Join(os.TempDir(), "restored_folder")
user := getTestUser()
user.ID = 1
user.Username = "test_user_restored"
admin := getTestAdmin()
admin.ID = 1
admin.Username = "test_admin_restored"
backupData := dataprovider.BackupData{}
backupData.Users = append(backupData.Users, user)
backupData.Admins = append(backupData.Admins, admin)
backupData.Folders = []vfs.BaseVirtualFolder{
{
MappedPath: mappedPath,
UsedQuotaSize: 123,
UsedQuotaFiles: 456,
LastQuotaUpdate: 789,
Users: []string{"user"},
},
{
MappedPath: mappedPath,
},
}
backupContent, err := json.Marshal(backupData)
assert.NoError(t, err)
_, _, err = httpdtest.LoaddataFromPostBody(nil, "0", "0", http.StatusBadRequest)
assert.NoError(t, err)
_, _, err = httpdtest.LoaddataFromPostBody(backupContent, "a", "0", http.StatusBadRequest)
assert.NoError(t, err)
_, _, err = httpdtest.LoaddataFromPostBody([]byte("invalid content"), "0", "0", http.StatusBadRequest)
assert.NoError(t, err)
_, _, err = httpdtest.LoaddataFromPostBody(backupContent, "0", "0", http.StatusOK)
assert.NoError(t, err)
user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
assert.NoError(t, err)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
admin, _, err = httpdtest.GetAdminByUsername(admin.Username, http.StatusOK)
assert.NoError(t, err)
_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
assert.NoError(t, err)
folders, _, err := httpdtest.GetFolders(1, 0, mappedPath, http.StatusOK)
assert.NoError(t, err)
if assert.Len(t, folders, 1) {
folder := folders[0]
assert.Equal(t, mappedPath, folder.MappedPath)
assert.Equal(t, int64(123), folder.UsedQuotaSize)
assert.Equal(t, 456, folder.UsedQuotaFiles)
assert.Equal(t, int64(789), folder.LastQuotaUpdate)
assert.Len(t, folder.Users, 0)
_, err = httpdtest.RemoveFolder(folder, http.StatusOK)
assert.NoError(t, err)
}
}
func TestLoaddata(t *testing.T) {
mappedPath := filepath.Join(os.TempDir(), "restored_folder")
user := getTestUser()
@ -3269,7 +3338,7 @@ func TestUpdateFolderQuotaUsageMock(t *testing.T) {
url, err := url.Parse(folderPath)
assert.NoError(t, err)
q := url.Query()
q.Add("folder_path", mappedPath)
q.Add("folder-path", mappedPath)
url.RawQuery = q.Encode()
req, _ = http.NewRequest(http.MethodGet, url.String(), nil)
setBearerForReq(req, token)
@ -3298,7 +3367,7 @@ func TestUpdateFolderQuotaUsageMock(t *testing.T) {
url, err = url.Parse(folderPath)
assert.NoError(t, err)
q = url.Query()
q.Add("folder_path", mappedPath)
q.Add("folder-path", mappedPath)
url.RawQuery = q.Encode()
req, _ = http.NewRequest(http.MethodDelete, url.String(), nil)
setBearerForReq(req, token)
@ -3360,7 +3429,7 @@ func TestStartFolderQuotaScanMock(t *testing.T) {
url, err := url.Parse(folderPath)
assert.NoError(t, err)
q := url.Query()
q.Add("folder_path", mappedPath)
q.Add("folder-path", mappedPath)
url.RawQuery = q.Encode()
req, _ = http.NewRequest(http.MethodDelete, url.String(), nil)
setBearerForReq(req, token)
@ -3435,7 +3504,7 @@ func TestGetFoldersMock(t *testing.T) {
url, err := url.Parse(folderPath + "?limit=510&offset=0&order=DESC")
assert.NoError(t, err)
q := url.Query()
q.Add("folder_path", mappedPath)
q.Add("folder-path", mappedPath)
url.RawQuery = q.Encode()
req, _ = http.NewRequest(http.MethodGet, url.String(), nil)
setBearerForReq(req, token)
@ -3460,7 +3529,7 @@ func TestGetFoldersMock(t *testing.T) {
url, err = url.Parse(folderPath)
assert.NoError(t, err)
q = url.Query()
q.Add("folder_path", mappedPath)
q.Add("folder-path", mappedPath)
url.RawQuery = q.Encode()
req, _ = http.NewRequest(http.MethodDelete, url.String(), nil)
setBearerForReq(req, token)
@ -3974,6 +4043,100 @@ func TestAdminUpdateSelfMock(t *testing.T) {
assert.Contains(t, rr.Body.String(), "You cannot disable yourself")
}
func TestWebMaintenanceMock(t *testing.T) {
token, err := getJWTTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
req, _ := http.NewRequest(http.MethodGet, webMaintenancePath, nil)
setJWTCookieForReq(req, token)
rr := executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
form := make(url.Values)
form.Set("mode", "a")
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webRestorePath, &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
form.Set("mode", "0")
form.Set("quota", "a")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webRestorePath, &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
form.Set("quota", "0")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, webRestorePath, &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
req, _ = http.NewRequest(http.MethodPost, webRestorePath+"?a=%3", &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
backupFilePath := filepath.Join(os.TempDir(), "backup.json")
err = createTestFile(backupFilePath, 0)
assert.NoError(t, err)
b, contentType, _ = getMultipartFormData(form, "backup_file", backupFilePath)
req, _ = http.NewRequest(http.MethodPost, webRestorePath, &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
err = createTestFile(backupFilePath, 10)
assert.NoError(t, err)
b, contentType, _ = getMultipartFormData(form, "backup_file", backupFilePath)
req, _ = http.NewRequest(http.MethodPost, webRestorePath, &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
user := getTestUser()
user.ID = 1
user.Username = "test_user_web_restore"
admin := getTestAdmin()
admin.ID = 1
admin.Username = "test_admin_web_restore"
backupData := dataprovider.BackupData{}
backupData.Users = append(backupData.Users, user)
backupData.Admins = append(backupData.Admins, admin)
backupContent, err := json.Marshal(backupData)
assert.NoError(t, err)
err = ioutil.WriteFile(backupFilePath, backupContent, os.ModePerm)
assert.NoError(t, err)
b, contentType, _ = getMultipartFormData(form, "backup_file", backupFilePath)
req, _ = http.NewRequest(http.MethodPost, webRestorePath, &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "Your backup was successfully restored")
user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
assert.NoError(t, err)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
admin, _, err = httpdtest.GetAdminByUsername(admin.Username, http.StatusOK)
assert.NoError(t, err)
_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
assert.NoError(t, err)
}
func TestWebUserAddMock(t *testing.T) {
token, err := getJWTTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
@ -4190,7 +4353,7 @@ func TestWebUserAddMock(t *testing.T) {
url, err := url.Parse(folderPath)
assert.NoError(t, err)
q := url.Query()
q.Add("folder_path", mappedDir)
q.Add("folder-path", mappedDir)
url.RawQuery = q.Encode()
req, _ = http.NewRequest(http.MethodDelete, url.String(), nil)
setJWTCookieForReq(req, token)
@ -4904,7 +5067,7 @@ func TestAddWebFoldersMock(t *testing.T) {
url, err := url.Parse(folderPath)
assert.NoError(t, err)
q := url.Query()
q.Add("folder_path", mappedPath)
q.Add("folder-path", mappedPath)
url.RawQuery = q.Encode()
req, _ = http.NewRequest(http.MethodGet, url.String(), nil)
setJWTCookieForReq(req, token)
@ -4920,7 +5083,7 @@ func TestAddWebFoldersMock(t *testing.T) {
url, err = url.Parse(folderPath)
assert.NoError(t, err)
q = url.Query()
q.Add("folder_path", mappedPath)
q.Add("folder-path", mappedPath)
url.RawQuery = q.Encode()
req, _ = http.NewRequest(http.MethodDelete, url.String(), nil)
setJWTCookieForReq(req, token)
@ -4970,7 +5133,7 @@ func TestWebFoldersMock(t *testing.T) {
url, err := url.Parse(folderPath)
assert.NoError(t, err)
q := url.Query()
q.Add("folder_path", folder.MappedPath)
q.Add("folder-path", folder.MappedPath)
url.RawQuery = q.Encode()
req, _ := http.NewRequest(http.MethodDelete, url.String(), nil)
setJWTCookieForReq(req, token)

View file

@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: SFTPGo
description: SFTPGo REST API
version: 2.4.1
version: 2.4.2
servers:
- url: /api/v2
@ -497,7 +497,7 @@ paths:
- DESC
example: ASC
- in: query
name: folder_path
name: folder-path
required: false
description: Filter by folder path, extact match case sensitive
schema:
@ -556,7 +556,7 @@ paths:
summary: Delete an existing folder
operationId: delete_folder
parameters:
- name: folder_path
- name: folder-path
in: query
description: path to the folder to delete
required: true
@ -994,15 +994,25 @@ paths:
tags:
- maintenance
summary: Backup SFTPGo data as data provider independent JSON
description: The backup is saved to a local file to avoid to expose sensitive data over the network. The output of dumpdata can be used as input for loaddata
description: The backup can be saved in a local file on the server, to avoid exposing sensitive data over the network, or returned as response body. The output of dumpdata can be used as input for loaddata
operationId: dumpdata
parameters:
- in: query
name: output_file
name: output-file
schema:
type: string
required: true
description: Path for the file to write the JSON serialized data to. This path is relative to the configured "backups_path". If this file already exists it will be overwritten
description: Path for the file to write the JSON serialized data to. This path is relative to the configured "backups_path". If this file already exists it will be overwritten. To return the backup as response body set `output_data` to true instead.
- in: query
name: output-data
schema:
type: integer
enum:
- 0
- 1
description: >
output_data:
* `0` or any other value != 1, the backup will be saved to a file on the server, `output_file` is required
* `1` the backup will be returned as response body
- in: query
name: indent
schema:
@ -1020,9 +1030,9 @@ paths:
content:
application/json:
schema:
$ref : '#/components/schemas/ApiResponse'
example:
message: "Data saved"
oneOf:
- $ref: '#/components/schemas/ApiResponse'
- $ref: '#/components/schemas/BackupData'
400:
$ref: '#/components/responses/BadRequest'
401:
@ -1034,46 +1044,78 @@ paths:
default:
$ref: '#/components/responses/DefaultResponse'
/loaddata:
parameters:
- in: query
name: scan-quota
schema:
type: integer
enum:
- 0
- 1
- 2
description: >
Quota scan:
* `0` no quota scan is done, the imported users/folders will have used_quota_size and used_quota_files = 0 or the existing values if they already exists. This is the default
* `1` scan quota
* `2` scan quota if the user has quota restrictions
required: false
- in: query
name: mode
schema:
type: integer
enum:
- 0
- 1
- 2
description: >
Mode:
* `0` New users/admins are added, existing users/admins are updated. This is the default
* `1` New users/admins are added, existing users/admins are not modified
* `2` New users are added, existing users are updated and, if connected, they are disconnected and so forced to use the new configuration
get:
tags:
- maintenance
summary: Restore SFTPGo data from a JSON backup
description: Users and folders will be restored one by one and the restore is stopped if a user/folder cannot be added or updated, so it could happen a partial restore
operationId: loaddata
summary: Restore SFTPGo data from a JSON backup file on the server
description: Users, folders and admins will be restored one by one and the restore is stopped if a user/folder/admin cannot be added or updated, so it could happen a partial restore
operationId: loaddata_from_file
parameters:
- in: query
name: input_file
name: input-file
schema:
type: string
required: true
description: Path for the file to read the JSON serialized data from. This can be an absolute path or a path relative to the configured "backups_path". The max allowed file size is 10MB
- in: query
name: scan_quota
schema:
type: integer
enum:
- 0
- 1
- 2
description: >
Quota scan:
* `0` no quota scan is done, the imported users will have used_quota_size and used_quota_files = 0 or the existing values if they already exists. This is the default
* `1` scan quota
* `2` scan quota if the user has quota restrictions
required: false
- in: query
name: mode
schema:
type: integer
enum:
- 0
- 1
- 2
description: >
Mode:
* `0` New users are added, existing users are updated. This is the default
* `1` New users are added, existing users are not modified
* `2` New users are added, existing users are updated and, if connected, they are disconnected and so forced to use the new configuration
responses:
200:
description: successful operation
content:
application/json:
schema:
$ref : '#/components/schemas/ApiResponse'
example:
message: "Data restored"
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
403:
$ref: '#/components/responses/Forbidden'
500:
$ref: '#/components/responses/InternalServerError'
default:
$ref: '#/components/responses/DefaultResponse'
post:
tags:
- maintenance
summary: Restore SFTPGo data from a JSON backup
description: Users, folders and admins will be restored one by one and the restore is stopped if a user/folder/admin cannot be added or updated, so it could happen a partial restore
operationId: loaddata_from_request_body
requestBody:
required: true
content:
application/json:
schema:
$ref : '#/components/schemas/BackupData'
responses:
200:
description: successful operation
@ -1873,6 +1915,23 @@ components:
score:
type: integer
description: if 0 the host is not listed
BackupData:
type: object
properties:
users:
type: array
items:
$ref: '#/components/schemas/User'
folders:
type: array
items:
$ref: '#/components/schemas/BaseVirtualFolder'
admins:
type: array
items:
$ref: '#/components/schemas/Admin'
version:
type: integer
PwdChange:
type: object
properties:

View file

@ -302,6 +302,7 @@ func (s *httpdServer) initializeRouter() {
router.With(checkPerm(dataprovider.PermAdminDeleteUsers)).Delete(folderPath, deleteFolderByPath)
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(dumpDataPath, dumpData)
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(loadDataPath, loadData)
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(loadDataPath, loadDataFromRequest)
router.With(checkPerm(dataprovider.PermAdminChangeUsers)).Put(updateUsedQuotaPath, updateUserQuotaUsage)
router.With(checkPerm(dataprovider.PermAdminChangeUsers)).Put(updateFolderUsedQuotaPath, updateVFolderQuotaUsage)
router.With(checkPerm(dataprovider.PermAdminViewDefender)).Get(defenderBanTime, getBanTime)
@ -365,6 +366,9 @@ func (s *httpdServer) initializeRouter() {
router.With(checkPerm(dataprovider.PermAdminQuotaScans)).Post(webScanVFolderPath, startVFolderQuotaScan)
router.With(checkPerm(dataprovider.PermAdminDeleteUsers)).Delete(webUserPath+"/{username}", deleteUser)
router.With(checkPerm(dataprovider.PermAdminQuotaScans)).Post(webQuotaScanPath, startQuotaScan)
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webMaintenancePath, handleWebMaintenance)
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webBackupPath, dumpData)
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webRestorePath, handleWebRestore)
})
router.Group(func(router chi.Router) {

View file

@ -34,12 +34,14 @@ const (
templateStatus = "status.html"
templateLogin = "login.html"
templateChangePwd = "changepwd.html"
templateMaintenance = "maintenance.html"
pageUsersTitle = "Users"
pageAdminsTitle = "Admins"
pageConnectionsTitle = "Connections"
pageStatusTitle = "Status"
pageFoldersTitle = "Folders"
pageChangePwdTitle = "Change password"
pageMaintenanceTitle = "Maintenance"
page400Title = "Bad request"
page403Title = "Forbidden"
page404Title = "Not found"
@ -70,11 +72,13 @@ type basePage struct {
ChangeAdminPwdURL string
FolderQuotaScanURL string
StatusURL string
MaintenanceURL string
UsersTitle string
AdminsTitle string
ConnectionsTitle string
FoldersTitle string
StatusTitle string
MaintenanceTitle string
Version string
LoggedAdmin *dataprovider.Admin
}
@ -129,6 +133,13 @@ type changePwdPage struct {
Error string
}
type maintenancePage struct {
basePage
BackupPath string
RestorePath string
Error string
}
type folderPage struct {
basePage
Folder vfs.BaseVirtualFolder
@ -191,6 +202,10 @@ func loadTemplates(templatesPath string) {
loginPath := []string{
filepath.Join(templatesPath, templateLogin),
}
maintenancePath := []string{
filepath.Join(templatesPath, templateBase),
filepath.Join(templatesPath, templateMaintenance),
}
usersTmpl := utils.LoadTemplate(template.ParseFiles(usersPaths...))
userTmpl := utils.LoadTemplate(template.ParseFiles(userPaths...))
adminsTmpl := utils.LoadTemplate(template.ParseFiles(adminsPaths...))
@ -202,6 +217,7 @@ func loadTemplates(templatesPath string) {
statusTmpl := utils.LoadTemplate(template.ParseFiles(statusPath...))
loginTmpl := utils.LoadTemplate(template.ParseFiles(loginPath...))
changePwdTmpl := utils.LoadTemplate(template.ParseFiles(changePwdPaths...))
maintenanceTmpl := utils.LoadTemplate(template.ParseFiles(maintenancePath...))
templates[templateUsers] = usersTmpl
templates[templateUser] = userTmpl
@ -214,6 +230,7 @@ func loadTemplates(templatesPath string) {
templates[templateStatus] = statusTmpl
templates[templateLogin] = loginTmpl
templates[templateChangePwd] = changePwdTmpl
templates[templateMaintenance] = maintenanceTmpl
}
func getBasePageData(title, currentURL string, r *http.Request) basePage {
@ -232,11 +249,13 @@ func getBasePageData(title, currentURL string, r *http.Request) basePage {
ConnectionsURL: webConnectionsPath,
StatusURL: webStatusPath,
FolderQuotaScanURL: webScanVFolderPath,
MaintenanceURL: webMaintenancePath,
UsersTitle: pageUsersTitle,
AdminsTitle: pageAdminsTitle,
ConnectionsTitle: pageConnectionsTitle,
FoldersTitle: pageFoldersTitle,
StatusTitle: pageStatusTitle,
MaintenanceTitle: pageMaintenanceTitle,
Version: version.GetAsString(),
LoggedAdmin: getAdminFromToken(r),
}
@ -291,6 +310,17 @@ func renderChangePwdPage(w http.ResponseWriter, r *http.Request, error string) {
renderTemplate(w, templateChangePwd, data)
}
func renderMaintenancePage(w http.ResponseWriter, r *http.Request, error string) {
data := maintenancePage{
basePage: getBasePageData(pageMaintenanceTitle, webMaintenancePath, r),
BackupPath: webBackupPath,
RestorePath: webRestorePath,
Error: error,
}
renderTemplate(w, templateMaintenance, data)
}
func renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Request, admin *dataprovider.Admin,
error string, isAdd bool) {
currentURL := webAdminPath
@ -796,6 +826,50 @@ func handleWebLogin(w http.ResponseWriter, r *http.Request) {
renderLoginPage(w, "")
}
func handleWebMaintenance(w http.ResponseWriter, r *http.Request) {
renderMaintenancePage(w, r, "")
}
func handleWebRestore(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(MaxRestoreSize)
if err != nil {
renderMaintenancePage(w, r, err.Error())
return
}
restoreMode, err := strconv.Atoi(r.Form.Get("mode"))
if err != nil {
renderMaintenancePage(w, r, err.Error())
return
}
scanQuota, err := strconv.Atoi(r.Form.Get("quota"))
if err != nil {
renderMaintenancePage(w, r, err.Error())
return
}
backupFile, _, err := r.FormFile("backup_file")
if err != nil {
renderMaintenancePage(w, r, err.Error())
return
}
defer backupFile.Close()
backupContent, err := ioutil.ReadAll(backupFile)
if err != nil || len(backupContent) == 0 {
if len(backupContent) == 0 {
err = errors.New("backup file size must be greater than 0")
}
renderMaintenancePage(w, r, err.Error())
return
}
if err := restoreBackup(backupContent, "", scanQuota, restoreMode); err != nil {
renderMaintenancePage(w, r, err.Error())
return
}
renderMessagePage(w, r, "Data restored", "", http.StatusOK, nil, "Your backup was successfully restored")
}
func handleGetWebAdmins(w http.ResponseWriter, r *http.Request) {
limit := defaultQueryLimit
if _, ok := r.URL.Query()["qlimit"]; ok {

View file

@ -486,7 +486,7 @@ func RemoveFolder(folder vfs.BaseVirtualFolder, expectedStatusCode int) ([]byte,
return body, err
}
q := url.Query()
q.Add("folder_path", folder.MappedPath)
q.Add("folder-path", folder.MappedPath)
url.RawQuery = q.Encode()
resp, err := sendHTTPRequest(http.MethodDelete, url.String(), nil, "", getDefaultToken())
if err != nil {
@ -510,7 +510,7 @@ func GetFolders(limit int64, offset int64, mappedPath string, expectedStatusCode
}
if len(mappedPath) > 0 {
q := url.Query()
q.Add("folder_path", mappedPath)
q.Add("folder-path", mappedPath)
url.RawQuery = q.Encode()
}
resp, err := sendHTTPRequest(http.MethodGet, url.String(), nil, "", getDefaultToken())
@ -678,7 +678,7 @@ func UnbanIP(ip string, expectedStatusCode int) error {
// Dumpdata requests a backup to outputFile.
// outputFile is relative to the configured backups_path
func Dumpdata(outputFile, indent string, expectedStatusCode int) (map[string]interface{}, []byte, error) {
func Dumpdata(outputFile, outputData, indent string, expectedStatusCode int) (map[string]interface{}, []byte, error) {
var response map[string]interface{}
var body []byte
url, err := url.Parse(buildURLRelativeToBase(dumpDataPath))
@ -686,8 +686,13 @@ func Dumpdata(outputFile, indent string, expectedStatusCode int) (map[string]int
return response, body, err
}
q := url.Query()
q.Add("output_file", outputFile)
if len(indent) > 0 {
if outputData != "" {
q.Add("output-data", outputData)
}
if outputFile != "" {
q.Add("output-file", outputFile)
}
if indent != "" {
q.Add("indent", indent)
}
url.RawQuery = q.Encode()
@ -706,8 +711,6 @@ func Dumpdata(outputFile, indent string, expectedStatusCode int) (map[string]int
}
// Loaddata restores a backup.
// New users are added, existing users are updated. Users will be restored one by one and the restore is stopped if a
// user cannot be added/updated, so it could happen a partial restore
func Loaddata(inputFile, scanQuota, mode string, expectedStatusCode int) (map[string]interface{}, []byte, error) {
var response map[string]interface{}
var body []byte
@ -716,11 +719,11 @@ func Loaddata(inputFile, scanQuota, mode string, expectedStatusCode int) (map[st
return response, body, err
}
q := url.Query()
q.Add("input_file", inputFile)
if len(scanQuota) > 0 {
q.Add("scan_quota", scanQuota)
q.Add("input-file", inputFile)
if scanQuota != "" {
q.Add("scan-quota", scanQuota)
}
if len(mode) > 0 {
if mode != "" {
q.Add("mode", mode)
}
url.RawQuery = q.Encode()
@ -738,6 +741,36 @@ func Loaddata(inputFile, scanQuota, mode string, expectedStatusCode int) (map[st
return response, body, err
}
// LoaddataFromPostBody restores a backup
func LoaddataFromPostBody(data []byte, scanQuota, mode string, expectedStatusCode int) (map[string]interface{}, []byte, error) {
var response map[string]interface{}
var body []byte
url, err := url.Parse(buildURLRelativeToBase(loadDataPath))
if err != nil {
return response, body, err
}
q := url.Query()
if scanQuota != "" {
q.Add("scan-quota", scanQuota)
}
if mode != "" {
q.Add("mode", mode)
}
url.RawQuery = q.Encode()
resp, err := sendHTTPRequest(http.MethodPost, url.String(), bytes.NewReader(data), "", getDefaultToken())
if err != nil {
return response, body, err
}
defer resp.Body.Close()
err = checkResponse(resp.StatusCode, expectedStatusCode)
if err == nil && expectedStatusCode == http.StatusOK {
err = render.DecodeJSON(resp.Body, &response)
} else {
body, _ = getResponseBody(resp)
}
return response, body, err
}
func checkResponse(actual int, expected int) error {
if expected != actual {
return fmt.Errorf("wrong status code: got %v want %v", actual, expected)

View file

@ -1,6 +1,6 @@
#!/bin/bash
NFPM_VERSION=2.2.2
NFPM_VERSION=2.2.3
NFPM_ARCH=${NFPM_ARCH:-amd64}
if [ -z ${SFTPGO_VERSION} ]
then

View file

@ -3,90 +3,93 @@
{{define "title"}}{{.Title}}{{end}}
{{define "page_body"}}
<!-- Page Heading -->
<h1 class="h5 mb-4 text-gray-800">{{if .IsAdd}}Add a new admin{{else}}Edit admin{{end}}</h1>
{{if .Error}}
<div class="card mb-4 border-left-warning">
<div class="card-body text-form-error">{{.Error}}</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">{{if .IsAdd}}Add a new admin{{else}}Edit admin{{end}}</h6>
</div>
<div class="card-body">
{{if .Error}}
<div class="card mb-4 border-left-warning">
<div class="card-body text-form-error">{{.Error}}</div>
</div>
{{end}}
<form id="admin_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
<div class="form-group row">
<label for="idUsername" class="col-sm-2 col-form-label">Username</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idUsername" name="username" placeholder=""
value="{{.Admin.Username}}" maxlength="255" autocomplete="nope" required {{if not .IsAdd}}readonly{{end}}>
</div>
</div>
<div class="form-group row">
<label for="idStatus" class="col-sm-2 col-form-label">Status</label>
<div class="col-sm-10">
<select class="form-control" id="idStatus" name="status">
<option value="1" {{if eq .Admin.Status 1 }}selected{{end}}>Active</option>
<option value="0" {{if eq .Admin.Status 0 }}selected{{end}}>Inactive</option>
</select>
</div>
</div>
<div class="form-group row">
<label for="idPassword" class="col-sm-2 col-form-label">Password</label>
<div class="col-sm-10">
<input type="password" class="form-control" id="idPassword" name="password" placeholder=""
{{if not .IsAdd}}aria-describedby="pwdHelpBlock" {{end}}>
{{if not .IsAdd}}
<small id="pwdHelpBlock" class="form-text text-muted">
If empty the current password will not be changed
</small>
{{end}}
</div>
</div>
<div class="form-group row">
<label for="idPermissions" class="col-sm-2 col-form-label">Permissions</label>
<div class="col-sm-10">
<select class="form-control" id="idPermissions" name="permissions" required multiple>
{{range $validPerm := .Admin.GetValidPerms}}
<option value="{{$validPerm}}" {{range $perm :=$.Admin.Permissions }}
{{if eq $perm $validPerm}}selected{{end}}{{end}}>{{$validPerm}}
</option>
{{end}}
</select>
</div>
</div>
<div class="form-group row">
<label for="idEmail" class="col-sm-2 col-form-label">Email</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idEmail" name="email" placeholder=""
value="{{.Admin.Email}}" maxlength="255">
</div>
</div>
<div class="form-group row">
<label for="idAllowedIP" class="col-sm-2 col-form-label">Allowed IP/Mask</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idAllowedIP" name="allowed_ip" placeholder=""
value="{{.Admin.GetAllowedIPAsString}}" maxlength="255" aria-describedby="allowedIPHelpBlock">
<small id="allowedIPHelpBlock" class="form-text text-muted">
Comma separated IP/Mask in CIDR format, for example "192.168.1.0/24,10.8.0.100/32"
</small>
</div>
</div>
<div class="form-group row">
<label for="idAdditionalInfo" class="col-sm-2 col-form-label">Additional info</label>
<div class="col-sm-10">
<textarea class="form-control" id="idAdditionalInfo" name="additional_info" rows="3"
aria-describedby="additionalInfoHelpBlock">{{.Admin.AdditionalInfo}}</textarea>
<small id="additionalInfoHelpBlock" class="form-text text-muted">
Free form text field
</small>
</div>
</div>
<button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Submit</button>
</form>
</div>
</div>
{{end}}
<form id="admin_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
<div class="form-group row">
<label for="idUsername" class="col-sm-2 col-form-label">Username</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idUsername" name="username" placeholder=""
value="{{.Admin.Username}}" maxlength="255" autocomplete="nope" required
{{if not .IsAdd}}readonly{{end}}>
</div>
</div>
<div class="form-group row">
<label for="idStatus" class="col-sm-2 col-form-label">Status</label>
<div class="col-sm-10">
<select class="form-control" id="idStatus" name="status">
<option value="1" {{if eq .Admin.Status 1 }}selected{{end}}>Active</option>
<option value="0" {{if eq .Admin.Status 0 }}selected{{end}}>Inactive</option>
</select>
</div>
</div>
<div class="form-group row">
<label for="idPassword" class="col-sm-2 col-form-label">Password</label>
<div class="col-sm-10">
<input type="password" class="form-control" id="idPassword" name="password" placeholder=""
{{if not .IsAdd}}aria-describedby="pwdHelpBlock" {{end}}>
{{if not .IsAdd}}
<small id="pwdHelpBlock" class="form-text text-muted">
If empty the current password will not be changed
</small>
{{end}}
</div>
</div>
<div class="form-group row">
<label for="idPermissions" class="col-sm-2 col-form-label">Permissions</label>
<div class="col-sm-10">
<select class="form-control" id="idPermissions" name="permissions" required multiple>
{{range $validPerm := .Admin.GetValidPerms}}
<option value="{{$validPerm}}"
{{range $perm := $.Admin.Permissions }}{{if eq $perm $validPerm}}selected{{end}}{{end}}>{{$validPerm}}
</option>
{{end}}
</select>
</div>
</div>
<div class="form-group row">
<label for="idEmail" class="col-sm-2 col-form-label">Email</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idEmail" name="email" placeholder=""
value="{{.Admin.Email}}" maxlength="255">
</div>
</div>
<div class="form-group row">
<label for="idAllowedIP" class="col-sm-2 col-form-label">Allowed IP/Mask</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idAllowedIP" name="allowed_ip" placeholder=""
value="{{.Admin.GetAllowedIPAsString}}" maxlength="255" aria-describedby="allowedIPHelpBlock">
<small id="allowedIPHelpBlock" class="form-text text-muted">
Comma separated IP/Mask in CIDR format, for example "192.168.1.0/24,10.8.0.100/32"
</small>
</div>
</div>
<div class="form-group row">
<label for="idAdditionalInfo" class="col-sm-2 col-form-label">Additional info</label>
<div class="col-sm-10">
<textarea class="form-control" id="idAdditionalInfo" name="additional_info" rows="3"
aria-describedby="additionalInfoHelpBlock">{{.Admin.AdditionalInfo}}</textarea>
<small id="additionalInfoHelpBlock" class="form-text text-muted">
Free form text field
</small>
</div>
</div>
<button type="submit" class="btn btn-primary float-right mt-3 mb-5 px-5 px-3">Submit</button>
</form>
{{end}}

View file

@ -84,6 +84,14 @@
</li>
{{end}}
{{ if .LoggedAdmin.HasPermission "manage_system"}}
<li class="nav-item {{if eq .CurrentURL .MaintenanceURL}}active{{end}}">
<a class="nav-link" href="{{.MaintenanceURL}}">
<i class="fas fa-wrench"></i>
<span>{{.MaintenanceTitle}}</span></a>
</li>
{{end}}
{{ if .LoggedAdmin.HasPermission "view_status"}}
<li class="nav-item {{if eq .CurrentURL .StatusURL}}active{{end}}">
<a class="nav-link" href="{{.StatusURL}}">

View file

@ -3,21 +3,27 @@
{{define "title"}}{{.Title}}{{end}}
{{define "page_body"}}
<h1 class="h5 mb-4 text-gray-800">Add a new folder</h1>
{{if .Error}}
<div class="card mb-4 border-left-warning">
<div class="card-body text-form-error">{{.Error}}</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Add a new folder</h6>
</div>
<div class="card-body">
{{if .Error}}
<div class="card mb-4 border-left-warning">
<div class="card-body text-form-error">{{.Error}}</div>
</div>
{{end}}
<form id="folder_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
<div class="form-group row">
<label for="idMappedPath" class="col-sm-2 col-form-label">Absolute Path</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idMappedPath" name="mapped_path" placeholder=""
value="{{.Folder.MappedPath}}" maxlength="512" autocomplete="nope" required>
</div>
</div>
<button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Submit</button>
</form>
</div>
</div>
{{end}}
<form id="folder_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
<div class="form-group row">
<label for="idMappedPath" class="col-sm-2 col-form-label">Absolute Path</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idMappedPath" name="mapped_path" placeholder=""
value="{{.Folder.MappedPath}}" maxlength="512" autocomplete="nope" required>
</div>
</div>
<button type="submit" class="btn btn-primary float-right mt-3 mb-5 px-5 px-3">Submit</button>
</form>
{{end}}

View file

@ -0,0 +1,64 @@
{{template "base" .}}
{{define "title"}}{{.Title}}{{end}}
{{define "page_body"}}
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Restore</h6>
</div>
<div class="card-body">
{{if .Error}}
<div class="card mb-4 border-left-warning">
<div class="card-body text-form-error">{{.Error}}</div>
</div>
{{end}}
<form id="restore_form" enctype="multipart/form-data" action="{{.RestorePath}}" method="POST">
<div class="form-group row">
<label for="idBackupFile" class="col-sm-2 col-form-label">Backup file</label>
<div class="col-sm-10">
<input type="file" class="form-control-file" id="idBackupFile" name="backup_file"
aria-describedby="BackupFileHelpBlock">
<small id="BackupFileHelpBlock" class="form-text text-muted">
Restore data from a JSON backup file
</small>
</div>
</div>
<div class="form-group row">
<label for="idMode" class="col-sm-2 col-form-label">Mode</label>
<div class="col-sm-10">
<select class="form-control" id="idMode" name="mode">
<option value="1">add only</option>
<option value="0">add and update</option>
<option value="2">add, update and disconnect</option>
</select>
</div>
</div>
<div class="form-group row">
<label for="idQuota" class="col-sm-2 col-form-label">After restore</label>
<div class="col-sm-10">
<select class="form-control" id="idQuota" name="quota">
<option value="0">no quota update</option>
<option value="1">update quota</option>
<option value="2">update quota if the user has quota restrictions</option>
</select>
</div>
</div>
<button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Restore</button>
</form>
</div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Backup</h6>
</div>
<div class="card-body">
<a class="btn btn-primary" href="{{.BackupPath}}?output-data=1" target="_blank">Backup your data</a>
</div>
</div>
{{end}}
{{define "extra_js"}}
{{end}}

File diff suppressed because it is too large Load diff