From 38edda3650e7946af8b72e4a0facac13bf7acc02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Thu, 18 Sep 2025 03:53:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0storage=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 27 ++- go.sum | 101 +++++++++++ pkg/storage/ftp.go | 341 +++++++++++++++++++++++++++++++++++ pkg/storage/local.go | 175 ++++++++++++++++++ pkg/storage/s3.go | 403 ++++++++++++++++++++++++++++++++++++++++++ pkg/storage/sftp.go | 341 +++++++++++++++++++++++++++++++++++ pkg/storage/types.go | 36 ++++ pkg/storage/webdav.go | 212 ++++++++++++++++++++++ 8 files changed, 1635 insertions(+), 1 deletion(-) create mode 100644 pkg/storage/ftp.go create mode 100644 pkg/storage/local.go create mode 100644 pkg/storage/s3.go create mode 100644 pkg/storage/sftp.go create mode 100644 pkg/storage/types.go create mode 100644 pkg/storage/webdav.go diff --git a/go.mod b/go.mod index bb78b9a9..dd1656e1 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,10 @@ module github.com/tnborg/panel go 1.24.0 require ( + github.com/aws/aws-sdk-go-v2 v1.39.0 + github.com/aws/aws-sdk-go-v2/config v1.31.8 + github.com/aws/aws-sdk-go-v2/credentials v1.18.12 + github.com/aws/aws-sdk-go-v2/service/s3 v1.88.1 github.com/bddjr/hlfhr v1.3.8 github.com/beevik/ntp v1.4.3 github.com/creack/pty v1.1.24 @@ -18,6 +22,7 @@ require ( github.com/gookit/validate v1.5.6 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-version v1.7.0 + github.com/jlaffaye/ftp v0.2.0 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/file v1.2.0 github.com/knadh/koanf/v2 v2.3.0 @@ -42,6 +47,7 @@ require ( github.com/ncruces/go-sqlite3 v0.29.0 github.com/ncruces/go-sqlite3/gormlite v0.24.0 github.com/orandin/slog-gorm v1.4.0 + github.com/pkg/sftp v1.13.9 github.com/pquerna/otp v1.5.0 github.com/robfig/cron/v3 v3.0.1 github.com/samber/lo v1.51.0 @@ -49,6 +55,7 @@ require ( github.com/shirou/gopsutil v3.21.11+incompatible github.com/spf13/cast v1.10.0 github.com/stretchr/testify v1.11.1 + github.com/studio-b12/gowebdav v0.11.0 github.com/tufanbarisyildirim/gonginx v0.0.0-20250620092546-c3e307e36701 github.com/urfave/cli/v3 v3.4.1 go.yaml.in/yaml/v3 v3.0.4 @@ -61,6 +68,20 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/G-Core/gcore-dns-sdk-go v0.3.3 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.7 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.7 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.7 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.7 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.4 // indirect + github.com/aws/smithy-go v1.23.0 // indirect github.com/boombuler/barcode v1.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -75,6 +96,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/libtnb/securecookie v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect @@ -95,4 +117,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/mholt/acmez/v3 => github.com/libtnb/acmez/v3 v3.0.0-20250707093727-dc5aedd96413 +replace ( + github.com/jlaffaye/ftp => github.com/devhaozi/ftp v0.0.0-20250917192218-e5bb3e04fadd + github.com/mholt/acmez/v3 => github.com/libtnb/acmez/v3 v3.0.0-20250707093727-dc5aedd96413 +) diff --git a/go.sum b/go.sum index 1f2ded76..d8b61e7f 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,42 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aws/aws-sdk-go-v2 v1.39.0 h1:xm5WV/2L4emMRmMjHFykqiA4M/ra0DJVSWUkDyBjbg4= +github.com/aws/aws-sdk-go-v2 v1.39.0/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00= +github.com/aws/aws-sdk-go-v2/config v1.31.8 h1:kQjtOLlTU4m4A64TsRcqwNChhGCwaPBt+zCQt/oWsHU= +github.com/aws/aws-sdk-go-v2/config v1.31.8/go.mod h1:QPpc7IgljrKwH0+E6/KolCgr4WPLerURiU592AYzfSY= +github.com/aws/aws-sdk-go-v2/credentials v1.18.12 h1:zmc9e1q90wMn8wQbjryy8IwA6Q4XlaL9Bx2zIqdNNbk= +github.com/aws/aws-sdk-go-v2/credentials v1.18.12/go.mod h1:3VzdRDR5u3sSJRI4kYcOSIBbeYsgtVk7dG5R/U6qLWY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.7 h1:Is2tPmieqGS2edBnmOJIbdvOA6Op+rRpaYR60iBAwXM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.7/go.mod h1:F1i5V5421EGci570yABvpIXgRIBPb5JM+lSkHF6Dq5w= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.7 h1:UCxq0X9O3xrlENdKf1r9eRJoKz/b0AfGkpp3a7FPlhg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.7/go.mod h1:rHRoJUNUASj5Z/0eqI4w32vKvC7atoWR0jC+IkmVH8k= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.7 h1:Y6DTZUn7ZUC4th9FMBbo8LVE+1fyq3ofw+tRwkUd3PY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.7/go.mod h1:x3XE6vMnU9QvHN/Wrx2s44kwzV2o2g5x/siw4ZUJ9g8= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.7 h1:BszAktdUo2xlzmYHjWMq70DqJ7cROM8iBd3f6hrpuMQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.7/go.mod h1:XJ1yHki/P7ZPuG4fd3f0Pg/dSGA2cTQBCLw82MH2H48= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.7 h1:zmZ8qvtE9chfhBPuKB2aQFxW5F/rpwXUgmcVCgQzqRw= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.7/go.mod h1:vVYfbpd2l+pKqlSIDIOgouxNsGu5il9uDp0ooWb0jys= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.7 h1:mLgc5QIgOy26qyh5bvW+nDoAppxgn3J2WV3m9ewq7+8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.7/go.mod h1:wXb/eQnqt8mDQIQTTmcw58B5mYGxzLGZGK8PWNFZ0BA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.7 h1:u3VbDKUCWarWiU+aIUK4gjTr/wQFXV17y3hgNno9fcA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.7/go.mod h1:/OuMQwhSyRapYxq6ZNpPer8juGNrB4P5Oz8bZ2cgjQE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.88.1 h1:+RpGuaQ72qnU83qBKVwxkznewEdAGhIWo/PQCmkhhog= +github.com/aws/aws-sdk-go-v2/service/s3 v1.88.1/go.mod h1:xajPTguLoeQMAOE44AAP2RQoUhF8ey1g5IFHARv71po= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.3 h1:7PKX3VYsZ8LUWceVRuv0+PU+E7OtQb1lgmi5vmUE9CM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.3/go.mod h1:Ql6jE9kyyWI5JHn+61UT/Y5Z0oyVJGmgmJbZD5g4unY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.4 h1:e0XBRn3AptQotkyBFrHAxFB8mDhAIOfsG+7KyJ0dg98= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.4/go.mod h1:XclEty74bsGBCr1s0VSaA11hQ4ZidK4viWK7rRfO88I= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.4 h1:PR00NXRYgY4FWHqOGx3fC3lhVKjsp1GdloDv2ynMSd8= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.4/go.mod h1:Z+Gd23v97pX9zK97+tX4ppAgqCt3Z2dIXB02CtBncK8= +github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= +github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/bddjr/hlfhr v1.3.8 h1:QQ6KYgtnBbvYvCWuu/tOnBZamKAPtJzesj2qbjgyn7o= github.com/bddjr/hlfhr v1.3.8/go.mod h1:oyIv4Q9JpCgZFdtH3KyTNWp7YYRWl4zl8k4ozrMAB4g= github.com/beevik/ntp v1.4.3 h1:PlbTvE5NNy4QHmA4Mg57n7mcFTmr1W1j3gcK7L1lqho= @@ -47,6 +83,8 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/devhaozi/ftp v0.0.0-20250917192218-e5bb3e04fadd h1:+ER2PTV0WcSIr2UJhGDLdxy7RaWvOML0PFsCRJMzXS8= +github.com/devhaozi/ftp v0.0.0-20250917192218-e5bb3e04fadd/go.mod h1:zuLAKdqFqFvNgkCrH0SC7K1XyUiydS7BFCmmoHUWWg0= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec= @@ -176,6 +214,7 @@ github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM= github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 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= @@ -258,6 +297,8 @@ github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/9 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= +github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= +github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= 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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= @@ -305,14 +346,19 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/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.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/studio-b12/gowebdav v0.11.0 h1:qbQzq4USxY28ZYsGJUfO5jR+xkFtcnwWgitp4Zp1irU= +github.com/studio-b12/gowebdav v0.11.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= @@ -336,6 +382,7 @@ github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZ github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/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.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= @@ -352,6 +399,11 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -374,6 +426,11 @@ golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +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.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +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-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -388,6 +445,13 @@ golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -398,6 +462,12 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -414,15 +484,41 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/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.1.0/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.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.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +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.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.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.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -446,6 +542,11 @@ golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +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.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= diff --git a/pkg/storage/ftp.go b/pkg/storage/ftp.go new file mode 100644 index 00000000..12850a35 --- /dev/null +++ b/pkg/storage/ftp.go @@ -0,0 +1,341 @@ +package storage + +import ( + "bytes" + "fmt" + "io" + "mime" + "path/filepath" + "strings" + "time" + + "github.com/jlaffaye/ftp" +) + +type FTPConfig struct { + Host string // FTP 服务器地址 + Port int // FTP 端口,默认 21 + Username string // 用户名 + Password string // 密码 + BasePath string // 基础路径 +} + +type FTP struct { + config FTPConfig +} + +func NewFTP(config FTPConfig) (Storage, error) { + if config.Port == 0 { + config.Port = 21 + } + config.BasePath = strings.Trim(config.BasePath, "/") + + f := &FTP{ + config: config, + } + + if err := f.ensureBasePath(); err != nil { + return nil, fmt.Errorf("failed to ensure base path: %w", err) + } + + return f, nil +} + +// connect 建立 FTP 连接 +func (f *FTP) connect() (*ftp.ServerConn, error) { + addr := fmt.Sprintf("%s:%d", f.config.Host, f.config.Port) + conn, err := ftp.Dial(addr) + if err != nil { + return nil, err + } + + err = conn.Login(f.config.Username, f.config.Password) + if err != nil { + conn.Quit() + return nil, err + } + + return conn, nil +} + +// ensureBasePath 确保基础路径存在 +func (f *FTP) ensureBasePath() error { + conn, err := f.connect() + if err != nil { + return err + } + defer conn.Quit() + + // 递归创建路径 + parts := strings.Split(f.config.BasePath, "/") + currentPath := "" + + for _, part := range parts { + if part == "" { + continue + } + + if currentPath == "" { + currentPath = part + } else { + currentPath = currentPath + "/" + part + } + + _ = conn.MakeDir(currentPath) + } + + return nil +} + +// getRemotePath 获取远程路径 +func (f *FTP) getRemotePath(path string) string { + path = strings.TrimPrefix(path, "/") + if f.config.BasePath == "" { + return path + } + if path == "" { + return f.config.BasePath + } + return fmt.Sprintf("%s/%s", f.config.BasePath, path) +} + +// MakeDirectory 创建目录 +func (f *FTP) MakeDirectory(directory string) error { + conn, err := f.connect() + if err != nil { + return err + } + defer conn.Quit() + + remotePath := f.getRemotePath(directory) + + // 递归创建目录 + parts := strings.Split(remotePath, "/") + currentPath := "" + + for _, part := range parts { + if part == "" { + continue + } + + if currentPath == "" { + currentPath = part + } else { + currentPath = currentPath + "/" + part + } + + // 尝试创建目录 + _ = conn.MakeDir(currentPath) + } + + return nil +} + +// DeleteDirectory 删除目录 +func (f *FTP) DeleteDirectory(directory string) error { + conn, err := f.connect() + if err != nil { + return err + } + defer conn.Quit() + + remotePath := f.getRemotePath(directory) + return conn.RemoveDir(remotePath) +} + +// Copy 复制文件到新位置 +func (f *FTP) Copy(oldFile, newFile string) error { + // FTP 不支持直接复制,需要下载再上传 + data, err := f.Get(oldFile) + if err != nil { + return err + } + return f.Put(newFile, string(data)) +} + +// Delete 删除文件 +func (f *FTP) Delete(files ...string) error { + conn, err := f.connect() + if err != nil { + return err + } + defer conn.Quit() + + for _, file := range files { + remotePath := f.getRemotePath(file) + if err := conn.Delete(remotePath); err != nil { + return err + } + } + return nil +} + +// Exists 检查文件是否存在 +func (f *FTP) Exists(file string) bool { + conn, err := f.connect() + if err != nil { + return false + } + defer conn.Quit() + + remotePath := f.getRemotePath(file) + _, err = conn.FileSize(remotePath) + return err == nil +} + +// Files 获取目录下的所有文件 +func (f *FTP) Files(path string) ([]string, error) { + conn, err := f.connect() + if err != nil { + return nil, err + } + defer conn.Quit() + + remotePath := f.getRemotePath(path) + entries, err := conn.List(remotePath) + if err != nil { + return nil, err + } + + var files []string + for _, entry := range entries { + if entry.Type == ftp.EntryTypeFile { + files = append(files, entry.Name) + } + } + + return files, nil +} + +// Get 读取文件内容 +func (f *FTP) Get(file string) ([]byte, error) { + conn, err := f.connect() + if err != nil { + return nil, err + } + defer conn.Quit() + + remotePath := f.getRemotePath(file) + resp, err := conn.Retr(remotePath) + if err != nil { + return nil, err + } + defer resp.Close() + + return io.ReadAll(resp) +} + +// LastModified 获取文件最后修改时间 +func (f *FTP) LastModified(file string) (time.Time, error) { + conn, err := f.connect() + if err != nil { + return time.Time{}, err + } + defer conn.Quit() + + remotePath := f.getRemotePath(file) + entries, err := conn.List(filepath.Dir(remotePath)) + if err != nil { + return time.Time{}, err + } + + fileName := filepath.Base(remotePath) + for _, entry := range entries { + if entry.Name == fileName { + return entry.Time, nil + } + } + + return time.Time{}, fmt.Errorf("file not found: %s", file) +} + +// MimeType 获取文件的 MIME 类型 +func (f *FTP) MimeType(file string) (string, error) { + ext := filepath.Ext(file) + mimeType := mime.TypeByExtension(ext) + if mimeType == "" { + return "application/octet-stream", nil + } + return mimeType, nil +} + +// Missing 检查文件是否不存在 +func (f *FTP) Missing(file string) bool { + return !f.Exists(file) +} + +// Move 移动文件到新位置 +func (f *FTP) Move(oldFile, newFile string) error { + conn, err := f.connect() + if err != nil { + return err + } + defer conn.Quit() + + oldPath := f.getRemotePath(oldFile) + newPath := f.getRemotePath(newFile) + + // 确保目标目录存在 + newDir := filepath.Dir(newPath) + if newDir != "." { + f.createDirectoryPath(conn, newDir) + } + + return conn.Rename(oldPath, newPath) +} + +// createDirectoryPath 递归创建目录路径 +func (f *FTP) createDirectoryPath(conn *ftp.ServerConn, path string) { + parts := strings.Split(path, "/") + currentPath := "" + + for _, part := range parts { + if part == "" { + continue + } + + if currentPath == "" { + currentPath = part + } else { + currentPath = currentPath + "/" + part + } + + _ = conn.MakeDir(currentPath) + } +} + +// Path 获取文件的完整路径 +func (f *FTP) Path(file string) string { + return fmt.Sprintf("ftp://%s:%d/%s", f.config.Host, f.config.Port, f.getRemotePath(file)) +} + +// Put 写入文件内容 +func (f *FTP) Put(file, content string) error { + conn, err := f.connect() + if err != nil { + return err + } + defer conn.Quit() + + remotePath := f.getRemotePath(file) + + // 确保目录存在 + remoteDir := filepath.Dir(remotePath) + if remoteDir != "." { + f.createDirectoryPath(conn, remoteDir) + } + + return conn.Stor(remotePath, bytes.NewReader([]byte(content))) +} + +// Size 获取文件大小 +func (f *FTP) Size(file string) (int64, error) { + conn, err := f.connect() + if err != nil { + return 0, err + } + defer conn.Quit() + + remotePath := f.getRemotePath(file) + return conn.FileSize(remotePath) +} diff --git a/pkg/storage/local.go b/pkg/storage/local.go new file mode 100644 index 00000000..15544f16 --- /dev/null +++ b/pkg/storage/local.go @@ -0,0 +1,175 @@ +package storage + +import ( + "io" + "mime" + "os" + "path/filepath" + "time" +) + +type Local struct { + basePath string +} + +func NewLocal(basePath string) Storage { + if basePath == "" { + basePath = "/" + } + return &Local{ + basePath: basePath, + } +} + +// MakeDirectory 创建目录 +func (n *Local) MakeDirectory(directory string) error { + fullPath := n.fullPath(directory) + return os.MkdirAll(fullPath, 0755) +} + +// DeleteDirectory 删除目录 +func (n *Local) DeleteDirectory(directory string) error { + fullPath := n.fullPath(directory) + return os.RemoveAll(fullPath) +} + +// Copy 复制文件到新位置 +func (n *Local) Copy(oldFile, newFile string) error { + srcPath := n.fullPath(oldFile) + dstPath := n.fullPath(newFile) + + // 确保目标目录存在 + if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil { + return err + } + + src, err := os.Open(srcPath) + if err != nil { + return err + } + defer func() { _ = src.Close() }() + + dst, err := os.Create(dstPath) + if err != nil { + return err + } + defer func() { _ = dst.Close() }() + + _, err = io.Copy(dst, src) + return err +} + +// Delete 删除文件 +func (n *Local) Delete(files ...string) error { + for _, file := range files { + fullPath := n.fullPath(file) + if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +// Exists 检查文件是否存在 +func (n *Local) Exists(file string) bool { + fullPath := n.fullPath(file) + _, err := os.Stat(fullPath) + return !os.IsNotExist(err) +} + +// Files 获取目录下的所有文件 +func (n *Local) Files(path string) ([]string, error) { + fullPath := n.fullPath(path) + entries, err := os.ReadDir(fullPath) + if err != nil { + return nil, err + } + + var files []string + for _, entry := range entries { + if !entry.IsDir() { + files = append(files, entry.Name()) + } + } + + return files, nil +} + +// Get 读取文件内容 +func (n *Local) Get(file string) ([]byte, error) { + fullPath := n.fullPath(file) + return os.ReadFile(fullPath) +} + +// LastModified 获取文件最后修改时间 +func (n *Local) LastModified(file string) (time.Time, error) { + fullPath := n.fullPath(file) + info, err := os.Stat(fullPath) + if err != nil { + return time.Time{}, err + } + return info.ModTime(), nil +} + +// MimeType 获取文件的 MIME 类型 +func (n *Local) MimeType(file string) (string, error) { + ext := filepath.Ext(file) + mimeType := mime.TypeByExtension(ext) + if mimeType == "" { + return "application/octet-stream", nil + } + return mimeType, nil +} + +// Missing 检查文件是否不存在 +func (n *Local) Missing(file string) bool { + return !n.Exists(file) +} + +// Move 移动文件到新位置 +func (n *Local) Move(oldFile, newFile string) error { + oldPath := n.fullPath(oldFile) + newPath := n.fullPath(newFile) + + // 确保目标目录存在 + if err := os.MkdirAll(filepath.Dir(newPath), 0755); err != nil { + return err + } + + return os.Rename(oldPath, newPath) +} + +// Path 获取文件的完整路径 +func (n *Local) Path(file string) string { + return n.fullPath(file) +} + +// Put 写入文件内容 +func (n *Local) Put(file, content string) error { + fullPath := n.fullPath(file) + + // 确保目录存在 + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + return err + } + + return os.WriteFile(fullPath, []byte(content), 0644) +} + +// Size 获取文件大小 +func (n *Local) Size(file string) (int64, error) { + fullPath := n.fullPath(file) + info, err := os.Stat(fullPath) + if err != nil { + return 0, err + } + return info.Size(), nil +} + +// fullPath 获取文件的完整路径 +func (n *Local) fullPath(file string) string { + if filepath.IsAbs(file) { + return file + } + return filepath.Join(n.basePath, file) +} diff --git a/pkg/storage/s3.go b/pkg/storage/s3.go new file mode 100644 index 00000000..9d48471b --- /dev/null +++ b/pkg/storage/s3.go @@ -0,0 +1,403 @@ +package storage + +import ( + "bytes" + "context" + "fmt" + "io" + "mime" + "path/filepath" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +// S3AddressingStyle S3 地址模式 +type S3AddressingStyle string + +const ( + // S3AddressingStylePath Path 模式:https://s3.region.amazonaws.com/bucket/key + S3AddressingStylePath S3AddressingStyle = "path" + // S3AddressingStyleVirtualHosted Virtual Hosted 模式:https://bucket.s3.region.amazonaws.com/key + S3AddressingStyleVirtualHosted S3AddressingStyle = "virtual-hosted" +) + +type S3Config struct { + Region string // AWS 区域 + Bucket string // S3 存储桶名称 + AccessKeyID string // 访问密钥 ID + SecretAccessKey string // 访问密钥 + Endpoint string // 自定义端点(如 MinIO) + BasePath string // 基础路径前缀 + AddressingStyle S3AddressingStyle // 地址模式 + ForcePathStyle bool // 强制使用 Path 模式(兼容旧版本) +} + +type S3 struct { + client *s3.Client + config S3Config +} + +func NewS3(cfg S3Config) (Storage, error) { + // 设置默认地址模式 + if cfg.AddressingStyle == "" { + if cfg.ForcePathStyle { + cfg.AddressingStyle = S3AddressingStylePath + } else { + cfg.AddressingStyle = S3AddressingStyleVirtualHosted + } + } + + cfg.BasePath = strings.Trim(cfg.BasePath, "/") + + var awsCfg aws.Config + var err error + + if cfg.Endpoint != "" { + // 自定义端点(如 MinIO) + awsCfg, err = config.LoadDefaultConfig(context.TODO(), + config.WithRegion(cfg.Region), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + cfg.AccessKeyID, cfg.SecretAccessKey, "")), + config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc( + func(service, region string, options ...interface{}) (aws.Endpoint, error) { + return aws.Endpoint{ + URL: cfg.Endpoint, + SigningRegion: cfg.Region, + }, nil + })), + ) + } else { + // 标准 AWS S3 + awsCfg, err = config.LoadDefaultConfig(context.TODO(), + config.WithRegion(cfg.Region), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + cfg.AccessKeyID, cfg.SecretAccessKey, "")), + ) + } + + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + + // 根据地址模式配置客户端 + usePathStyle := cfg.AddressingStyle == S3AddressingStylePath || cfg.ForcePathStyle + client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.UsePathStyle = usePathStyle + }) + + s := &S3{ + client: client, + config: cfg, + } + + if s.config.BasePath != "" { + if err := s.ensureBasePath(); err != nil { + return nil, fmt.Errorf("failed to ensure base path: %w", err) + } + } + + return s, nil +} + +// ensureBasePath 确保基础路径存在 +func (s *S3) ensureBasePath() error { + key := s.config.BasePath + "/" + _, err := s.client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(s.config.BasePath), + Key: aws.String(key), + Body: bytes.NewReader([]byte{}), + }) + return err +} + +// getKey 获取完整的对象键 +func (s *S3) getKey(file string) string { + file = strings.TrimPrefix(file, "/") + if s.config.BasePath == "" { + return file + } + if file == "" { + return s.config.BasePath + } + return fmt.Sprintf("%s/%s", s.config.BasePath, file) +} + +// MakeDirectory 创建目录(S3中实际创建一个空的目录标记对象) +func (s *S3) MakeDirectory(directory string) error { + key := s.getKey(directory) + if !strings.HasSuffix(key, "/") { + key += "/" + } + + _, err := s.client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(s.config.Bucket), + Key: aws.String(key), + Body: bytes.NewReader([]byte{}), + }) + + return err +} + +// DeleteDirectory 删除目录 +func (s *S3) DeleteDirectory(directory string) error { + prefix := s.getKey(directory) + if prefix != "" && !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + + // 列出所有文件 + var objects []types.ObjectIdentifier + paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{ + Bucket: aws.String(s.config.Bucket), + Prefix: aws.String(prefix), + }) + + for paginator.HasMorePages() { + output, err := paginator.NextPage(context.TODO()) + if err != nil { + return err + } + + for _, obj := range output.Contents { + if obj.Key != nil { + objects = append(objects, types.ObjectIdentifier{ + Key: obj.Key, + }) + } + } + } + + if len(objects) == 0 { + return nil + } + + // 批量删除 + _, err := s.client.DeleteObjects(context.TODO(), &s3.DeleteObjectsInput{ + Bucket: aws.String(s.config.Bucket), + Delete: &types.Delete{ + Objects: objects, + }, + }) + + return err +} + +// Copy 复制文件到新位置 +func (s *S3) Copy(oldFile, newFile string) error { + sourceKey := s.getKey(oldFile) + destKey := s.getKey(newFile) + + _, err := s.client.CopyObject(context.TODO(), &s3.CopyObjectInput{ + Bucket: aws.String(s.config.Bucket), + CopySource: aws.String(fmt.Sprintf("%s/%s", s.config.Bucket, sourceKey)), + Key: aws.String(destKey), + }) + + return err +} + +// Delete 删除文件 +func (s *S3) Delete(files ...string) error { + if len(files) == 0 { + return nil + } + + // 批量删除 + var objects []types.ObjectIdentifier + for _, file := range files { + key := s.getKey(file) + objects = append(objects, types.ObjectIdentifier{ + Key: aws.String(key), + }) + } + + _, err := s.client.DeleteObjects(context.TODO(), &s3.DeleteObjectsInput{ + Bucket: aws.String(s.config.Bucket), + Delete: &types.Delete{ + Objects: objects, + }, + }) + + return err +} + +// Exists 检查文件是否存在 +func (s *S3) Exists(file string) bool { + key := s.getKey(file) + _, err := s.client.HeadObject(context.TODO(), &s3.HeadObjectInput{ + Bucket: aws.String(s.config.Bucket), + Key: aws.String(key), + }) + return err == nil +} + +// Files 获取目录下的所有文件 +func (s *S3) Files(path string) ([]string, error) { + prefix := s.getKey(path) + if prefix != "" && !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + + var files []string + paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{ + Bucket: aws.String(s.config.Bucket), + Prefix: aws.String(prefix), + Delimiter: aws.String("/"), + }) + + for paginator.HasMorePages() { + output, err := paginator.NextPage(context.TODO()) + if err != nil { + return nil, err + } + + for _, obj := range output.Contents { + if obj.Key != nil && !strings.HasSuffix(*obj.Key, "/") { + fileName := strings.TrimPrefix(*obj.Key, prefix) + if fileName != "" && !strings.Contains(fileName, "/") { + files = append(files, fileName) + } + } + } + } + + return files, nil +} + +// Get 读取文件内容 +func (s *S3) Get(file string) ([]byte, error) { + key := s.getKey(file) + output, err := s.client.GetObject(context.TODO(), &s3.GetObjectInput{ + Bucket: aws.String(s.config.Bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, err + } + defer output.Body.Close() + + return io.ReadAll(output.Body) +} + +// LastModified 获取文件最后修改时间 +func (s *S3) LastModified(file string) (time.Time, error) { + key := s.getKey(file) + output, err := s.client.HeadObject(context.TODO(), &s3.HeadObjectInput{ + Bucket: aws.String(s.config.Bucket), + Key: aws.String(key), + }) + if err != nil { + return time.Time{}, err + } + + if output.LastModified != nil { + return *output.LastModified, nil + } + return time.Time{}, nil +} + +// MimeType 获取文件的 MIME 类型 +func (s *S3) MimeType(file string) (string, error) { + key := s.getKey(file) + output, err := s.client.HeadObject(context.TODO(), &s3.HeadObjectInput{ + Bucket: aws.String(s.config.Bucket), + Key: aws.String(key), + }) + if err != nil { + return "", err + } + + if output.ContentType != nil { + return *output.ContentType, nil + } + + // 根据文件扩展名推断 + ext := filepath.Ext(file) + mimeType := mime.TypeByExtension(ext) + if mimeType == "" { + return "application/octet-stream", nil + } + return mimeType, nil +} + +// Missing 检查文件是否不存在 +func (s *S3) Missing(file string) bool { + return !s.Exists(file) +} + +// Move 移动文件到新位置 +func (s *S3) Move(oldFile, newFile string) error { + // 先复制 + if err := s.Copy(oldFile, newFile); err != nil { + return err + } + // 再删除原文件 + return s.Delete(oldFile) +} + +// Path 获取文件的完整路径 +func (s *S3) Path(file string) string { + // 根据地址模式返回不同的 URL 格式 + key := s.getKey(file) + + if s.config.Endpoint != "" { + // 自定义端点 + return fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(s.config.Endpoint, "/"), s.config.Bucket, key) + } + + switch s.config.AddressingStyle { + case S3AddressingStyleVirtualHosted: + // Virtual Hosted 模式:https://bucket.s3.region.amazonaws.com/key + return fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", s.config.Bucket, s.config.Region, key) + case S3AddressingStylePath: + // Path 模式:https://s3.region.amazonaws.com/bucket/key + return fmt.Sprintf("https://s3.%s.amazonaws.com/%s/%s", s.config.Region, s.config.Bucket, key) + default: + // 默认返回 s3:// 协议格式 + return fmt.Sprintf("s3://%s/%s", s.config.Bucket, key) + } +} + +// Put 写入文件内容 +func (s *S3) Put(file, content string) error { + key := s.getKey(file) + + // 推断 MIME 类型 + ext := filepath.Ext(file) + contentType := mime.TypeByExtension(ext) + if contentType == "" { + contentType = "application/octet-stream" + } + + _, err := s.client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(s.config.Bucket), + Key: aws.String(key), + Body: bytes.NewReader([]byte(content)), + ContentType: aws.String(contentType), + }) + + return err +} + +// Size 获取文件大小 +func (s *S3) Size(file string) (int64, error) { + key := s.getKey(file) + output, err := s.client.HeadObject(context.TODO(), &s3.HeadObjectInput{ + Bucket: aws.String(s.config.Bucket), + Key: aws.String(key), + }) + if err != nil { + return 0, err + } + + if output.ContentLength != nil { + return *output.ContentLength, nil + } + return 0, nil +} diff --git a/pkg/storage/sftp.go b/pkg/storage/sftp.go new file mode 100644 index 00000000..204bc941 --- /dev/null +++ b/pkg/storage/sftp.go @@ -0,0 +1,341 @@ +package storage + +import ( + "bytes" + "fmt" + "io" + "mime" + "os" + "path/filepath" + "strings" + "time" + + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" +) + +type SFTPConfig struct { + Host string // SFTP 服务器地址 + Port int // SFTP 端口,默认 22 + Username string // 用户名 + Password string // 密码 + PrivateKey string // SSH 私钥路径或内容 + BasePath string // 基础路径 + Timeout time.Duration // 连接超时时间 +} + +type SFTP struct { + config SFTPConfig +} + +func NewSFTP(config SFTPConfig) (Storage, error) { + if config.Port == 0 { + config.Port = 22 + } + if config.Timeout == 0 { + config.Timeout = 30 * time.Second + } + config.BasePath = strings.Trim(config.BasePath, "/") + + s := &SFTP{ + config: config, + } + + if err := s.ensureBasePath(); err != nil { + return nil, fmt.Errorf("failed to ensure base path: %w", err) + } + + return s, nil +} + +// connect 建立 SFTP 连接 +func (s *SFTP) connect() (*sftp.Client, func(), error) { + var auth []ssh.AuthMethod + + // 密码认证 + if s.config.Password != "" { + auth = append(auth, ssh.Password(s.config.Password)) + } + + // 私钥认证 + if s.config.PrivateKey != "" { + var signer ssh.Signer + var err error + + if _, statErr := os.Stat(s.config.PrivateKey); statErr == nil { + // 私钥文件路径 + keyBytes, err := os.ReadFile(s.config.PrivateKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to read private key file: %w", err) + } + signer, err = ssh.ParsePrivateKey(keyBytes) + } else { + // 私钥内容 + signer, err = ssh.ParsePrivateKey([]byte(s.config.PrivateKey)) + } + + if err != nil { + return nil, nil, fmt.Errorf("failed to parse private key: %w", err) + } + auth = append(auth, ssh.PublicKeys(signer)) + } + + clientConfig := &ssh.ClientConfig{ + User: s.config.Username, + Auth: auth, + Timeout: s.config.Timeout, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port) + sshClient, err := ssh.Dial("tcp", addr, clientConfig) + if err != nil { + return nil, nil, err + } + + sftpClient, err := sftp.NewClient(sshClient) + if err != nil { + _ = sshClient.Close() + return nil, nil, err + } + + cleanup := func() { + _ = sftpClient.Close() + _ = sshClient.Close() + } + + return sftpClient, cleanup, nil +} + +// ensureBasePath 确保基础路径存在 +func (s *SFTP) ensureBasePath() error { + if s.config.BasePath == "" { + return nil + } + + client, cleanup, err := s.connect() + if err != nil { + return err + } + defer cleanup() + + return client.MkdirAll(s.config.BasePath) +} + +// getRemotePath 获取远程路径 +func (s *SFTP) getRemotePath(path string) string { + path = strings.TrimPrefix(path, "/") + if s.config.BasePath == "" { + return path + } + if path == "" { + return s.config.BasePath + } + return filepath.Join(s.config.BasePath, path) +} + +// MakeDirectory 创建目录 +func (s *SFTP) MakeDirectory(directory string) error { + client, cleanup, err := s.connect() + if err != nil { + return err + } + defer cleanup() + + remotePath := s.getRemotePath(directory) + return client.MkdirAll(remotePath) +} + +// DeleteDirectory 删除目录 +func (s *SFTP) DeleteDirectory(directory string) error { + client, cleanup, err := s.connect() + if err != nil { + return err + } + defer cleanup() + + remotePath := s.getRemotePath(directory) + return client.RemoveDirectory(remotePath) +} + +// Copy 复制文件到新位置 +func (s *SFTP) Copy(oldFile, newFile string) error { + // SFTP 不支持直接复制,需要读取再写入 + data, err := s.Get(oldFile) + if err != nil { + return err + } + return s.Put(newFile, string(data)) +} + +// Delete 删除文件 +func (s *SFTP) Delete(files ...string) error { + client, cleanup, err := s.connect() + if err != nil { + return err + } + defer cleanup() + + for _, file := range files { + remotePath := s.getRemotePath(file) + if err := client.Remove(remotePath); err != nil { + return err + } + } + return nil +} + +// Exists 检查文件是否存在 +func (s *SFTP) Exists(file string) bool { + client, cleanup, err := s.connect() + if err != nil { + return false + } + defer cleanup() + + remotePath := s.getRemotePath(file) + _, err = client.Stat(remotePath) + return err == nil +} + +// Files 获取目录下的所有文件 +func (s *SFTP) Files(path string) ([]string, error) { + client, cleanup, err := s.connect() + if err != nil { + return nil, err + } + defer cleanup() + + remotePath := s.getRemotePath(path) + entries, err := client.ReadDir(remotePath) + if err != nil { + return nil, err + } + + var files []string + for _, entry := range entries { + if !entry.IsDir() { + files = append(files, entry.Name()) + } + } + + return files, nil +} + +// Get 读取文件内容 +func (s *SFTP) Get(file string) ([]byte, error) { + client, cleanup, err := s.connect() + if err != nil { + return nil, err + } + defer cleanup() + + remotePath := s.getRemotePath(file) + remoteFile, err := client.Open(remotePath) + if err != nil { + return nil, err + } + defer func() { _ = remoteFile.Close() }() + + return io.ReadAll(remoteFile) +} + +// LastModified 获取文件最后修改时间 +func (s *SFTP) LastModified(file string) (time.Time, error) { + client, cleanup, err := s.connect() + if err != nil { + return time.Time{}, err + } + defer cleanup() + + remotePath := s.getRemotePath(file) + stat, err := client.Stat(remotePath) + if err != nil { + return time.Time{}, err + } + + return stat.ModTime(), nil +} + +// MimeType 获取文件的 MIME 类型 +func (s *SFTP) MimeType(file string) (string, error) { + ext := filepath.Ext(file) + mimeType := mime.TypeByExtension(ext) + if mimeType == "" { + return "application/octet-stream", nil + } + return mimeType, nil +} + +// Missing 检查文件是否不存在 +func (s *SFTP) Missing(file string) bool { + return !s.Exists(file) +} + +// Move 移动文件到新位置 +func (s *SFTP) Move(oldFile, newFile string) error { + client, cleanup, err := s.connect() + if err != nil { + return err + } + defer cleanup() + + oldPath := s.getRemotePath(oldFile) + newPath := s.getRemotePath(newFile) + + // 确保目标目录存在 + newDir := filepath.Dir(newPath) + if newDir != "." { + _ = client.MkdirAll(newDir) + } + + return client.Rename(oldPath, newPath) +} + +// Path 获取文件的完整路径 +func (s *SFTP) Path(file string) string { + return fmt.Sprintf("sftp://%s:%d/%s", s.config.Host, s.config.Port, s.getRemotePath(file)) +} + +// Put 写入文件内容 +func (s *SFTP) Put(file, content string) error { + client, cleanup, err := s.connect() + if err != nil { + return err + } + defer cleanup() + + remotePath := s.getRemotePath(file) + + // 确保目录存在 + remoteDir := filepath.Dir(remotePath) + if remoteDir != "." { + _ = client.MkdirAll(remoteDir) + } + + remoteFile, err := client.Create(remotePath) + if err != nil { + return err + } + defer func() { _ = remoteFile.Close() }() + + _, err = io.Copy(remoteFile, bytes.NewReader([]byte(content))) + return err +} + +// Size 获取文件大小 +func (s *SFTP) Size(file string) (int64, error) { + client, cleanup, err := s.connect() + if err != nil { + return 0, err + } + defer cleanup() + + remotePath := s.getRemotePath(file) + stat, err := client.Stat(remotePath) + if err != nil { + return 0, err + } + + return stat.Size(), nil +} diff --git a/pkg/storage/types.go b/pkg/storage/types.go new file mode 100644 index 00000000..c2ea8650 --- /dev/null +++ b/pkg/storage/types.go @@ -0,0 +1,36 @@ +package storage + +import ( + "time" +) + +type Storage interface { + // MakeDirectory creates a directory. + MakeDirectory(directory string) error + // DeleteDirectory deletes the given directory. + DeleteDirectory(directory string) error + // Copy the given file to a new location. + Copy(oldFile, newFile string) error + // Delete deletes the given file(s). + Delete(file ...string) error + // Exists determines if a file exists. + Exists(file string) bool + // Files gets all the files from the given directory. + Files(path string) ([]string, error) + // Get gets the contents of a file. + Get(file string) ([]byte, error) + // LastModified gets the file's last modified time. + LastModified(file string) (time.Time, error) + // MimeType gets the file's mime type. + MimeType(file string) (string, error) + // Missing determines if a file is missing. + Missing(file string) bool + // Move a file to a new location. + Move(oldFile, newFile string) error + // Path gets the full path for the file. + Path(file string) string + // Put writes the contents of a file. + Put(file, content string) error + // Size gets the file size of a given file. + Size(file string) (int64, error) +} diff --git a/pkg/storage/webdav.go b/pkg/storage/webdav.go new file mode 100644 index 00000000..0ca60185 --- /dev/null +++ b/pkg/storage/webdav.go @@ -0,0 +1,212 @@ +package storage + +import ( + "bytes" + "fmt" + "io" + "mime" + "path/filepath" + "strings" + "time" + + "github.com/studio-b12/gowebdav" +) + +type WebDavConfig struct { + URL string // WebDAV 服务器 URL + Username string // 用户名 + Password string // 密码 + BasePath string // 基础路径 + Timeout time.Duration // 连接超时时间 +} + +type WebDav struct { + client *gowebdav.Client + config WebDavConfig +} + +func NewWebDav(config WebDavConfig) (Storage, error) { + if config.Timeout == 0 { + config.Timeout = 30 * time.Second + } + config.BasePath = strings.Trim(config.BasePath, "/") + + client := gowebdav.NewClient(config.URL, config.Username, config.Password) + client.SetTimeout(config.Timeout) + + w := &WebDav{ + client: client, + config: config, + } + + if err := w.ensureBasePath(); err != nil { + return nil, fmt.Errorf("failed to ensure base path: %w", err) + } + + return w, nil +} + +// ensureBasePath 确保基础路径存在 +func (w *WebDav) ensureBasePath() error { + if w.config.BasePath == "" { + return nil + } + + return w.client.MkdirAll(w.config.BasePath, 0755) +} + +// getRemotePath 获取远程路径 +func (w *WebDav) getRemotePath(path string) string { + path = strings.TrimPrefix(path, "/") + if w.config.BasePath == "" { + return path + } + if path == "" { + return w.config.BasePath + } + return filepath.Join(w.config.BasePath, path) +} + +// MakeDirectory 创建目录 +func (w *WebDav) MakeDirectory(directory string) error { + remotePath := w.getRemotePath(directory) + return w.client.MkdirAll(remotePath, 0755) +} + +// DeleteDirectory 删除目录 +func (w *WebDav) DeleteDirectory(directory string) error { + remotePath := w.getRemotePath(directory) + return w.client.RemoveAll(remotePath) +} + +// Copy 复制文件到新位置 +func (w *WebDav) Copy(oldFile, newFile string) error { + oldPath := w.getRemotePath(oldFile) + newPath := w.getRemotePath(newFile) + + // 确保目标目录存在 + newDir := filepath.Dir(newPath) + if newDir != "." { + _ = w.client.MkdirAll(newDir, 0755) + } + + return w.client.Copy(oldPath, newPath, false) +} + +// Delete 删除文件 +func (w *WebDav) Delete(files ...string) error { + for _, file := range files { + remotePath := w.getRemotePath(file) + if err := w.client.Remove(remotePath); err != nil { + return err + } + } + return nil +} + +// Exists 检查文件是否存在 +func (w *WebDav) Exists(file string) bool { + remotePath := w.getRemotePath(file) + _, err := w.client.Stat(remotePath) + return err == nil +} + +// Files 获取目录下的所有文件 +func (w *WebDav) Files(path string) ([]string, error) { + remotePath := w.getRemotePath(path) + entries, err := w.client.ReadDir(remotePath) + if err != nil { + return nil, err + } + + var files []string + for _, entry := range entries { + if !entry.IsDir() { + files = append(files, entry.Name()) + } + } + + return files, nil +} + +// Get 读取文件内容 +func (w *WebDav) Get(file string) ([]byte, error) { + remotePath := w.getRemotePath(file) + reader, err := w.client.ReadStream(remotePath) + if err != nil { + return nil, err + } + defer func() { _ = reader.Close() }() + + return io.ReadAll(reader) +} + +// LastModified 获取文件最后修改时间 +func (w *WebDav) LastModified(file string) (time.Time, error) { + remotePath := w.getRemotePath(file) + stat, err := w.client.Stat(remotePath) + if err != nil { + return time.Time{}, err + } + + return stat.ModTime(), nil +} + +// MimeType 获取文件的 MIME 类型 +func (w *WebDav) MimeType(file string) (string, error) { + ext := filepath.Ext(file) + mimeType := mime.TypeByExtension(ext) + if mimeType == "" { + return "application/octet-stream", nil + } + return mimeType, nil +} + +// Missing 检查文件是否不存在 +func (w *WebDav) Missing(file string) bool { + return !w.Exists(file) +} + +// Move 移动文件到新位置 +func (w *WebDav) Move(oldFile, newFile string) error { + oldPath := w.getRemotePath(oldFile) + newPath := w.getRemotePath(newFile) + + // 确保目标目录存在 + newDir := filepath.Dir(newPath) + if newDir != "." { + _ = w.client.MkdirAll(newDir, 0755) + } + + return w.client.Rename(oldPath, newPath, false) +} + +// Path 获取文件的完整路径 +func (w *WebDav) Path(file string) string { + remotePath := w.getRemotePath(file) + return fmt.Sprintf("%s/%s", strings.TrimSuffix(w.config.URL, "/"), remotePath) +} + +// Put 写入文件内容 +func (w *WebDav) Put(file, content string) error { + remotePath := w.getRemotePath(file) + + // 确保目录存在 + remoteDir := filepath.Dir(remotePath) + if remoteDir != "." { + _ = w.client.MkdirAll(remoteDir, 0755) + } + + return w.client.WriteStream(remotePath, bytes.NewReader([]byte(content)), 0644) +} + +// Size 获取文件大小 +func (w *WebDav) Size(file string) (int64, error) { + remotePath := w.getRemotePath(file) + stat, err := w.client.Stat(remotePath) + if err != nil { + return 0, err + } + + return stat.Size(), nil +}